Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Managed HSM Key Terraform Module

Creates an HSM-protected key inside an Azure Key Vault Managed HSM. Creating it requires data-plane RBAC that Azure RBAC cannot grant. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • ☁️ Creates one azurerm_key_vault_managed_hardware_security_module_key (the keystone this) — a key generated inside FIPS 140-2 Level 3 hardware.
  • 🔴 Records the failure that catches everyone once: a Managed HSM has two independent authorization systems, and Owner on the resource grants no ability to create a key.
  • 🔒 Emits no key material at all — not even the public half, unlike azurerm_key_vault_key. There is nothing here to redact and nothing to leak.
  • 🔴 Flags both dates as a one-way door: once set, an expiry can never be unset, and deleting either date argument forces replacement -- moving one, in either direction, does not.
  • 🧮 Emits both the unversioned id and the versioned_id, and explains which one a consumer should reference.

💡 Why it matters: every HSM key type here is hardware-protected, so the interesting risks are not about algorithm choice — they are about who is permitted to use the key, and which URI form a consumer pinned.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

flowchart TB
  rg["terraform-azurerm-resource-group"]
  certs["terraform-azurerm-key-vault  emits certificate_ids for the security domain"]
  mhsm["terraform-azurerm-key-vault-managed-hardware-security-module  ARM Microsoft.KeyVault/managedHSMs"]
  ra["terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment  LOCAL RBAC"]
  key["terraform-azurerm-key-vault-managed-hardware-security-module-key"]
  rot["terraform-azurerm-key-vault-managed-hardware-security-module-key-rotation-policy  at most ONE per key"]
  vid["versioned_id  changes on every rotation"]
  uid["id  the unversioned data-plane URI"]
  consumer["terraform-azurerm-disk-encryption-set  and other CMK consumers"]

  rg -->|"name and location"| mhsm
  certs -->|"certificate_ids for the security domain"| mhsm
  mhsm -->|"id as managed_hsm_id, an ARM path"| key
  mhsm -->|"id"| ra
  ra -.->|"Crypto User must exist first, or create fails"| key
  key -->|"id as managed_hsm_key_id, a data-plane URI"| rot
  key --> uid
  key --> vid
  uid -->|"follows rotations"| consumer
  vid -->|"pinned, does NOT follow rotations"| consumer

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef keystone fill:#004578,stroke:#00243c,color:#ffffff;
  classDef neutral fill:#F3F2F1,stroke:#8A8886,color:#201F1E;
  class key this;
  class mhsm keystone;
  class rg,certs,ra,rot,vid,uid,consumer neutral;
Loading

Two edges deserve attention. The dotted one is a local-RBAC role assignment that must exist before this key can be created — a dependency no input on this module can express. And the split at the bottom is the choice between the unversioned id, which follows rotations, and versioned_id, which does not.


🧬 What this module builds

flowchart TB
  name["name  force-new, rejects both a Resource ID and a URI"]
  hsmid["managed_hsm_id  force-new, ARM path, rejects the hsm_uri form"]
  kt["key_type  force-new, EC-HSM or oct-HSM or RSA-HSM, all HSM-protected"]
  opts["key_opts  a SET, case-sensitive, empty is allowed and reported"]
  curve["curve  required for EC-HSM only"]
  size["key_size  required for RSA-HSM and oct-HSM only"]
  dates["expiration_date  a ONE-WAY DOOR  plus not_before_date"]
  tags["tags  present here, absent on the rotation policy"]

  res["azurerm_key_vault_managed_hardware_security_module_key.this"]

  uri["id  a DATA-PLANE URI, not an ARM Resource ID"]
  vid["versioned_id  changes on every rotation"]
  nopub["no_public_key_material_is_emitted_by_this_resource"]
  rbac["creating_this_key_requires_data_plane_rbac_that_azure_rbac_cannot_grant"]

  name --> res
  hsmid --> res
  kt -->|"decides which of the two below applies"| curve
  kt --> size
  kt --> res
  opts --> res
  curve --> res
  size --> res
  dates --> res
  tags --> res
  res --> uri
  res --> vid
  res --> nopub
  res --> rbac

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef neutral fill:#F3F2F1,stroke:#8A8886,color:#201F1E;
  class res this;
  class name,hsmid,kt,opts,curve,size,dates,tags,uri,vid,nopub,rbac neutral;
Loading
Resource Cardinality Purpose
azurerm_key_vault_managed_hardware_security_module_key.this single, many per Managed HSM One HSM-protected key.

Nine arguments plus timeouts. key_type decides which of curve and key_size applies — and forbids the other.


✅ Provider / Versions

Item Value
Terraform >= 1.12.0
Provider hashicorp/azurerm ~> 4.0
Provider block None in this module. The caller configures provider "azurerm" { features {} }, auth and subscription.
Module type Standalone — one resource, no children.
ARM provider Microsoft.KeyVault

Schema notes that bite:

  • 🔴 Creating a key is a data-plane operation. Azure RBAC governs the control plane only; keys need Managed HSM local RBAC. Azure's own security baseline records "Azure RBAC for Data Plane: Supported — False" for this service.
  • 🔴 This resource emits no key material, not even the public key. No public_key_pem, no public_key_openssh, no e/n/x/y — all of which azurerm_key_vault_key does emit. Fetch the public key from the data plane instead.
  • 🔴 id is a data-plane URI, https://<hsm>.managedhsm.azure.net/keys/<name>, not an ARM Resource ID. Meanwhile managed_hsm_id is an ARM path. One family, two conventions.
  • 🔴 expiration_date can never be unset once set — the provider states the API restores the purged key, so even destroy-and-recreate brings it back.
  • 🔴 Both dates are force-new in one direction only, and the direction is REMOVAL. The CustomizeDiff predicate is old value non-empty, new value empty, so deleting expiration_date or not_before_date replaces the key. Moving a date is an in-place update whichever way it moves. Under the provider defaults a replacement soft-deletes the key and then recovers it -- same material, same versions, old properties restored -- so the edit is churn and a no-op. Only with purge_soft_deleted_hsm_keys_on_destroy enabled and HSM purge protection off is the key genuinely purged and a new one generated, and only then is anything wrapped under it unrecoverable.
  • 🔴 There is no software key type. Only EC-HSM, oct-HSM and RSA-HSM; the plain RSA and EC values from Key Vault do not exist here.
  • ⚠️ key_opts is case-sensitive and is a set here where Key Vault uses a list. wrapKey, not wrapkey.
  • ⚠️ There is no inline rotation_policy block, unlike azurerm_key_vault_key — rotation is a separate resource, which is why the sibling module exists.
  • ⚠️ Destroy behaviour depends on the caller's features {} block — purge_soft_deleted_hardware_security_module_keys_on_destroy.
  • ⚠️ Force-new: name, managed_hsm_id, key_type, key_size, curve, and expiration_date / not_before_date when removed.
  • ⚠️ key_size and curve are ExactlyOneOf each other -- a cross-field rule absent from the binary schema. This module's key_type-keyed checks are stricter and imply it in both directions.
  • ⚠️ curve carries a diff suppression: a stored SECP256K1 equals a configured P-256K. Same curve, old and new names; there is nothing to correct.
  • 🔴 Any update rotates the version. versioned_id is marked newly-computed whenever key_opts, either date or tags change -- so editing a tag produces a new key version.
  • ⚠️ All four timeouts exist. A misspelled key is discarded silently.

🔑 Required Azure RBAC Roles / Permissions

Two planes, and only one of them is Azure RBAC.

Plane Operation Role Scope
Data plane (local RBAC) Create, read, update, delete this key Managed HSM Crypto User /keys or /keys/<key-name>
Data plane (local RBAC) Purge a soft-deleted key Managed HSM Crypto Officer /keys or /keys/<key-name>
Data plane (local RBAC) Rotate / create new versions Managed HSM Crypto Officer /keys or /keys/<key-name>
Control plane (Azure RBAC) Manage the Managed HSM resource, read its tags Managed HSM Contributor the Managed HSM
Control plane (Azure RBAC) Read the resource record Reader the Managed HSM

🔴 Owner or Contributor on the Managed HSM grants nothing here. Microsoft documents a dual-plane model: control-plane access does not confer data-plane access. The identity running Terraform needs a local RBAC assignment, and without it the apply fails on authorization while every Azure RBAC check looks correct.

⚠️ And that assignment is an ordering problem, not just a permissions one. A configuration that creates the HSM and this key in one pass will try to create the key before the identity is permitted to. See example 3.

✅ Plan access here is not credential access. Refreshing this resource needs data-plane read, but the provider returns no key material — so an identity granted plan rights learns metadata and never bytes. That is unusual enough in this library to be worth stating explicitly.

🔒 Assign administrative roles to Entra security groups rather than individuals, which Microsoft recommends to avoid locking yourself out of an HSM when an account is deleted, and consider PIM for just-in-time elevation.


Azure Prerequisites

  • An existing Managed HSM, activated with its security domain downloaded. See terraform-azurerm-key-vault-managed-hardware-security-module.
  • A Managed HSM local-RBAC role assignment granting the Terraform identity Crypto User at /keys, created before this key. Use terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment, and order it with depends_on — nothing in this module's inputs can express that dependency.
  • Microsoft.KeyVault registered in the subscription.
  • Network reachability of the HSM's data plane if it restricts public access — the data-plane endpoint is what Terraform talks to, not management.azure.com.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; this module declares none of these.

📁 Module Structure

terraform-azurerm-key-vault-managed-hardware-security-module-key/
├── providers.tf     # required_version + pinned azurerm; no provider block
├── variables.tf     # 10 inputs, 17 validations
├── main.tf          # the keystone `this` + ID-parsing and reporting locals
├── outputs.tf       # id first, then what the module cannot grant or verify
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

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

  name           = "key-storage-cmk"
  managed_hsm_id = module.managed_hsm.id

  key_type = "RSA-HSM"
  key_size = 4096
  key_opts = ["wrapKey", "unwrapKey"]

  tags = { environment = "prod" }
}

🔴 This will fail unless the Terraform identity already holds Crypto User on the HSM's /keys scope. Example 3 shows the full ordering.

💡 wrapKey / unwrapKey only. A key used to protect another key does not need sign or encrypt.


🔌 Cross-Module Contract

Consumes

Input Type Source
name string caller — force-new
managed_hsm_id string terraform-azurerm-key-vault-managed-hardware-security-module output id (the ARM path, not hsm_uri) — force-new
key_type string caller — EC-HSM / oct-HSM / RSA-HSM, force-new
key_opts set(string) caller — case-sensitive
key_size number caller — required for RSA/oct, forbidden for EC, force-new
curve string caller — required for EC, forbidden otherwise, force-new
expiration_date / not_before_date string caller — UTC …Z form
tags map(string) caller
timeouts object(...) caller

Emits

Output Description Consumed by
id The data-plane URI. Unversioned. the rotation-policy module; terraform-azurerm-disk-encryption-set
versioned_id The version-pinned URI. Changes on rotation. consumers that must not follow a rotation
name / managed_hsm_id / key_type / key_size / curve Configuration echo. Force-new. review
expiration_date / not_before_date The dates, or null. review
tags The effective tags. review
managed_hsm_name / managed_hsm_resource_group Derived from the ARM ID. inventory
permitted_operations Derived, sorted. review
key_permits_no_operations Derived. Reported, not rejected. review
rsa_key_size_is_below_the_modern_2048_bit_floor Derived. Reported, not rejected. security review
no_public_key_material_is_emitted_by_this_resource Always true. integration design
creating_this_key_requires_data_plane_rbac_that_azure_rbac_cannot_grant Always true. permissions review
expiration_date_can_never_be_unset_once_set Always true. change review
destroy_behaviour_depends_on_a_provider_features_toggle_the_caller_owns Always true. change review

📚 Example Library

1 · An RSA wrapping key
module "hsm_key" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"

  name           = "key-storage-cmk"
  managed_hsm_id = module.managed_hsm.id

  key_type = "RSA-HSM"
  key_size = 4096
  key_opts = ["wrapKey", "unwrapKey"]
}

ℹ️ key_size is required for RSA-HSM and would be rejected for EC-HSM, where curve takes its place.

2 · An EC signing key
module "hsm_signing_key" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"

  name           = "key-code-signing"
  managed_hsm_id = module.managed_hsm.id

  key_type = "EC-HSM"
  curve    = "P-384"
  key_opts = ["sign", "verify"]
}

⚠️ It is P-521, not P-512. That curve genuinely is 521 bits. And P-256K is the Koblitz curve secp256k1 — a different curve from P-256, not a variant of it.

3 · 🔴 The local-RBAC ordering that makes the difference between working and not
data "azurerm_client_config" "current" {}

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

  name                = "hsm-prod"
  resource_group_name = module.rg.name
  location            = "eastus2"
  tenant_id           = data.azurerm_client_config.current.tenant_id
  admin_object_ids    = [data.azurerm_client_config.current.object_id]
}

# Managed HSM Crypto User -- lets the identity create and delete keys.
module "crypto_user" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment.git?ref=v1.0.0"

  managed_hsm_id     = module.managed_hsm.id
  name               = "1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
  scope              = "/keys"
  role_definition_id = "/Microsoft.KeyVault/providers/Microsoft.Authorization/roleDefinitions/21dbd100-6940-42c2-9190-5d6cb909625b"
  principal_id       = data.azurerm_client_config.current.object_id
}

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

  name           = "key-storage-cmk"
  managed_hsm_id = module.managed_hsm.id
  key_type       = "RSA-HSM"
  key_size       = 4096
  key_opts       = ["wrapKey", "unwrapKey"]

  # Nothing in this module's inputs can express this dependency: the role
  # assignment is a sibling resource, and the key does not reference it.
  depends_on = [module.crypto_user]
}

🔴 Without the depends_on, a first apply usually fails. Terraform sees no data dependency between the key and the role assignment, so it may create the key first — before the identity is permitted to. The error is an authorization failure on the data plane, which reads like a mistake in the HSM configuration.

⚠️ name on the role assignment is a GUID you choose, not a friendly name. The role_definition_id GUID above is the built-in Crypto User role.

💡 depends_on IS valid on a module block — it is lifecycle that is not. So this is expressible from the caller's side even though the module cannot express it internally.

4 · Why `Contributor` is not enough
output "read_this_before_granting_access" {
  # Always true.
  value = module.hsm_key.creating_this_key_requires_data_plane_rbac_that_azure_rbac_cannot_grant
}
# Control plane -- Azure RBAC. Does NOT let you create a key.
az role assignment create --role "Managed HSM Contributor" --assignee <id> --scope <hsm resource id>

# Data plane -- Managed HSM local RBAC. This is the one that matters.
az keyvault role assignment create --hsm-name hsm-prod \
  --role "Managed HSM Crypto User" --assignee <id> --scope /keys

🔴 Two different CLI commands, two different systems. az role assignment is Azure RBAC; az keyvault role assignment is local RBAC. Microsoft's security baseline states plainly that Azure RBAC for the data plane is not supported on this service.

ℹ️ Local RBAC has exactly two scope shapes: / or /keys for the whole HSM, and /keys/<key-name> for one key. Prefer the second where an application needs one key.

5 · 🔒 No public key comes out of Terraform
output "what_you_can_and_cannot_get" {
  value = {
    # Available.
    unversioned = module.hsm_key.id
    versioned   = module.hsm_key.versioned_id

    # NOT available -- this resource emits no key material at all.
    no_public_key = module.hsm_key.no_public_key_material_is_emitted_by_this_resource
  }
}
# The only way to obtain the public key.
az keyvault key download --hsm-name hsm-prod --name key-code-signing --file public.pem

🔴 azurerm_key_vault_key emits public_key_pem, public_key_openssh, e, n, x and y. This resource emits none of them. Any pipeline that assumes a key resource yields a PEM needs rewriting for Managed HSM.

✅ That is a stronger position than redaction, not a gap. There is nothing to mark sensitive because there is nothing to expose. Worth remembering what sensitive = true would have bought anyway: it redacts plan output and does not encrypt state — the control that matters is an encrypted, access-controlled backend, never a local state file in a repository.

6 · 🔴 `expiration_date` is a one-way door
module "hsm_key" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"

  name           = "key-tde"
  managed_hsm_id = module.managed_hsm.id
  key_type       = "RSA-HSM"
  key_size       = 3072
  key_opts       = ["wrapKey", "unwrapKey"]

  # Setting this cannot be undone. Read the output below first.
  expiration_date = "2027-12-31T23:59:59Z"
}

output "understand_before_setting_an_expiry" {
  value = module.hsm_key.expiration_date_can_never_be_unset_once_set
}

🔴 Removing the argument later does not clear the expiry. The provider states that the underlying API restores the purged key, so even destroying and recreating brings the expiry back. Terraform stops managing the value; Azure keeps enforcing it.

🔴 And it is force-new in one direction only -- the direction is REMOVAL, not direction of travel. The CustomizeDiff predicate is old value non-empty, new value empty, so deleting the argument replaces the key; moving the date is an ordinary in-place update either way. not_before_date carries the identical rule. Under the provider defaults a replacement soft-deletes the key and then recovers it -- same material, same versions, old properties restored -- so the edit is churn and a no-op. Only with purge_soft_deleted_hsm_keys_on_destroy enabled and HSM purge protection off is the key genuinely purged and a new one generated, and only then is anything wrapped under it unrecoverable. Read together with the note above: deleting the line replaces the key and the recovered key brings the expiry back with it.

💡 Prefer a rotation policy. The sibling module expresses expiry as a duration applied to each newly rotated key, so you never edit an absolute date on a key something depends on.

7 · The two ID conventions, and how each is rejected
# Rejected -- this is the parent module's `hsm_uri` output, not its `id`.
managed_hsm_id = "https://hsm-prod.managedhsm.azure.net/"

# Rejected -- a KEY VAULT, not a Managed HSM. A vault key is azurerm_key_vault_key.
managed_hsm_id = ".../providers/Microsoft.KeyVault/vaults/kv-prod"

# Accepted.
managed_hsm_id = ".../providers/Microsoft.KeyVault/managedHSMs/hsm-prod"
# And in the other direction -- `name` rejects the URI this resource EMITS.
name = "https://hsm-prod.managedhsm.azure.net/keys/key-tde"  # rejected
name = "key-tde"                                             # accepted

⚠️ The parent module emits both id and hsm_uri, and both are legitimate values elsewhere in this family — which is exactly why passing the wrong one is easy. The sibling rotation-policy module takes the URI form, so the two modules want opposite things.

ℹ️ The managedHSMs pattern is matched case-insensitively, because Azure returns that segment capitalised while other tooling lower-cases it, and a case difference is not a real error.

8 · `key_opts` is case-sensitive — and unknown values are allowed
# Rejected -- differs from a documented operation only by case.
key_opts = ["sign", "wrapkey"]

# Rejected -- same reason.
key_opts = ["WrapKey"]

# Accepted -- correct casing.
key_opts = ["wrapKey", "unwrapKey"]

# ACCEPTED -- unrecognised, so possibly an operation added since.
key_opts = ["sign", "somethingNew"]

⚠️ The camelCase pair is where this goes wrong: wrapKey and unwrapKey. The provider states the values are case-sensitive and documents the list as operations it includes, so this module rejects the near miss and lets an unknown through.

ℹ️ A set, not a list — reordering never produces a diff. azurerm_key_vault_key uses a list for the same argument.

9 · A key that permits nothing — reported, not rejected
module "hsm_key" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"

  name           = "key-placeholder"
  managed_hsm_id = module.managed_hsm.id
  key_type       = "EC-HSM"
  curve          = "P-521"

  # Accepted by the provider, and therefore accepted here.
  key_opts = []
}

output "almost_certainly_a_mistake" {
  value = module.hsm_key.key_permits_no_operations
}
almost_certainly_a_mistake = true

ℹ️ The provider documents no minimum, so refusing an empty set would be inventing a constraint. But a key permitting nothing fails when an application tries to use it, not at apply — so the fact is surfaced here instead. It costs an HSM partition either way.

10 · RSA key size — a report, not a floor
module "legacy_key" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"

  name           = "key-legacy-interop"
  managed_hsm_id = module.managed_hsm.id
  key_type       = "RSA-HSM"
  key_size       = 1024
  key_opts       = ["wrapKey", "unwrapKey", "sign"]
}

output "worth_challenging" {
  value = module.legacy_key.rsa_key_size_is_below_the_modern_2048_bit_floor
}
worth_challenging = true

⚠️ Reported rather than rejected, and the reason is the documentation itself — the provider offers 1024 as an example key_size, so this module will not refuse a value its own source of truth presents as legitimate. Overriding the provider on a published point is not this module's job.

🔴 1024-bit RSA is below every current recommendation, and a key generated inside an HSM is no stronger than its modulus. If this reads true, the question is whether the size was chosen or copied.

ℹ️ The flag is key_type-aware. An oct-HSM key of 256 bits is a normal AES length and correctly reads false; only RSA-HSM is assessed.

⚠️ A documentation wrinkle to know about: the provider describes key_size as being in bytes and then gives bit values as examples. Azure reads it as bits. Pass the familiar bit lengths.

11 · Versioned or unversioned — the choice that outlives this module
# A disk encryption set that should FOLLOW rotations.
module "disk_encryption" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-disk-encryption-set.git?ref=v1.0.0"

  name                = "des-prod"
  resource_group_name = module.rg.name
  location            = "eastus2"

  # The UNVERSIONED uri -- resolves to whatever the current version is.
  managed_hsm_key_id = module.hsm_key.id
}

output "pinning" {
  value = {
    follows_rotation = module.hsm_key.id
    pinned_forever   = module.hsm_key.versioned_id
  }
}

💡 Reference id where a consumer should benefit from rotation, and versioned_id where it must not. Encryption at rest generally wants the first; something that must keep verifying old signatures wants the second.

⚠️ versioned_id changes on every rotation, so a configuration that hard-codes its literal value drifts the moment a rotation fires. Reference the output rather than copying it.

12 · Many keys on one HSM
variable "keys" {
  type = map(object({
    key_type = string
    key_size = optional(number)
    curve    = optional(string)
    key_opts = set(string)
  }))
  default = {
    storage_cmk = { key_type = "RSA-HSM", key_size = 4096, key_opts = ["wrapKey", "unwrapKey"] }
    sql_tde     = { key_type = "RSA-HSM", key_size = 3072, key_opts = ["wrapKey", "unwrapKey"] }
    signing     = { key_type = "EC-HSM", curve = "P-384", key_opts = ["sign", "verify"] }
  }
}

module "hsm_keys" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
  for_each = var.keys

  name           = "key-${each.key}"
  managed_hsm_id = module.managed_hsm.id

  key_type = each.value.key_type
  key_size = try(each.value.key_size, null)
  curve    = try(each.value.curve, null)
  key_opts = each.value.key_opts

  tags = { purpose = each.key }
}

✅ A Managed HSM holds many keys, so for_each is the right shape here — unlike the sibling rotation policy, of which a key has at most one.

💡 The optional key_size / curve pair per entry mirrors the resource's own rule, so each key supplies only the sizing argument its key_type permits.

13 · 🏗️ End-to-end composition — an HSM-backed CMK with rotation
provider "azurerm" {
  features {
    # DECIDES whether destroying a key soft-deletes or purges it.
    key_vault {
      purge_soft_deleted_hardware_security_module_keys_on_destroy = false
    }
  }
}

data "azurerm_client_config" "current" {}

variable "location" {
  type    = string
  default = "eastus2"
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-hsm-prod"
  location = var.location

  tags = { environment = "prod" }
}

# The security domain needs three certificates; the key-vault module owns them.
module "kv_security_domain" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"

  name                = "kv-hsm-sd"
  resource_group_name = module.rg.name
  location            = var.location
  tenant_id           = data.azurerm_client_config.current.tenant_id

  # Three self-signed certificates, generated in the vault. The security domain is
  # encrypted to these public keys, and the private halves are what let you restore
  # the HSM -- so treat them as the HSM's root of recovery.
  certificates = {
    for k in ["sd1", "sd2", "sd3"] : k => {
      certificate_policy = {
        issuer_parameters = { name = "Self" }
        key_properties = {
          exportable = true
          key_type   = "RSA"
          reuse_key  = false
          key_size   = 2048
        }
        secret_properties = { content_type = "application/x-pkcs12" }
        x509_certificate_properties = {
          subject            = "CN=hsm-security-domain-${k}"
          validity_in_months = 12
        }
      }
    }
  }
}

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

  name                = "hsm-prod-eastus2"
  resource_group_name = module.rg.name
  location            = var.location
  tenant_id           = data.azurerm_client_config.current.tenant_id
  admin_object_ids    = [data.azurerm_client_config.current.object_id]

  security_domain_key_vault_certificate_ids = values(module.kv_security_domain.certificate_ids)
  security_domain_quorum                    = 2

  tags = { environment = "prod" }
}

# Local RBAC -- the data-plane grant Azure RBAC cannot give.
module "crypto_user" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment.git?ref=v1.0.0"

  managed_hsm_id     = module.managed_hsm.id
  name               = "1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
  scope              = "/keys"
  role_definition_id = "/Microsoft.KeyVault/providers/Microsoft.Authorization/roleDefinitions/21dbd100-6940-42c2-9190-5d6cb909625b"
  principal_id       = data.azurerm_client_config.current.object_id
}

# THIS MODULE.
module "hsm_key" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"

  name           = "key-storage-cmk"
  managed_hsm_id = module.managed_hsm.id

  key_type = "RSA-HSM"
  key_size = 4096
  key_opts = ["wrapKey", "unwrapKey"]

  # No expiration_date on purpose -- the rotation policy below handles expiry,
  # and setting it here would be irreversible.

  tags = { environment = "prod" }

  depends_on = [module.crypto_user]
}

# Rotation, as a separate resource -- there is no inline block on a Managed HSM key.
module "hsm_key_rotation" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key-rotation-policy.git?ref=v1.0.0"

  # The DATA-PLANE URI, which is this module's `id` -- not an ARM path.
  managed_hsm_key_id = module.hsm_key.id

  expire_after       = "P90D"
  time_before_expiry = "P30D"
}

# A consumer that SHOULD follow rotations, so it takes the unversioned uri.
module "disk_encryption" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-disk-encryption-set.git?ref=v1.0.0"

  name                = "des-prod"
  resource_group_name = module.rg.name
  location            = var.location

  managed_hsm_key_id       = module.hsm_key.id
  auto_key_rotation_enabled = true

  tags = { environment = "prod" }
}

output "posture" {
  value = {
    hsm              = module.hsm_key.managed_hsm_name
    operations       = module.hsm_key.permitted_operations
    weak_rsa         = module.hsm_key.rsa_key_size_is_below_the_modern_2048_bit_floor
    permits_nothing  = module.hsm_key.key_permits_no_operations
    no_public_key    = module.hsm_key.no_public_key_material_is_emitted_by_this_resource
    rotation_trigger = module.hsm_key_rotation.rotation_trigger
  }
}

🔴 Three things in this composition exist only because of the dual-plane model: the azurerm_key_vault_managed_hardware_security_module_role_assignment resource, the depends_on, and the fact that neither is expressible as a module input.

💡 features {} appears here and never inside a module. The toggle shown decides whether destroying the key leaves it recoverable — the safe default is false, meaning soft delete.

⚠️ The disk encryption set takes id, not versioned_id, so it follows each rotation. Pinning it to a version would quietly freeze it at today's key.


📥 Inputs

Input Type Default Notes
name string — Required. Force-new. Rejects both an ARM ID and a URI.
managed_hsm_id string — Required. Force-new. ARM path, not hsm_uri.
key_type string — Required. Force-new. EC-HSM / oct-HSM / RSA-HSM.
key_opts set(string) — Required. Case-sensitive. Empty is allowed and reported.
key_size number null Required for RSA/oct, forbidden for EC. Force-new.
curve string null Required for EC, forbidden otherwise. Force-new.
expiration_date string null Irreversible once set. Force-new only if removed.
not_before_date string null Ordinary; editable and removable.
tags map(string) {} In-place.
timeouts object(...) null All four operations.
Full schemas
variable "name" { type = string }

# Anchored, case-insensitive on managedHSMs; rejects hsm_uri and a Key Vault ID.
variable "managed_hsm_id" { type = string }

# Closed set -- every option is HSM-protected. No software RSA or EC exists here.
variable "key_type" { type = string }

# Near miss rejected, unknown allowed. A set, not a list.
variable "key_opts" { type = set(string) }

variable "key_size" {
  type    = number
  default = null
}

variable "curve" {
  type    = string
  default = null
}

# Carries the inverted-date cross-check, referencing not_before_date.
variable "expiration_date" {
  type    = string
  default = null
}

variable "not_before_date" {
  type    = string
  default = null
}

variable "tags" {
  type    = map(string)
  default = {}
}

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

🧾 Outputs

Output Type Notes
id string A data-plane URI, unversioned. What the rotation policy consumes.
versioned_id string Version-pinned. Changes on rotation.
name / managed_hsm_id / key_type / key_size / curve string/number Force-new.
expiration_date / not_before_date string Or null.
tags map(string) In-place.
managed_hsm_name / managed_hsm_resource_group string Derived from the ARM ID.
permitted_operations list(string) Derived, sorted.
key_permits_no_operations bool Derived. Reported, not rejected.
rsa_key_size_is_below_the_modern_2048_bit_floor bool Derived, key_type-aware.
no_public_key_material_is_emitted_by_this_resource bool Always true.
creating_this_key_requires_data_plane_rbac_that_azure_rbac_cannot_grant bool Always true.
expiration_date_can_never_be_unset_once_set bool Always true.
destroy_behaviour_depends_on_a_provider_features_toggle_the_caller_owns bool Always true.

🔒 Nothing is marked sensitive, because there is nothing to mark. The provider emits no key material — see no_public_key_material_is_emitted_by_this_resource.


🧠 Architecture Notes

A Managed HSM has two independent authorization systems, and the one that matters here is not Azure RBAC. Microsoft documents a dual-plane model: the control plane at management.azure.com is governed by Azure RBAC, and the data plane at <hsm-name>.managedhsm.azure.net is governed by Managed HSM local RBAC. Creating, reading, rotating or deleting a key is a data-plane operation, so Owner or Contributor on the Managed HSM resource confers no ability to create this key — Azure's own security baseline records "Azure RBAC for Data Plane: Supported — False" for the service. The identity running Terraform needs a local-RBAC assignment, typically Crypto User to create and delete and Crypto Officer to purge or rotate, at scope /keys or /keys/<key-name>.

That grant is also an ordering problem this module cannot express. The provider's own example wires depends_on from the key to two role-assignment resources, because a configuration creating the HSM and the key in one pass will otherwise attempt the key before the identity is permitted to. No input on this module can carry that dependency — the role assignment is a sibling resource owned by terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment — so it belongs in the composition, ordered explicitly. depends_on is valid on a module block; it is lifecycle that is not.

The resource emits no key material whatsoever, not even the public half, and that is a categorical difference from azurerm_key_vault_key. A Key Vault key emits public_key_pem, public_key_openssh and the raw components e, n, x and y. A Managed HSM key emits none of them; its only cryptographic identifiers are the two URIs. So there is nothing here to mark sensitive and nothing to leak — a stronger position than redaction, since this suite's usual practice is to emit a public key unredacted and keep the private half out of Terraform, and here the provider has removed the question. The consequence is practical: if you need the public key to pin a certificate or hand to a partner, fetch it from the data plane with az keyvault key download, and expect any pipeline that assumes a key resource yields a PEM to need rewriting. It is also worth remembering what sensitive = true would have bought anyway — it redacts plan output and does not encrypt state.

The converse of all that is a pleasant one: plan access here is not credential access. Refreshing this resource requires data-plane read on the key, but since the provider returns no material, an identity granted plan rights learns the key's metadata and never its bytes. In much of this library the opposite holds, so the distinction is worth stating rather than assuming.

expiration_date is one of the few genuinely irreversible edits in this library. The provider states that once set it cannot be unset even if the key is deleted and recreated, because the underlying API restores the purged key rather than creating a fresh one. On top of that, both dates are force-new in one direction only — and the direction is removal, not direction of travel. The CustomizeDiff predicate is old value non-empty, new value empty, so deleting either date argument replaces the key, while moving a date is an ordinary in-place update whichever way it moves. The two facts compound: under the provider defaults the replacement soft-deletes and then recovers the key, which brings the old expiry back, so deleting the line churns the resource and does not clear the expiry. Only with purge_soft_deleted_hsm_keys_on_destroy enabled and HSM purge protection off is the key genuinely purged and regenerated. The remedy is to express expiry through the sibling rotation policy instead, as a duration applied to each newly rotated version.

Two ID conventions live in one family, and each module rejects the other's. This resource's managed_hsm_id is an ARM Resource ID; its own id is a data-plane URI, and the sibling rotation policy consumes that URI. The parent module emits both an ARM id and an hsm_uri, both legitimate somewhere, which is precisely why the wrong one is easy to pass — so managed_hsm_id rejects the URI form by name, and name rejects the URI form too. The managedHSMs segment is matched case-insensitively, because Azure returns it capitalised while other tooling lower-cases it and a case difference is not a real error.

Every key type is hardware-protected, which shifts where the risk sits. EC-HSM, oct-HSM and RSA-HSM are the only options; the plain RSA and EC types that Key Vault accepts do not exist, and passing one is the predictable mistake when adapting a Key Vault configuration. key_type also decides which sizing argument applies — curve for EC, key_size for RSA and oct — and this module rejects supplying the wrong one as contradictory rather than merely redundant. Both halves of that pairing are placed on the argument they constrain, because a validation may only reference its own variable, which means an error can name a field the caller was not editing; the messages say so.

Two facts are reported rather than enforced, for different reasons. An empty key_opts produces a key that permits nothing: the provider documents no minimum, so refusing it would invent a constraint, and the failure would otherwise surface only when an application tried to use the key. An RSA-HSM key below 2048 bits is below every current recommendation, but the provider's own documentation offers 1024 as an example key_size — so this module declines to override its source of truth on a published point and surfaces the fact instead. That flag is key_type-aware, so a 256-bit oct-HSM AES key correctly reads false. A documentation wrinkle sits underneath it: the provider describes key_size as bytes and then gives bit values as examples, and Azure reads it as bits.

Finally, what happens on destroy is not this module's decision. Whether the key is soft-deleted or purged depends on purge_soft_deleted_hardware_security_module_keys_on_destroy in the caller's features {} block, so the same module can leave a recoverable key in one configuration and permanently destroy it in another. A purged key is unrecoverable, and anything encrypted under it is unrecoverable with it.


🧱 Design Principles

Concern This module's position Why
Data-plane RBAC Named in a constant output and headlined in the RBAC table. Azure RBAC cannot grant it; the apply fails otherwise.
The role-assignment ordering Documented in prose and an example, not faked as an input. It is a sibling resource this module does not own.
Key material None emitted; nothing marked sensitive. The provider exposes none — stronger than redaction.
Plan vs credential access Explicitly stated as not equivalent here. Elsewhere in this library it is.
expiration_date Named in a constant output as irreversible. It survives destroy-and-recreate.
Key type Closed set, with the -HSM suffix named in the message. Adapting a Key Vault config is the predictable mistake.
curve / key_size Required and forbidden checks, placed on each. A validation may only reference its own variable.
Empty key_opts Reported, not rejected. The provider documents no minimum.
RSA below 2048 Reported, not rejected. The provider offers 1024 as an example.
The two ID forms Each rejects the other by name. Both are legitimate elsewhere in the family.
Destroy behaviour Named as the caller's features {} decision. It is not visible from this module.
tags Carried as the universal tail. The resource exposes tags — unlike its rotation-policy sibling.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 — never a branch. Plan-only; a human applies from CI.

Before the first apply — the grant Azure RBAC will not give you:

az keyvault role assignment create --hsm-name hsm-prod \
  --role "Managed HSM Crypto User" --assignee <object-id> --scope /keys

# Confirm it landed.
az keyvault role assignment list --hsm-name hsm-prod --assignee <object-id> --scope /keys

After the apply:

# Metadata, including the current version.
az keyvault key show --hsm-name hsm-prod --name key-storage-cmk

# The public key -- which Terraform will not give you.
az keyvault key download --hsm-name hsm-prod --name key-storage-cmk --file public.pem

🧪 Testing

What the offline gate proves: that the configuration parses against the pinned provider, that formatting is canonical, and — via terraform console with variables files grouped by which variables each validation references — that all 17 validations fire on the input each targets. Grouping mattered here: key_size and curve both reference key_type, and expiration_date references not_before_date.

Suppression was demonstrated, not assumed. A file with a bad key_type and a negative key_size and the invalid curve P-512 produced exactly one error — every validation on key_size and curve was skipped because both reference the failed key_type. Re-running with a valid key_type then produced all of them. The same happened with the dates: a malformed not_before_date suppressed expiration_date's own shape check, so that check was only proved once not_before_date was left unset. A short error list is not proof a check is missing.

Proved with a failing value each: an empty name, a Resource ID in name, and the data-plane URI in name; hsm_uri in managed_hsm_id and a Key Vault ID in the same argument; key_type = "RSA"; key_opts containing wrapkey; key_size negative, fractional, missing for RSA-HSM, and present for EC-HSM; curve = "P-512", missing for EC-HSM, and present for RSA-HSM; both dates malformed; and a not_before_date a year after expiration_date.

The near-miss rule was proved in both directions in one file: wrapkey rejected while somethingNew passed.

Every derived value was printed rather than reasoned about, against all four key_type shapes. rsa_key_size_is_below_the_modern_2048_bit_floor read true for RSA-HSM/1024 and false for RSA-HSM/4096, EC-HSM and oct-HSM/256 — confirming the flag is key-type-aware rather than a bare numeric comparison. key_permits_no_operations read false with three operations and true with an empty set. permitted_operations came back sorted. The parsed HSM name, resource group and subscription were confirmed against two different Resource IDs.

What only an apply exercises: whether the HSM exists and is activated, and whether the identity holds the local-RBAC role — the most likely failure and one no offline check can reach.

What no Terraform run exercises at all: whether the key is actually usable by the applications that need it, and what the features {} toggle will do on a future destroy.


💬 Example Output

Outputs:

creating_this_key_requires_data_plane_rbac_that_azure_rbac_cannot_grant = true
curve = null
destroy_behaviour_depends_on_a_provider_features_toggle_the_caller_owns = true
expiration_date = null
expiration_date_can_never_be_unset_once_set = true
id = "https://hsm-prod-eastus2.managedhsm.azure.net/keys/key-storage-cmk"
key_permits_no_operations = false
key_size = 4096
key_type = "RSA-HSM"
managed_hsm_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hsm-prod/providers/Microsoft.KeyVault/managedHSMs/hsm-prod-eastus2"
managed_hsm_name = "hsm-prod-eastus2"
managed_hsm_resource_group = "rg-hsm-prod"
name = "key-storage-cmk"
no_public_key_material_is_emitted_by_this_resource = true
not_before_date = null
permitted_operations = [
  "unwrapKey",
  "wrapKey",
]
rsa_key_size_is_below_the_modern_2048_bit_floor = false
tags = {
  "environment" = "prod"
}
versioned_id = "https://hsm-prod-eastus2.managedhsm.azure.net/keys/key-storage-cmk/9a8b7c6d5e4f3210"

🔍 Troubleshooting

Symptom Cause Fix
Apply fails: forbidden / unauthorized on the key The identity has Azure RBAC but no local RBAC. az keyvault role assignment create --role "Managed HSM Crypto User" --scope /keys.
Apply fails on the first run, succeeds on the second The key was created before the role assignment landed. Add depends_on from the module to the role assignment.
key_type must be one of EC-HSM, oct-HSM or RSA-HSM A Key Vault value such as RSA. Add the -HSM suffix.
managed_hsm_id is a data-plane URI The parent's hsm_uri instead of its id. Pass module.managed_hsm.id.
managed_hsm_id must be a full Managed HSM Resource ID A Key Vault ID, or a truncated path. Use a managedHSMs ID; a vault key is azurerm_key_vault_key.
key_size is required when key_type is RSA-HSM or oct-HSM Sizing argument missing. Set key_size, or switch to EC-HSM with a curve.
curve must not be set unless key_type is EC-HSM Both sizing arguments supplied. Remove whichever the key type does not use.
curve must be one of P-256, P-256K, P-384 or P-521 Usually P-512. Use P-521 — that curve is 521 bits.
a key_opts entry differs ... only by case wrapkey or WrapKey. Use wrapKey / unwrapKey.
expiration_date must be a UTC datetime A date alone, or a +00:00 offset. Use 2027-12-31T23:59:59Z.
An expiry will not go away It cannot be unset, even via destroy. Accept it, or use a new key; prefer a rotation policy next time.
Plan wants to replace the key after a date edit A date argument was removed, not moved. CustomizeDiff force-news on old non-empty, new empty. Put the line back. Moving a date in either direction is in-place; only deleting it replaces the key, and the replacement will not clear the date anyway.
A tag edit produced a new key version Every update to an HSM key creates a new version; the provider marks versioned_id newly-computed on a tags change. Expected. Reference id where a consumer should follow rotation, and keep frequently-edited metadata off the key.
No public_key_pem output exists This resource emits no key material. az keyvault key download.
A consumer stopped picking up rotations It was wired to versioned_id. Point it at id instead.
A destroyed key vanished permanently The caller's features {} purge toggle was on. Set it to false for soft delete.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."