Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure SQL Managed Instance Transparent Data Encryption Terraform Module

Chooses the key that encrypts a SQL Managed Instance's data at rest β€” Microsoft-managed, Key Vault, or Managed HSM. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Blast radius


🧩 Overview

  • πŸ”‘ Sets the encryption protector for a SQL Managed Instance: a service-managed key, a Key Vault key, or a Managed HSM key.
  • βœ… The empty call is already encrypted. Supplying no key leaves transparent data encryption on with a Microsoft-managed key β€” so nothing here is inverted.
  • ⏱️ Moves the provider's apply-time versioned-key rule to validate, where it costs nothing.
  • 🧾 Emits the untracked ARM record the provider creates alongside β€” {vaultName}_{keyName}_{keyVersion}.
  • πŸ” Destroy reverts to a service-managed key; it deletes nothing and decrypts nothing.

πŸ’‘ Why it matters: a customer-managed key is often reached for as though it were strictly stronger. The data is encrypted either way. What changes is who holds the key β€” and therefore who can render the instance unreadable by deleting, disabling or expiring it. This module treats that as a control transfer with a real operational cost, and says so.


❀️ Support this project

If this module saved you time:


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

flowchart TB
    MI["terraform-azurerm-mssql-managed-instance"]
    THIS["terraform-azurerm-mssql-managed-instance-transparent-data-encryption"]
    ADM["terraform-azurerm-mssql-managed-instance-active-directory-administrator"]
    SAP["terraform-azurerm-mssql-managed-instance-security-alert-policy"]
    KV["terraform-azurerm-key-vault"]
    SRV["terraform-azurerm-mssql-server"]

    MI -->|"id"| THIS
    MI -->|"id"| ADM
    MI -->|"name and resource_group_name"| SAP
    KV -->|"key_ids or key_versionless_ids"| THIS
    MI -->|"identity_principal_id wraps the key"| KV
    THIS -.->|"also creates an untracked managed instance key record"| MI
    SRV -.->|"the logical server has its own protector resource"| THIS

    style THIS fill:#0078D4,stroke:#004578,color:#ffffff
    style MI fill:#004578,stroke:#004578,color:#ffffff
    style ADM fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style SAP fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style KV fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style SRV fill:#F3F2F1,stroke:#8A8886,color:#201F1E
Loading

The managed instance feeds its id here and to the Entra administrator module; the security alert policy takes the parent as a name instead, which is why that edge is labelled differently. The key vault feeds either a versioned key_ids entry or a versionless key_versionless_ids one β€” and which you pick is governed by auto_rotation_enabled. The dotted edge back to the instance is the record this module creates without Terraform tracking it: a managed instance key, named after the vault, key and version.


🧬 What this module builds

flowchart TB
    MID["managed_instance_id (required, force-new)"]
    KEY["key_vault_key_id XOR managed_hsm_key_id, or neither"]
    ROT["auto_rotation_enabled (false demands a VERSIONED key)"]

    TDE["azurerm_mssql_managed_instance_transparent_data_encryption.this"]

    OID["id"]
    OKIND["key_kind"]
    OMIK["managed_instance_key_id"]
    OREV["destroying_this_resource_reverts_to_a_service_managed_key"]

    MID --> TDE
    KEY --> TDE
    ROT --> TDE
    TDE --> OID
    TDE --> OKIND
    TDE --> OMIK
    TDE --> OREV

    style TDE fill:#0078D4,stroke:#004578,color:#ffffff
    style MID fill:#004578,stroke:#004578,color:#ffffff
    style KEY fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style ROT fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OID fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OKIND fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OMIK fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    style OREV fill:#F3F2F1,stroke:#8A8886,color:#201F1E
Loading
Resource Count Notes
azurerm_mssql_managed_instance_transparent_data_encryption.this 1 One per instance; the ID always ends /encryptionProtector/current. With a customer-managed key the provider also creates an untracked managed instance key record

βœ… Provider / Versions

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

Schema notes that bite:

  • πŸ”΄ key_vault_key_id and managed_hsm_key_id are mutually exclusive. The conflict is enforced by the provider and is invisible in the binary schema.
  • πŸ”΄ A versionless key requires auto_rotation_enabled = true, and the provider raises that during apply β€” it only discovers the problem while resolving the key. This module raises the equivalent at validate.
  • πŸ”΄ The provider creates a second ARM record Terraform does not track: .../managedInstances/<instance>/keys/{vaultName}_{keyName}_{keyVersion}. It appears in no plan and is destroyed by nothing.
  • πŸ”΄ Destroy does not delete. An encryption protector cannot be removed, only switched; the provider's delete reverts the instance to a service-managed key, which its own source explains is to prevent an accidental lockout.
  • πŸ”΄ timeouts.delete is accepted and ignored. The delete function reads the create/update deadline β€” consistent with the delete really being an update.
  • πŸ”΄ There is no requires-import guard, and the provider documents why: the service always creates a protector with the instance, so an already-exists check would always fire. An apply silently replaces whatever was configured.
  • ⚠️ State may show both key fields for an HSM key. A Managed HSM key identifier is also a valid key identifier, so the read populates key_vault_key_id too and suppresses the diff. That is not drift.
  • πŸ”΄ The exclusion between the two key arguments is decided by an argument's PRESENCE, not by its value β€” and the two disagree about what empty means. An empty string in the unused slot is refused exactly as a second real key would be: key_vault_key_id = "" beside a Managed HSM key fails terraform validate with "key_vault_key_id": conflicts with managed_hsm_key_id. managed_hsm_key_id is worse, because the provider's create tests it for an explicit null rather than for emptiness β€” an empty string there reaches a key-identifier parser and the apply fails, with nothing visible offline. That is not a theoretical value: an empty string is exactly what this resource's read returns for whichever key argument is unused, so a composition wiring one instance's key outputs into another's inputs supplies one. This module renders both arguments through a normalizing local so a blank arrives as an absent argument, and its own mutual-exclusion check counts a blank as unset for the same reason. Reported as an_empty_string_is_not_the_same_as_an_unset_key.
  • ⚠️ The versionless-key diff suppression only applies when auto_rotation_enabled is true.
  • ℹ️ managed_hsm_key_id does not exist on the provider's 5.0 line, where key_vault_key_id carries both kinds of identifier. This library pins ~> 4.0 deliberately.
  • ℹ️ No tags, no location. Tag the managed instance instead.

πŸ”‘ Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.Sql/managedInstances/encryptionProtector/read and /write the managed instance set the protector
Microsoft.Sql/managedInstances/keys/read and /write the managed instance the side-effect key record the provider creates
SQL Security Manager (or Contributor) the managed instance the built-in role carrying the above
get, wrapKey, unwrapKey on the key the key vault or Managed HSM granted to the managed instance's identity, not the Terraform principal
get on the key the key vault or Managed HSM granted to the Terraform principal, and needed only for a VERSIONLESS key identifier β€” see below

πŸ”’ The key permissions belong to the instance's identity β€” with one exception, and it is on the path this module recommends. The instance's own identity is what wraps and unwraps the database encryption key. But when the configured identifier is versionless, the provider has to discover the current version before it can name the key record, and it does that by reading the key from the vault or Managed HSM as the Terraform principal, during apply. That principal therefore needs a data-plane get on the key: on a vault, the Key Vault Crypto User role or an access policy granting get; on a Managed HSM, the equivalent role assignment on the key. A versioned identifier needs none of this, because there is nothing to resolve. resolving_a_versionless_key_reads_the_key_as_you reports which case a configuration is in.

⚠️ The failure is late and reads as unrelated: the apply fails with a key-vault authorization error while RESOLVING the key -- before the key record is created and before the protector is set -- not with a plan-time permission message.

πŸ”΄ Whoever controls the key controls the instance's readability. Vault delete rights are, in effect, instance-destroy rights β€” and no Azure role on the SQL side limits that. Review who can delete or disable the key with the same seriousness as who can delete the instance.


Azure Prerequisites

  • The Microsoft.Sql resource provider registered on the subscription.
  • An existing Azure SQL Managed Instance with a system-assigned identity.
  • For a customer-managed key: a Key Vault or Managed HSM with purge protection enabled, a supported key, and get/wrapKey/unwrapKey granted to the instance's identity.
  • A versioned key identifier unless auto_rotation_enabled is true.
  • The caller configures provider "azurerm" { features {} }, auth and subscription.

πŸ“ Module Structure

terraform-azurerm-mssql-managed-instance-transparent-data-encryption/
β”œβ”€β”€ providers.tf    # required_version + pinned azurerm; no provider block
β”œβ”€β”€ variables.tf    # 5 inputs, deeply typed, with the key-pairing and version rules
β”œβ”€β”€ main.tf         # the single keystone `this`
β”œβ”€β”€ outputs.tf      # id first, then the derived key facts, then the posture facts
β”œβ”€β”€ README.md       # this file
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id = module.sqlmi.id
}

That is service-managed encryption, and it is already secure β€” see example 1. Reach for a customer-managed key only when you want the control, and read example 3 before you do.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
managed_instance_id Resource ID terraform-azurerm-mssql-managed-instance β†’ id
key_vault_key_id key identifier terraform-azurerm-key-vault β†’ key_ids[…] or key_versionless_ids[…]
managed_hsm_key_id key identifier a Managed HSM; mutually exclusive with the above

Emits

Output Description
id Resource ID of the encryption protector (first)
key_kind service_managed, key_vault or managed_hsm
managed_instance_key_id the untracked side-effect record
vault_name / key_name / key_version parsed from the key identifier
the_configured_key_is_versioned the pairing check against auto-rotation
losing_the_key_makes_the_instance_unreadable constant true

πŸ“š Example Library

1 Β· The empty call is already encrypted
module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id = module.sqlmi.id
}

output "key" {
  value = module.mi_tde.key_kind # "service_managed"
}

βœ… Omitting a key does not turn encryption off. Transparent data encryption stays on with a Microsoft-managed key. That is why nothing in this module inverts a provider default β€” unlike its neighbours in this family, the safe configuration is already the empty one.

2 Β· A Key Vault key, versioned
module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id = module.sqlmi.id
  key_vault_key_id    = module.tde_vault.key_ids["tde"]
}

ℹ️ key_ids from the key-vault module are versioned identifiers, which is exactly what auto_rotation_enabled = false β€” the default β€” requires.

⚠️ Rotating the key in the vault now means editing this configuration. Example 4 avoids that.

3 Β· What a customer-managed key actually buys, and costs
module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id = module.sqlmi.id
  key_vault_key_id    = module.tde_vault.key_ids["tde"]
}

output "risk" {
  value = {
    cmk_in_use     = module.mi_tde.this_instance_uses_a_customer_managed_key
    vault_to_watch = module.mi_tde.vault_name
  }
}

πŸ”΄ The data was already encrypted before this. What changes is who holds the key β€” and therefore who can render the instance unreadable by deleting it, disabling it, letting it expire, or removing the instance's access to the vault. No Terraform plan predicts any of that.

πŸ”’ Purge protection on the vault is not optional here. It, and a soft-delete retention long enough to notice, are what stand between an accident and a permanent one.

4 Β· Versionless plus auto-rotation, so rotation is not a change request
module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id   = module.sqlmi.id
  key_vault_key_id      = module.tde_vault.key_versionless_ids["tde"]
  auto_rotation_enabled = true
}

πŸ’‘ This is usually the better posture. The instance follows the key as it rotates in the vault, so a rotation stops being a Terraform apply.

ℹ️ The provider resolves the current version at apply and suppresses the resulting diff β€” but only when auto_rotation_enabled is true, which is why the two must be set together.

5 Β· The rule this module moves from apply to validate
# REFUSED at plan:
#   key_vault_key_id      = module.tde_vault.key_versionless_ids["tde"]
#   auto_rotation_enabled = false        # <-- versionless key, rotation off

πŸ”΄ The provider raises this during APPLY, because it only discovers the problem while resolving the key against the vault: "key_vault_key_id must be a versioned Key Vault Key ID when auto_rotation_enabled is false".

βœ… This module enforces the same rule offline, at terraform plan β€” offline, in seconds, before anything is touched. Either add the version or set auto_rotation_enabled = true.

6 Β· A Managed HSM key
module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id = module.sqlmi.id
  managed_hsm_key_id  = var.hsm_tde_key_id
}

output "kind" {
  value = module.mi_tde.key_kind # "managed_hsm"
}

⚠️ State will show key_vault_key_id populated too, with the same value. A Managed HSM key identifier is also a valid key identifier, so the provider's read sets both and suppresses the diff. That is expected, not drift.

ℹ️ This argument does not exist on the provider's 5.0 line, where key_vault_key_id is expected to carry both kinds. This library pins ~> 4.0 deliberately, so it works today and will need revisiting later.

7 Β· The two key arguments conflict
# REFUSED at plan by this module, and by the provider:
#   key_vault_key_id   = module.tde_vault.key_ids["tde"]
#   managed_hsm_key_id = var.hsm_tde_key_id

⚠️ The conflict is invisible in the binary schema β€” it lives only in the provider's Go source. This module refuses the combination at plan with a message naming which one to keep, rather than deferring to a generic conflict error.

8 Β· The record Terraform creates and does not track
module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id = module.sqlmi.id
  key_vault_key_id    = module.tde_vault.key_ids["tde"]
}

output "untracked_record" {
  value = {
    name = module.mi_tde.managed_instance_key_name # "kv-tde_tde-key_abc123..."
    id   = module.mi_tde.managed_instance_key_id
  }
}

πŸ”΄ When a key is supplied the provider creates a managed instance KEY record β€” {vaultName}_{keyName}_{keyVersion} β€” and points the protector at it. Terraform does not manage that record as a separate resource, so it appears in no plan and is destroyed by nothing.

πŸ’‘ Its name and ID are recomputed here so that something names it. They are null when the key is versionless, because the version is only resolved at apply and cannot honestly be known offline.

9 Β· Auto-rotation with no key governs nothing
module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id   = module.sqlmi.id
  auto_rotation_enabled = true
  # no key -- so this setting applies to nothing
}

output "inert" {
  value = module.mi_tde.auto_rotation_setting_governs_nothing_here # true
}

ℹ️ Reported, not refused. A service-managed key is rotated by Microsoft on its own schedule, so the setting is accepted and simply does not apply. Setting it ahead of supplying a key is harmless, and refusing it would reject a legal configuration.

10 Β· Moving from service-managed to customer-managed, and back
module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id = module.sqlmi.id

  # Add a key to take control; remove it to hand control back.
  key_vault_key_id = var.take_key_control ? module.tde_vault.key_ids["tde"] : null
}

ℹ️ Only managed_instance_id is force-new, so switching between a service-managed and a customer-managed key is an in-place update, not a replacement. Nothing is re-encrypted from scratch β€” the protector changes, not the data.

πŸ”΄ And removing the module entirely does the same thing as setting the key to null. Destroy reverts to a service-managed key; it does not decrypt anything and does not leave the instance unprotected.

11 Β· The timeout that does nothing
module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id = module.sqlmi.id
  key_vault_key_id    = module.tde_vault.key_ids["tde"]

  timeouts = {
    create = "45m" # governs create, update AND the so-called delete
    delete = "45m" # accepted, stored, never consulted
  }
}

πŸ”΄ The provider's delete function reads the create/update deadline, which is consistent with what it does β€” the delete is really an update back to a service-managed key. timeouts.delete is therefore accepted and has no effect. Set timeouts.create.

12 Β· Several instances sharing one key
module "mi_tde" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"
  for_each = module.managed_instances

  managed_instance_id   = each.value.id
  key_vault_key_id      = module.tde_vault.key_versionless_ids["tde"]
  auto_rotation_enabled = true
}

output "instances_on_a_customer_managed_key" {
  value = [
    for k, m in module.mi_tde : k
    if m.this_instance_uses_a_customer_managed_key
  ]
}

πŸ”΄ One key protecting several instances means one deletion can lock all of them. That is a legitimate design β€” it centralises key control β€” but the blast radius should be a decision, not an accident.

πŸ’‘ Each instance still needs its own identity granted wrapKey and unwrapKey on that key.

13 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

data "azurerm_client_config" "current" {}

module "sqlmi" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance.git?ref=v1.0.0"

  name                = "sqlmi-platform-eus2"
  resource_group_name = "rg-data-platform"
  location            = "eastus2"
  sku_name            = "GP_Gen5"
  vcores              = 8
  storage_size_in_gb  = 512
  subnet_id           = var.managed_instance_subnet_id
  license_type        = "BasePrice"
  # Both are required through this module: the provider pairs each with an
  # inline Entra administrator block that this module does not expose.
  administrator_login          = "sqladmin"
  administrator_login_password = var.mi_admin_password # out of band; never committed
}

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

  name                = "kv-sqlmi-tde"
  resource_group_name = "rg-data-platform"
  location            = "eastus2"
  tenant_id           = data.azurerm_client_config.current.tenant_id

  keys = {
    tde = {
      key_type = "RSA"
      key_size = 2048
      key_opts = ["unwrapKey", "wrapKey"]
    }
  }
}

# The instance's own identity is what wraps and unwraps -- not Terraform's.
resource "azurerm_role_assignment" "mi_uses_tde_key" {
  scope                = module.tde_vault.id
  role_definition_name = "Key Vault Crypto Service Encryption User"
  principal_id         = module.sqlmi.identity_principal_id
}

module "mi_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-transparent-data-encryption.git?ref=v1.0.0"

  managed_instance_id   = module.sqlmi.id
  key_vault_key_id      = module.tde_vault.key_versionless_ids["tde"]
  auto_rotation_enabled = true

  depends_on = [azurerm_role_assignment.mi_uses_tde_key]
}

# The other identity control on the same instance.
module "mi_entra_admin" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-managed-instance-active-directory-administrator.git?ref=v1.0.0"

  managed_instance_id = module.sqlmi.id
  login_username      = "sql-mi-admins"
  object_id           = var.sql_admin_group_object_id
  tenant_id           = data.azurerm_client_config.current.tenant_id
}

output "encryption_posture" {
  value = {
    key_kind        = module.mi_tde.key_kind
    vault           = module.mi_tde.vault_name
    key             = module.mi_tde.key_name
    follows_rotation = module.mi_tde.auto_rotation_enabled
    untracked_record = module.mi_tde.managed_instance_key_id
  }
}

πŸ”’ A versionless key with auto-rotation, so key rotation never becomes a Terraform change.

⚠️ The depends_on is deliberate: nothing in the protector references the role assignment, so without it Terraform may set the protector before the instance can wrap the key.

πŸ”΄ kv-sqlmi-tde is now load-bearing. Deleting that vault or that key makes sqlmi-platform-eus2 unreadable, and purge protection is what makes the mistake recoverable.


πŸ“₯ Inputs

Identity β€” managed_instance_id (required, force-new). Key β€” key_vault_key_id, managed_hsm_key_id (at most one; neither means service-managed). Rotation β€” auto_rotation_enabled. Tail β€” timeouts. (There is no tags: the resource exposes none.)

Full schemas
Name Type Default Notes
managed_instance_id string β€” Required, force-new. Anchored; a logical server ID is refused.
key_vault_key_id string null A key identifier. Conflicts with managed_hsm_key_id. Versioned unless rotation is on.
managed_hsm_key_id string null Same rules. Absent on the provider's 5.0 line.
auto_rotation_enabled bool false The provider's default, kept β€” see Architecture Notes.
timeouts object({create, read, update, delete}) null 30/5/30/30. delete is ignored by the provider.

🧾 Outputs

Output Description
id Resource ID of the encryption protector (first)
managed_instance_id the parent, as configured
managed_instance_name / resource_group_name / subscription_id parsed from the parent ID
key_vault_key_id / managed_hsm_key_id / auto_rotation_enabled as the provider reports them
key_kind service_managed, key_vault or managed_hsm
vault_name / key_name / key_version parsed from the key identifier
managed_instance_key_name / managed_instance_key_id the untracked side-effect record
this_instance_uses_a_customer_managed_key true for CMK
the_configured_key_is_versioned pairing check against auto-rotation
auto_rotation_setting_governs_nothing_here true when set with no key
the_empty_call_is_already_encrypted constant true
a_customer_managed_key_is_a_control_transfer_not_an_upgrade constant true
losing_the_key_makes_the_instance_unreadable constant true
the_instance_needs_wrap_and_unwrap_on_the_vault constant true
a_versionless_key_requires_auto_rotation constant true
the_provider_creates_a_second_arm_record_it_does_not_track constant true
destroying_this_resource_reverts_to_a_service_managed_key constant true
timeouts_delete_is_accepted_and_ignored constant true
creating_this_resource_overwrites_the_existing_protector constant true
an_empty_string_is_not_the_same_as_an_unset_key constant true β€” why both key arguments are rendered through a normalizing local
resolving_a_versionless_key_reads_the_key_as_you true for a versionless key: the apply reads it as your principal
the_two_key_arguments_are_mutually_exclusive constant true
state_may_show_both_key_fields_for_an_hsm_key constant true
managed_hsm_key_id_does_not_exist_on_the_provider_five_zero_line constant true
one_protector_per_managed_instance constant true
force_new_fields / fields_that_can_change_after_creation lifecycle
fields_azure_returns_on_read where drift is detectable
no_secret_is_accepted_or_emitted_by_this_module constant true
this_resource_supports_no_azure_resource_tags constant true

πŸ”’ A key is referenced here by identifier, never carried. Nothing is marked sensitive, and that is correct β€” a key identifier is not a secret.


🧠 Architecture Notes

Nothing is inverted here, and the reasoning is worth stating. Two neighbouring modules in this family diverge from provider defaults because those defaults make the feature do nothing. This one does not, for two reasons. The empty call already produces the safe resource β€” no key means service-managed encryption, not absent encryption. And auto_rotation_enabled cannot be flipped safely, because true and false demand different key identifiers: defaulting it to true would silently change what counts as a legal key ID for every caller, a worse trap than the one it would fix.

A customer-managed key is a control transfer. The data is encrypted either way. What changes is who holds the key, and therefore who can render the instance unreadable. That is a genuine control and a genuine operational responsibility, not a security upgrade on its own β€” and it makes vault delete rights into instance-destroy rights, which no Azure role on the SQL side limits.

One rule is moved forward. The provider raises "must be a versioned Key Vault Key ID when auto_rotation_enabled is false" during apply, because it only discovers the problem while resolving the key against the vault. The same rule is a pure shape check β€” does the identifier have a version segment? β€” so this module enforces it at terraform plan, offline and in seconds. The error message says the module is mirroring the provider rather than inventing a rule.

A second ARM record is created and never tracked. With a customer-managed key the provider creates a managed instance key named {vaultName}_{keyName}_{keyVersion} and points the protector at it. It appears in no plan and is destroyed by nothing. Its name and ID are recomputed and emitted β€” and are null under a versionless identifier, because the version is only resolved at apply and guessing would be worse than saying so.

Destroy reverts rather than deletes. An encryption protector cannot be removed, only switched between customer-managed and service-managed. The provider's delete issues an update back to service-managed, which its own source explains is to avoid an accidental lockout if someone removed the vault keys. So a terraform destroy here hands key control back to Microsoft; it decrypts nothing and leaves no instance unprotected. Consistent with that, timeouts.delete is ignored in favour of the create/update deadline.

Nothing is marked sensitive, deliberately. A key identifier is a URL, not key material. Marking it would break plan review while protecting nothing β€” and the honest control here is who can use the key, not who can read its name.


🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Encryption at rest on, with a service-managed key β€” the empty call supply a key to take control
Key control Microsoft's key_vault_key_id or managed_hsm_key_id
Two keys at once refused at plan not available
Versionless key without rotation refused at plan β€” the provider's own rule, moved forward not available
Rotation false β€” the provider's default, kept because it decides what key IDs are legal set to true with a versionless key
Secret handling none β€” a key is referenced, never carried not available
Key lifecycle not controlled by this module β€” purge protection is the caller's job β€”

πŸš€ Runbook

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

Pin the module with ?ref=v1.0.0 β€” never a branch. This module is plan-only in this repository; a human applies from CI.


πŸ§ͺ Testing

terraform validate proves, offline and with no credentials: the type schemas, the anchored managed_instance_id pattern (a logical server ID is refused), the key-identifier shape for both key arguments (a secret or certificate identifier is refused), the mutual exclusion of the two key arguments, and β€” the substantive one β€” the versioned-key rule that the provider only raises at apply.

Only terraform plan and apply β€” which need credentials β€” exercise: whether the key exists, whether the instance's identity can wrap and unwrap it, whether the vault has purge protection, and what the current version of a versionless key resolves to.

Nothing at either stage reports that destroying this resource reverts to a service-managed key, that a second ARM record was created, or that timeouts.delete is ignored. That is what the constant outputs are for.

Note the asymmetry: a validation failure blocks terraform destroy as well as apply, which is why the key checks mirror provider rules rather than inventing stricter ones.


πŸ’¬ Example Output

id                        = "/subscriptions/.../resourceGroups/rg-data-platform/providers/Microsoft.Sql/managedInstances/sqlmi-platform-eus2/encryptionProtector/current"
key_kind                  = "key_vault"
vault_name                = "kv-sqlmi-tde"
key_name                  = "tde"
key_version               = null
auto_rotation_enabled     = true
managed_instance_key_name = null
this_instance_uses_a_customer_managed_key = true
the_configured_key_is_versioned           = false
force_new_fields          = ["managed_instance_id"]

ℹ️ key_version and managed_instance_key_name are null here because the configured identifier is versionless β€” the version is resolved at apply. With a versioned identifier both are populated.


πŸ” Troubleshooting

Symptom Cause Fix
must be a VERSIONED key ID when auto_rotation_enabled is false a versionless identifier with rotation off Add the version, or set auto_rotation_enabled = true. This is the provider's own rule, enforced earlier
both key_vault_key_id and managed_hsm_key_id are set two key arguments Keep the one matching where the key lives
must be a Key Vault KEY identifier a secret or certificate identifier, or a Resource ID Use the key URL β€” https://<vault>/keys/<name>[/<version>]
managed_instance_id must be a SQL MANAGED INSTANCE Resource ID a logical server ID was passed A logical server has its own encryption-protector resource
Apply fails saying the key cannot be accessed the instance's identity lacks get/wrapKey/unwrapKey, or the vault lacks purge protection Grant the role to identity_principal_id and enable purge protection β€” example 13
State shows key_vault_key_id set when only managed_hsm_key_id was configured an HSM key identifier is also a valid key identifier; the read sets both Expected, not drift
A plan wants to change the key every run a versionless identifier with auto_rotation_enabled = false β€” the diff suppression only applies when rotation is on Set auto_rotation_enabled = true, or use a versioned identifier
timeouts.delete had no effect the provider's delete reads the create/update deadline Set timeouts.create
terraform destroy left the instance encrypted a protector cannot be deleted, only switched back to service-managed Expected, and deliberate β€” it prevents lockout
An existing protector configuration was replaced without warning there is no requires-import guard, because the service always creates a protector Expected. Reconcile the portal configuration into this module's inputs
Databases became inaccessible the key was deleted, disabled, expired, or access was revoked Restore the key from soft delete if purge protection allowed it; otherwise this is not recoverable from Terraform

πŸ”— Related Docs


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