Custom roles in a nutshell
Built-in Azure roles are cut for jobs, not tasks. How to find the few actions a task needs, why the first custom role gets made by hand, and how to move role definitions into Terraform with an identity of their own.
Azure ships 970 built-in roles. Four of them can read the keys of a function app. All four can also change the app, and two of them can change everything in the subscription.
Part 1 built access from security groups and built-in roles. This piece is the step after it. It covers writing your own role, finding the few actions a task needs, and getting the role into code when the identity that deploys your infrastructure is not allowed to create one.
Built-in roles are cut for jobs
A built-in role describes a job. Website Contributor is the role for managing websites, so it carries everything a person in that job might need, from deploying code to deleting the app.
Most access requests are smaller than a job. Somebody wants to read a function key to call an endpoint while testing. Nothing in the built-in list matches that task, so the request ends in a Contributor security group.
A custom role is a role definition you write yourself, with exactly the actions the task needs, assignable only where you say.
Finding the actions a task needs
Read the operation, not the label
Every permission in Azure is an operation string, Provider/resource/verb. The verb is read, write, delete or action, and anything that is not plain reading or changing is an action.
Reading a function key is an action. The portal calls this operation:
Microsoft.Web/sites/host/listkeys/action
The built-in Reader role looks like it should be enough, and it is not. Reader can see every resource and change none, because it grants only */read. That pattern never matches an action, and the portal reads keys through listkeys, so a Reader sees the function and not its keys.
Check who already holds it
Before writing a role, check which built-in roles already grant the operation. A text search misses most of them, because wildcards grant operations without naming them. The script at the end of this article does the matching, and for listkeys it prints four roles:
Contributor
Owner
Website Contributor
Logic Apps Standard Contributor
Owner and Contributor grant *. The other two grant Microsoft.Web/sites/*, which is write on the whole app.
Actions and DataActions
A role definition has two allow lists. Actions covers the control plane, which is managing the resource. DataActions covers the data plane, which is the data inside it, such as blobs or Key Vault secrets. Each list has a Not twin that removes operations from a wildcard.
The split decides where a role can be assigned.
If you create a custom role with
DataActions, that role can’t be assigned at management group scope.
Azure custom roles, custom role properties
Function keys are a control-plane action, so this role needs Actions only.
The role
The whole role is four actions. The two slots variants are there because a function app with deployment slots keeps separate keys on each slot.
A role definition is itself a resource, and it lives at one scope. In Terraform that is the scope argument. assignable_scopes is a separate list of the places where the role may be assigned.
While a role definition is a management group or subscription-level resource, a role definition can be used in multiple subscriptions that share the same Microsoft Entra tenant.
Azure custom roles, custom role properties
In the example the scope is the first subscription, a1. The role can be assigned on both a1 and a2.
Changing the scope or role_definition_id replaces the role, and Azure refuses to delete a role that still has assignments. Both are pinned to values that never move.
locals {
scope = "/subscriptions/00000000-0000-0000-0000-0000000000a1"
assignable_scopes = [
local.scope,
"/subscriptions/00000000-0000-0000-0000-0000000000a2",
]
}
resource "azurerm_role_definition" "keys" {
role_definition_id = "00000000-0000-0000-0000-0000000000b1"
name = "Function Keys Reader"
scope = local.scope
description = "Read function keys only."
permissions {
actions = [
"Microsoft.Web/sites/host/listkeys/action",
"Microsoft.Web/sites/functions/listkeys/action",
"Microsoft.Web/sites/slots/host/listkeys/action",
"Microsoft.Web/sites/slots/functions/listkeys/action",
]
}
assignable_scopes = local.assignable_scopes
}
The role has no reads, no writes and no DataActions. It goes to people who already hold Reader, so they can find the app and then read its keys.
Assignable scopes
assignable_scopes decides where the role can be assigned at all. List subscriptions one by one when the role must stay on a few of them. A management group in the list makes the role assignable on every subscription beneath it, which is usually more than you meant.
Microsoft also caps how far one role can reach.
You can define only one management group in
AssignableScopesof a custom role.
Azure custom roles, custom role limits
Why the first role is made by hand
Creating a role needs Microsoft.Authorization/roleDefinitions/write, and Microsoft is specific about where you need it.
Users that are granted this action on all the
AssignableScopesof the custom role can create (or delete) custom roles for use in those scopes.
Azure custom roles, who can create, delete, update, or view a custom role
The identity that deploys infrastructure usually lacks it, on purpose. A pipeline that assigns roles should hold Role Based Access Control Administrator (RBAC Administrator) with a condition on what it may assign. The role carries roleAssignments/write and not roleDefinitions/write. Contributor cannot create roles either, because its NotActions exclude Microsoft.Authorization/*/Write.
Only two of the 970 built-in roles grant roleDefinitions/write, Owner and User Access Administrator. Both can also assign any role to anyone, themselves included. Giving one of them to a pipeline so it can create a key reader turns the pipeline into something that can make itself Owner.
So the first custom role gets created in the portal, by a person who holds Owner or User Access Administrator. From that moment it is undocumented infrastructure. Terraform can assign it, but it does not own it.
What a hand-made role costs
As a worked example, take an estate of about 100 subscriptions where the key reader above started by hand. Terraform assigns it by name and never sees the definition, which causes two problems.
A new subscription fails mid-apply
The role’s assignable scopes are a list somebody edits in the portal, and a custom role is only listed at the scopes it is assignable on. When a new subscription joins the configuration, the built-in roles are assigned and the custom one fails mid-apply, because Terraform cannot find it there.
A changed role is not drift
Reading the role with a data source turns a missing or renamed role into a plan error. A changed role is still invisible. Someone can add Microsoft.Web/sites/write to it in the portal, and the next plan reports no changes.
Until the definition is in code, read the role as data and put two postconditions on it. A failed check block is only a warning, and a failed postcondition stops the plan.
locals {
allowed = toset([
for s in local.assignable_scopes :
provider::azurerm::normalise_resource_id(s)
])
key_actions = toset([
"microsoft.web/sites/host/listkeys/action",
"microsoft.web/sites/functions/listkeys/action",
"microsoft.web/sites/slots/host/listkeys/action",
"microsoft.web/sites/slots/functions/listkeys/action",
])
}
data "azurerm_role_definition" "keys" {
name = "Function Keys Reader"
scope = local.scope
lifecycle {
postcondition {
condition = length(setsubtract(
toset([
for s in self.assignable_scopes :
provider::azurerm::normalise_resource_id(s)
]),
local.allowed,
)) == 0
error_message = "Role is assignable outside its scopes."
}
postcondition {
condition = alltrue([
length(self.permissions) == 1,
length(self.permissions[0].data_actions) == 0,
toset([
for a in self.permissions[0].actions : lower(a)
]) == local.key_actions,
])
error_message = "Role permissions changed."
}
}
}
The first fails when the role becomes assignable anywhere the configuration does not list. That covers an extra subscription and a management group above them, which would make the role reach every subscription beneath it.
Note:
normalise_resource_idis a function the azurerm provider ships, from Terraform 1.8 and azurerm 4.0. It fixes the casing of an ID’s fixed parts, such assubscriptionsandproviders, so both sides compare as the same string. It leaves the IDs and names themselves as they are.
The second catches an added action, an added data action or a second permission block. Permission strings are case-insensitive, so actions are compared in lower case.
Bringing role definitions into code
The recipe is to give role definitions their own Terraform root and their own identity, separate from the pipeline that assigns roles.
| Definer | Deployer |
|---|---|
roles/ |
access/ |
| defines roles | assigns roles |
roleDefinitions/write |
RBAC Administrator |
| own role out of reach | admin roles excluded |
The definer gets one job
No built-in role grants roleDefinitions/write without also granting role assignments, so the definer’s role is itself a custom role, built around reading, writing and deleting role definitions.
Keep the definer’s own role out of its reach. Whoever can update a role can widen it for everyone who holds it.
Define the definer’s role at a parent management group, and assign it at the child management group that holds the subscriptions it manages.
{
"Name": "Role Definer",
"Description": "Create and update role definitions.",
"Actions": [
"Microsoft.Authorization/roleDefinitions/read",
"Microsoft.Authorization/roleDefinitions/write",
"Microsoft.Authorization/roleDefinitions/delete"
],
"AssignableScopes": [
"/providers/Microsoft.Management/managementGroups/acme"
]
}
Keep this file and a short bootstrap script in the roles repository. The script creates the role from the file with az role definition create, then assigns it by role ID to the definer’s service principal at the child management group.
Any person with Owner or User Access Administrator on the parent can run it, and it is reviewed like every other change.
The definer then has write on the child and below, and none on the parent, so it cannot widen its own role. A new subscription inherits the assignment once it is placed under the child.
Only reviewed code runs as the definer
The definer can still widen the roles it manages, so only reviewed code may sign in as it. Its federated credential decides which pipeline runs may use the identity. On GitHub Actions, bind it to the main branch with a protection rule that requires review, or to an environment whose deployment branches are limited to that branch.
On Azure DevOps the credential names the service connection, not a branch, so put a branch control check and an approval on that connection, and authorize only the roles pipeline to use it.
Pull request plans run as a third identity that holds Reader, which reads role definitions and writes nothing. A plan executes the code in the pull request, including any provider it downloads, so it must never hold the token that writes roles.
The deployer assigns everything but admin roles
The deployer keeps RBAC Administrator, with a condition that stops it from assigning the admin roles. Microsoft recommends this over Owner.
Instead of assigning the Owner or User Access Administrator roles, a more secure method is to constrain a delegate’s ability to create role assignments.
Delegate Azure access management to others, issues with the current delegation method
The condition excludes four roles by role ID, which are Owner, User Access Administrator, RBAC Administrator and the definer’s role. Every other role stays assignable, so a new custom role needs no change to it.
The condition belongs to the assignment, not the role. RBAC Administrator assigned without one, from any tool, can assign Owner.
Microsoft adds one warning to this condition.
If a new built-in or custom role is later added that includes the permission to create role assignments, this condition would not prevent the delegate from assigning roles.
Delegate Azure access management to others, allow most roles
The definer’s role is on the list for a reason. A deployer that can assign it to itself can create a role that grants roleAssignments/write, assign that role too, and step outside its condition.
A precondition in the roles root that fails the plan when any other managed role would grant roleAssignments/write or roleDefinitions/write, wildcards included, puts that change in front of a reviewer.
If eligible assignments through Privileged Identity Management (PIM) are part of the plan, Microsoft lists one limit.
You can’t delegate role assignment management for custom roles with conditions using Privileged Identity Management.
Delegate Azure access management to others, known issues
So the deployer’s conditioned assignment stays active, not eligible.
Assign by role ID, not by name. The ID survives a rename, and the definer’s state can hand it to the deployer.
Import the hand-made role
Terraform adopts the existing definition with an import block. The ID is the role definition ID and its scope, joined by a pipe, and normalise_resource_id gives the definition ID the casing the provider expects.
locals {
role_id = provider::azurerm::normalise_resource_id(
join("/", [
local.scope,
"providers/Microsoft.Authorization",
"roleDefinitions",
"00000000-0000-0000-0000-0000000000b1",
])
)
}
import {
to = azurerm_role_definition.keys
id = "${local.role_id}|${local.scope}"
}
The first plan after the import compares the role in the tenant with the role in code. That diff is the first time anyone sees what the portal edits did. The next role is written in code from the start.
When not to bother
A custom role is a thing somebody has to maintain. Every new subscription has to be in its assignable scopes, and every new operation the task needs is a change to the role.
A slightly-too-broad built-in role with PIM on top is often the better trade. If a person needs the access now and then, an eligible Website Contributor assignment with a time box costs less than a role nobody remembers to update. Write the custom role when the access is standing, frequent, or held by a pipeline.
Check it yourself
The four-role list near the top comes from this script, so you do not have to trust it. Export the built-in roles first.
az role definition list --query "[?roleType=='BuiltInRole']" -o json > builtins.json
Then match one operation against every role’s Actions and NotActions.
import fnmatch, json
OP = "microsoft.web/sites/host/listkeys/action"
def hit(patterns):
return any(
fnmatch.fnmatch(OP, p.lower())
for p in patterns
)
roles = json.load(open("builtins.json"))
print(len(roles), "built-in roles")
for role in roles:
for perm in role["permissions"]:
if hit(perm["actions"]) and not hit(
perm["notActions"]
):
print(role["roleName"])
Change OP to test any operation. The count moves as Microsoft adds roles. It was 927 in August 2026 and 970 in October.
What this bought, and what stays manual
- A key reader with four actions. Assignable only where the configuration says.
- Role changes are pull requests. A widened role shows up as a diff, not as a portal edit nobody sees.
- A new subscription is a one-line change. Its scope goes into the role in code, and the definer already holds rights there through the child management group.
Three things stay manual:
- The definer’s own role. A person with Owner or User Access Administrator on the parent runs the bootstrap script once.
- The deployer’s condition. It changes only when a new role can assign or define roles, and the same kind of person makes that change.
- Choosing the actions. The script tells you who holds an operation. Which operations a task needs still takes the provider’s operation list, and a test with the role assigned.
A follow-up to the identity series. Part 1 is Azure identity, from startup to enterprise, Part 3 is Privileged access with PIM.