How to perform role assignments at every scope in Azure Bicep

Learn how to perform role assignments at every scope in Azure Bicep

How to perform role assignments at every scope in Azure Bicep

Have you ever copied and pasted the same role assignment code three times, once for each Azure scope?

If you maintain infrastructure at different scopes (e.g., management groups, subscriptions, and resource groups), you know the pattern. You need consistent RBAC policies, but each scope demands its own deployment target. You write similar Bicep modules, then duplicate the same logic across the scope. But the deployment commands and resource scopes differ just enough to force separate templates.

This repetition can breed drifting. One template gains a condition parameter, and another gets updated role mappings. A third still references an older API version. Onboarding new team members means explaining why "it's the same pattern, just different scopes".

In this blog post, you'll learn about a recent addition to the Azure Verified Modules (AVM) public registry, where one module rules them all.

One module, three scopes

AVM now ships a role assignment module that handles all three scopes from a single pattern. Alexander Sehr created a multi-scope design, and it's live in the public registry at version 0.1.0.

The parent lives at avm/res/authorization/role-assignment, and each child module is published under a scope-specific path:

  • br/public:avm/res/authorization/role-assignment/mg-scope:0.1.0
  • br/public:avm/res/authorization/role-assignment/sub-scope:0.1.0
  • br/public:avm/res/authorization/role-assignment/rg-scope:0.1.0

You pick the scope you need, reference the corresponding module, and supply at least the same core parameters every time: principalId and roleDefinitionIdOrName. The rest, such as conditions, descriptions, or telemetry toggles, remain optional across all three variants.

Why this matters

Imagine now a scenario where you're setting up RBAC for a new compliance initiative. Your platform team needs Reader access at the management group level to audit all subscriptions. Your development team needs Contributor rights scoped to their individual resource groups, and lastly, the finance departments want cost visibility across specific subscriptions.

You can already see it here. Three different scopes, three different audiences, but the same underlying task: to assign a role to a principal. Before this module, you maintained three separate templates. The management group would look something like:

// mg-role-assignment.bicep
targetScope = 'managementGroup'

resource mgRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(managementGroup().id, principalId, roleDefinitionId)
  properties: {
    principalId: principalId
    roleDefinitionId: roleDefinitionId
    principalType: 'ServicePrincipal'
  }
}

The subscription version is another:

targetScope = 'subscription'

resource subRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(subscription().id, principalId, roleDefinitionId)
  properties: {
    principalId: principalId
    roleDefinitionId: roleDefinitionId
    principalType: 'ServicePrincipal'
  }
}

And the resource group in a third:

// rg-role-assignment.bicep
targetScope = 'resourceGroup'

resource rgRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(resourceGroup().id, principalId, roleDefinitionId)
  properties: {
    principalId: principalId
    roleDefinitionId: roleDefinitionId
    principalType: 'ServicePrincipal'
  }
}

When you need to add ABAC conditions or update role mappings, you'll probably go to edit each template individually. Then your favorite smart newcomer asked:

"Why the hell is the same logic applied three times?"

You've to explain that there are different scopes, different deployment targets.

Now you write your role assignment logic once. One pattern, many scopes. That's the design of the AVM module.

Choosing your scope

The parent module handles scoping by adding the required parameters or specifying the current context.

For example, imagine you want to deploy at the management group scope. It will look as follows:

module roleAssignment 'br/public:avm/res/authorization/role-assignment:0.1.0' = {
  name: 'roleAssignmentDeployment'
  params: {
    principalId: '<principal object id>'
    roleDefinitionIdOrName: 'Resource Policy Contributor'
    location: '<location>'
    principalType: 'ServicePrincipal'
    // Omit managementGroupId to use current scope, or add it to target a specific group
    managementGroupId: '<management-group-id>'
  }
}

If you're going to do it for a subscription, you can use the subscriptionId:

module roleAssignment 'br/public:avm/res/authorization/role-assignment:0.1.0' = {
  name: 'roleAssignmentDeployment'
  params: {
    principalId: '<principal object id>'
    roleDefinitionIdOrName: 'Reader'
    location: '<location>'
    principalType: 'ServicePrincipal'
    subscriptionId: '<subscription-id>'
  }
}

And lastly, the resource group-level:

module roleAssignment 'br/public:avm/res/authorization/role-assignment:0.1.0' = {
  name: 'roleAssignmentDeployment'
  params: {
    principalId: '<principal object id>'
    roleDefinitionIdOrName: '/providers/Microsoft.Authorization/roleDefinitions/c2f4ef07-c644-48eb-af81-4b1b4947fb11'
    location: '<location>'
    principalType: 'ServicePrincipal'
    subscriptionId: '<subscription-id>'
    resourceGroupName: '<resource-group-name>'
  }
}

Summary

The module is live in the Bicep public registry for you to use. Reference it once, control the scope with parameters, and deploy. One module, all scopes. No more drift in templates.

Are you going to get started with it? Bet you do!