Upcoming book:

Modern .NET Development with Azure and DevOps

Passwordless CosmosDB from Container Apps with Bicep

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:

  1. We tell the CosmosClient to use an instance of DefaultAzureCredential, which attempts to retrieve the credentials from a number of places, including for example the Azure CLI configuration and environment variables, and
  2. 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:

  1. CosmosDB
  2. User Assigned Identity and related assignments
  3. 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.