Azure / bicep

Bicep is a declarative language for describing and deploying Azure resources
MIT License
3.26k stars 753 forks source link

Discussion: Lack of resource referencing is a pain #15653

Open aidan-harding opened 21 hours ago

aidan-harding commented 21 hours ago

I was moaning about this on Bluesky and encouraged to raise an issue here to explain it. Apologies if I have an X-Y problem here or some other mistake. But maybe it's simply a case for improving resource referencing. I'd also say that bicep is very elegant, which is why I'm struggling here because it feels inelegant in this use case.

What I'm trying to do is create a resource group containing a number of resources, and then allow them access to each other using Managed Identities and RBAC with standard roles.

Importantly, I want the role assignment to be scoped to just the resource I'm granting access to. e.g. I want to grant a managed identity access for to a specific storage account, scoped at the level of that account. I don't want it at the subscription level because it may not be allowed access to other storage resources.

To give a small example, say we create an Azure Data Factory and a Storage Account. Then try to give the ADF managed identity access to the Storage Account.

It would look like this:

main.bicep

targetScope='subscription'

param locationName string
@allowed([
  'dev'
  'prd'
  'sbx'
  'shd'
  'stg'
  'tst'
  'uat'
])
param environmentName string
param projectName string
param location string
param instance int

resource newResourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = {
  name: 'rg-${projectName}-${environmentName}-${locationName}-${instance}'
  location: location
}

module storageAccount 'storage.bicep' = {
  name: 'st${projectName}${environmentName}${locationName}${instance}'
  scope: newResourceGroup
  params: {
    storageLocation: location
    storageName: 'st${projectName}${environmentName}${locationName}${instance}'
  }
}

module dataFactory 'data-factory.bicep' = {
  name: 'adf-${projectName}-${environmentName}-${locationName}-${instance}'
  scope: newResourceGroup
  params: {
    location: location
    name: 'adf-${projectName}-${environmentName}-${locationName}-${instance}'
    dfsStorageUrl: storageAccount.outputs.dfsUri
    storageName: storageAccount.name
  }
}

storage.bicep

param storageLocation string
param storageName string

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-04-01' = {
  name: storageName
  location: storageLocation
  sku: {
    name: 'Standard_RAGRS'
  }
  kind: 'StorageV2'
  properties: { 
    minimumTlsVersion: 'TLS1_2'
    encryption: {
      requireInfrastructureEncryption:true
    }
  }
}

output dfsUri string = storageAccount.properties.primaryEndpoints.dfs

data-factory.bicep

param location string
param name string
param storageName string
param dfsStorageUrl string

resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' = {
  name: name
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    publicNetworkAccess: 'Disabled'
  }
}

resource storageService 'Microsoft.DataFactory/factories/linkedservices@2018-06-01' = {
  name: 'storageService'
  parent: dataFactory
  properties: {
    type:'AzureBlobFS'
    typeProperties: {
      url: dfsStorageUrl
    }
  }
}

I read the docs: https://github.com/MicrosoftDocs/azure-docs/blob/main/articles/azure-resource-manager/bicep/scenarios-rbac.md

But getting the standard role definitions seemed super janky. So I read more here. And also this blog post https://yourazurecoach.com/2023/02/02/my-developer-friendly-bicep-module-for-role-assignments/.

They addressed the problem of reading in the standard role ids by listing them all out in a neat module. But, the scope becomes tricky e.g. if we try to assign like this:

param storageAccountName string
param principalId string

@allowed[
    'Device'
    'ForeignGroup'
    'Group'
    'ServicePrincipal'
    'User'
    ''
]
param principalType string = ''

@allowed([
    'Storage Blob Data Contributor'
    'Storage Blob Data Reader'
])
param roleDefinition string

var roles = {
    // See https://docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles for these mappings and more.
    'Storage Blob Data Contributor': '/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe'
    'Storage Blob Data Reader': '/providers/Microsoft.Authorization/roleDefinitions/2a2b9908-6ea1-4ae2-8e65-a410df84e7d1'
}

var roleDefinitionId = roles[roleDefinition]

resource storageAccount 'Microsoft.Storage/storageAccounts@2021-06-01' existing = {
    name: storageAccountName
}

resource roleAuthorization 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
    // Generate a unique but deterministic resource name
    name: guid('storage-rbac', storageAccount.id, resourceGroup().id, principalId, roleDefinitionId)
    scope: storageAccount
    properties: {
        principalId: principalId
        roleDefinitionId: roleDefinitionId
        principalType: empty(principalType) ? null : principalType
    }
}

Then this module can only handle storage accounts because retrieving the existing resource requires a string literal to specify the resource type, 'Microsoft.Storage/storageAccounts@2021-06-01'.

If you could pass in an actual resource, then it would not be restricted like that.

Having poked this around a whole load, I'VE FOUND A WAY THAT I LIKE!

Instead of having a module that does the role assignment, I have a module which encapsulates retrieving the role id. And then everything is not so ugly. So, that means adding the following to data-factory.bicep:

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-04-01' existing = {
  name: storageName
}

module storageAccountContributorRole 'standard-role.bicep' = {
  name: 'storageAccountContributorRole'
  params: {standardRoleName:'Storage Account Contributor'}
}

resource roleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
  name: guid(storageName, 'Storage Account Contributor', dataFactory.id)
  scope: storageAccount
  properties: {
    roleDefinitionId: storageAccountContributorRole.outputs.standardRoleId
    principalId: dataFactory.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

Which feels reasonably elegant. standard-role.bicep looks like this:

@allowed([
  'Access Review Operator Service Role'
  'AcrDelete'
  'AcrImageSigner'
  'AcrPull'
  'AcrPush'
...
  ])
  param standardRoleName string

  var roleIds = {
  'Access Review Operator Service Role': resourceId('Microsoft.Authorization/roleAssignments', '76cc9ee4-d5d3-4a45-a930-26add3d73475')
  AcrDelete: resourceId('Microsoft.Authorization/roleAssignments', 'c2f4ef07-c644-48eb-af81-4b1b4947fb11')
  AcrImageSigner: resourceId('Microsoft.Authorization/roleAssignments', '6cef56e8-d556-48e5-a04f-b8e64114680f')
  AcrPull: resourceId('Microsoft.Authorization/roleAssignments', '7f951dda-4ed3-4680-a7ca-43fe172d538d')
  AcrPush: resourceId('Microsoft.Authorization/roleAssignments', '8311e382-0749-4cb8-b61a-304f252e45ec')
...
  }

output standardRoleId string = roleIds[standardRoleName]

I ended up putting the whole thing into a public repo https://github.com/processity/bicep-role-assignment

This feels OK now. One thing that I think it shows is that this use case of assigning roles in a way where bicep helps you with auto-complete for the standard roles might be worth adding to the docs.

alex-frankel commented 2 hours ago

+1 on this being one of the jankier parts of bicep today for the two reasons you identified:

Once that is done you should be able to write a module like the following (I left the data factory references alone, but the data factory resource could also be passed in):

generic-role-assignment.bicep

param res resource

resource roleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
  scope: res

  name: guid(res.name, 'Storage Account Contributor', dataFactory.id)
  properties: {
    roleDefinitionId: builtInRoleDefinition('Storage Account Contributor')// storageAccountContributorRole.outputs.standardRoleId
    principalId: dataFactory.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

Does that get you closer to what you are looking for?