azureazure-bicep

Secret scoped role definition and assignment using bicep


I am trying to create two reusable bicep modules to allow reading specific secrets in chosen key vaults. To do this, I first declare the role definition:

targetScope = 'subscription'

param subscriptionId string
param resourceGroupName string
param keyVaultName string
param allowedSecrets array

param managementGroupRoot string

var keyVaultScope = '/subscriptions/${subscriptionId}/resourcegroups/${resourceGroupName}/providers/Microsoft.KeyVault/vaults/${keyVaultName}'

var assignableScopes = [for secretName in allowedSecrets: '${keyVaultScope}/secrets/${secretName}']

var roleName = 'Limitied ${keyVaultName} secret reader ${managementGroupRoot}'

// Permissions based on Key Vault Secrets User
// https://www.azadvertizer.net/azrolesadvertizer/4633458b-17de-408a-b874-0445c86b69e6.html
resource key_vault_secrets_user_role_definition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = {
  name: '4633458b-17de-408a-b874-0445c86b69e6'
}

resource role_definition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' = {
  name: guid(roleName)
  properties: {
    roleName: roleName
    description: 'Allows reading specific secrets in the ${keyVaultName} key vault in ${managementGroupRoot}'
    assignableScopes: assignableScopes
    permissions: key_vault_secrets_user_role_definition.properties.permissions
  }
}

output roleDefinitionId string = role_definition.id

The role definition creation works well, and it results in this role definition:

{
    "assignableScopes": [
        "/subscriptions/subscriptionId/resourcegroups/resourceGroupName/providers/Microsoft.KeyVault/vaults/keyVaultName/secrets/secretName",
        "/subscriptions/subscriptionId/resourcegroups/resourceGroupName/providers/Microsoft.KeyVault/vaults/keyVaultName/secrets/anotherSecret"
    ],
    "description": "Allows reading specific secrets in the xxx} key vault in xxx",
    "id": "/subscriptions/xxx/providers/Microsoft.Authorization/roleDefinitions/xxx",
    "name": "c64aa8eb-479d-5c2d-8f25-b1acb151c0af",
    "permissions": [
        {
            "actions": [],
            "dataActions": [
                "Microsoft.KeyVault/vaults/secrets/getSecret/action",
                "Microsoft.KeyVault/vaults/secrets/readMetadata/action"
            ],
            "notActions": [],
            "notDataActions": []
        }
    ],
    "roleName": "Limitied key vault secret reader xxx",
    "roleType": "CustomRole",
    "type": "Microsoft.Authorization/roleDefinitions"
}

Next, I want to assign this role to a service principal. Here's where I'm not entirely clear on the details, but since I want this principal to be able to read n number of individual secrets, I made the assmuption that I would need to iterate on the assignable scopes.

To do that, I have a main file:

targetScope = 'managementGroup'

resource roleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = {
  name: roleDefinitionId
}

module example 'module.bicep' = {
    name: 'example-${managementGroup().name}'
    scope: resourceGroup(keyVaultSubscriptionId, keyVaultResourceGroupName)
    params: {
      roleDefinitionId: roleDefinitionId
      assignableScopes: roleDefinition.properties.assignableScopes
      managementGroupName: managementGroup().name
      keyVaultName: keyVaultName
    }
  }

The module then looks like this:

targetScope = 'resourceGroup'

param roleDefinitionId string
param assignableScopes array = []
param managementGroupName string
param keyVaultName string
param principalId string

// Full scope looks like this:
// '/subscriptions/<sub>/resourcegroups/<rg>/providers/Microsoft.KeyVault/vaults/<vault>/<secret>'
// Hence 8 is the secret name
// Also verifies that the secrets exist
var secretNames = [for scope in assignableScopes: split(scope, '/')[8]]
resource secretResources 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' existing = [for secret in secretNames: {
  name: '${keyVaultName}/${secret}'
}]

// Iterating the secretResources array is not supported, so we iterate the scope which they are based
resource regressionTestKeyVaultReaderAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = [for (scope, index) in assignableScopes: {
  name: guid(managementGroupName, principalId, scope)
  scope: secretResources[index] // Access by index and apply this role assignment to all assignable scopes
  properties: {
    principalId: principalId
    roleDefinitionId: roleDefinitionId
  }
}]

However, this fails with the following error:

ERROR: ***"code": "InvalidTemplate", "message": "Deployment template validation failed: 'The template resource 'exmaple-xxx' at line '97' and column '5' is not valid: Unable to evaluate template language function 'extensionResourceId': function requires exactly two multi-segmented arguments. The first must be the parent resource id while the second must be resource type including resource provider namespace. Current function arguments '/providers/Microsoft.Management/managementGroups/ESD,Microsoft.Authorization/roleDefinitions,/subscriptions/***/providers/Microsoft.Authorization/roleDefinitions/xxx'. Please see https://aka.ms/arm-template-expressions/#extensionresourceid for usage details.. Please see https://aka.ms/arm-template-expressions for usage details.'.", "additionalInfo": [***"type": "TemplateViolation", "info": ***"lineNumber": 97, "linePosition": 5, "path": "properties.template.resources[6]"***]***

I am using az to deploy in a GitHub pipeline so I tried to access the request and response, to no avail:

$deployment = az deployment mg create | ConvertFrom-Json // additional params 

Write-Host "Request: $(ConvertTo-Json -InputObject $deployment.request)" // Request: null
Write-Host "Response: $(ConvertTo-Json -InputObject $deployment.response)" // Response: null

The error is very cryptic to me and I don't really know what is going on as I'm not even using that utility method that is being referenced. I'm guessing the conversion to ARM does something in the background. vscode says everything is fine and dandy.

What am I doing wrong? My only guess is the scope part of the assignment, but I have no ideas on how to correct it.

Any help would be greatly appreciated.

Update

Some additional information that I found while trying to solve this. The validation of the template fails and the deployment doesn't even start. I built both the main and the module bicep files to see if that would give some additional context. The module looks fine but main has an error on the module resource:

enter image description here

So this is in the main file with targetScope = 'managementGroup', and the module with targetScope = 'resourceGroup' shows no validation errors when built.

Update 2

When compiled to ARM, I see the following value is passed from main to the module:

"assignableScopes": {
  "value": "[reference(extensionResourceId(managementGroup().id, 'Microsoft.Authorization/roleDefinitions', parameters('secretReaderRoleDefinitionId')), '2018-01-01-preview').assignableScopes]"
},

AFAICT this is 3 arguments, and the error I get in the GitHub pipeline says:

Unable to evaluate template language function 'extensionResourceId': function requires exactly two multi-segmented arguments.

That doesn't seem to be true when reading the docs about that function.

Update 3

The error is produced in a GitHub pipeline where I'm running on ubuntu-latest. I'm going to replicate the same command locally and see If I can get it to work here in case of a runner issue.

Update 4

Exact same error reproduced outside of the GitHub pipeline.

Update 5 For transparency, I dropped this approach and used a default role within Azure instead.


Solution

  • A couple thoughts...

    1. Creating a custom roleDef with limited assignable scopes doesn't have a ton of value from a security perspective, because the built-in roleDef has the same permissions has a broader scope - and the principal that assigns one would be able to assign the other.

    2. If your goal is to simply iterate over the secrets and assign the role to those secrets all you need is the resourceId of those secrets. It looks like you're trying to pull that list from the roleDefinition (instead of passing to the template) which is possible but seems somewhat complex. That would mean that any time you want to "adjust" this deployment you have to define a new role or modify the existing, both have some downstream consequences. There are a finite number of custom roles that can be defined in a tenant and as you change them you could break existing assignments unintentionally (either remove access or inadvertently give access to new ones).

    That said, I don't see that specific error in your code but perhaps a few others - try this:

    main.bicep

    targetScope = 'managementGroup'
    
    param roleDefinitionId string
    param keyVaultSubscriptionId string
    param keyVaultResourceGroupName string
    param keyVaultName string
    param principalId string
    
    resource roleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = {
      scope: subscription(keyVaultSubscriptionId)
      name: roleDefinitionId
    }
    
    module example 'module.bicep' = {
        name: 'example-${managementGroup().name}'
        scope: resourceGroup(keyVaultSubscriptionId, keyVaultResourceGroupName)
        params: {
          roleDefinitionId: roleDefinitionId
          assignableScopes: roleDefinition.properties.assignableScopes
          keyVaultName: keyVaultName
          principalId: principalId
        }
      }
    
    

    module.bicep

    targetScope = 'resourceGroup'
    
    param roleDefinitionId string
    param assignableScopes array
    param keyVaultName string
    param principalId string
    
    var secretNames = [for scope in assignableScopes: last(split(scope, '/'))]
    resource secretResources 'Microsoft.KeyVault/vaults/secrets@2021-11-01-preview' existing = [for secret in secretNames: {
      name: '${keyVaultName}/${secret}'
    }]
    
    resource roleDef 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
      name: roleDefinitionId
    }
    
    resource regressionTestKeyVaultReaderAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = [for (scope, index) in assignableScopes: {
      name: guid(roleDef.id, principalId, scope)
      scope: secretResources[index]
      properties: {
        principalId: principalId
        roleDefinitionId: roleDef.id
      }
    }]