Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Kusto Cluster Customer-Managed Key Terraform Module

Encrypts an Azure Data Explorer cluster's data at rest under a key you control (azurerm_kusto_cluster_customer_managed_key) — Key Vault or Managed HSM, with the exactly-one-of rule enforced at plan time. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources

🧩 Overview

  • 🔐 Declares one customer-managed key configuration for a Kusto cluster, as a keystone resource named this.
  • 🧭 Decides who holds the key, not whether data is encrypted. Kusto encrypts at rest either way; this makes the key yours, which is a governance and revocation capability rather than an encryption upgrade.
  • ✅ Simpler than the equivalent Service Bus module, and worth saying so. azurerm_kusto_cluster has no inline customer-managed-key block, so this separate resource is the only way to express the configuration — nothing competes with it and there is no perpetual diff to live with.
  • 🚦 Enforces "exactly one of key_vault_id or managed_hsm_key_id" at plan time. Every field on this resource is optional in the schema, so neither being set is accepted at plan and fails at apply.
  • 🔄 Leaves key_version null by default so a rotation inside Key Vault takes effect without a Terraform change.
  • 🗝️ Takes references, never key material — nothing secret passes through configuration or state.

💡 Why it matters: The trade-off of customer-managed keys is rarely stated plainly, so here it is: you gain the ability to render the cluster's data unreadable at will, and you accept that a vault outage, a deleted key or an expired key does the same thing accidentally. The vault joins the cluster's availability path. That is worth doing deliberately — and it is why purge protection is a prerequisite rather than a suggestion.

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

flowchart LR
  cluster["terraform-azurerm-kusto-cluster"]
  db["terraform-azurerm-kusto-database"]
  adc["terraform-azurerm-kusto-attached-database-configuration"]
  script["terraform-azurerm-kusto-script"]
  cosmos["terraform-azurerm-kusto-cosmosdb-data-connection"]
  eg["terraform-azurerm-kusto-eventgrid-data-connection"]
  eh["terraform-azurerm-kusto-eventhub-data-connection"]
  iot["terraform-azurerm-kusto-iothub-data-connection"]
  cmk["terraform-azurerm-kusto-cluster-customer-managed-key"]
  mpe["terraform-azurerm-kusto-cluster-managed-private-endpoint"]
  cpa["terraform-azurerm-kusto-cluster-principal-assignment"]
  dpa["terraform-azurerm-kusto-database-principal-assignment"]
  cluster -->|"hosts"| db
  cluster -->|"attaches leader db"| adc
  db -->|"target of"| script
  db -->|"ingests via"| cosmos
  db -->|"ingests via"| eg
  db -->|"ingests via"| eh
  db -->|"ingests via"| iot
  cluster -->|"encrypted by, BY ID"| cmk
  cluster -->|"private egress via, BY NAME"| mpe
  cluster -->|"data-plane roles: ALL databases"| cpa
  db -->|"data-plane roles: ONE database, prefer this"| dpa
  mpe -->|"makes the source reachable"| eh
  classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef ext fill:#f2f2f2,stroke:#c8c8c8,color:#111111;
  class cmk me;
  class cluster,db,adc,script,cosmos,eg,eh,iot,mpe,cpa,dpa ext;
Loading

🧬 What this module builds

flowchart TB
  what["WHO HOLDS THE KEY, not whether data is encrypted: Kusto encrypts at rest either way"]
  simple["SIMPLER THAN THE SERVICE BUS EQUIVALENT: azurerm_kusto_cluster has NO inline customer_managed_key block, so nothing competes with this resource and there is NO perpetual diff"]
  xor["EXACTLY ONE of key_vault_id or managed_hsm_key_id. Every field is optional in the schema, so NEITHER is accepted at plan and fails at apply: rejected here at plan instead."]
  cycle["all three cross-field checks live on ONE variable, read one-directionally, to avoid a validation cycle"]
  kv["key_vault_id plus key_name: the Key Vault path"]
  hsm["managed_hsm_key_id: the FIPS 140-2 Level 3 single-tenant path, versionless or versioned"]
  ver["key_version left NULL is the better default: the cluster follows the current version so rotation needs no Terraform change"]
  ident["user_identity null means the cluster's SYSTEM-assigned identity reads the key"]
  grant["that identity still needs Get plus Wrap Key plus Unwrap Key: a SEPARATE grant to a DIFFERENT principal, and the most-missed step"]
  vault["purge protection and soft delete on the vault: REQUIRED, and the apply is refused without both"]
  avail["the vault joins the AVAILABILITY PATH of the cluster: revoke the key and the data is unreadable"]
  this["terraform-azurerm-kusto-cluster-customer-managed-key"]
  res["azurerm_kusto_cluster_customer_managed_key.this"]
  out["outputs: id which the provider sets to the CLUSTER id, plus derived posture booleans. The key reference is NOT re-emitted."]

  what -->|"read this first"| this
  simple -->|"good news"| this
  xor -->|"enforced at plan"| this
  cycle -->|"how"| xor
  kv -->|"path A"| xor
  hsm -->|"path B"| xor
  ver -->|"rotation"| kv
  ident -->|"who reads it"| this
  grant -->|"without it the apply fails"| ident
  vault -->|"prerequisite"| kv
  avail -->|"the cost of custody"| vault
  this -->|"creates"| res
  res -->|"emits"| out

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class res keystone;
  class what,simple,xor,cycle,kv,hsm,ver,ident,grant,vault,avail,out sib;
Loading

Resource inventory

Resource Count Notes
azurerm_kusto_cluster_customer_managed_key.this 1 The keystone. A 1:1 configuration record for the cluster — no name, no tags.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication.

Schema notes that bite — confirmed against the live provider schema:

  • cluster_id is force-new. Changing it creates a new encryption configuration rather than moving the existing one.
  • Every other field is marked optional in the schema, including both key paths. So a call with neither key_vault_id nor managed_hsm_key_id passes the provider's own validation and fails at apply. This module rejects it at plan.
  • Exactly one of key_vault_id / managed_hsm_key_id is legal. Both is a conflict; neither is the trap above.
  • The id this resource exports is the cluster's Resource ID, not a distinct child ID — so an import block takes the cluster ID.
  • azurerm_kusto_cluster has no inline customer_managed_key block. Unlike Service Bus, there is no second declaration of the same field and therefore no drift.
  • user_identity null means the cluster's system-assigned identity is used.
  • No tags and no name. The universal tail is timeouts only.
  • Kusto cluster operations are slow; the provider's generous default timeouts are appropriate.

🔑 Required Azure RBAC Roles / Permissions

Two audiences, and conflating them causes most failed first applies.

The caller:

Scope Role / permission Why
The Kusto cluster Contributor, or a custom role with Microsoft.Kusto/clusters/write The encryption configuration is a write against the cluster.
The key or its vault Key Vault Crypto Officer, or rights to set an access policy The caller must be able to grant the cluster access to the key.

The identity that reads the key — the cluster's system-assigned identity by default, or user_identity:

Scope Role / permission Why
The key Key Vault Crypto Service Encryption User, or Get / Wrap Key / Unwrap Key on an access-policy vault ⚠️ A separate grant to a different principal, and the step most often missed. Without it the apply fails with a key-access error that reads like a wrong key ID.

Azure Prerequisites

  • The Microsoft.Kusto and Microsoft.KeyVault resource providers registered on the subscription.
  • An existing Kusto cluster with a managed identity. This module does not create or enable one.
  • A Key Vault with purge protection and soft delete enabled, or a Managed HSM. 🔒 Both are required, not advisable: the provider reads the vault during create and refuses with "must be configured for both Purge Protection and Soft Delete" if either is off. That check lives in the provider's create path rather than in its schema, so it needs credentials and cannot fire offline — nothing local will warn you before the apply. The requirement is a good one: without soft delete, a deleted key would leave the cluster's data permanently unreadable.
  • An existing RSA key in that vault or HSM.
  • The reading identity granted key access, with time allowed for role-assignment propagation.

📁 Module Structure

terraform-azurerm-kusto-cluster-customer-managed-key/
├── providers.tf   # required_version + the pinned azurerm provider. No provider block.
├── variables.tf   # cluster_id, the two key paths, key_name/key_version, user_identity, timeouts
├── main.tf        # the keystone azurerm_kusto_cluster_customer_managed_key.this
├── outputs.tf     # id (the cluster ID — see the note), cluster_id, three derived posture booleans
├── README.md      # this document
├── SCOPE.md       # the cross-module contract
├── LICENSE        # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "adx_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-customer-managed-key.git?ref=v1.0.0"

  cluster_id   = module.adx.id
  key_vault_id = module.kv.id
  key_name     = azurerm_key_vault_key.adx.name
}

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block. This module declares none of them.

⚠️ Before running this: the cluster's identity needs Get, Wrap Key and Unwrap Key on the key. Example 4 shows the grant — it is the step most first attempts miss.

🔌 Cross-Module Contract

Consumes

Input Type Source module
cluster_id string terraform-azurerm-kusto-cluster → id
key_vault_id string terraform-azurerm-key-vault → id
key_name string the key's name
key_version string caller decision — null lets rotation happen without a Terraform change
managed_hsm_key_id string a Managed HSM key, provisioned out of band
user_identity string terraform-azurerm-user-assigned-identity → id

Emits

Output Description Consumed by
id The configuration's ID — the cluster's Resource ID. imports, state reading
cluster_id The cluster this key applies to. governance review
uses_managed_hsm Derived. governance review
key_version_pinned Derived — false is usually preferable. governance review
uses_system_assigned_identity Derived. governance review

📚 Example Library

The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:

variable "adx_prod_id" {
  description = "id of an existing adx prod that these examples reference but do not create."
  type        = string
}

variable "adx_staging_id" {
  description = "id of an existing adx staging that these examples reference but do not create."
  type        = string
}

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

data "azurerm_client_config" "current" {}
1 · The Key Vault path — the common case
module "adx_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-customer-managed-key.git?ref=v1.0.0"

  cluster_id   = module.adx.id
  key_vault_id = module.kv.id
  key_name     = azurerm_key_vault_key.adx.name
}

🔒 Three references and nothing else. No key material passes through configuration or state.

💡 key_version is deliberately absent — see example 3 for why that is the better default.

2 · The Managed HSM path
module "adx_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-customer-managed-key.git?ref=v1.0.0"

  cluster_id         = module.adx.id
  managed_hsm_key_id = var.adx_hsm_key_id
}

ℹ️ Choose this when the key must live in a FIPS 140-2 Level 3 single-tenant HSM rather than the multi-tenant Key Vault service. key_name is not used here — the HSM key ID carries the key name, and supplying it is rejected at plan, matching the provider's own rule that key_name requires key_vault_id. key_version is accepted on this path but ignored, because the provider derives the version from the key URI; the module reports that through its key_version_is_ignored_on_the_managed_hsm_path output rather than refusing it.

⚠️ The trade-off is operational: a Managed HSM is a heavier resource to run. Rotation, though, is not necessarily manual here — the provider accepts a versionless key URI (https://<hsm>.managedhsm.azure.net/keys/<key>) as readily as a versioned one, and the versionless form auto-rotates exactly as an unpinned Key Vault key does. Prefer it for the same reason.

3 · Why `key_version` should usually stay null
# ✅ Preferred: follows the key's current version.
module "adx_cmk" {
  # ...
  key_name = azurerm_key_vault_key.adx.name
  # key_version deliberately omitted
}
# ⚠️ Pinned: every rotation is now a configuration change someone must remember.
module "adx_cmk" {
  # ...
  key_name    = azurerm_key_vault_key.adx.name
  key_version = azurerm_key_vault_key.adx.version
}

💡 With no version pinned, rotating the key inside Key Vault takes effect without touching Terraform. Pin it only when a control requires the exact version to be recorded in configuration — and then accept that until someone updates the value, the cluster keeps using the old key material.

ℹ️ key_version_pinned is emitted so a review can see which mode is in force.

4 · The grant the cluster itself needs (most-missed step)
# The CLUSTER's identity must be able to use the key. This is a DIFFERENT principal
# from the one running Terraform, and it is a separate grant.
resource "azurerm_role_assignment" "adx_wrap_unwrap" {
  scope                = azurerm_key_vault_key.adx.resource_versionless_id
  role_definition_name = "Key Vault Crypto Service Encryption User"
  principal_id         = module.adx.identity_principal_id
}

module "adx_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-customer-managed-key.git?ref=v1.0.0"

  cluster_id   = module.adx.id
  key_vault_id = module.kv.id
  key_name     = azurerm_key_vault_key.adx.name

  depends_on = [azurerm_role_assignment.adx_wrap_unwrap]
}

⚠️ Without this grant the apply fails with a key-access error that reads like a wrong key ID. The depends_on is deliberate: nothing in this module's arguments references the role assignment, so the ordering is not otherwise expressed — and role assignments take time to propagate, so a retry is sometimes needed even with correct ordering.

5 · Prepare the vault first: purge protection and soft delete
module "kv" {
  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-adx-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  purge_protection_enabled   = true # non-negotiable for a vault backing encryption
  soft_delete_retention_days = 90
}

🔒 Both settings are required, not advisable — the provider reads the vault during create and refuses the apply with "must be configured for both Purge Protection and Soft Delete" if either is off. Beyond that refusal, the reasons stand on their own: the soft-delete window is the only recovery path if the key is deleted, and purge protection cannot be applied retroactively to a key that is already purged. Enable both before a cluster depends on the vault.

⚠️ That refusal fires at apply, not at validate or plan. The check lives in the provider's create path rather than in its schema, so it needs credentials and a live vault to run — a misconfigured vault costs you an apply, not a plan.

ℹ️ Purge protection also cannot be turned back off. That permanence is the point.

6 · A user-assigned identity instead
module "adx_identity" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"

  name                = "id-adx-cmk"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "adx_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-customer-managed-key.git?ref=v1.0.0"

  cluster_id    = module.adx.id
  key_vault_id  = module.kv.id
  key_name      = azurerm_key_vault_key.adx.name
  user_identity = module.adx_identity.id
}

💡 Reach for this when the same identity must be reused across resources, or when it has to exist before the cluster does. Otherwise leaving user_identity null is simpler: the cluster module already defaults to a system-assigned identity, so there is one fewer resource to create and grant.

⚠️ The grant moves with the choice — it is now module.adx_identity.principal_id that needs key access, not the cluster's own identity.

7 · What the plan-time guards reject
# ❌ Neither path set. The provider's schema ACCEPTS this and fails at apply.
module "adx_cmk" {
  cluster_id = module.adx.id
}
Error: Invalid value for variable

  Exactly one of key_vault_id or managed_hsm_key_id must be set. Supplying both is
  a conflict; supplying neither is accepted by the provider's schema and then fails
  at apply, so it is rejected here instead.
# ❌ Key Vault path with no key name.
module "adx_cmk" {
  cluster_id   = module.adx.id
  key_vault_id = module.kv.id
}
Error: Invalid value for variable

  key_name is required when key_vault_id is set — a vault reference alone does not
  identify a key.

💡 All three checks live on the single key_vault_id variable, reading the others one-directionally. Terraform rejects validations that reference each other across variables, so splitting them would produce a cycle — the same trap the authorization-rule modules elsewhere in this suite hit.

8 · The `id` output is the cluster's ID
output "adx_cmk_id" {
  value = module.adx_cmk.id
}
adx_cmk_id = "/subscriptions/00000000-.../resourceGroups/rg-analytics/providers/Microsoft.Kusto/clusters/adxprod"

ℹ️ Not a mistake in this document: the provider exports this resource's id as the cluster's Resource ID. It matters when writing an import block, which takes exactly that value:

import {
  to = module.adx_cmk.azurerm_kusto_cluster_customer_managed_key.this
  id = "/subscriptions/00000000-.../providers/Microsoft.Kusto/clusters/adxprod"
}
9 · No perpetual diff here — unlike Service Bus
# Second and subsequent plans, from an unchanged configuration:
No changes. Your infrastructure matches the configuration.

💡 Worth stating because the equivalent Service Bus module cannot say it. There, an inline customer_managed_key block exists on the namespace resource, so a separately-declared key produces a diff on every plan forever. azurerm_kusto_cluster has no such block, so this resource is the only declaration of the field and nothing fights it.

ℹ️ If you are porting a pattern from that module, this is the difference: no ignore_changes discussion is needed.

10 · The revocation drill — and what it costs
# Disabling the key in Key Vault renders the cluster's data unreadable.
# This is deliberate — it is the capability a customer-managed key buys you.

⚠️ Rehearse it in a non-production cluster before you need it. Two things surprise teams: the effect is not instantaneous, so "revoked" and "actually unreadable" are minutes apart; and re-enabling the key does not always bring the cluster back without a service-side refresh.

🔒 The corollary is that the vault is now a production dependency of the cluster. A vault firewall change, a deleted key or an expired key takes analytics down. Put the vault under the same change control as the cluster.

11 · One key per cluster, at scale
locals {
  clusters = {
    prod    = var.adx_prod_id
    staging = var.adx_staging_id
  }
}

module "adx_cmk" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-customer-managed-key.git?ref=v1.0.0"
  for_each = local.clusters

  cluster_id   = each.value
  key_vault_id = module.kv.id
  key_name     = azurerm_key_vault_key.per_cluster[each.key].name
}

💡 A key per cluster means revocation is per cluster. Sharing one key means revoking it takes every cluster offline together — occasionally what you want during an incident, usually not.

⚠️ Keyed by a stable string, not by index, so adding a third cluster does not disturb the first two.

12 · What a governance review should assert
output "adx_encryption_posture" {
  value = {
    cluster            = module.adx_cmk.cluster_id
    in_managed_hsm     = module.adx_cmk.uses_managed_hsm
    version_pinned     = module.adx_cmk.key_version_pinned     # expect false
    system_identity    = module.adx_cmk.uses_system_assigned_identity
  }
}

ℹ️ Three derived booleans rather than a re-emitted key reference. The posture is reviewable without this module becoming a second source of truth for the key — Key Vault is.

💡 version_pinned = true is worth a question in review: it usually means a rotation will silently not take effect.

13 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

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

  name     = "rg-analytics-prod"
  location = "eastus"
}

# 1 · A vault that can safely back encryption at rest.
module "kv" {
  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-adx-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  purge_protection_enabled   = true
  soft_delete_retention_days = 90
}

resource "azurerm_key_vault_key" "adx" {
  name         = "adx-cmk"
  key_vault_id = module.kv.id
  key_type     = "RSA"
  key_size     = 2048
  key_opts     = ["unwrapKey", "wrapKey"] # only what encryption at rest needs
}

# 2 · The cluster, with its default system-assigned identity.
module "adx" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster.git?ref=v1.0.0"

  name                = "adxprod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  sku = {
    name     = "Standard_E8ads_v5"
    capacity = 2
  }
}

# 3 · The cluster's identity must be able to wrap and unwrap with the key.
resource "azurerm_role_assignment" "adx_wrap_unwrap" {
  scope                = azurerm_key_vault_key.adx.resource_versionless_id
  role_definition_name = "Key Vault Crypto Service Encryption User"
  principal_id         = module.adx.identity_principal_id
}

# 4 · Link the key. This is the module.
module "adx_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-customer-managed-key.git?ref=v1.0.0"

  cluster_id   = module.adx.id
  key_vault_id = module.kv.id
  key_name     = azurerm_key_vault_key.adx.name

  depends_on = [azurerm_role_assignment.adx_wrap_unwrap]
}

output "analytics_encryption" {
  value = {
    cluster        = module.adx_cmk.cluster_id
    version_pinned = module.adx_cmk.key_version_pinned
  }
}

🔒 What the composition gets right: no key material appears anywhere, the key is restricted to wrapKey/unwrapKey rather than granted broad operations, purge protection is on before anything depends on the vault, and the version is unpinned so rotation needs no code change.

💡 Note the depends_on. Nothing in this module's arguments references the role assignment, so without it Terraform is free to link the key before the cluster can read it.

📥 Inputs

Input Type Default Notes
cluster_id string — Required. Force-new.
key_vault_id string null Exactly one of this / managed_hsm_key_id. Requires key_name.
key_name string null Required with key_vault_id; rejected with the HSM path.
key_version string null Null follows the current version — the better default.
managed_hsm_key_id string null The alternative to the Key Vault path.
user_identity string null Null = the cluster's system-assigned identity.
timeouts object(...) null create / read / update / delete.
Full schemas
variable "cluster_id" {
  type = string
  # ⚠️ Force-new.
}

variable "key_vault_id" {
  type    = string
  default = null
  # Carries all three cross-field checks (one-directionally, to avoid a validation cycle):
  #   1. exactly one of key_vault_id / managed_hsm_key_id
  #   2. key_name required when key_vault_id is set
  #   3. key_name rejected on the managed-HSM path (key_version is ACCEPTED there but ignored,
  #      and reported through an output rather than refused)
  # 🔒 A reference to a vault, not key material. Purge protection + soft delete FIRST.
}

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

variable "key_version" {
  type    = string
  default = null
  # 💡 Null follows the key's current version, so rotation needs no Terraform change.
}

variable "managed_hsm_key_id" {
  type    = string
  default = null
  # The FIPS 140-2 Level 3 single-tenant path. Versioned, so rotation IS a config change.
}

variable "user_identity" {
  type    = string
  default = null
  # Null = the cluster's system-assigned identity. Either way the identity needs
  # Get + Wrap Key + Unwrap Key on the key — a separate grant to a different principal.
}

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

🧾 Outputs

Output Description Kind
id The Resource ID of the encryption configuration Passthrough
cluster_id The Kusto cluster this key applies to Passthrough
uses_managed_hsm Whether the key lives in a Managed HSM (true) or in Key Vault (false) Derived
key_version_pinned Derived
uses_system_assigned_identity Whether the cluster's system-assigned identity reads the key (true) or a supplied user-assigned identity does (false) Derived
terraform_id_is_the_cluster_id Always true, and it has a consequence worth checking for before you apply Constant
destroy_reverts_to_microsoft_managed_keys Always true, and it is not what "destroy" usually means Constant
key_vault_must_have_purge_protection_and_soft_delete Always true, and it is enforced rather than advised Constant
serializes_against_other_operations_on_the_same_cluster Always true Constant
every_operation_is_an_update_to_the_cluster Always true Constant
refuses_a_cluster_that_already_has_a_key Always true Constant
key_version_is_ignored_on_the_managed_hsm_path True when a key_version has been supplied alongside a managed_hsm_key_id, in which case IT DOES NOTHING Derived
key_rotation_is_automatic True when no key version is pinned by either path — a null key_version on the Key Vault path, or a versionless key URI on the managed-HSM path — meaning the cluster follows the key's current version and a rotation in the vault takes effect without a Terraform change Passthrough

🔒 No secrets here, and none possible: this module handles key references, never key material. The key reference is deliberately not re-emitted — Key Vault is the source of truth for the key, not this module.

🧠 Architecture Notes

  • The schema's optionality is the real hazard. Every field except cluster_id is optional, so the provider will accept a call that specifies no key at all and fail only at apply. That is precisely the class of error this library's typing rules exist to catch, so the exactly-one-of rule is enforced at plan.

  • All three cross-field checks live on one variable. Terraform rejects validations that reference each other across variables — the resulting error is a cycle, not a helpful message. Putting all three on key_vault_id and reading the others one-directionally avoids it. This is the same pattern the authorization-rule modules in this suite use, and the reason is worth remembering rather than rediscovering.

  • No inline block means no drift, and that is not universal. The equivalent Service Bus resource has an inline customer_managed_key block on its namespace, so a separately-declared key produces a permanent diff. azurerm_kusto_cluster has no such block. If you are carrying a mental model over from that module, this is the difference.

  • Unpinned key_version is a deliberate default, not laziness. Following the key's current version means Key Vault owns rotation. Pinning moves rotation into Terraform, where it becomes a change someone has to remember — and until they do, the cluster silently keeps using superseded key material.

  • The identity grant is the most common first-apply failure, and it is easy to misdiagnose because the error reads like a wrong key ID. It is a grant to the cluster's identity, not the caller's, and role assignments propagate asynchronously.

  • The Key Vault becomes a production dependency of the cluster. A firewall change, a deleted key or an expired key can each make data unreadable. Encryption custody and analytics availability are now coupled.

  • The id quirk is worth internalizing. The provider exports this resource's id as the cluster's Resource ID. State reads and imports both behave accordingly.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Key material in configuration or state impossible — the module takes references only —
Key material in outputs never emitted; the key reference is not re-emitted either —
Ambiguous key configuration rejected at plan: exactly one path, and a named key on the vault path —
Key rotation unpinned version, so Key Vault owns rotation pin key_version, knowingly
Vault hardening documented as a prerequisite: purge protection + soft delete before the cluster depends on the vault —
Identity a managed identity, never embedded credentials —
Key permissions documented as exactly Get, Wrap Key, Unwrap Key grant more, knowingly
Custody trade-off stated plainly: the vault joins the cluster's availability path —
  • Before applying: confirm the cluster has an identity, and that the identity has key access.
  • Before applying: confirm purge protection and soft delete are on the vault.
  • Before applying: decide whether key_version should be pinned, and default to not pinning it.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the source to a tag — ?ref=v1.0.0 — never a branch.
  • Plan-only from here. A human applies from CI.
  • ⚠️ Order on first apply: cluster and identity, then the key grant, then this module. Example 4 shows the depends_on.
  • ℹ️ Subsequent plans are clean — there is no perpetual diff on this path.
  • To import an existing configuration, use the cluster's Resource ID.

🧪 Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • cluster_id is present and typed string;
  • the timeouts value is an object of Go duration strings - but note that a key the type does not declare is SILENTLY DISCARDED rather than refused, so a misspelling here produces no error anywhere and the provider default quietly applies;
  • no validation cycle exists among the cross-field checks (a cycle is a validate-time error);
  • the module declares no provider block.

Every validation {} here was verified by driving deliberately bad values through the conditions directly, because terraform validate does not evaluate module-input validations — a broken condition would otherwise ship clean and fail in a caller's plan. All 21 cases behave as intended, and roughly a third of them are positive controls: a check that is too strict is as much a defect as one that is missing, since a failed validation blocks terraform destroy as well as apply. Accepted: both key paths in their valid shapes, a versioned and a versionless HSM key URI, a sovereign-cloud HSM host, a valid user-assigned identity, and a compound duration such as 1h15m. Rejected: both paths at once, neither path, a vault without key_name, key_name on the HSM path, a database ID or a Key Vault ID passed as cluster_id, a key URI passed where a Resource ID or a bare name belongs, blank strings, a principal GUID passed as user_identity, and a bare number passed as a timeout.

The two outputs that are not constants — key_rotation_is_automatic and key_version_pinned — were separately evaluated across all six combinations of key path and version pinning, because terraform validate never evaluates an output expression at all.

What only plan and apply exercise:

  • whether the cluster has a managed identity at all;
  • whether that identity holds Get / Wrap Key / Unwrap Key on the key, and whether the grant has propagated;
  • whether the key type and size are supported;
  • whether the vault's firewall permits the cluster to reach it.

💬 Example Output

Outputs:

cluster_id                    = "/subscriptions/00000000-.../providers/Microsoft.Kusto/clusters/adxprod"
id                            = "/subscriptions/00000000-.../providers/Microsoft.Kusto/clusters/adxprod"
key_version_pinned            = false
uses_managed_hsm              = false
uses_system_assigned_identity = true

ℹ️ id and cluster_id being identical is the provider quirk, not a copy-paste error in this document.

🔍 Troubleshooting

Symptom Cause Fix
Plan rejects the call with "exactly one of…" Neither key path set, or both. Set exactly one (example 7).
Plan rejects "key_name is required" key_vault_id supplied with no key_name. Add the key name.
Plan rejects key_name on the HSM path The HSM key ID already carries the key name, and the provider itself requires key_vault_id alongside key_name. Remove key_name.
key_version set on the HSM path had no effect It is accepted there and then ignored — the version comes from the key URI. Put the version in the URI, or remove key_version; the key_version_is_ignored_on_the_managed_hsm_path output flags the combination.
Apply fails with a key-access or vault error The cluster's identity lacks Get / Wrap Key / Unwrap Key. Grant it (example 4); retry after propagation.
Same error, and the grant exists The vault firewall blocks the cluster, or the grant went to the caller's identity rather than the cluster's. Allow trusted Azure services; confirm the principal_id.
Apply fails and the cluster has no identity Customer-managed keys need one. Enable it on the cluster module.
A key rotation produced no Terraform diff key_version is null, so the cluster follows the current version. Expected, and preferred.
A key rotation produced no effect key_version is pinned to the old version. Update it, or unpin (example 3).
Data became unreadable after a vault change The vault is on the cluster's availability path. Restore key access. Rehearse this in non-production (example 10).
import fails with "not found" The import ID is a child ID rather than the cluster's Resource ID. Import with the cluster ID (example 8).

🔗 Related Docs

  • azurerm_kusto_cluster_customer_managed_key — provider documentation for this resource.
  • azurerm_kusto_cluster — the cluster and its managed identity. Note it has no inline customer-managed-key block.
  • azurerm_key_vault_key — the key this module references.
  • Customer-managed keys in Azure Data Explorer — the service's own account of key custody and revocation.
  • Sibling modules in this family: terraform-azurerm-kusto-cluster, terraform-azurerm-kusto-database, terraform-azurerm-kusto-cluster-managed-private-endpoint, terraform-azurerm-kusto-cluster-principal-assignment, terraform-azurerm-kusto-database-principal-assignment, plus terraform-azurerm-key-vault.
  • This module's SCOPE.md.

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