Terraform and Azure Managed Identity

09 June

I love getting to a point with Infrastructure as Code (IaC) where not only are the resources reproducable, but also encoding good security and utilisation of cloud resources into the contents. Firstly, support in Azure Storage for Active Directory access control went GA and utilising this over an access key is one of those security considerations that seems could be automated. Secondly, managed identities are a fantastic way to get the power of Azure Active Directory without the process of keeping secrets and other management secure.

My tool of choice in Azure has been Azure Resource Manager (ARM) templates, but needing to do this across GCP as well these days, I’ve come back to Terraform as a great tool for IaC templates and a consistent tool across many resources, providers etc. Two resources to be aware of is the Terraform Azure Provider docs, but also resources are still created in ARM so the ARM Template Reference is also a required resource to determine exactly what might be acceptable for certain parameters.

What we’ll create

  • Compute: Azure App Service
    • Managed Identity
  • Storage
    • Container
    • Blob
  • Role Assignment: Storage blob data reader for our managed identity
  • Application to utilise managed identity to read blob object

Prerequisites

Terraform

All azure resources need a resource group so we’ll start by creating a main.tf with two variables and the resource group itself. Nothing too exciting here, but we’ll use these in later resources.

variable "app_name" {
  type        = string
  description = "The common name to use for resources"
  default     = "tf-az-roles"
}

variable "environment" {
  type        = string
  description = "The deployment environment description"
  default     = "dev"
}

resource "azurerm_resource_group" "test" {
  name     = "${var.app_name}-rg"
  location = "Australia East"

  tags = {
    environment = var.environment
  }
}

App Service

The app service and app hosting plan are created here. They’re using locations aligned with the containing resource group and a free tier. The block of interest for our purposes is the identity block which creates a managed identity for us. The terraform docs for the identity are quite good and outline that we can utilise this later using azurerm_app_service.test.identity.0.principal_id.

resource "azurerm_app_service_plan" "test" {
  name                = "${var.app_name}-appserviceplan"
  location            = azurerm_resource_group.test.location
  resource_group_name = azurerm_resource_group.test.name

  sku {
    tier = "Free"
    size = "F1"
  }
}

resource "azurerm_app_service" "test" {
  name                = "${var.app_name}-app-service"
  location            = azurerm_resource_group.test.location
  resource_group_name = azurerm_resource_group.test.name
  app_service_plan_id = azurerm_app_service_plan.test.id

  identity {
    type = "SystemAssigned"
  }
}

Storage

One big advantage of terraform is that we can create more than just the parent resource: here we will also create a container and blob in our storage account. A great way to have all PaaS resources correctly created and can simplify our codebase by assuming they exist versus creating them at runtime. For our purposes of using RBAC, there’s nothing special here from any other deployment of a storage account.

resource "azurerm_storage_account" "test" {
  name                     = "${replace(var.app_name, "-", "")}storageaccount"
  resource_group_name      = azurerm_resource_group.test.name
  location                 = azurerm_resource_group.test.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

resource "azurerm_storage_container" "test" {
  name                 = "${var.app_name}-container"
  storage_account_name = azurerm_storage_account.test.name
  resource_group_name  = azurerm_resource_group.test.name
}

resource "azurerm_storage_blob" "test" {
  name                   = "hello.txt"
  resource_group_name    = azurerm_resource_group.test.name
  storage_account_name   = azurerm_storage_account.test.name
  storage_container_name = azurerm_storage_container.test.name
  type                   = "block"
  content_type           = "application/text"
  source                 = "hello.txt"
}

Roles and Assignments

Finally our managed identity gets to do something: we’re going to assign it to a rule within our resource group scoped to blob data reader. This is a built in role and others can be found at https://docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#storage-blob-data-reader. It’s worth noting that either the role_definition_name or the role_definition_id are needed and are mutually exclusive. The name seems easier to read and communicate to others, but there maybe a case were the role GUID may be more to your benefit.

// https://docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#storage-blob-data-reader
resource "azurerm_role_assignment" "test" {
  role_definition_name = "Storage Blob Data Reader"
  scope                = azurerm_storage_account.test.id
  principal_id         = azurerm_app_service.test.identity[0].principal_id
}

With this addition, our managed identity should now have permissions scoped to read only within this storage account.

Reader web app

We’ll create a very bare bones ASP.NET Core Web API with a single endpoint that returns our blob’s content. This will be sufficient to demonstrate using our managed identity to get an access token and subsequently using that access token to read from storage.

The following commands can be run from terminal and create our web api and add two packages: one used to simplify getting an access token using our managed identity and the second Azure storage libraries.

$ dotnet new webapi -o app
$ cd app
$ dotnet add package Microsoft.Azure.Services.AppAuthentication
$ dotnet add package WindowsAzure.Storage

From our template, we’ll modify the ValuesController to the content below. Deleting all the endpoints apart from the GET /api/values which will return the blobs content.

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Services.AppAuthentication;
using Microsoft.WindowsAzure.Storage.Auth;
using Microsoft.WindowsAzure.Storage.Blob;

namespace app.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        private static Uri BlobUri = new Uri("https://tfazrolesstorageaccount.blob.core.windows.net/tf-az-roles-container/hello.txt");

        // GET api/values
        [HttpGet]
        public async Task<ActionResult<string>> Get()
        {
            // Get an access token for azure storage using the managed identity
            var accessToken = await GetStorageAccessTokenAsync();

            // Use our access token as the credential
            var credential = new TokenCredential(accessToken);
            var storageCredentials = new StorageCredentials(credential);

            // Create a reference to our desired blob using the access token storage credentials
            var blob = new CloudBlockBlob(BlobUri, storageCredentials);

            // Read the entire blob and return it as a string
            using (var reader = new StreamReader(await blob.OpenReadAsync()))
            {
                var contents = await reader.ReadToEndAsync();
                return contents;
            }
        }

        private Task<string> GetStorageAccessTokenAsync()
        {
            var azureServiceTokenProvider = new AzureServiceTokenProvider();
            return azureServiceTokenProvider.GetAccessTokenAsync(resource: "https://storage.azure.com/");
        }
    }
}

We’ll publish our webapp and use the az webapp from the Azure CLI to deploy our zipped published files.

$ dotnet publish -c Release -o ~/Desktop/publish
$ zip -j -r archive.zip ~/Desktop/publish/*
$ az webapp deployment source config-zip -g tf-az-roles-rg -n tf-az-roles-app-service --src archive.zip

To test this out, head to <your-web-name>.azurewebsites.net/api/values and you should see the text of our uploaded file.


You can grab the code I’ve used here from my BlogCodeSamples GitHub Repo