Skip to main content

Provision the Upload Archive with Terraform (Microsoft Azure)

This page is the infrastructure-as-code alternative to Part 2 of the

Configure File Upload Capture (Microsoft Azure)

guide. It produces the same Azure resources as the console walkthrough there — a resource group, a Key Vault customer-managed key, a hardened Blob Storage account, a private container, and the container-scoped RBAC grant the Anzenna app uses — in a single terraform apply.

Before starting:

  • Complete Part 1 in the

    Microsoft Azure guide

    (generate an age recipient keypair).

  • Complete 2a in the Microsoft Azure guide (consent the Anzenna app into your tenant with az ad sp create). The Terraform looks up that service principal — it must already exist before you apply.

When the apply finishes, return to the Microsoft Azure guide and continue at Part 3 (Register everything in Anzenna).

terraform {
required_version = ">= 1.5"

required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}

variable "tenant_id" {
type = string
description = "Your Entra tenant ID — the tenant that will hold the archive storage."
}

variable "subscription_id" {
type = string
description = "Subscription (within tenant_id) that will hold the archive storage."
}

variable "location" {
type = string
default = "westus2"
description = "Azure region for the archive. Must satisfy your data-residency requirements."
}

variable "customer_label" {
type = string
description = "Short identifier. Becomes part of the resource group, Key Vault, and storage account names. Lowercase alphanumeric or hyphens, 2-32 chars."

validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$", var.customer_label))
error_message = "customer_label must be lowercase alphanumeric or hyphens, 2-32 chars, not starting/ending with a hyphen."
}
}

variable "container_name" {
type = string
default = "uploads"
}

variable "federation_app_client_id" {
type = string
default = "245121cf-892e-4210-a296-88850ee586d3"
description = "Application (client) ID of the Anzenna File Archival app you consent into your tenant. The default is Anzenna's production app."
}

variable "object_expiration_days" {
type = number
default = 90
description = "Lifecycle expiry for archived blobs."
}

provider "azurerm" {
features {}
subscription_id = var.subscription_id
tenant_id = var.tenant_id
storage_use_azuread = true

# The storage account disables shared-key access, so data-plane calls (creating
# the container) must go through Entra RBAC. storage_use_azuread routes them there.
resource_provider_registrations = "none"
}

provider "azuread" {
tenant_id = var.tenant_id
}

data "azurerm_client_config" "current" {}

# The Anzenna app's service principal must already be consented into your tenant
# (az ad sp create --id <app-id>) before apply — this looks it up by its appId.
data "azuread_service_principal" "federation_app" {
client_id = var.federation_app_client_id
}

resource "random_id" "suffix" {
byte_length = 3
}

locals {
# Storage account names: 3-24 chars, lowercase alphanumeric, no hyphens, globally unique.
label_clean = substr(replace(var.customer_label, "-", ""), 0, 12)
storage_account_name = "anzup${local.label_clean}${random_id.suffix.hex}"
}

resource "azurerm_resource_group" "upload" {
name = "anzenna-upload-${var.customer_label}"
location = var.location
}

# Customer-managed key. Key Vault needs soft-delete + purge protection to back a
# storage account CMK. RBAC-authorized so access is granted with role assignments.
resource "azurerm_key_vault" "upload" {
name = "anzup-kv-${random_id.suffix.hex}"
location = azurerm_resource_group.upload.location
resource_group_name = azurerm_resource_group.upload.name
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
purge_protection_enabled = true
soft_delete_retention_days = 7
rbac_authorization_enabled = true
}

# The Terraform caller needs crypto-officer rights to mint the key in an RBAC vault.
resource "azurerm_role_assignment" "tf_caller_kv_officer" {
scope = azurerm_key_vault.upload.id
role_definition_name = "Key Vault Crypto Officer"
principal_id = data.azurerm_client_config.current.object_id
}

resource "azurerm_key_vault_key" "upload" {
name = "anzenna-upload-${var.customer_label}"
key_vault_id = azurerm_key_vault.upload.id
key_type = "RSA"
key_size = 2048
key_opts = ["wrapKey", "unwrapKey"]

depends_on = [azurerm_role_assignment.tf_caller_kv_officer]
}

# Storage account: secure transfer + TLS 1.2, no anonymous access, no shared-key
# access (Entra-only), infrastructure (double) encryption, versioning + soft delete.
resource "azurerm_storage_account" "upload" {
name = local.storage_account_name
resource_group_name = azurerm_resource_group.upload.name
location = azurerm_resource_group.upload.location
account_tier = "Standard"
account_replication_type = "LRS"
min_tls_version = "TLS1_2"
https_traffic_only_enabled = true
allow_nested_items_to_be_public = false
shared_access_key_enabled = false
infrastructure_encryption_enabled = true

identity {
type = "SystemAssigned"
}

blob_properties {
versioning_enabled = true

delete_retention_policy {
days = 7
}
}

# The standalone CMK resource below owns the key. azurerm reads the live CMK back
# into this account's state, so without this every plan would try to null it out.
lifecycle {
ignore_changes = [customer_managed_key]
}
}

# Let the account's identity wrap/unwrap with the CMK, then point the account at it.
resource "azurerm_role_assignment" "sa_kv_encryption_user" {
scope = azurerm_key_vault.upload.id
role_definition_name = "Key Vault Crypto Service Encryption User"
principal_id = azurerm_storage_account.upload.identity[0].principal_id
}

resource "azurerm_storage_account_customer_managed_key" "upload" {
storage_account_id = azurerm_storage_account.upload.id
key_vault_id = azurerm_key_vault.upload.id
key_name = azurerm_key_vault_key.upload.name

depends_on = [azurerm_role_assignment.sa_kv_encryption_user]
}

# The caller needs blob-owner to create a container when shared-key access is off.
resource "azurerm_role_assignment" "tf_caller_blob_owner" {
scope = azurerm_storage_account.upload.id
role_definition_name = "Storage Blob Data Owner"
principal_id = data.azurerm_client_config.current.object_id
}

resource "azurerm_storage_container" "upload" {
name = var.container_name
storage_account_name = azurerm_storage_account.upload.name
container_access_type = "private"

depends_on = [azurerm_role_assignment.tf_caller_blob_owner]
}

# The grant: the Anzenna app can write blobs in this container only — scoped to
# the container, not the account. Least privilege.
resource "azurerm_role_assignment" "app_blob_writer" {
scope = "${azurerm_storage_account.upload.id}/blobServices/default/containers/${azurerm_storage_container.upload.name}"
role_definition_name = "Storage Blob Data Contributor"
principal_id = data.azuread_service_principal.federation_app.object_id
}

# Lifecycle expiry. The account-level CMK encrypts every write transparently, so
# there is no per-write encryption-header check like the AWS bucket policy needs.
resource "azurerm_storage_management_policy" "upload" {
storage_account_id = azurerm_storage_account.upload.id

rule {
name = "expire-objects"
enabled = true

filters {
blob_types = ["blockBlob"]
prefix_match = ["${var.container_name}/"]
}

actions {
base_blob {
delete_after_days_since_creation_greater_than = var.object_expiration_days
}

version {
delete_after_days_since_creation = 7
}
}
}
}

output "storage_account_name" {
value = azurerm_storage_account.upload.name
}

output "container_name" {
value = azurerm_storage_container.upload.name
}

output "blob_endpoint" {
value = azurerm_storage_account.upload.primary_blob_endpoint
}

output "tenant_id" {
value = var.tenant_id
}
  1. Save the file (e.g. main.tf) and create a terraform.tfvars with your tenant, subscription, and label:

    tenant_id = "<your-tenant-id>" subscription_id = "<your-subscription-id>" customer_label = "yourorg"

  2. Authenticate and select the subscription:

    az login --tenant <your-tenant-id> az account set --subscription <your-subscription-id>

  3. Run terraform init && terraform apply.

  4. Read the outputs you'll paste into Anzenna in Part 3:

    terraform output

    The destination form maps as: Storage account = storage_account_name, Container = container_name, Tenant ID = your tenant_id. Leave Object key prefix blank.

You're done with Azure. Return to the

Microsoft Azure setup guide

and continue at Part 3 (Register everything in Anzenna).


Need help? Contact Anzenna Support at support@anzenna.ai for assistance.