Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Managed HSM Role Assignment Terraform Module

Grants a Managed HSM local RBAC role to a principal. This is the authorization system Azure RBAC cannot reach. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • ☁️ Creates one azurerm_key_vault_managed_hardware_security_module_role_assignment (the keystone this) — a data-plane grant on a Managed HSM.
  • 🔴 Records why this module exists at all: Managed HSM local RBAC is a separate authorization system from Azure RBAC, so azurerm_role_assignment cannot do this job.
  • 🔴 States the dependency trap plainly: nothing consumes this module's outputs, yet other resources cannot be created without it — so depends_on is required, and its absence produces a configuration that fails once and then succeeds.
  • 🧮 Derives is_hsm_wide_scope, because /keys and /keys/prod-cmk differ by a few characters and by the whole blast radius of the grant.
  • ⚠️ Names the mistake it cannot catch: an application ID and an object ID are both GUIDs, and passing the wrong one fails silently.

💡 Why it matters: this is the resource that decides who can use the keys. Getting the scope wrong is the difference between an application that can use its own key and one that can use every key the HSM will ever hold.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

flowchart TB
  rg["terraform-azurerm-resource-group"]
  mhsm["terraform-azurerm-key-vault-managed-hardware-security-module  ARM Microsoft.KeyVault/managedHSMs"]
  rdef["terraform-azurerm-key-vault-managed-hardware-security-module-role-definition  only when a built-in role is too broad"]
  builtin["Built-in local roles  Crypto User, Crypto Officer, Administrator, Crypto Auditor  already exist"]
  ra["terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment"]
  key["terraform-azurerm-key-vault-managed-hardware-security-module-key  cannot be created without the grant"]
  rot["terraform-azurerm-key-vault-managed-hardware-security-module-key-rotation-policy"]

  rg -->|"name and location"| mhsm
  mhsm -->|"id as managed_hsm_id, an ARM path"| rdef
  mhsm -->|"id as managed_hsm_id"| ra
  rdef -->|"resource_manager_id as role_definition_id  NOT its id"| ra
  builtin -.->|"or a built-in role path, needing no definition resource"| ra
  ra -.->|"depends_on, because nothing references it"| key
  ra -.->|"depends_on"| rot

  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 ra this;
  class mhsm keystone;
  class rg,rdef,builtin,key,rot neutral;
Loading

Two edges tell the story. The solid one from the role definition carries resource_manager_id, not id — the assignment wants the path without the HSM base URL. And every edge out of this module is dotted, because they are depends_on relationships rather than data references: nothing reads this resource's outputs.


🧬 What this module builds

flowchart TB
  n["name  a GUID YOU choose, force-new"]
  hsm["managed_hsm_id  ARM path, force-new  rejects hsm_uri and a vault ID"]
  sc["scope  slash, slash-keys, or slash-keys-slash-name  force-new  rejects an Azure RBAC scope"]
  rdid["role_definition_id  starts /Microsoft.KeyVault/  force-new  rejects both an ARM path and the https form"]
  pid["principal_id  an Entra OBJECT id, force-new"]
  to["timeouts  only THREE: create, read, delete"]

  res["azurerm_key_vault_managed_hardware_security_module_role_assignment.this"]

  wide["is_hsm_wide_scope  the most consequential line in the plan"]
  skey["scoped_key_name  null when HSM-wide"]
  role["recognised_builtin_role_name  null rather than a guess"]
  appid["an_application_id_passed_here_cannot_be_distinguished_from_an_object_id"]
  prereq["this_assignment_is_a_prerequisite_that_nothing_references"]

  n --> res
  hsm --> res
  sc --> res
  rdid --> res
  pid --> res
  to --> res
  sc --> wide
  sc --> skey
  rdid --> role
  pid --> appid
  res --> prereq

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef neutral fill:#F3F2F1,stroke:#8A8886,color:#201F1E;
  class res this;
  class n,hsm,sc,rdid,pid,to,wide,skey,role,appid,prereq neutral;
Loading
Resource Cardinality Purpose
azurerm_key_vault_managed_hardware_security_module_role_assignment.this single, many per Managed HSM One local-RBAC grant, at one scope, to one principal.

Five arguments plus timeouts. All five are force-new, which is why timeouts has only three keys.


✅ 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 (data plane)

Schema notes that bite:

  • 🔴 This is not azurerm_role_assignment. Managed HSM local RBAC is a separate authorization system, with its own scopes, its own role definitions and its own CLI (az keyvault role assignment, not az role assignment).
  • 🔴 timeouts has only three keys — create, read, delete. There is no update. Copy a four-key block from a sibling module and the update entry is discarded silently.
  • 🔴 role_definition_id is a third ID convention, beginning /Microsoft.KeyVault/ with no subscription in it. Wire the role-definition module's resource_manager_id, not its id.
  • 🔴 name is a GUID you supply, not one Azure allocates. There is no default.
  • 🔴 scope is a local-RBAC path, not a Resource ID. Only /, /keys and /keys/<key-name> exist.
  • 🔴 Nothing references this resource, and things depend on it. See Architecture Notes.
  • ⚠️ principal_id is the Entra object ID. An application ID is the same shape and grants nothing.
  • ⚠️ No tags — the provider exposes none.
  • ⚠️ resource_id is a deprecated attribute and is deliberately not emitted.
  • ⚠️ Force-new: every argument.
  • ⚠️ lifecycle is not valid inside a module block, though depends_on is.

🔑 Required Azure RBAC Roles / Permissions

Two planes, and creating this resource needs the one Azure RBAC cannot grant.

Plane Operation Role Scope
Data plane — local RBAC Create, read or delete a role assignment Managed HSM Administrator / or /keys
Data plane — local RBAC Read role assignments Managed HSM Crypto Auditor / or /keys
Control plane — Azure RBAC Manage the Managed HSM resource Managed HSM Contributor the Managed HSM

🔴 Assigning a role is itself a data-plane operation, governed by the Microsoft.KeyVault/managedHsm/roleAssignments/write/action data action — which is held by Administrator, not by Crypto User or Crypto Officer. So an identity that can use keys generally cannot grant access to them, which is the correct separation and a common surprise.

⚠️ The bootstrap problem is real. A brand-new Managed HSM has exactly the administrators listed in its admin_object_ids, set at creation. Terraform must run as one of them — or as a member of a group that is one — to create any assignment at all.

🔒 Assign administrative roles to Entra security groups, not individuals. Microsoft recommends it because a deleted user account can leave an HSM with no administrator and no route back in. Consider PIM for just-in-time elevation.


Azure Prerequisites

  • An existing, activated Managed HSM, and its ARM Resource ID.
  • Terraform running as a Managed HSM Administrator on that HSM.
  • A role definition to grant — either a built-in role's UUID, or a custom one from terraform-azurerm-key-vault-managed-hardware-security-module-role-definition.
  • The principal's Entra object ID.
  • A GUID for name, generated once and kept stable.
  • 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-role-assignment/
├── providers.tf     # required_version + pinned azurerm; no provider block
├── variables.tf     # 6 inputs, 13 validations
├── main.tf          # the keystone `this` + scope- and role-parsing locals
├── outputs.tf       # id first, then what the module cannot catch
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

data "azurerm_client_config" "current" {}

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

  name           = "1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
  managed_hsm_id = module.managed_hsm.id

  # Least privilege: one key, not the whole HSM.
  scope = "/keys/key-storage-cmk"

  role_definition_id = "/Microsoft.KeyVault/providers/Microsoft.Authorization/roleDefinitions/21dbd100-6940-42c2-9190-5d6cb909625b"
  principal_id       = data.azurerm_client_config.current.object_id
}

💡 That UUID is the built-in Managed HSM Crypto User role. No role-definition resource is needed to use it.


🔌 Cross-Module Contract

Consumes

Input Type Source
name string caller — a GUID you generate, force-new
managed_hsm_id string terraform-azurerm-key-vault-managed-hardware-security-module output id — force-new
scope string caller — /, /keys, or /keys/<key-name> — force-new
role_definition_id string …-role-definition output resource_manager_id, or a built-in role path — force-new
principal_id string an Entra object ID — force-new
timeouts object(...) caller — three keys only

Emits

Output Description Consumed by
id The assignment's ID, with the HSM base URL. nothing — see the constant output below
name / managed_hsm_id / scope / role_definition_id / principal_id Configuration echo. All force-new. review
managed_hsm_name / managed_hsm_resource_group Derived from the ARM ID. inventory
is_hsm_wide_scope Derived. The line that matters most in a plan. security review
scoped_key_name Derived, null when HSM-wide. security review
role_definition_uuid Derived. review
recognised_builtin_role_name Derived, null rather than a guess. review
an_application_id_passed_here_cannot_be_distinguished_from_an_object_id Always true. troubleshooting
this_assignment_is_a_prerequisite_that_nothing_references Always true. composition design
the_deprecated_resource_id_attribute_is_deliberately_not_emitted Always true. design review
this_resource_carries_no_tags Always true. design review

📚 Example Library

1 · A built-in role at a single key — the least-privilege default
module "crypto_user" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment.git?ref=v1.0.0"

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

✅ Key-scoped, so this principal can use one key and no others — including keys created later.

2 · 🔴 The `depends_on` that makes a key creatable
module "crypto_user" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment.git?ref=v1.0.0"

  name               = "1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
  managed_hsm_id     = module.managed_hsm.id
  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"]

  # REQUIRED. The key does not reference this assignment, so Terraform sees no
  # dependency and is free to attempt the key first.
  depends_on = [module.crypto_user]
}

🔴 Without the depends_on, a first apply typically fails and a second succeeds — the most misleading failure mode there is, because it looks like eventual consistency and is a missing edge in the dependency graph.

ℹ️ This grant is HSM-wide on purpose here, because Terraform is creating keys that do not exist yet and cannot be named in a key-scoped grant.

3 · Wiring a custom role — `resource_manager_id`, not `id`
module "wrap_only_role" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-definition.git?ref=v1.0.0"

  name           = "7b6d2b5a-1c4e-4f6a-9a1e-2c3d4e5f6a7b"
  managed_hsm_id = module.managed_hsm.id
  role_name      = "Wrap and Unwrap Only"

  permissions = [{
    data_actions = [
      "Microsoft.KeyVault/managedHsm/keys/wrap/action",
      "Microsoft.KeyVault/managedHsm/keys/unwrap/action",
    ]
  }]
}

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

  name           = "3f4a5b6c-7d8e-9f0a-1b2c-3d4e5f6a7b8c"
  managed_hsm_id = module.managed_hsm.id
  scope          = "/keys/key-storage-cmk"

  # `resource_manager_id`, NOT `id`. The id carries the HSM base URL and is rejected.
  role_definition_id = module.wrap_only_role.resource_manager_id
  principal_id       = var.application_object_id
}

🔴 The role-definition module emits both, and only one works here. id includes the HSM's data-plane base URL; resource_manager_id is the bare /Microsoft.KeyVault/... path this argument requires.

4 · The three ID conventions in one family
Argument Form Example
managed_hsm_id ARM Resource ID /subscriptions/…/providers/Microsoft.KeyVault/managedHSMs/hsm-prod
role_definition_id provider-relative path /Microsoft.KeyVault/providers/Microsoft.Authorization/roleDefinitions/<uuid>
a key's id (elsewhere) data-plane URI https://hsm-prod.managedhsm.azure.net/keys/key-cmk
# Rejected -- an Azure RBAC role definition ID. Those DO begin /subscriptions/.
role_definition_id = "/subscriptions/.../providers/Microsoft.Authorization/roleDefinitions/21dbd100-..."

# Rejected -- the role-definition module's `id`, with the HSM base URL.
role_definition_id = "https://hsm-prod.managedhsm.azure.net//Microsoft.KeyVault/providers/..."

# Accepted.
role_definition_id = "/Microsoft.KeyVault/providers/Microsoft.Authorization/roleDefinitions/21dbd100-6940-42c2-9190-5d6cb909625b"

⚠️ The first rejection is the important one. An Azure RBAC role definition ID is a plausible, well-formed value that belongs to a different resource entirely — azurerm_role_assignment — and the error message says so.

5 · Scope, and the output that surfaces it
output "check_the_blast_radius" {
  value = {
    hsm_wide = module.crypto_user.is_hsm_wide_scope
    key      = module.crypto_user.scoped_key_name
  }
}
scope is_hsm_wide_scope scoped_key_name
/ true null
/keys true null
/keys/key-storage-cmk false "key-storage-cmk"

🔴 /keys and /keys/prod-cmk differ by a few characters and by the entire reach of the grant, and a plan diff shows only the string. That is what this output is for.

ℹ️ There is no per-version scope. An assignment always covers every version of the key it names, so rotating a key never narrows or widens an existing grant.

6 · What the scope validation rejects
# Rejected -- an Azure RBAC scope. Local RBAC does not use Resource IDs.
scope = "/subscriptions/.../resourceGroups/rg-hsm"

# Rejected -- no such shape. There is no per-version scope.
scope = "/keys/key-storage-cmk/9a8b7c6d"

# Rejected -- empty.
scope = ""

# Accepted.
scope = "/keys"
scope = "/keys/key-storage-cmk"
scope = "/"

ℹ️ Microsoft documents exactly two shapes, so this is a genuinely closed set and is enforced as one — unlike the open value sets elsewhere in this library where an unrecognised value is allowed through.

7 · ⚠️ The mistake this module cannot catch
output "read_this_when_a_grant_seems_to_do_nothing" {
  # Always true.
  value = module.crypto_user.an_application_id_passed_here_cannot_be_distinguished_from_an_object_id
}
# WRONG -- the application (client) id, shown first on an app registration blade.
# 11111111-2222-3333-4444-555555555555

# RIGHT -- the service principal's OBJECT id.
az ad sp show --id 11111111-2222-3333-4444-555555555555 --query id -o tsv

🔴 Both are 36-character GUIDs and nothing in their shape distinguishes them. The module validates that the value is a GUID and can do nothing about which GUID it is.

🔴 The failure is completely silent. Azure accepts the assignment against a principal that is not a directory object, and the application it was meant for is denied everything. No plan and no apply reports a problem.

💡 Safe sources: data.azurerm_client_config.current.object_id, or a user-assigned identity module's principal_id output.

8 · Recognised role names, and why unknown means `null`
output "what_am_i_granting" {
  value = {
    uuid = module.crypto_user.role_definition_uuid
    name = module.crypto_user.recognised_builtin_role_name
  }
}
# Crypto User
name = "Managed HSM Crypto User"

# A custom role, or a built-in one this module does not hard-code
name = null

ℹ️ It recognises only Crypto User and Crypto Officer — the two the provider's own examples use. A null means "not one of those two", not "this is a custom role": Administrator and Crypto Auditor also return null.

✅ Emitting null rather than a plausible guess is deliberate. A permissions review is exactly where a confident wrong label does damage. Confirm any other UUID with az keyvault role definition list --hsm-name <hsm>.

9 · Several grants for one HSM
variable "grants" {
  type = map(object({
    role_uuid    = string
    scope        = string
    principal_id = string
    name         = string
  }))
  default = {
    terraform_ci = {
      role_uuid    = "21dbd100-6940-42c2-9190-5d6cb909625b" # Crypto User
      scope        = "/keys"
      principal_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
      name         = "1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
    }
    rotation_officer = {
      role_uuid    = "515eb02d-2335-4d2d-92f2-b1cbdf9c3778" # Crypto Officer
      scope        = "/keys/key-storage-cmk"
      principal_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
      name         = "1e243909-064c-6ac3-84e9-1c8bf8d6ad23"
    }
  }
}

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

  name               = each.value.name
  managed_hsm_id     = module.managed_hsm.id
  scope              = each.value.scope
  role_definition_id = "/Microsoft.KeyVault/providers/Microsoft.Authorization/roleDefinitions/${each.value.role_uuid}"
  principal_id       = each.value.principal_id
}

output "blast_radius_review" {
  value = { for k, m in module.grants : k => {
    hsm_wide = m.is_hsm_wide_scope
    key      = m.scoped_key_name
    role     = m.recognised_builtin_role_name
  } }
}

💡 Each grant carries its own GUID name, committed in the variable. Generating one at random would replace the assignment on every run.

✅ The review output is the point of a for_each like this — it turns a set of opaque grants into a table a reviewer can read.

10 · `timeouts` has three keys, not four
module "crypto_user" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment.git?ref=v1.0.0"

  name               = "1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
  managed_hsm_id     = module.managed_hsm.id
  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

  timeouts = {
    create = "10m"
    read   = "5m"
    delete = "10m"
    # An `update` key here would be DISCARDED SILENTLY -- there is no update
    # operation, because every argument is force-new.
  }
}

⚠️ Object-type conversion drops an undeclared key with no error at all, so copying a four-key block from the sibling key or role-definition module loses the update entry without complaint. That is the one place a wrong input here produces no signal.

11 · The deprecated attribute this module will not emit
output "why_there_is_no_resource_id_output" {
  # Always true.
  value = module.crypto_user.the_deprecated_resource_id_attribute_is_deliberately_not_emitted
}

ℹ️ The provider marks resource_id on this resource as (Deprecated). Emitting it would invite a consuming configuration to depend on a field the provider intends to remove, making its later removal a breaking change to this module.

⚠️ Note the contrast with the role-definition sibling, whose resource_manager_id is not deprecated and is emitted — it is the value this module's role_definition_id consumes. Two similarly-named attributes, opposite decisions.

12 · This is not `azurerm_role_assignment`
azurerm_role_assignment this module
System Azure RBAC (control plane) Managed HSM local RBAC (data plane)
Scope a Resource ID /, /keys, /keys/<name>
Role ID /subscriptions/…/roleDefinitions/<uuid> /Microsoft.KeyVault/providers/…/roleDefinitions/<uuid>
CLI az role assignment create az keyvault role assignment create
Grants key use? No Yes
# Both are often needed, and they do different jobs.
module "hsm_contributor" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.managed_hsm.id

  role_assignments = {
    manage_the_resource = {
      principal_id         = data.azurerm_client_config.current.object_id
      role_definition_name = "Managed HSM Contributor"
    }
  }
}

🔴 Azure RBAC lets you manage the HSM resource; local RBAC lets you use the keys inside it. Azure's own security baseline records "Azure RBAC for Data Plane: Supported — False" for this service.

13 · 🏗️ End-to-end composition — an HSM, a custom role, grants, a key and rotation
provider "azurerm" {
  features {}
}

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" }
}

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

  # Terraform must be an administrator to create any assignment at all.
  admin_object_ids = [data.azurerm_client_config.current.object_id]

  tags = { environment = "prod" }
}

# A custom role, because no built-in role expresses wrap/unwrap without sign.
module "wrap_only_role" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-definition.git?ref=v1.0.0"

  name           = "7b6d2b5a-1c4e-4f6a-9a1e-2c3d4e5f6a7b"
  managed_hsm_id = module.managed_hsm.id
  role_name      = "Wrap and Unwrap Only"
  description    = "Envelope encryption for the storage CMK. No sign, no export, no delete."

  permissions = [{
    data_actions = [
      "Microsoft.KeyVault/managedHsm/keys/wrap/action",
      "Microsoft.KeyVault/managedHsm/keys/unwrap/action",
      "Microsoft.KeyVault/managedHsm/keys/read/action",
    ]
  }]
}

# THIS MODULE, twice. Terraform needs Crypto User HSM-wide to create keys...
module "terraform_crypto_user" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment.git?ref=v1.0.0"

  name               = "1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
  managed_hsm_id     = module.managed_hsm.id
  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
}

# ...and Crypto Officer to write a rotation policy and perform the rotations.
module "terraform_crypto_officer" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment.git?ref=v1.0.0"

  name               = "1e243909-064c-6ac3-84e9-1c8bf8d6ad23"
  managed_hsm_id     = module.managed_hsm.id
  scope              = "/keys"
  role_definition_id = "/Microsoft.KeyVault/providers/Microsoft.Authorization/roleDefinitions/515eb02d-2335-4d2d-92f2-b1cbdf9c3778"
  principal_id       = data.azurerm_client_config.current.object_id
}

# The application gets the custom role, at ONE key only.
module "app_wrap_only" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment.git?ref=v1.0.0"

  name               = "3f4a5b6c-7d8e-9f0a-1b2c-3d4e5f6a7b8c"
  managed_hsm_id     = module.managed_hsm.id
  scope              = "/keys/key-storage-cmk"
  role_definition_id = module.wrap_only_role.resource_manager_id
  principal_id       = var.application_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"]

  tags = { environment = "prod" }

  # The grants do not appear in this module's arguments, so the edge must be explicit.
  depends_on = [module.terraform_crypto_user, module.terraform_crypto_officer]
}

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"

  managed_hsm_key_id = module.hsm_key.id
  expire_after       = "P90D"
  time_before_expiry = "P30D"

  depends_on = [module.terraform_crypto_officer]
}

output "authorization_review" {
  value = {
    terraform_user     = module.terraform_crypto_user.is_hsm_wide_scope
    terraform_officer  = module.terraform_crypto_officer.is_hsm_wide_scope
    app_scope_is_wide  = module.app_wrap_only.is_hsm_wide_scope
    app_key            = module.app_wrap_only.scoped_key_name
    app_role           = module.wrap_only_role.role_name
    app_effective      = module.wrap_only_role.effective_data_actions
  }
}

🔴 Three assignments, and only one of them should be HSM-wide by intent. Terraform's two grants are HSM-wide because it creates keys that do not exist yet; the application's grant is key-scoped because it uses exactly one. authorization_review makes that asymmetry visible at plan time.

⚠️ var.application_object_id is a var.* rather than a module reference on purpose — the application's service principal is created outside this configuration, and it must be the object ID.

💡 The custom role exists because no built-in role fits. Crypto User would also grant sign, decrypt and export, which this application has no need of.


📥 Inputs

Input Type Default Notes
name string — Required. A GUID you supply. Force-new.
managed_hsm_id string — Required. ARM path. Force-new.
scope string — Required. /, /keys, /keys/<name>. Force-new.
role_definition_id string — Required. /Microsoft.KeyVault/…. Force-new.
principal_id string — Required. Entra object ID. Force-new.
timeouts object(...) null Three keys only — no update.
Full schemas
variable "name" { type = string }               # GUID-validated
variable "managed_hsm_id" { type = string }      # anchored ARM path; rejects hsm_uri and a vault ID
variable "scope" { type = string }               # closed set: "/", "/keys", "/keys/<name>"
variable "role_definition_id" { type = string }  # rejects an ARM path and the https form
variable "principal_id" { type = string }        # GUID-validated; object vs application id is undetectable

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

🧾 Outputs

Output Type Notes
id string With the HSM base URL. Nothing consumes it.
name / managed_hsm_id / scope / role_definition_id / principal_id string All force-new. Not sensitive.
managed_hsm_name / managed_hsm_resource_group string Derived.
is_hsm_wide_scope bool Derived. Read this in every plan.
scoped_key_name string Derived. null when HSM-wide.
role_definition_uuid string Derived.
recognised_builtin_role_name string Derived. null rather than a guess.
an_application_id_passed_here_cannot_be_distinguished_from_an_object_id bool Always true.
this_assignment_is_a_prerequisite_that_nothing_references bool Always true.
the_deprecated_resource_id_attribute_is_deliberately_not_emitted bool Always true.
this_resource_carries_no_tags bool Always true.

🔒 Nothing is sensitive. A role definition path is a permission reference and an object ID identifies a principal; neither grants anything on its own, and redacting them would obstruct exactly the review these outputs exist for.


🧠 Architecture Notes

Managed HSM local RBAC is a separate authorization system, and that is the whole reason this resource exists. Microsoft documents a dual-plane model: the control plane at management.azure.com is governed by Azure RBAC, and the data plane at <hsm>.managedhsm.azure.net by local RBAC. Using a key is a data-plane operation, so azurerm_role_assignment — which grants Azure RBAC roles at Resource ID scopes — cannot grant it. The two systems have different scopes, different role definition paths and different CLI commands, and Azure's security baseline records "Azure RBAC for Data Plane: Supported — False" for this service.

Nothing references this resource, and other resources cannot be created without it. That combination is unusual and it is the module's sharpest trap. A Managed HSM key needs a Crypto User grant to exist before Terraform can create it — but the key does not reference the assignment, so Terraform sees no dependency and is free to attempt the key first. The result is a configuration that fails on a first apply and succeeds on a second, which reads like eventual consistency and is actually a missing edge in the graph. The fix is depends_on from each consumer to this module, in the composition. depends_on is valid on a module block; it is lifecycle that is not, and this suite repeats that caveat often enough to be worth disambiguating.

Three ID conventions live in this family and this module touches two of them. managed_hsm_id is an ARM Resource ID. role_definition_id is a provider-relative path beginning /Microsoft.KeyVault/ with no subscription in it at all — so the role-definition module's resource_manager_id is the output to wire, not its id, which carries the HSM's data-plane base URL. Elsewhere in the family a key's id is a data-plane URI. Each of the wrong forms is rejected by name, and the most valuable rejection is an Azure RBAC role definition ID: those genuinely begin /subscriptions/, are well-formed, and belong to a different resource entirely.

Every argument is force-new, which is why timeouts has three keys instead of four. There is no update operation to time out. This is coherent rather than an oversight, but it interacts badly with object-type conversion: a four-key timeouts block copied from the sibling key or role-definition module loses its update entry silently, with no error anywhere. That is the one place a wrong input to this module produces no signal at all.

scope is a genuinely closed set, and the module enforces it as one. Microsoft documents / or /keys for the whole HSM and /keys/<key-name> for a single key — nothing else, and in particular no per-version scope, so an assignment always covers every version of the key it names and rotation never changes its reach. Because the set is closed rather than an open list of examples, this is one of the few places in this library where an unrecognised value is rejected outright rather than allowed through. The derived is_hsm_wide_scope exists because /keys and /keys/prod-cmk differ by a handful of characters and by the entire blast radius of the grant, and a plan shows only the string.

The one mistake the module cannot catch is the one most likely to be made. A service principal has an application (client) ID and an object ID; principal_id wants the object ID; both are 36-character GUIDs with nothing in their shape to tell them apart. So the module validates GUID-ness and can do no more. The failure is silent — Azure accepts an assignment against a principal that is not a directory object, and the intended application is denied everything — which is why it is emitted as a constant output with the az ad sp show command that answers it.

Two decisions about attributes are worth stating. resource_id is marked (Deprecated) by the provider and is deliberately not emitted: an output is an interface, and re-exposing a field the provider intends to remove would make its removal a breaking change here. By contrast the role-definition sibling's similarly-named resource_manager_id is not deprecated and is emitted, because it is the value this module consumes. And there are no tags — the provider exposes none on either role resource, while the Managed HSM key does, so the answer varies resource by resource across this family and cannot be inferred from a neighbour.

Finally, assigning a role is itself an administrative act. The roleAssignments/write data action belongs to Managed HSM Administrator, not to Crypto User or Crypto Officer — so an identity that can use keys generally cannot grant access to them. On a brand-new HSM the only administrators are those listed in admin_object_ids at creation, so Terraform must run as one of them to create any assignment at all. Microsoft's advice to assign administrative roles to Entra security groups rather than individuals matters most here: a deleted user account can leave an HSM with no administrator and no route back in.


🧱 Design Principles

Concern This module's position Why
Local vs Azure RBAC Named throughout, with a comparison table. azurerm_role_assignment cannot do this job.
The depends_on requirement A constant output plus a worked example. Nothing references the resource; things depend on it.
role_definition_id Rejects an ARM path and the https form, by name. Three conventions coexist; both wrong values are plausible.
scope Enforced as a closed set. Microsoft documents exactly two shapes.
Blast radius Derived is_hsm_wide_scope and scoped_key_name. The raw string buries the difference.
name GUID-validated; a friendly name rejected. You supply it, and a readable name is the natural guess.
Object vs application ID Named in a constant output; not detectable. Both are GUIDs; the failure is silent.
Unrecognised role UUID null, never a guess. A wrong label in a permissions review does damage.
resource_id Not emitted. The provider deprecated it; an output is an interface.
timeouts Three keys, with the silent-drop warning. There is no update operation.
tags Omitted, with an output saying why. The provider exposes none.
Secrets None. Nothing marked sensitive. Paths and object IDs grant nothing alone.

🚀 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:

# Confirm Terraform's identity is an HSM administrator.
az keyvault role assignment list --hsm-name hsm-prod-eastus2 --assignee <object-id> --scope /

# Get the OBJECT id for a service principal -- not its application id.
az ad sp show --id <application-id> --query id -o tsv

After the apply:

# What was actually granted, and where.
az keyvault role assignment list --hsm-name hsm-prod-eastus2 -o table

# NOTE: a scope of / or /keys does NOT list key-level assignments -- query those separately.
az keyvault role assignment list --hsm-name hsm-prod-eastus2 --scope /keys/key-storage-cmk -o table

🧪 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 — that all 13 validations fire on the input each targets. No validation on this module references another variable, so validation suppression is not a factor here and grouping was by intent rather than by dependency.

Proved with a failing value each: an empty name and a friendly name where a GUID belongs; the parent's hsm_uri and a Key Vault ID in managed_hsm_id; an empty scope, an Azure RBAC scope, and a per-version scope that does not exist; an empty role_definition_id, an Azure RBAC role definition ID, and the role-definition module's id with its https:// base URL; and an empty principal_id plus a display name where a GUID belongs.

Every derived value was printed rather than reasoned about, against all three scope shapes. is_hsm_wide_scope returned true for both / and /keys with scoped_key_name = null, and false for /keys/key-storage-cmk with the key name parsed correctly. recognised_builtin_role_name returned "Managed HSM Crypto User" and "Managed HSM Crypto Officer" for the two known UUIDs and null for an unrecognised one — confirming it declines rather than guesses. The parsed HSM name and resource group were checked against a real ARM ID.

One harness note worth recording: Git Bash rewrites any -var value beginning with a slash into a Windows path, so -var 'scope=/keys' arrived as C:/Program Files/Git/keys and produced a wrong answer that looked like a module defect. Every slash-leading value was moved into a variables file.

What only an apply exercises: whether the HSM exists, and whether Terraform's identity is an HSM administrator — the most likely failure.

What no Terraform run exercises at all: whether principal_id is the object ID rather than the application ID, and whether the granted role actually permits what the application needs.


💬 Example Output

Outputs:

an_application_id_passed_here_cannot_be_distinguished_from_an_object_id = true
id = "https://hsm-prod-eastus2.managedhsm.azure.net//providers/Microsoft.Authorization/roleAssignments/1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
is_hsm_wide_scope = false
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 = "1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
principal_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
recognised_builtin_role_name = "Managed HSM Crypto User"
role_definition_id = "/Microsoft.KeyVault/providers/Microsoft.Authorization/roleDefinitions/21dbd100-6940-42c2-9190-5d6cb909625b"
role_definition_uuid = "21dbd100-6940-42c2-9190-5d6cb909625b"
scope = "/keys/key-storage-cmk"
scoped_key_name = "key-storage-cmk"
the_deprecated_resource_id_attribute_is_deliberately_not_emitted = true
this_assignment_is_a_prerequisite_that_nothing_references = true
this_resource_carries_no_tags = true

🔍 Troubleshooting

Symptom Cause Fix
Apply fails: forbidden on the role assignment Terraform is not an HSM Administrator. Add it to admin_object_ids, or grant Administrator at /.
A key fails on the first apply, works on the second The key was attempted before the grant. Add depends_on = [module.<this>] to the key.
name must be a GUID A friendly name. Generate a GUID and commit it.
scope looks like an Azure RBAC scope A Resource ID. Use /keys or /keys/<key-name>.
scope must be "/" or "/keys" … Usually a per-version scope. There is none; scope the key, not a version.
role_definition_id is an ARM Resource ID An Azure RBAC role definition. Use a Managed HSM role path, or azurerm_role_assignment.
role_definition_id begins with https:// The role-definition module's id. Use its resource_manager_id.
managed_hsm_id is a data-plane URI The parent's hsm_uri. Pass module.managed_hsm.id.
The grant exists but the app is denied everything principal_id is the application ID. az ad sp show --id <app-id> --query id.
The grant exists but the operation is still denied The role lacks the needed data action. Check effective_data_actions on the role definition.
An update timeout is ignored There is no update operation. Remove it; only create, read, delete exist.
A tag edit is rejected The provider exposes no tags here. Tag the Managed HSM.
az keyvault role assignment list shows nothing A / or /keys query omits key-level assignments. Query --scope /keys/<key-name> too.

🔗 Related Docs


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