Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Data Factory Customer Managed Key Terraform Module

Encryption configured on an existing factory — where terraform destroy succeeds and changes nothing. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • 🔐 Configures customer-managed-key encryption on an existing Data Factory. It creates nothing: its Resource ID is the factory's.
  • 🔴 terraform destroy makes no API call. The provider logs that the key cannot be removed once added and returns — the resource leaves state and the factory stays encrypted.
  • 📌 The key identifier must be versioned, so key rotation is a Terraform change rather than something the vault does on its own.
  • 👤 Omitting the identity selects the factory's system-assigned identity — a choice, not an absence.
  • ⚖️ The factory resource can set the same encryption inline, and this suite's factory module exposes both arguments — prefer that where you own the factory. Configuring both fails the apply; it does not drift quietly.
  • 🗝️ Whichever identity is used needs Get, Wrap Key and Unwrap Key on the vault — or, on the RBAC route, two roles, not one — granted elsewhere and unverifiable here.
  • 🚫 Three vendor prerequisites the provider cannot see: the factory must be empty, it must not be managed-VNet enabled, and the vault must share its region and Entra tenant.

💡 Why it matters: two of this resource's behaviours are invisible in a plan and irreversible in practice. Enabling customer-managed encryption cannot be undone without recreating the factory, and disabling it in Terraform does not disable it in Azure.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

flowchart TB
  ADF["azurerm_data_factory<br/>ITS OWN ARGUMENTS CAN SET<br/>THE SAME KEY - prefer those"]
  THIS["THIS MODULE<br/>customer managed key<br/>ITS id IS THE FACTORY'S id"]
  KEY["azurerm_key_vault_key<br/>VERSIONED identifier required"]
  KV["azurerm_key_vault<br/>purge protection and soft delete<br/>are the real controls"]
  UAI["user assigned identity<br/>OPTIONAL - omitting selects<br/>the system assigned one"]
  PERM["Get, Wrap Key, Unwrap Key<br/>granted elsewhere, unverifiable here"]

  ADF -->|"data_factory_id"| THIS
  KEY -->|"customer_managed_key_id"| THIS
  KV -->|"holds"| KEY
  UAI -.->|"user_assigned_identity_id"| THIS
  UAI -->|"must ALREADY be assigned to"| ADF
  PERM -.->|"held by the factory's identity"| KV

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef keystone fill:#004578,stroke:#002B44,color:#ffffff
  classDef other fill:#F0F3F7,stroke:#B8C4D0,color:#1B1B1B
  class THIS me
  class ADF keystone
  class KEY,KV,UAI,PERM other
Loading

Note the top node: the factory can configure this itself — and using both is not a quiet double-write. This resource's create reads the factory first and returns an already exists error whenever the factory carries any encryption configuration, whoever set it, so the second writer fails loudly. The two also disagree on the key form: the factory accepts a versioned or a versionless identifier, while this resource accepts only a versioned one.


🧬 What this module builds

flowchart TB
  IN1["var.data_factory_id<br/>the ONLY force-new field"]
  IN2["var.customer_managed_key_id<br/>MUST be VERSIONED"]
  IN3["var.user_assigned_identity_id<br/>omit to use system assigned"]
  R["azurerm_data_factory_customer_managed_key.this<br/>id IS the factory's id"]
  O1["key_vault_name / key_name / key_version"]
  O2["key_rotation_requires<br/>_a_terraform_change"]
  O3["destroy_removes_it_from_state<br/>_but_not_from_azure"]
  O4["uses_user_assigned_identity"]
  O5["the_factory_resource_can_also<br/>_set_a_customer_managed_key"]

  IN1 --> R
  IN2 --> R
  IN3 --> R
  IN2 --> O1
  IN2 --> O2
  R --> O3
  IN3 --> O4
  R --> O5

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef io fill:#F0F3F7,stroke:#B8C4D0,color:#1B1B1B
  class R me
  class IN1,IN2,IN3,O1,O2,O3,O4,O5 io
Loading

Resource inventory

Resource Count Note
azurerm_data_factory_customer_managed_key 1 (this) A per-factory singleton. No name, no location, no tags.

✅ Provider / Versions

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

Schema notes that bite

  • 🔴 Delete() makes no API call. It logs that the key cannot be removed from a Data Factory once added and returns. Destroy succeeds, state is cleared, and the factory remains encrypted.
  • 🔴 The resource's id is the factory's id. It configures rather than creates, and imports by the factory's ID.
  • 🔴 The read hard-errors on a factory with no encryption, where a missing factory is marked gone and dropped from state. So if the factory's encryption is removed out of band — including by the documented remedy of recreating the factory — this resource becomes unrefreshable and plan, apply and destroy all fail until it is removed from state by hand.
  • 🔴 An existing encryption configuration fails the create. Before writing, the create fetches the factory and returns an already exists error if any encryption is already configured. The only silent-overwrite path is a caller who has enabled the provider feature that skips import checks, which nothing here can see.
  • 🔴 customer_managed_key_id must be VERSIONED on THIS resource — a versionless identifier is rejected, and so is a secret or certificate identifier. This is not a family rule: azurerm_data_factory parses a field of the same name as either form, so a value that is legal inline on the factory is refused here.
  • 🔴 The factory resource can set the same encryption inline via customer_managed_key_id and customer_managed_key_identity_id, and the two disagree on identity: this suite's factory module requires a user-assigned identity alongside the key; this resource treats it as optional.
  • ⚠️ Omitting user_assigned_identity_id selects the system-assigned identity, not "no identity".
  • ⚠️ Only data_factory_id is force-new. The key and identity update in place.
  • ⚠️ No name, location or tags.
  • ⚠️ No CustomizeDiff, no ConflictsWith, no lock on the factory. The create and update perform a read-modify-write of the factory's whole payload, so a concurrent write from elsewhere can be lost.
  • ⚠️ The apply does not wait for the key to be wrapped. One ARM write, no poller — a green apply means the request was accepted, not that encryption is in force.

🔑 Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.DataFactory/factories/write the Data Factory Encryption is set on the factory itself.
Microsoft.DataFactory/factories/read the Data Factory Refresh and plan.
Microsoft.ManagedIdentity/userAssignedIdentities/assign/action the identity For whoever assigns it to the factory, which must happen first.
Key Vault Get, Wrap Key, Unwrap Key the key Held by the factory's identity, not the Terraform principal. Granted by an access policy.
Key Vault Crypto Service Encryption User and Key Vault Secrets User the Key Vault The RBAC alternative to that access policy. Microsoft documents both roles — the crypto role alone is not enough.

🔒 The permission that makes this work is not held by whoever runs Terraform. The factory's identity — user-assigned if named here, system-assigned otherwise — needs key permissions on the vault. Nothing here grants or checks them.

🔴 And the RBAC route needs TWO roles, not one. Microsoft's access-policy route grants Get, Unwrap Key and Wrap Key; its role-based route requires Key Vault Crypto Service Encryption User and Key Vault Secrets User. The crypto role on its own is the common mistake, and its failure mode is a factory that cannot wrap its key with no clear cause.

⚠️ Nothing reports which identity actually wraps the key. The resource exposes no effective-identity attribute, so when no user-assigned identity is supplied the principal to grant is the factory's own managed service identity — readable from the factory, not from here. And note there is no delete permission in this table: the provider's delete makes no call, so none is exercised.


Azure Prerequisites

  • An existing Data Factory whose encryption is not already configured — by the factory resource inline, by the portal, or by a previous apply. If it is, the create fails with already exists; adopt it with import.
  • 🔴 An EMPTY Data Factory. Microsoft documents that a customer-managed key can only be configured on a factory containing no linked services, no pipelines and no data flows, and that deploying one to a non-empty factory results in an error. Enable the key immediately after the factory is created.
  • 🔴 A factory that is NOT managed-virtual-network enabled. Microsoft states this approach does not work with such factories — and this suite's factory module defaults managed_virtual_network_enabled to true, so a factory built on its defaults is not a candidate. Disabling the flag afterwards is force-new in that direction and destroys the factory, so decide it at creation time.
  • The vault in the factory's region and Entra tenant. Microsoft makes region and tenant hard requirements and says explicitly that the two may be in different subscriptions.
  • An RSA or RSA-HSM key. No other key type is supported; an EC key is a well-formed identifier that fails at the ARM write.
  • A Key Vault key — not a secret, not a certificate — and its versioned identifier.
  • Purge protection and soft delete on that vault. Azure requires them for customer-managed keys, and they stand between a deleted key and an unrecoverable factory.
  • If a user-assigned identity is named, it must already be assigned to the factory and hold Get, Wrap Key and Unwrap Key (or both RBAC roles above). Omitting it selects the system-assigned identity, which needs the same.

📁 Module Structure

terraform-azurerm-data-factory-customer-managed-key/
├── providers.tf    # required_version + the pinned azurerm; no provider block
├── variables.tf    # four inputs, six validations
├── main.tf         # locals splitting the key URL and reading the identity choice
├── outputs.tf      # 47 outputs, led by the destroy that does nothing
├── README.md       # this file
├── SCOPE.md        # the cross-module contract
├── LICENSE         # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "factory_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-customer-managed-key.git?ref=v1.0.0"

  data_factory_id         = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-eastus/providers/Microsoft.DataFactory/factories/adf-corp"
  customer_managed_key_id = "https://kv-corp.vault.azure.net/keys/adf-cmk/1a2b3c4d5e6f7890abcdef1234567890"
}

⚠️ Read the destroy behaviour before applying this. Enabling customer-managed encryption cannot be undone without recreating the factory.


🔌 Cross-Module Contract

Consumes

Input Type Source
data_factory_id string terraform-azurerm-data-factory (id)
customer_managed_key_id string a Key Vault key's versioned identifier
user_assigned_identity_id string, optional terraform-azurerm-user-assigned-identity (id)
timeouts object (four keys) caller

Emits

Output Description
id The Data Factory's own Resource ID.
key_vault_name, key_name, key_version The key, parsed from the URL.
key_versionless_id For comparison against a key module's versionless output.
uses_user_assigned_identity False means system-assigned.
destroy_removes_it_from_state_but_not_from_azure The headline fact.

📚 Example Library

1 · The minimum call
module "factory_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-customer-managed-key.git?ref=v1.0.0"

  data_factory_id         = var.data_factory_id
  customer_managed_key_id = var.versioned_key_id
}

ℹ️ No identity supplied means the factory's system-assigned identity is used — which must still hold Get, Wrap Key and Unwrap Key on the vault.

2 · Read this before applying: the destroy does nothing
output "irreversibility" {
  value = {
    destroy_is_a_no_op   = module.factory_cmk.destroy_removes_it_from_state_but_not_from_azure
    removal_needs_rebuild = module.factory_cmk.removing_the_key_requires_recreating_the_factory
    block_removal_silent = module.factory_cmk.a_removed_module_block_leaves_encryption_in_place
  }
}

🔴 terraform destroy succeeds, clears state, and leaves the factory encrypted. The only signal is an INFO-level log line. Reverting to Microsoft-managed encryption means deleting and recreating the factory — and everything inside it.

3 · With a user-assigned identity
module "factory_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-customer-managed-key.git?ref=v1.0.0"

  data_factory_id           = module.data_factory.id
  customer_managed_key_id   = var.versioned_key_id
  user_assigned_identity_id = module.identity.id

  # Redundant, and kept only as documentation of intent -- see the note below.
  depends_on = [module.data_factory]
}

ℹ️ The depends_on is redundant, not load-bearing. Referencing module.data_factory.id already creates a graph edge to the output, which depends on the factory resource — the same resource that carries the identity block — so the identity attachment is ordered whether or not depends_on is written. It is harmless; the reason sometimes given for it ("this module takes the ID, not the identity, so nothing orders them") is simply untrue.

⚠️ What the graph cannot order is anything outside it: the Key Vault access policy or role assignments, and the factory being empty and not managed-VNet enabled when the key lands.

4 · A versionless key is refused
# REJECTED offline:
#   customer_managed_key_id = "https://kv-corp.vault.azure.net/keys/adf-cmk"
#
# So is a SECRET identifier, and a CERTIFICATE identifier.

output "why" {
  value = module.factory_cmk.the_key_identifier_must_be_versioned
}

ℹ️ Three separate checks mirror the three stages of the provider's own parse: the path-segment count, the nested-item type (compared case-sensitively, so /Keys/ is refused too), and the version. All three read a single variable, so all three fire from the variable value alone — on a calling configuration and under terraform console, though not from validate inside this module's own directory, which evaluates no variables at all.

⚠️ The version requirement belongs to this resource, not to the family. azurerm_data_factory accepts a versioned or a versionless identifier on a field of the same name, so a value copied from a factory configuration may need its version added.

5 · Rotation is a Terraform change
variable "adf_key_version" {
  type        = string
  description = "Bump this when the key is rotated in the vault, then apply."
}

module "factory_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-customer-managed-key.git?ref=v1.0.0"

  data_factory_id         = var.data_factory_id
  customer_managed_key_id = "https://kv-corp.vault.azure.net/keys/adf-cmk/${var.adf_key_version}"
}

output "rotation_model" {
  value = module.factory_cmk.key_rotation_requires_a_terraform_change
}

⚠️ Unlike a Key Vault secret reference elsewhere in this suite, which follows the vault automatically, a versioned key does not. Rotating in the vault leaves the factory on the old version until it is disabled — at which point the factory loses access to its own metadata, with no Terraform diff having warned anyone.

6 · Confirming which key is in use, independently of the version
variable "expected_key_versionless_id" {
  type        = string
  description = "The key this factory is meant to use, without a version."
}

check "using_the_expected_key" {
  assert {
    condition     = module.factory_cmk.key_versionless_id == var.expected_key_versionless_id
    error_message = "The factory is encrypted with a different key than expected."
  }
}

output "key_detail" {
  value = {
    vault   = module.factory_cmk.key_vault_name
    key     = module.factory_cmk.key_name
    version = module.factory_cmk.key_version
  }
}

💡 The versioned identifier changes on every rotation; the versionless one does not. Comparing the latter answers "is this still the right key" without failing on a legitimate rotation.

⚠️ Note the trap in the other direction. The terraform-azurerm-key-vault module emits key_versionless_ids, a map whose description recommends it "for CMK wiring that should follow rotation". This resource cannot accept a versionless identifier — it requires a version. Use that output for the comparison above, never as customer_managed_key_id.

7 · Omitting the identity is a choice, not an absence
output "identity_in_use" {
  value = {
    user_assigned = module.factory_cmk.uses_user_assigned_identity # false => SYSTEM-assigned
    name          = module.factory_cmk.user_assigned_identity_name # null when system-assigned
  }
}

⚠️ false does not mean "no identity". It means the factory's system-assigned identity is used, and it needs exactly the same key permissions.

8 · The conflict to check before adopting this module
# The FACTORY module can set the same encryption inline:
#
#   module "data_factory" {
#     ...
#     customer_managed_key_id          = var.versioned_key_id
#     customer_managed_key_identity_id = module.identity.id
#   }
#
# Doing that AND using this module FAILS THE APPLY: whichever runs second
# finds the factory already encrypted and errors with `already exists`.

output "check_this_first" {
  value = module.factory_cmk.the_factory_resource_can_also_set_a_customer_managed_key
}

🔒 Prefer the factory's inline arguments where you own the factory — on one ground only: they require a user-assigned identity alongside the key, which is the stricter default. (Note even that is a creation-time plan-stage check on the factory, not a standing rule.)

⚠️ Do not prefer them for removal behaviour. Both fields on the factory are Optional and Computed, so deleting them from a configuration leaves the key in place with no diff — exactly as silent as this resource's inert destroy. Neither path can revert a factory to Microsoft-managed encryption.

🔴 And they are not additive. Setting both is not two writes to one field — it is a failed apply, because this resource refuses to create against a factory that already carries encryption.

9 · What the module cannot verify
output "unverifiable" {
  value = {
    key_and_permissions = module.factory_cmk.the_module_cannot_verify_the_key_or_the_permissions
    assignment          = module.factory_cmk.the_identity_must_already_be_assigned_to_the_factory
    vault_permissions   = module.factory_cmk.the_identity_needs_key_permissions_on_the_vault
  }
}

💡 Four things fail at Azure rather than at plan: the key may not exist, the version may be disabled, the identity may not be assigned, and it may not hold the key permissions.

10 · The availability risk this introduces
output "availability_risk" {
  value = module.factory_cmk.losing_access_to_the_key_disables_the_factory
}

🔒 Customer-managed encryption trades control for a dependency. If the key is deleted, disabled, or its permissions revoked, the factory cannot decrypt its own metadata. Purge protection and soft delete on the vault are the controls that matter, and they live on the Key Vault, not here.

11 · A per-factory singleton
output "singleton" {
  value = {
    one_per_factory = module.factory_cmk.this_is_a_per_factory_singleton
    id_is_factorys  = module.factory_cmk.this_resource_configures_the_factory_rather_than_creating_anything
  }
}

🔴 Because the resource's identity is the factory's, a second instance against the same factory fails its create — the provider reads the factory, finds encryption already configured, and returns an already exists error naming the factory's Resource ID. The steady state is a failed apply, not silent contention. Two instances declared in a single apply are a race to avoid rather than a documented outcome.

12 · The change surface
output "change_surface" {
  value = {
    force_new  = module.factory_cmk.force_new_fields                      # ["data_factory_id"]
    updatable  = module.factory_cmk.fields_that_can_change_after_creation # key + identity
    from_azure = module.factory_cmk.fields_azure_returns_on_read
  }
}

ℹ️ Because both fields are read back, a key or identity changed outside Terraform is detected as drift.

🔴 The read is not otherwise conventional, though. A deleted factory is marked gone and dropped from state; a factory that exists with no encryption returns a hard error instead. So drift detection works while the encryption is there and stops the run entirely once it is not.

13 · Importing an existing configuration
import {
  to = module.factory_cmk.azurerm_data_factory_customer_managed_key.this
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-eastus/providers/Microsoft.DataFactory/factories/adf-corp"
}

⚠️ The import ID is the factory's ID, with no child path — this resource has no identity of its own. It is also the only way to reconcile a factory that is encrypted while Terraform has no record of it, which is exactly what removing the module block produces.

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

# 1. The identity that will reach the key.
module "identity" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"

  name                = "uai-adf-cmk"
  resource_group_name = "rg-data-eastus"
  location            = "eastus"
}

# 2. The factory, with that identity assigned. Note it does NOT set the key
#    inline here -- doing both fails the apply, because this resource refuses
#    to create against a factory that already carries encryption.
module "data_factory" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory.git?ref=v1.0.0"

  name                = "adf-corp"
  resource_group_name = "rg-data-eastus"
  location            = "eastus"

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

  # REQUIRED FOR THIS COMPOSITION TO APPLY. That module defaults this to true
  # as an anti-exfiltration posture, and Microsoft states the customer-managed
  # key approach does not work with a managed-VNet factory. Turning it off
  # later destroys and recreates the factory, so it is decided here.
  managed_virtual_network_enabled = false

  tags = { owner = "data-platform" }
}

# 3. THIS MODULE -- encryption, configured on the factory above.
module "factory_cmk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-customer-managed-key.git?ref=v1.0.0"

  data_factory_id           = module.data_factory.id
  customer_managed_key_id   = var.versioned_key_id
  user_assigned_identity_id = module.identity.id

  # Redundant: referencing module.data_factory.id already orders this after the
  # factory resource, which is where the identity block lives. Kept as intent.
  depends_on = [module.data_factory]
}

output "encryption_status" {
  description = "What is configured, and what Terraform cannot confirm or undo."
  value = {
    factory          = module.factory_cmk.data_factory_id
    vault            = module.factory_cmk.key_vault_name
    key              = module.factory_cmk.key_name
    version          = module.factory_cmk.key_version
    identity         = module.factory_cmk.user_assigned_identity_name
    reversible       = false # Removing the key requires recreating the factory.
    permissions_ok   = false # Key permissions are granted and checked elsewhere.
  }
}

⚠️ Both literal false values are deliberate and are not module outputs. Nothing in this composition grants the identity Get, Wrap Key and Unwrap Key on the vault — do that with a Key Vault access policy, or with both the Key Vault Crypto Service Encryption User and Key Vault Secrets User roles — and nothing can undo the encryption once applied.

🔴 Two conditions decide whether this composition applies at all, and neither is expressible in HCL. The factory must be empty when the key lands, which it is on the first apply and is not on any later one; and it must not be managed-VNet enabled, which is why managed_virtual_network_enabled = false is written out above rather than left to the factory module's secure default of true. The vault must also sit in the factory's region and Entra tenant — a different subscription is explicitly fine.


📥 Inputs

Input Type Required Note
data_factory_id string ✅ Force-new, and this resource's own id.
customer_managed_key_id string ✅ A versioned Key Vault key identifier.
user_assigned_identity_id string — Omit to use the factory's system-assigned identity.
timeouts object — Four keys; the delete one bounds nothing.
Full schemas
variable "data_factory_id" {
  type = string
  # /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.DataFactory
  #   /factories/<factory>
  # Anchored at both ends. THIS RESOURCE'S OWN ID IS THIS VALUE.
}

variable "customer_managed_key_id" {
  type = string
  # https://<vault>.vault.azure.net/keys/<key>/<version>
  # Three checks, mirroring the three stages of the provider's own parse:
  #   2-or-3 path segments (a trailing slash is trimmed, so it is allowed);
  #   the type segment must be exactly lower-case `keys`;
  #   the version must be present -- ON THIS RESOURCE ONLY, not on the factory.
  # NOT force-new -- which is what makes rotation possible.
}

variable "user_assigned_identity_id" {
  type    = string
  default = null
  # /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.ManagedIdentity
  #   /userAssignedIdentities/<name>
  # OMITTING IT SELECTS THE FACTORY'S SYSTEM-ASSIGNED IDENTITY -- not "none".
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
  # Provider defaults: create 30m, update 30m, read 5m, delete 30m -- though
  # the delete makes no API call, so its timeout bounds nothing. Each value is
  # a Go duration: `1h30m`, `1.5h`, `500ms` and a bare `0` are all accepted.
}

🧾 Outputs

Output Description Note
id The Data Factory's own Resource ID. first
data_factory_id / data_factory_name The encrypted factory.
resource_group_name / subscription_id Where it lives.
this_resource_configures_the_factory_rather_than_creating_anything Why the ID is the factory's. constant
destroy_removes_it_from_state_but_not_from_azure The headline fact. constant
removing_the_key_requires_recreating_the_factory The provider's own remedy. constant
the_delete_timeout_bounds_nothing No API call to bound. constant
a_removed_module_block_leaves_encryption_in_place The silent drift. constant
customer_managed_key_id The versioned identifier in use.
key_vault_name / key_name / key_version The key, parsed. derived
key_versionless_id For key-identity comparison across rotations. derived
the_key_identifier_must_be_versioned The provider's rule. constant
key_rotation_requires_a_terraform_change The inverse of a secret reference. constant
the_key_can_be_changed_in_place Why rotation works at all. constant
uses_user_assigned_identity False means system-assigned. derived
user_assigned_identity_id / user_assigned_identity_name The identity, or null. derived
user_assigned_identity_is_in_the_factorys_subscription Null when unused. derived
the_identity_must_already_be_assigned_to_the_factory The ordering rule. constant
the_identity_needs_key_permissions_on_the_vault The third-resource requirement. constant
the_module_cannot_verify_the_key_or_the_permissions Four uncheckable things. constant
the_factory_resource_can_also_set_a_customer_managed_key Check before adopting. constant
this_is_a_per_factory_singleton One per factory. constant
force_new_fields data_factory_id only. list
fields_that_can_change_after_creation The key and the identity. list
fields_azure_returns_on_read Both — so out-of-band changes are drift. list
secure_by_default_cannot_apply_to_this_resource Stated, not skipped. constant
losing_access_to_the_key_disables_the_factory The availability risk. constant
no_secret_is_accepted_or_emitted_by_this_module A key ID is a URL. constant
lifecycle_prevent_destroy_is_not_available_to_a_module_caller And the destroy is inert anyway. constant
the_provider_takes_no_lock_on_the_data_factory Read-modify-write of the whole payload. constant
a_factory_without_encryption_makes_this_resource_unrefreshable The read hard-errors. constant
an_existing_encryption_configuration_fails_the_create The conflict is loud, not silent. constant
overwriting_existing_encryption_needs_a_provider_feature_flag The one silent-overwrite path. constant
the_factory_must_be_empty_when_the_key_is_applied Vendor prerequisite. constant
managed_virtual_network_factories_are_not_supported Collides with this suite's factory default. constant
the_vault_must_share_the_factorys_region_and_tenant Region and tenant bind; subscription does not. constant
only_rsa_keys_are_supported RSA / RSA-HSM only. constant
the_rbac_route_needs_two_key_vault_roles One role looks sufficient and is not. constant
the_apply_does_not_wait_for_the_factory_to_wrap_the_key No poller. constant
the_provider_does_not_report_which_identity_wraps_the_key No effective-identity attribute. constant
cmk_is_excluded_from_the_factorys_arm_template_by_default And including it overrides Git settings. constant
this_resource_supports_no_azure_resource_tags Tag the factory. constant

No output is sensitive.


🧠 Architecture Notes

It configures rather than creates, and its Resource ID proves it. This resource has no name, no location and no identity of its own: its id is the Data Factory's, and it is imported by that ID. That makes it a per-factory singleton — and that singleton is enforced, not merely conventional: a second instance against one factory fails its create with an already exists error, because the provider reads the factory first and refuses to write over encryption that is already there.

And the read is where this resource is genuinely dangerous. The provider distinguishes two failures and treats them oppositely: a factory that has been deleted is marked gone and quietly dropped from state, while a factory that exists with no encryption configuration returns a hard error. There is no way back through Terraform once that happens — plan, apply and destroy all refresh first, so all three fail, and only terraform state rm clears it. The state that triggers it is reachable by following the documented remedy for removing the key, which is to delete and recreate the factory: do that while this resource is still in state and the state is stuck.

The destroy is inert, in two ways that are easy to miss. The provider's delete function logs that the key cannot be removed once added and returns without calling Azure. So terraform destroy succeeds, the resource leaves state, and the factory stays encrypted — and separately, simply deleting the module block from a configuration produces a clean plan while leaving Terraform with no record of encryption still in force. That second case is a drift only an import can reconcile. Both are emitted as constants because neither appears anywhere in a plan.

Rotation is a Terraform change, which is the inverse of the credential modules in this family. The provider requires a versioned key identifier, so the factory is pinned to one version. Rotating the key in the vault does not update the factory; the new version must be written here and applied. Get that wrong and nothing warns you — there is no diff, no error, and no drift, until the old version is disabled and the factory loses access to its own metadata. key_versionless_id exists so a check block can confirm the right key is in use without failing on a legitimate rotation.

Omitting the identity selects one rather than none. With no user_assigned_identity_id, the factory's system-assigned identity is used — and needs exactly the same Get, Wrap Key and Unwrap Key permissions on the vault. uses_user_assigned_identity is emitted precisely because false reads as "no identity" and means something else.

And there is a decision to make before adopting this module at all. The Data Factory resource carries customer_managed_key_id and customer_managed_key_identity_id inline, and this suite's factory module exposes both — with a check requiring the identity whenever the key is set, which this resource does not (and which the provider applies at creation only). Configuring both does not drift: it fails. This resource's create fetches the factory and returns an already exists error whenever encryption is already configured, whoever set it. The two also disagree on the key form — the factory takes a versioned or a versionless identifier, this resource only a versioned one. Prefer the factory's own arguments where you own the factory, on the strength of the identity rule and not of removal behaviour, which is equally silent on both paths because the factory's fields are Optional and Computed.

Then there is the case this module was positioned for, which the service refuses. The obvious reason to reach for a separate resource is a factory somebody else owns — and a factory somebody else owns has pipelines in it, which is precisely what Microsoft says will not work: a customer-managed key can only be configured on an empty factory. Nor can the factory be managed-VNet enabled. So the honest scope is narrow: a factory created but not yet populated, whose lifecycle sits in a different configuration from its encryption.


🧱 Design Principles

Concern This module's position Opt-out
Secure by default Cannot apply — both consequential arguments are required or near-required, so there is no empty call. Both identifier forms are validated strictly instead. —
Irreversibility The inert destroy is emitted in four outputs, including the removed-module-block case. —
Rotation The versioned requirement and its consequence are stated; a versionless form is emitted for comparison. —
Ambiguous omissions uses_user_assigned_identity distinguishes system-assigned from none. —
Unverifiable facts The key, the version, the assignment and the vault permissions are all stated as uncheckable. —
Service limits The empty-factory rule, the managed-VNet exclusion, the region/tenant pairing and the RSA-only key rule are reported through outputs, never enforced — the vendor can change any of them without the provider changing, and a failed validation {} would block destroy as well as apply. —
Validation width Each check mirrors one stage of the provider's own parse and nothing more. A trailing slash, a dotless host and a Managed HSM URL are all accepted, because the provider accepts them. —
Cross-module conflict Named, with a recommendation rather than a warning. —
Secrets None accepted, none emitted — a key ID is a URL. —

🚀 Runbook

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

Pin the source at ?ref=v1.0.0, never a branch. This module is plan-only; a human applies from CI.

⚠️ validate run inside this directory proves the configuration parses and type-checks. It evaluates no variables, so it fires none of the six validation {} blocks — see 🧪 Testing for what does.


🧪 Testing

Which command fires which check is not interchangeable, and getting it backwards is the usual mistake. There are six validation {} blocks — the anchored factory ID; three on the key identifier (segment count, lower-case keys type, version present); the user-assigned identity ID; and the Go-duration form of each timeouts value. All six read a single variable each, and that is what decides where they fire:

Command Which of the six fire
terraform validate inside this module's directory none — no variables are evaluated
terraform validate on a calling configuration all six
terraform console -no-color with an immediate EOF on stdin all six

So the Runbook's validate proves the module parses; console against a root module that declares these variables is the harness that actually exercises the checks. Terraform also skips a validation whose referenced variable has already failed, so a short error list is not evidence a check is missing.

Every widening needs its own accepting case, or the check quietly narrows over time. Three values the provider accepts are accepted here deliberately and should stay proven: a key identifier with a trailing slash (the provider's parser trims one before counting segments), a dotless vault host (no .vault.azure.net assertion exists anywhere in the parser), and a Managed HSM URL (whose support is unconfirmed either way, so it is not refused). Likewise 1h30m, 1.5h, 500ms and a bare 0 are all legal Go durations and are all accepted — a ^[0-9]+(s|m|h)$ shape would refuse every one of them, and a failed validation blocks destroy.

The key identifier deserves four negatives rather than one, because every plausible wrong value is well-formed: a versionless key ID, a secret ID, a certificate ID, and an ARM Resource ID. Each must be refused. And every value derived from the identity needs its null branch asserted, since the common case is that no user-assigned identity is supplied at all.

What no offline check can reach: whether the factory exists, whether it is empty, whether it is managed-VNet enabled, whether the vault shares its region and tenant, whether the key exists and is RSA, whether the identity is assigned to the factory, and whether it holds the vault permissions. Nor can any test observe the inert delete, the already exists guard on create, or the read's hard error on missing encryption — all three are read from the provider's source rather than exercised.


💬 Example Output

id                      = "/subscriptions/00000000-.../providers/Microsoft.DataFactory/factories/adf-corp"
data_factory_id         = "/subscriptions/00000000-.../providers/Microsoft.DataFactory/factories/adf-corp"
data_factory_name       = "adf-corp"
customer_managed_key_id = "https://kv-corp.vault.azure.net/keys/adf-cmk/1a2b3c4d5e6f7890abcdef1234567890"
key_vault_name          = "kv-corp"
key_name                = "adf-cmk"
key_version             = "1a2b3c4d5e6f7890abcdef1234567890"
key_versionless_id      = "https://kv-corp.vault.azure.net/keys/adf-cmk"
uses_user_assigned_identity = true
user_assigned_identity_name = "uai-adf-cmk"
force_new_fields        = ["data_factory_id"]
fields_that_can_change_after_creation = ["customer_managed_key_id", "user_assigned_identity_id"]
destroy_removes_it_from_state_but_not_from_azure = true

🔍 Troubleshooting

Symptom Cause Fix
terraform destroy succeeded but the factory is still encrypted The provider's delete makes no API call. Expected, and not fixable here. Removing the key requires deleting and recreating the factory.
Terraform has no record of encryption that is clearly in place The module block was removed, which changes nothing in Azure. import the configuration using the factory's Resource ID.
A key was silently re-pointed with no already exists error The caller's provider block enables the feature that skips import checks and permits overwriting existing resources. Audit that provider feature. Nothing in this module can see or report it.
The apply fails referencing the identity The user-assigned identity is not assigned to the factory. Assign it in the factory's identity block first, and add an explicit depends_on.
The factory cannot use the key The identity lacks Get, Wrap Key or Unwrap Key on the vault. Grant them via an access policy, or via both the Key Vault Crypto Service Encryption User and Key Vault Secrets User roles.
A versionless key identifier is rejected This resource parses the identifier as a versioned nested item. azurerm_data_factory accepts either form on a field of the same name, so a value copied from a factory configuration can be legal there and refused here. Supply the full identifier including the version.
The factory stopped working after a key rotation The pinned version was disabled; a versioned reference does not follow the vault. Update customer_managed_key_id to the new version and apply.
The apply fails with already exists naming the factory's Resource ID The factory already carries encryption — set by the factory resource inline, by the portal, or by an earlier apply. The create refuses to write over it. Choose one writer. To adopt what is already there, import using the factory's ID rather than applying.
The apply fails and the factory contains pipelines, linked services or data flows Microsoft supports configuring a customer-managed key only on an empty factory; a non-empty one errors. Enable the key on a newly created, still-empty factory. There is no in-place path for a populated factory.
The apply fails and the factory has a managed virtual network This approach does not work with managed-VNet enabled factories. Not fixable in place: disabling the flag is force-new on the factory. Create the factory without a managed VNet, or use a different encryption route.
plan, apply and destroy all fail with retrieving encryption for ... The factory exists but no longer has an encryption configuration, so the read hard-errors instead of marking the resource gone. Recreating the factory to remove the key produces exactly this. terraform state rm the resource. Nothing else clears it — every command refreshes first.
The factory cannot wrap its key although the crypto role is granted The RBAC route needs two roles; Key Vault Crypto Service Encryption User alone is not sufficient. Also grant Key Vault Secrets User, or use an access policy with Get, Unwrap Key and Wrap Key.
The apply succeeded but the key is refused or the factory is inaccessible The key is not RSA or RSA-HSM, or the vault is in a different region or Entra tenant from the factory. A different subscription is fine. Use an RSA/RSA-HSM key in a vault co-located with the factory.
The apply succeeded but encryption is not yet in force There is no poller: the provider issues one ARM write and returns once it is accepted. Confirm the wrap on the factory itself before treating the change as done.
Applying failed because purge protection is disabled Azure requires purge protection and soft delete on the vault for customer-managed keys. Enable both on the Key Vault.
Omitting the identity did not disable identity-based access Omission selects the factory's system-assigned identity. Grant that identity the same key permissions, or supply a user-assigned one.

🔗 Related Docs


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