Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Storage Account Customer Managed Key Terraform Module

Bind a customer-managed key (CMK) to an existing Azure storage account so encryption at rest uses a key you control in Key Vault or a Managed HSM instead of the Microsoft-managed key. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources

🧩 Overview

  • πŸ” Switches a storage account's server-side encryption from the Microsoft-managed key to a customer-managed key you hold in Azure Key Vault or a Managed HSM.
  • πŸ” Steers toward a versionless key so the account tracks the key's current version and adopts rotations without a Terraform change.
  • 🧷 Accepts exactly one key source β€” key_vault_key_id (preferred), key_vault_id, key_vault_uri, or managed_hsm_key_id β€” and rejects an ambiguous or empty call at plan time.
  • πŸͺͺ Reaches the key with the storage account's system-assigned identity by default, or a user-assigned identity, including the cross-tenant federated-identity path.
  • 🧩 Is a standalone resource on purpose, so the storage account and the key vault can be created without a dependency cycle.

πŸ’‘ Why it matters: Regulated data platforms are routinely required to own the key that encrypts data at rest β€” to rotate it, audit its use, and revoke it independently of the platform. This module is that control for Azure Storage, applied as a reviewable, least-surprise piece of configuration.

❀️ Support this project

If this module saves you time, please consider supporting it:

πŸ—ΊοΈ Where this fits in the family

flowchart LR
  rg["terraform-azurerm-resource-group"]
  uai["terraform-azurerm-user-assigned-identity"]
  kv["terraform-azurerm-key-vault + key"]
  sa["terraform-azurerm-storage-account"]
  cmk["terraform-azurerm-storage-account-customer-managed-key"]

  rg -->|"contains"| kv
  rg -->|"contains"| sa
  rg -->|"contains"| uai
  kv -->|"key_vault_key_id"| cmk
  sa -->|"storage_account_id"| cmk
  uai -->|"user_assigned_identity_id"| cmk
  cmk -->|"re-keys encryption of"| sa

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef target fill:#004578,stroke:#002d4d,color:#fff;
  classDef sib fill:#eef3f8,stroke:#b9c8d8,color:#1b2733;
  class cmk me;
  class sa target;
  class rg,uai,kv sib;
Loading

This module consumes its siblings by id: the storage account, the Key Vault key, and (optionally) a user-assigned identity. It owns none of them β€” it only wires the key to the account.

🧬 What this module builds

flowchart LR
  sa_in["storage_account_id"]
  key_in["key_vault_key_id / key_vault_id / key_vault_uri / managed_hsm_key_id"]
  idn["user_assigned_identity_id (+ federated_identity_client_id)"]
  this["azurerm_storage_account_customer_managed_key.this"]
  out_id["id"]
  out_kv["key_vault_key_id"]

  sa_in -->|"protects"| this
  key_in -->|"exactly one"| this
  idn -->|"reaches key via"| this
  this -->|"emits"| out_id
  this -->|"emits"| out_kv

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#002d4d,color:#fff;
  classDef io fill:#eef3f8,stroke:#b9c8d8,color:#1b2733;
  class this keystone;
  class sa_in,key_in,idn,out_id,out_kv io;
Loading

Resource inventory

Resource Count Role
azurerm_storage_account_customer_managed_key.this 1 The keystone: binds the chosen key to the storage account
timeouts (dynamic block) 0–1 Optional per-operation timeouts

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
Provider hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures provider "azurerm" { features {} }, authentication, and subscription

Schema notes that bite (verified against the live provider schema):

  • storage_account_id is force-new β€” changing it destroys the association (reverting the old account to its Microsoft-managed key) and rebuilds it against the new account.
  • Exactly one of key_vault_key_id, key_vault_id, key_vault_uri, managed_hsm_key_id may be set; none or more than one is rejected.
  • federated_identity_client_id requires user_assigned_identity_id β€” it is the cross-tenant path and cannot stand alone.
  • key_vault_id, key_vault_uri, managed_hsm_key_id, key_name, and key_version are the older split fields, superseded by key_vault_key_id in the ~> 4.0 line and removed in the provider's next major line. Prefer key_vault_key_id.
  • This resource type does not support tags; the module exposes no tags variable.

πŸ”‘ Required Azure RBAC Roles / Permissions

  • The caller's identity needs Microsoft.Storage/storageAccounts/write on the target account β€” most simply Storage Account Contributor scoped to the storage account or its resource group β€” to set the account's encryption configuration.
  • Granting the storage account's identity crypto access to the key is a prerequisite performed out of band (see below), not an action this module takes.

Azure Prerequisites

  • The Microsoft.Storage and Microsoft.KeyVault resource providers registered on the subscription.
  • The target storage account already exists and has a managed identity β€” its system-assigned identity, or a user-assigned identity passed as user_assigned_identity_id.
  • The Key Vault (or Managed HSM) and the key already exist. The vault must have soft delete and purge protection enabled β€” Azure refuses to use a vault without purge protection as a storage CMK source.
  • The identity the account uses is granted crypto access to the key out of band: on an RBAC-authorization vault, the Key Vault Crypto Service Encryption User role on the key or vault; on an access-policy vault, the Get, Wrap Key, and Unwrap Key key permissions.
  • For the cross-tenant path, a multi-tenant application whose client ID is passed as federated_identity_client_id, with the user-assigned identity federated to it.
  • The caller configures the provider "azurerm" { features {} } block, authentication, and subscription.

πŸ“ Module Structure

terraform-azurerm-storage-account-customer-managed-key/
β”œβ”€β”€ providers.tf   # required_version + azurerm ~> 4.0 pin; no provider block
β”œβ”€β”€ variables.tf   # storage_account_id, the four key sources + companions, identity, timeouts
β”œβ”€β”€ main.tf        # the single azurerm_storage_account_customer_managed_key.this
β”œβ”€β”€ outputs.tf     # id (first), storage_account_id, key_vault_key_id
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # the cross-module contract
β”œβ”€β”€ LICENSE        # MIT, Copyright (c) 2026 Casey Wood
└── .gitignore     # canonical library ignore set

βš™οΈ Quick Start

# The caller configures the provider, authentication, and the mandatory features {} block.
provider "azurerm" {
  features {}
}

module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"

  # Versionless key ID -> the account tracks the current version (auto-rotation).
  key_vault_key_id = "https://kv-example.vault.azure.net/keys/cmk-storage"
}

πŸ”’ Pin ?ref=v1.0.0 (or a later released tag) β€” never a branch. Plan-only; a human applies from CI.

πŸ”Œ Cross-Module Contract

Consumes

Input Type Typical source
storage_account_id string terraform-azurerm-storage-account (id)
key_vault_key_id / key_vault_id / key_vault_uri / managed_hsm_key_id string terraform-azurerm-key-vault (a key ID / vault ID / vault URI)
user_assigned_identity_id string terraform-azurerm-user-assigned-identity (id)

Emits

Output Description
id Resource ID of the CMK association (equals the storage account ID). Emitted first.
storage_account_id Resource ID of the protected storage account.
key_vault_key_id Resolved Key Vault key ID in effect; null for a Managed HSM key.

πŸ“š Example Library

The examples read the current tenant from the provider rather than hard-coding it.

data "azurerm_client_config" "current" {}
1 Β· Minimal call β€” versionless key (auto-rotation)

The recommended baseline: one storage account, one versionless Key Vault key ID.

module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"
  key_vault_key_id   = "https://kv-example.vault.azure.net/keys/cmk-storage"
}

πŸ’‘ A versionless key ID (no trailing version segment) lets the account follow the key's current version, so a rotation in Key Vault is adopted without a plan.

2 Β· Pinned key version

Freeze the account on a specific version by appending it to the key ID.

module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"
  key_vault_key_id   = "https://kv-example.vault.azure.net/keys/cmk-storage/abcdef0123456789abcdef0123456789"
}

⚠️ A pinned version does not auto-rotate. You must update this value on each rotation, or the account keeps using the old version.

3 Β· Split form β€” key_vault_id + key_name (auto-rotation)

The older split form: reference the vault and key by separate fields, leaving key_version unset for auto-rotation.

module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"
  key_vault_id       = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-example"
  key_name           = "cmk-storage"
}

ℹ️ key_vault_key_id is the forward-compatible choice; the split fields are supported for existing configurations and are removed in the provider's next major line.

4 Β· Split form β€” pinned version
module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"
  key_vault_id       = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-example"
  key_name           = "cmk-storage"
  key_version        = "abcdef0123456789abcdef0123456789"
}

πŸ”’ key_name / key_version only apply to the split forms (key_vault_id / key_vault_uri). The module validates this so a stray pairing with key_vault_key_id is caught early.

5 Β· Managed HSM key

Use a key held in a Managed HSM pool (single-tenant, FIPS 140-2 Level 3) where the compliance regime calls for a dedicated HSM.

module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"
  managed_hsm_key_id = "https://hsm-example.managedhsm.azure.net/keys/cmk-storage"
}

πŸ’‘ A versionless Managed HSM key ID also auto-rotates. The key_vault_key_id output is null when the source is a Managed HSM key.

6 Β· User-assigned identity access

Reach the key with a specific user-assigned identity instead of the account's system-assigned identity.

module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id        = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"
  key_vault_key_id          = "https://kv-example.vault.azure.net/keys/cmk-storage"
  user_assigned_identity_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-id/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uai-storage-cmk"
}

πŸ”’ The chosen identity must already hold Key Vault Crypto Service Encryption User on the key (RBAC vault) or Get / Wrap Key / Unwrap Key (access-policy vault). Grant it out of band.

7 Β· Cross-tenant federated identity

The storage account is in one tenant; the key vault is in another. Pair a user-assigned identity with the multi-tenant application's client ID and the vault's URI.

module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id           = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"
  key_vault_uri                = "https://kv-customer.vault.azure.net/"
  key_name                     = "cmk-storage"
  user_assigned_identity_id    = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-id/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uai-xtenant"
  federated_identity_client_id = "11111111-2222-3333-4444-555555555555"
}

⚠️ federated_identity_client_id cannot stand alone β€” it requires user_assigned_identity_id. The module validates this.

8 Β· Custom operation timeouts
module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"
  key_vault_key_id   = "https://kv-example.vault.azure.net/keys/cmk-storage"

  timeouts = {
    create = "30m"
    update = "30m"
    delete = "30m"
  }
}

ℹ️ Timeouts are Go duration strings. Omit any field to accept the provider default.

9 Β· One key across many accounts (for_each at scale)

Apply the same organizational key to a set of storage accounts.

locals {
  cmk_accounts = {
    logs     = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stlogs01"
    exports  = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexports01"
    archive  = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/starchive01"
  }
}

module "storage_cmk" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"
  for_each = local.cmk_accounts

  storage_account_id = each.value
  key_vault_key_id   = "https://kv-example.vault.azure.net/keys/cmk-storage"
}

πŸ’‘ A stable map key (logs, exports, archive) keeps the plan steady when accounts are added or removed.

10 Β· Per-account keys (for_each over a map of objects)

Give each account its own key and identity.

locals {
  cmk = {
    logs = {
      storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stlogs01"
      key_vault_key_id   = "https://kv-example.vault.azure.net/keys/cmk-logs"
    }
    exports = {
      storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexports01"
      key_vault_key_id   = "https://kv-example.vault.azure.net/keys/cmk-exports"
    }
  }
}

module "storage_cmk" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"
  for_each = local.cmk

  storage_account_id = each.value.storage_account_id
  key_vault_key_id   = each.value.key_vault_key_id
}
11 Β· Why this is a separate resource (breaking the cycle)

The storage account needs an identity; the vault grant needs that identity; the CMK needs the grant. Model the account, its identity, the grant, and this binding as separate steps β€” never the key inline on the account.

# 1) Storage account with a system-assigned identity (its own module).
# 2) Grant that identity crypto access on the key (RBAC role assignment, its own module).
# 3) Bind the key here, after the grant exists.
module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id = module.storage_account.id
  key_vault_key_id   = module.key_vault.key_ids["storage-cmk"]

  depends_on = [module.crypto_role] # ensure the grant lands before the binding
}

ℹ️ Because encryption enablement depends on the key access already existing, ordering the grant before this binding (implicitly via references, or explicitly via depends_on) avoids a first-apply failure.

12 Β· Security / least-privilege variant

System-assigned identity, versionless key, RBAC-authorization vault β€” the tightest common posture.

module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id = module.storage_account.id

  # Versionless -> auto-rotation; no user-assigned identity -> the account's own
  # system-assigned identity, scoped by a single Crypto Service Encryption User grant.
  key_vault_key_id = module.key_vault.key_ids["storage-cmk"]
}

πŸ”’ Keep the grant to the account's identity as narrow as possible β€” the Key Vault Crypto Service Encryption User role on the single key, not the whole vault, where your governance allows it.

13 Β· πŸ—οΈ End-to-end composition

Resource group, Key Vault with a key, user-assigned identity, storage account, and this CMK binding, wired by outputs. Sibling module inputs/outputs are illustrative of this suite.

provider "azurerm" {
  features {}
}

module "resource_group" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-data"
  location = "eastus2"
}

module "user_assigned_identity" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"
  name                = "uai-storage-cmk"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location
}

module "key_vault" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"

  tenant_id = data.azurerm_client_config.current.tenant_id
  name                = "kv-data-eus2"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location

  # Hardened by default in this suite: RBAC authZ, purge protection on.
  keys = {
    storage-cmk = {
      key_type = "RSA"
      key_size = 3072
      key_opts = ["wrapKey", "unwrapKey"]
    }
  }
}

# Grant the identity crypto access to the key (its own aggregation module).
module "crypto_role" {
  source               = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
  scope                = module.key_vault.id
  role_assignments = {
    storage-cmk = {
      role_definition_name = "Key Vault Crypto Service Encryption User"
      principal_id         = module.user_assigned_identity.principal_id
    }
  }
}

module "storage_account" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
  name                = "stdataeus201"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location

  identity = {
    type         = "UserAssigned"
    identity_ids = [module.user_assigned_identity.id]
  }
}

module "storage_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account-customer-managed-key.git?ref=v1.0.0"

  storage_account_id        = module.storage_account.id
  key_vault_key_id          = module.key_vault.key_ids["storage-cmk"]
  user_assigned_identity_id = module.user_assigned_identity.id

  depends_on = [module.crypto_role] # the grant must exist before the account can use the key
}

πŸ”’ The key vault carries purge protection and RBAC authorization from the sibling module's secure defaults β€” both are prerequisites for a storage CMK. The depends_on orders the crypto grant ahead of the binding so the first apply succeeds.

πŸ“₯ Inputs

Identity (required)

Name Type Description
storage_account_id string Resource ID of the storage account to protect. Force-new.

Key source (set exactly one of the four; key_name / key_version accompany the split forms)

Name Type Default Description
key_vault_key_id string null Preferred single-field key ID. Versionless = auto-rotation.
key_vault_id string null Split form β€” vault ID; pair with key_name.
key_vault_uri string null Split form for cross-tenant β€” vault URI; pair with key_name.
managed_hsm_key_id string null A key in a Managed HSM pool.
key_name string null Key name for the split forms.
key_version string null Pin a version; leave null for auto-rotation.

Access identity (optional)

Name Type Default Description
user_assigned_identity_id string null Identity used to reach the key; defaults to the account's system-assigned identity.
federated_identity_client_id string null Cross-tenant app client ID; requires user_assigned_identity_id.

Universal tail (this resource type does not support tags)

Name Type Default Description
timeouts object null Optional create / read / update / delete duration strings.
Full variable schemas
variable "storage_account_id" {
  type = string # required; force-new
}

# Key source β€” exactly one of the following four (enforced by validation):
variable "key_vault_key_id"   { type = string, default = null } # preferred
variable "key_vault_id"       { type = string, default = null }
variable "key_vault_uri"      { type = string, default = null }
variable "managed_hsm_key_id" { type = string, default = null }

# Companions to the split (key_vault_id / key_vault_uri) forms:
variable "key_name"    { type = string, default = null }
variable "key_version" { type = string, default = null } # null = auto-rotation

# Access identity:
variable "user_assigned_identity_id"    { type = string, default = null }
variable "federated_identity_client_id" { type = string, default = null } # requires user_assigned_identity_id

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
}

Validations enforced by the module:

  • Exactly one of key_vault_key_id / key_vault_id / key_vault_uri / managed_hsm_key_id.
  • key_name / key_version only with key_vault_id or key_vault_uri.
  • federated_identity_client_id only with user_assigned_identity_id.

🧾 Outputs

Output Description Kind
id Resource ID of this binding - which IS THE STORAGE ACCOUNT'S OWN ID, not a distinct child resource's Passthrough
storage_account_id Resource ID of the storage account whose encryption this binding controls Passthrough
key_vault_key_id The Key Vault key in force, as Azure resolved it Passthrough
user_assigned_identity_id The user-assigned identity the storage account uses to reach the key, or null when the account's system-assigned identity is used instead Passthrough
federated_identity_client_id Client ID of the multi-tenant application used for CROSS-TENANT customer-managed keys, or null in the ordinary single-tenant case Passthrough
key_source_argument_used Which of the four mutually-exclusive key-source forms this call used: key_vault_key_id, key_vault_id, key_vault_uri or managed_hsm_key_id Passthrough
deprecated_key_source_in_use True when the call uses one of the key-source forms the provider has deprecated - key_vault_uri, key_vault_id or managed_hsm_key_id, along with their key_name and key_version companions Passthrough
key_rotation_is_automatic True when the key is referenced WITHOUT a version, so the account follows the key's current version and a rotation in Key Vault is adopted with no Terraform change Passthrough
uses_cross_tenant_key True when a federated identity client ID is supplied, meaning the key lives in a DIFFERENT Entra tenant from the storage account Derived
uses_managed_hsm True when the key is held in a Managed HSM rather than a standard Key Vault Derived
identity_used_is_system_assigned True when no user-assigned identity was supplied, so the storage account's SYSTEM-assigned identity is used to reach the key Derived
destroy_reverts_to_microsoft_managed_keys Constant true, and the fact most worth knowing here Constant
is_a_singleton_per_storage_account Constant true Constant
vault_needs_purge_protection Constant true, and a prerequisite rather than a recommendation Constant
key_access_can_break_after_apply Constant true Constant
account_must_support_customer_managed_keys Constant true Constant

No secret is emitted. The resolved key ID is a resource identifier, not key material.

🧠 Architecture Notes

  • A binding, not a container. The keystone this has no name of its own; its id is the storage account's ID. The module therefore emits id first and then useful, non-secret computed values β€” it has no name output because the resource has no name field.
  • Force-new on storage_account_id. Re-pointing this at a different account is a destroy-and-create: the old account reverts to the Microsoft-managed key. Treat the account/binding pair as one unit.
  • Own the CMK in exactly one place. Do not also configure a customer_managed_key block inline on the storage account resource for the same account β€” managing it both ways produces perpetual diffs. This module is the single owner.
  • Exactly one key source, enforced at parse time. The validation block surfaces a missing or ambiguous key source as a clear error before any API call, mirroring the provider's own constraint.
  • Deprecated split fields still work but signal the future. key_vault_id, key_vault_uri, managed_hsm_key_id, key_name, and key_version are retained in the ~> 4.0 line and removed in the next major line. The module keeps them for existing configurations and steers new callers to key_vault_key_id. Outputs read only the non-deprecated attribute, so a key_vault_key_id caller sees no deprecation notices.
  • Auto-rotation is a property of the reference, not a toggle. A versionless key ID (or an unset key_version) is what makes the account follow rotations; a version segment pins it.
  • features {} is the caller's. In isolation the module has no provider block; a root module must supply provider "azurerm" { features {} }.

🧱 Design Principles

Concern Secure default (minimal call) Opt-out (caller must type it)
Key rotation Versionless key β†’ auto-rotation (key_version unset) Pin a version (key_version or a versioned key ID)
Key reference field Prefer key_vault_key_id (non-deprecated, next-major-ready) Use a split / deprecated field
Key source count Exactly one β€” enforced by validation β€” (cannot be relaxed)
Access identity Account's system-assigned identity Supply user_assigned_identity_id
Cross-tenant Off (same tenant) Set user_assigned_identity_id + federated_identity_client_id
Secret handling No secret accepted or emitted β€”

πŸš€ Runbook

# From the module folder β€” offline, no cloud calls.
terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module ?ref=v1.0.0 (or a later released tag) β€” never a branch.
  • This library is plan-only during authoring; a human runs terraform plan / apply from CI against real credentials.

πŸ§ͺ Testing

The offline proof gate covers everything that does not require Azure:

  • terraform init -backend=false β€” resolves the pinned provider without a backend.
  • terraform validate β€” proves the configuration is type-correct against the pinned provider schema, including the module's validation blocks.
  • terraform fmt -check β€” enforces canonical formatting.

What only a human terraform plan (from CI, with credentials) exercises: whether the storage account has a usable identity, whether that identity holds crypto access on the key, and whether the vault has purge protection β€” the ARM API validates these at plan/apply time, not offline.

πŸ’¬ Example Output

$ terraform output
id                 = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"
storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stexample01"
key_vault_key_id   = "https://kv-example.vault.azure.net/keys/cmk-storage/9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d"

πŸ” Troubleshooting

Symptom Cause Fix
one of managed_hsm_key_id, key_vault_id, key_vault_uri, key_vault_key_id must be specified No key source set Set exactly one of the four key fields.
Module error: "Set exactly one key source" Two or more key fields set Leave exactly one non-null.
federated_identity_client_id: requires user_assigned_identity_id Cross-tenant client ID set alone Also set user_assigned_identity_id.
403 / access-denied to Key Vault on apply The account's identity lacks crypto access, or the vault lacks purge protection Grant Key Vault Crypto Service Encryption User (or Get/Wrap/Unwrap) and enable soft delete + purge protection.
Deprecation warning about key_vault_id / key_vault_uri / managed_hsm_key_id / key_name / key_version Using a split / deprecated field Migrate to key_vault_key_id.
Rotation not picked up by the account Pinned key_version (or a versioned key ID) Use a versionless key reference.
Perpetual diff / re-key on every plan The CMK is managed both here and inline on the storage account Own it in one place β€” remove the inline block.
Replacement planned unexpectedly storage_account_id changed Expected β€” it is force-new; treat account and binding as one unit.

πŸ”— Related Docs

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."