Using a managed identity is the best way to handle authentication in Azure Functions, and for those who want more control, a user-assigned managed identity is the right choice. By explicitly assigning the identity to specific resources, it becomes easier to enforce the principle of least privilege, ensuring that the function has only the access it needs and nothing more.

However, setting up a user-assigned managed identity with an Azure Functions Cosmos DB trigger is not always straightforward. This article walks through the necessary steps to get everything working, including configuring the function to use the identity and ensuring that the leases container is in place for the Cosmos DB trigger to function.

Defining the Cosmos DB Trigger

A typical Cosmos DB trigger in an Azure Function looks something like this:

[Function("ResultFunction")]
public void Run([CosmosDBTrigger(
    databaseName: "foo-svc-bar",
    containerName: "results",
    Connection = "CosmosDbConnection")] IReadOnlyList<AssessmentResult> input)
{
    if (input != null && input.Count > 0)
    {
        _logger.LogInformation("CosmosDbTrigger");
        _logger.LogInformation("Documents modified: " + input.Count);
        _logger.LogInformation("Tenant Id: " + input[0].TenantId);
        _logger.LogInformation("Task Id: " + input[0].TaskId);
    }
}

By default, Azure Functions use a Connection name to specify how to talk to Cosmos DB. Back in the olden days this would likely be a connection string with an access key embedded in it. However in order to make use of managed Identity we have to set this connection in a very precise way to indicate to Azure Functions we aren’t passing in an access key.

In order to achieve this we need to pass an environment variable with the connection name “CosmosDbConnection” but append two underscores and “__accountEndpoint”. The connection name can be anything and it’s a creative writing exercise in the C# code above but the following underscores must be exact. The value needs to be set to the Cosmos DB accounts endpoint — essentially a fully qualified URL for the Cosmos DB account. That flips Azure Functions into “managed Identity mode”.

Discovering the Required Environment Variables

While researching how to configure the user-assigned managed identity, I came across Microsoft’s documentation, which clarified the exact environment variables needed. It is apparently possible.

When hosted in the Azure Functions service, identity-based connections use a managed identity. The system-assigned identity is used by default, although a user-assigned identity can be specified with the credential and clientID properties. Note that configuring a user-assigned identity with a resource ID is not supported. When run in other contexts, such as local development, your developer identity is used instead, although this can be customized. See Local development with identity-based connections.

By default, Azure Functions use a system-assigned managed identity, but a user-assigned identity can be specified using the credential and clientId properties. However, specifying a resource ID instead of a client ID is not supported, which initially caused confusion.

The relevant environment variables are:

  • CosmosDbConnection__clientId: Specifies the user-assigned identity to be used when obtaining a token. This property accepts a client ID.
  • CosmosDbConnection__credential: Should be set to managedidentity to ensure that the function uses managed identity authentication.

Without properly setting these variables, I encountered an error when trying to start the function:

Starting the listener for prefix=’’, monitoredContainer=’results’, monitoredDatabase=’tsg-svc-assessment’, leaseContainer=’leases’, leaseDatabase=’tsg-svc-assessment’, functionId=’Host.Functions.ResultFunction’ failed. Exception: System.InvalidOperationException: This builder instance has already been used to build a processor. Create a new instance to build another. at Microsoft.Azure.Cosmos.ChangeFeedProcessorBuilder.Build() at Microsoft.Azure.WebJobs.Extensions.CosmosDB.CosmosDBTriggerListener1.StartProcessorAsync() in D:\a\_work\1\s\src\WebJobs.Extensions.CosmosDB\Trigger\CosmosDBTriggerListener.cs:line 151 at Microsoft.Azure.WebJobs.Extensions.CosmosDB.CosmosDBTriggerListener1.StartAsync(CancellationToken cancellationToken) in D:\a_work\1\s\src\WebJobs.Extensions.CosmosDB\Trigger\CosmosDBTriggerListener.cs:line 102.

The problem was that my function was not properly configured to use the user-assigned identity. The missing or misconfigured environment variables caused authentication failures, leading to this intermediate error state. Once I correctly specified the environment variables, the function started successfully.

Configuring Environment Variables in Terraform

When configuring the Azure Function to use a user-assigned managed identity, setting up environment variables correctly is crucial. Without this step, the function will not authenticate properly with Cosmos DB. Here’s the Terraform configuration that ensures the function uses the right identity:

app_settings = {
  "CosmosDbConnection__accountEndpoint" = data.azurerm_cosmosdb_account.main.endpoint
  "CosmosDbConnection__credential"      = "managedidentity"
  "CosmosDbConnection__clientId"        = azurerm_user_assigned_identity.function.client_id
}

This configuration tells the Cosmos DB trigger where to find the Cosmos DB account and specifies that it should use the user-assigned identity for authentication.

Creating the Leases Container

Another essential component is the leases container. The Cosmos DB trigger requires a leases container to keep track of processed changes. Without it, the function will fail to start. The following Terraform resource ensures that the container is in place:

resource "azurerm_cosmosdb_sql_container" "leases" {
  name                  = "leases"
  resource_group_name   = data.azurerm_cosmosdb_account.main.resource_group_name
  account_name          = data.azurerm_cosmosdb_account.main.name
  database_name         = azurerm_cosmosdb_sql_database.assessment.name
  partition_key_paths   = ["/id"]
  partition_key_version = 1
}

Assigning Permissions to the Managed Identity

Additionally, the user-assigned identity must be granted the correct permissions to interact with Cosmos DB. Assigning the built-in Cosmos DB Built-in Data Contributor role allows the function to write to the necessary containers:

# Cosmos DB Built-in Data Contributor
data "azurerm_cosmosdb_sql_role_definition" "writer" {
  resource_group_name = var.cosmosdb_account.resource_group
  account_name        = var.cosmosdb_account.name
  role_definition_id  = "00000000-0000-0000-0000-000000000002"
}

resource "azurerm_cosmosdb_sql_role_assignment" "function_writer" {

  resource_group_name = data.azurerm_cosmosdb_sql_role_definition.writer.resource_group_name
  account_name        = data.azurerm_cosmosdb_sql_role_definition.writer.account_name
  role_definition_id  = data.azurerm_cosmosdb_sql_role_definition.writer.id
  scope               = data.azurerm_cosmosdb_account.main.id
  principal_id        = azurerm_user_assigned_identity.function.principal_id

}

Conclusion

With this setup, the function will authenticate using the user-assigned managed identity, the Cosmos DB trigger will function correctly, and permissions will be properly configured. Using managed identities removes the need for storing secrets while enforcing security best practices. While it takes some effort to get everything configured correctly, the benefits of having explicit control over authentication and permissions make it worth it.