My other posts
Passwordless CosmosDB from Container Apps with Bicep
Table of contents
Introduction
Azure's Bicep language is a great tool to automate the deployment of Azure resources. As with other IaC languages, the main idea here is to be able to quickly re-deploy an entire environment without having to do manual configuration. In this post, we'll explore how we can prevent changes to CosmosDB resources by using User Assigned Identities to connect to CosmosDB, as using the primary/secondary key gives applications the ability to manage containers, databases, and other resources within the account. On the other hand, using CosmosDB built-in RBAC support for data-plane operations, ensures that applications can only interact with data.
Environment set up
The focus of this post is around the CosmosDB and Container Apps configuration, achieved through Bicep, so, I'll skip the code for creating a Container Registry and a Log Analytics Workspace with Application Insights for the Container Apps Environment. If you are interested in seeing these in more detail, I have another article with a walkthrough using Bicep as well, here.
We will also need an API that can communicate with CosmosDB. You can use any language that you want to do this, but for this post, I'll be using a very simple ASP.NET Core API. This example uses a simplified version of the getting started guides for CosmosDB.
Given a simple Family
class:
public class Family
{
[JsonProperty(PropertyName = "id")] public string Id { get; set; }
[JsonProperty(PropertyName = "partitionKey")] public string PartitionKey { get; set; }
public string LastName { get; set; }
}
We will have a GET /test
endpoint to retrieve an existing item:
app.MapGet("/test", async context =>
{
var client = context.RequestServices.GetRequiredService<CosmosClient>();
var container = client.GetContainer("ToDoList", "Items");
var item = await container.ReadItemAsync<Family>("Andersen.1", new PartitionKey("Andersen"));
await context.Response.WriteAsJsonAsync(item.Resource);
});
For those unfamiliar with ASP.NET Core, the assignment for var client
retrieves an instance of CosmosClient
(the CosmosDB C# client) from the built-in Dependency Injection container. That client can be configured with just:
var builder = WebApplication.CreateBuilder(args);
var identity = new DefaultAzureCredential();
var cosmosClient = new CosmosClient(builder.Configuration.GetValue<string>("COSMOSDB_URI"), identity);
builder.Services.AddSingleton(cosmosClient);
There are 2 interesting parts to the snippet above:
- We tell the
CosmosClient
to use an instance ofDefaultAzureCredential
, which attempts to retrieve the credentials from a number of places, including for example the Azure CLI configuration and environment variables, and - We retrieve the endpoint to be used from the configuration, which for this post will be an environment variable, called
COSMOSDB_URI
The code above is obviously a sample, and CosmosDB has an SDK for a number of other languages as well, the idea here is to have something to test with. You also need to have the API containerized, and the image needs to be stored in an Azure Container Registry (or the Bicep template needs to be edited to get the image from another registry). For this post, I'll assume we have an image called test
with a latest
version.
Resources creation
With the environment set up, we can start creating our Bicep modules to deploy the resources we need:
- CosmosDB
- User Assigned Identity and related assignments
- Container Apps Environment and app
I will define all resources as if they were in the same Bicep file. For larger Bicep projects, you'd be better off separating into files and using modules instead.
To keep things flexible, let's use an existing Container Registry and allow it to be in another subscription/resource group:
@description('Container Registry subscription id')
param containerRegistrySubscriptionId string
@description('Container Registry resource group')
param containerRegistryResourceGroup string
@description('Container Registry resource name')
param containerRegistryName string
resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-06-01-preview' existing = {
name: containerRegistryName
scope: resourceGroup(containerRegistrySubscriptionId, containerRegistryResourceGroup)
}
This allow us to deploy everything at once, as we need the image to be in the registry for the Container App to be deployed.
CosmosDb
The Bicep template for CosmosDB will depend significantly on what features of CosmosDB you want to use, backup options, etc. A simple, empty account would be:
@description('Location for all resources.')
param location string = resourceGroup().location
@description('Resource name for CosmosDB')
param accountName string
resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = {
name: accountName
kind: 'GlobalDocumentDB'
location: location
properties: {
consistencyPolicy: {
defaultConsistencyLevel: 'Session'
}
locations: [
{
locationName: location
failoverPriority: 0
isZoneRedundant: false
}
]
databaseAccountOfferType: 'Standard'
}
}
You'd likely want to also deploy a SQL database and a set of containers.
User Assigned Identity
For the identity, we want to allow it to pull images from the Container Registry, and to have the built-in Data Contributor role. To be able to deploy everything at once, we need to use a User Assigned Identity, rather than the System identity.
Let's start by creating the identity:
resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: '${containerAppName}-id'
location: location
}
To support retrieving images from the Container Registry without using the Admin User, we need to assign the ACR Pull role to the identity:
var acrPullRoleId = resourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')
resource userAssignedIdentityPullAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(containerRegistry.id, userAssignedIdentity.id, acrPullRoleId)
properties: {
roleDefinitionId: acrPullRoleId
principalId: userAssignedIdentity.properties.principalId
principalType: 'ServicePrincipal'
}
}
Lastly, we need to assign the Data Contributor role to the identity:
var cosmosDbDataContributorRoleId = '00000000-0000-0000-0000-000000000002'
resource cosmosAccountRoleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2023-04-15' existing = {
parent: cosmosAccount
name: cosmosDbDataContributorRoleId
}
var cappRoleAssignmentId = guid(cosmosAccount.id, userAssignedIdentity.id, cosmosDbDataContributorRoleId)
resource userAssignedIdentityCosmosEditorAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15' = {
parent: cosmosAccount
name: cappRoleAssignmentId
properties: {
roleDefinitionId: cosmosAccountRoleDefinition.id
principalId: userAssignedIdentity.properties.principalId
scope: cosmosAccount.id
}
}
Container App
With the CosmosDB and identity created, we can now focus on the last two resources, the Container App and its Environment.
The simplest Environment, ignoring a custom VNET and the Log Analyitcs Workspace configuration, can be deployed with just:
@description('Resource name for the Container Apps Environment')
param containerAppEnvName string
resource containerAppEnv 'Microsoft.App/managedEnvironments@2023-05-01' = {
name: containerAppEnvName
location: location
properties: { }
}
And finally, the app itself:
@description('Resource name for the Container App')
param containerAppName string
resource containerApp 'Microsoft.App/containerApps@2023-05-01' = {
name: containerAppName
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${userAssignedIdentity.id}': {}
}
}
properties: {
managedEnvironmentId: containerAppEnv.id
configuration: {
ingress: {
external: true
targetPort: 80
allowInsecure: false
traffic: [
{
latestRevision: true
weight: 100
}
]
}
registries: [
{
identity: userAssignedIdentity.id
server: containerRegistry.properties.loginServer
}
]
}
template: {
containers: [
{
name: containerAppName
image: '${containerRegistry.properties.loginServer}/test:latest'
resources: {
cpu: json('.25')
memory: '.5Gi'
}
env: [
{
name: 'AZURE_CLIENT_ID'
value: userAssignedIdentity.properties.principalId
}
{
name: 'COSMOSDB_URI'
value: cosmosAccount.properties.documentEndpoint
}
]
}
]
}
}
}
Important to note that:
- We assign the identity we created previously
- We use the identity to pull the images from the container registry
- The
AZURE_CLIENT_ID
environment variable is assigned the value of the Service Principal that sits behind the identity, so that the Azure SDK knows to use this principal - The
COSMOSDB_URI
environment variable contains the endpoint for CosmosDB that the sample application expects
Granting access to developers
If you deploy your development environments with Bicep as well, you may want to have the same restrictions applied to developers. The benefits are the same, access is granted through identity rather than master key, so only data access is allowed by default. You can also grant access to the management plane, but by using Azure RBAC roles rather than CosmosDB RBAC roles. To achieve the former, we can reuse code similar to what we did:
var principalIdsSplit = split(principalIds, ',')
resource cosmosAccountRoleUserAssignments 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15' = [for principalId in principalIdsSplit: {
parent: cosmosAccount
name: guid(cosmosAccount.id, principalId, cosmosAccountRoleDefinition.id)
properties: {
roleDefinitionId: cosmosAccountRoleDefinition.id
principalId: principalId
scope: cosmosAccount.id
}
}]
To simplify usage from DevOps tools that don't support passing multiple values, the simplest is to use comma-separated values and use the built-in split
function. If you don't need to worry about this, you could just use a variable with the values that you need.
Principal IDs, in this case, refer to the Object ID property of Microsoft Entra (formerly Azure Active Directory) users and/or groups.
Conclusion
This might be the first of many posts with a focus on securing Azure resources with Bicep and other techniques. I hope you learned something useful! As usual, let me know your thoughts in the comments section, or privately via email.