Back to blog
AzureBest PracticesCloud SecurityIdentity & AccessOperations & Compliance

Custom Role Has Owner-Equivalent Permissions in Azure

Learn how to detect and fix Azure custom roles that grant Microsoft.Authorization write access, an Owner-equivalent privilege escalation risk, with CLI and IaC remediation.

TL;DR

This check flags Azure custom roles that include Microsoft.Authorization/*/write or role assignment write permissions, which let a holder grant themselves or anyone else Owner access. Scope those actions out of your custom roles and reserve assignment rights for the built-in Owner and User Access Administrator roles.

Azure built-in roles are well documented and tightly governed, but custom roles are where things quietly go wrong. A custom role that looks innocuous on paper can hand out the keys to an entire subscription if it includes the wrong authorization actions. This check catches exactly that case: a custom role definition that can write role assignments, which is functionally equivalent to being an Owner.


What this check detects

The check inspects every custom role definition in your Azure tenant and flags any that grant write access to Microsoft.Authorization resources. Specifically, it looks for these actions in the role's permissions block:

  • Microsoft.Authorization/*
  • Microsoft.Authorization/*/write
  • Microsoft.Authorization/roleAssignments/write
  • Microsoft.Authorization/roleDefinitions/write
  • * (the wildcard that covers everything, including authorization)

Any of these gives the role holder the ability to create role assignments. And if you can create role assignments, you can assign yourself the Owner role. That makes the custom role Owner-equivalent regardless of what it is named or what it was intended to do.

Note: In Azure RBAC, the difference between Owner and Contributor comes down to one thing: Owner can manage role assignments, Contributor cannot. The Microsoft.Authorization/*/write permission is the line that separates "can do work" from "can grant access." That single permission is the entire ballgame.


Why it matters

The danger here is privilege escalation. Picture a custom role called AppDeploymentOperator that someone created to let a deployment pipeline manage resources. Along the way they pasted in Microsoft.Authorization/* because a deployment template needed to assign a managed identity some permissions. Now every principal holding that role can grant Owner to any account they control.

This plays out in a few common ways:

  • Compromised service principal. An attacker who phishes or steals credentials for a service principal holding this role does not stop at the resources the role nominally covers. They assign themselves Owner on the subscription and persist.
  • Insider escalation. A developer with a "limited" custom role realizes they can write role assignments, grants themselves broader access, and now operates outside the boundary their team set for them.
  • Lateral movement. An attacker who lands on a VM with a managed identity assigned this role uses the identity to assign Owner to a fresh service principal, creating a durable backdoor that survives credential rotation on the original identity.

The role looks scoped. The blast radius is total. That gap between perceived and actual privilege is exactly what makes these findings worth chasing down.

Warning: Custom roles assigned at the management group or subscription scope are the worst case. A single Owner-equivalent custom role at management group scope can compromise every subscription beneath it.


How to fix it

The fix is to remove the authorization write permissions from the custom role, or to redesign the role so it no longer needs them. Start by finding the offending definitions.

Step 1: Find custom roles with authorization write access

az role definition list --custom-role-only true \
  --query "[?contains(to_string(permissions[].actions[]), 'Microsoft.Authorization') || contains(to_string(permissions[].actions[]), '*')].{Name:roleName, Id:name, Actions:permissions[0].actions}" \
  --output json

Review the output and identify which roles include the broad authorization actions or a bare wildcard.

Step 2: Inspect the full definition

az role definition list --name "AppDeploymentOperator" --output json

You will get something like this:

{
  "roleName": "AppDeploymentOperator",
  "permissions": [
    {
      "actions": [
        "Microsoft.Resources/deployments/*",
        "Microsoft.Compute/virtualMachines/*",
        "Microsoft.Authorization/*"
      ],
      "notActions": [],
      "dataActions": [],
      "notDataActions": []
    }
  ],
  "assignableScopes": [
    "/subscriptions/00000000-0000-0000-0000-000000000000"
  ]
}

Step 3: Decide what the role actually needs

In most cases the role does not need to write role assignments at all. If it genuinely needs to read them (for example, to verify an assignment exists), use the read-only action instead:

  • Replace Microsoft.Authorization/* with Microsoft.Authorization/*/read if read access is enough.
  • Remove the authorization actions entirely if the role does not touch RBAC.
  • If the role legitimately needs to assign one specific role to managed identities, see the note below before granting write access.

Note: If a workload truly needs to assign roles (for example, an automation account that provisions resources and wires up managed identities), do not give it broad write access. Constrain it with conditions using delegated role assignment management with conditions, which lets a principal assign only specific roles to specific principal types.

Step 4: Update the role definition

Export the current definition to a file, edit out the dangerous actions, then update.

# Export to a file
az role definition list --name "AppDeploymentOperator" \
  --query "[0]" --output json > role.json

# Edit role.json: change "Microsoft.Authorization/*" to "Microsoft.Authorization/*/read"
# then apply the update

az role definition update --role-definition role.json

Danger: Before you tighten a role, list who holds it. Removing permissions in use can break a production pipeline or lock out an automation identity mid-job. Run the assignment check first so you know exactly what you are about to affect.

# See who is assigned the role before you change it
az role assignment list \
  --role "AppDeploymentOperator" \
  --all \
  --query "[].{Principal:principalName, Type:principalType, Scope:scope}" \
  --output table

Step 5: Verify

az role definition list --name "AppDeploymentOperator" \
  --query "[0].permissions[0].actions" --output json

Confirm the output no longer contains Microsoft.Authorization/*/write, Microsoft.Authorization/*, or a bare *.


How to prevent it from happening again

Manual cleanup fixes today's problem. The next over-privileged custom role is one copy-paste away, so put guardrails in front of role creation.

Use Azure Policy to flag risky custom roles

Azure Policy can audit custom role definitions, though policy evaluation of role definitions is limited. A more reliable approach for most teams is to gate custom roles in CI/CD where they are defined as code.

Define custom roles as code and scan them in CI

If your roles live in Terraform or Bicep, you can block the dangerous actions at pull request time. Here is an example Terraform definition and an OPA/Conftest policy to guard it.

# azurerm_role_definition in Terraform
resource "azurerm_role_definition" "app_deployment_operator" {
  name        = "AppDeploymentOperator"
  scope       = data.azurerm_subscription.current.id
  description = "Deploy app resources, no RBAC write"

  permissions {
    actions = [
      "Microsoft.Resources/deployments/*",
      "Microsoft.Compute/virtualMachines/*",
      "Microsoft.Authorization/*/read"
    ]
    not_actions = []
  }

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

A Conftest policy that fails the build if any role grants authorization write access:

package main

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "azurerm_role_definition"
  action := resource.change.after.permissions[_].actions[_]
  forbidden := {"*", "Microsoft.Authorization/*", "Microsoft.Authorization/*/write", "Microsoft.Authorization/roleAssignments/write"}
  forbidden[action]
  msg := sprintf("Custom role '%s' grants Owner-equivalent permission: %s", [resource.change.after.name, action])
}

Run it against your Terraform plan output:

terraform plan -out=tfplan.bin
terraform show -json tfplan.bin > tfplan.json
conftest test tfplan.json --policy ./policies

Tip: Pair the CI gate with a scheduled scan in Lensix so you catch roles created outside your pipeline, by hand in the portal or by another team. CI catches what flows through your repos, continuous scanning catches everything else.


Best practices

  • Prefer built-in roles. Azure provides over 100 built-in roles. Only create a custom role when no built-in role fits. Fewer custom roles means fewer places for this mistake to hide.
  • Keep assignment rights in two roles only. Owner and User Access Administrator are the built-in roles meant to manage RBAC. Treat the ability to write role assignments as something that belongs to those roles and nowhere else.
  • Avoid wildcards in custom roles. A bare * or a service-level wildcard like Microsoft.Authorization/* almost always grants more than intended. List the specific actions a role needs.
  • Scope roles narrowly. Set assignableScopes to the smallest scope that works, a resource group rather than a subscription, a subscription rather than a management group.
  • Use PIM for standing privilege. Where a human genuinely needs assignment rights, deliver them through Privileged Identity Management with time-bound, approval-gated activation rather than a permanent custom role.
  • Audit custom roles on a schedule. Roles drift as teams add actions over time. Review every custom role definition quarterly and confirm each action still earns its place.

The core idea is simple. The permission to grant access is the most powerful permission in your tenant, so keep it out of the roles you hand to applications and operators. A deployment role should deploy. A monitoring role should read. Neither should be able to make itself an Owner.