Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure SQL Server Transparent Data Encryption Terraform Module

Chooses which key encrypts an Azure SQL logical server's data at rest β€” service-managed, Key Vault, or Managed HSM β€” and reports what that choice costs. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • πŸ” Manages azurerm_mssql_server_transparent_data_encryption β€” the single encryption protector record on an Azure SQL logical server.
  • πŸ—οΈ Accepts a versioned Key Vault key or a versioned Managed HSM key. They are mutually exclusive.
  • πŸ›‘οΈ Leaves the empty call already encrypted: no key means a Microsoft-managed key, and transparent data encryption stays on.
  • πŸ” Passes auto_rotation_enabled through, reports when it governs nothing, and reports the one combination where it guarantees a permanent diff β€” rotation on, against the pinned version this resource insists on.
  • 🧾 Emits the parsed vault, key name and key version, plus the facts a plan cannot show you.
  • ⚠️ Reports the overlap that matters most: azurerm_mssql_server owns this same setting.

πŸ’‘ Why it matters: transparent data encryption is always on for an Azure SQL server. The only question is who holds the key β€” and taking it is a transfer of control, not an extra layer of protection. This module makes that transfer explicit, refuses the identifiers the provider will refuse anyway, and names the one overlap that produces a permanent diff instead of an error.


❀️ Support this project

If this module saved you time:


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

flowchart TB
    RG["terraform-azurerm-resource-group"]
    SRV["terraform-azurerm-mssql-server"]
    KV["terraform-azurerm-key-vault"]
    HSM["terraform-azurerm-key-vault-managed-hardware-security-module-key"]
    TDE["terraform-azurerm-mssql-server-transparent-data-encryption"]
    MITDE["terraform-azurerm-mssql-managed-instance-transparent-data-encryption"]
    RA["terraform-azurerm-role-assignments"]

    RG -->|"name to resource_group_name"| SRV
    SRV -->|"id to server_id"| TDE
    KV -->|"key_ids[k] to key_vault_key_id"| TDE
    HSM -->|"versioned_id to managed_hsm_key_id"| TDE
    SRV -->|"identity_principal_id"| RA
    KV -->|"id to scope"| RA
    SRV -.->|"OWNS THE SAME FIELD as transparent_data_encryption_key_vault_key_id -- pick one"| TDE
    TDE -.->|"managed-instance twin: versionless key allowed"| MITDE

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class TDE self
    class SRV keystone
    class RG,KV,HSM,RA,MITDE ext
Loading

The server module feeds id in; a key module feeds a versioned identifier in. The dotted edge back from the server is not a data flow β€” it is the warning: azurerm_mssql_server carries its own transparent_data_encryption_key_vault_key_id argument that writes this very record. Pick one of the two. The dotted edge to the managed-instance twin marks the same idea on a different keystone, with a different rule about versions.


🧬 What this module builds

flowchart TB
    V1["server_id"]
    V2["key_vault_key_id (versioned)"]
    V3["managed_hsm_key_id (versioned, deprecated)"]
    V4["auto_rotation_enabled"]
    V5["timeouts"]

    THIS["azurerm_mssql_server_transparent_data_encryption.this"]

    O1["id -- .../encryptionProtector/current"]
    O2["key_kind -- service_managed / key_vault / managed_hsm"]
    O3["vault_name, key_name, key_version"]
    O4["rotation_setting_is_inert"]
    O5["posture flags -- control transfer, revert on destroy"]

    V1 --> THIS
    V2 --> THIS
    V3 --> THIS
    V4 --> THIS
    V5 --> THIS
    THIS --> O1
    THIS --> O2
    THIS --> O3
    THIS --> O4
    THIS --> O5

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class THIS keystone
    class O1 self
    class V1,V2,V3,V4,V5,O2,O3,O4,O5 ext
Loading

Resource inventory

Resource Count Notes
azurerm_mssql_server_transparent_data_encryption 1 (this) One per server. Its Resource ID always ends /encryptionProtector/current β€” there is no name to choose.

βœ… Provider / Versions

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

Schema notes that bite β€” each verified against the live provider schema and the resource's own source:

  • πŸ”΄ azurerm_mssql_server owns this same setting. Its optional transparent_data_encryption_key_vault_key_id argument writes the same encryption protector. Use exactly one. Setting both does not fail cleanly: the server's read writes the live protector key into the server's own state unconditionally, and that argument is optional rather than computed β€” so a server whose configuration leaves it empty plans a change back to empty on every run once this module sets a key, and the server's update then hands that empty string to a key-identifier parser.
  • πŸ”΄ The key must ALWAYS be versioned here. Both key arguments are validated versioned-only, unconditionally. https://kv.vault.azure.net/keys/tde is refused; https://kv.vault.azure.net/keys/tde/abc123… is required.
  • πŸ”΄ The managed-instance twin is different. On azurerm_mssql_managed_instance_transparent_data_encryption, a versionless key is legal whenever auto-rotation is on and the provider resolves the current version. Do not carry that assumption across.
  • ⚠️ auto_rotation_enabled therefore decides nothing about legality on this resource. It is passed through; the key must be versioned either way.
  • πŸ”΄ But it is far from inert β€” auto_rotation_enabled = true here buys a PERMANENT DIFF. Azure's automated rotation is off by default; switched on, the server watches the vault and moves the protector to the newest key version within 24 hours of one appearing. This resource requires a pinned version and reads the live version straight back, so every plan after that first rotation proposes putting the older version back β€” and applying it does exactly that. The twin escapes this by accepting a versionless value and suppressing the diff; there is no equivalent here. Reported as azure_rotates_past_the_pinned_version, not refused.
  • ℹ️ Versioned-only is the PROVIDER's rule, not the service's. Azure SQL Database itself supports a versionless TDE protector and resolves the latest enabled version; the restriction belongs to this provider release. Worth knowing before treating the constraint as immovable.
  • πŸ”΄ Making a key the protector creates a SECOND ARM record that Terraform never tracks. The key is registered on the server as Microsoft.Sql/servers/<server>/keys/<vault>_<key>_<version> before the protector is repointed. Nothing removes it β€” not a key change, not a destroy, not the revert to a service-managed key β€” so one accumulates per key version ever used. Emitted as server_key_id because no other state refers to it.
  • ⚠️ managed_hsm_key_id is deprecated here and current on the twin, in the same provider release. Supplying a value prints Warning: Argument is deprecated at terraform validate; leaving it null is silent, because the warning is raised on the value.
  • ⚠️ The provider populates BOTH key fields for an HSM key, because an HSM key identifier is also a valid key identifier. State showing both is normal.
  • πŸ”΄ 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.
  • ⚠️ Destroy reverts, it does not delete. Removing this resource returns the server to a service-managed key. Encryption is never switched off.
  • ⚠️ timeouts.delete is accepted and IGNORED β€” the delete reads the create/update deadline. An undeclared timeouts key is discarded silently, with no error.
  • ℹ️ Only server_id is force-new. The keys and the rotation flag update in place.
  • ℹ️ The resource declares a state upgrader (schema version 1 from 0), so older state is migrated on first use.
  • ℹ️ No tags and no location. Tag the SQL server instead.

πŸ”‘ Required Azure RBAC Roles / Permissions

Least privilege, at the smallest scope that works.

Scope Permission Why
The SQL logical server Microsoft.Sql/servers/encryptionProtector/read Refresh and plan.
The SQL logical server Microsoft.Sql/servers/encryptionProtector/write Create, update, and the revert-on-destroy.
The SQL logical server Microsoft.Sql/servers/keys/write Registering the key on the server precedes making it the protector.
The SQL logical server Built-in fit: SQL Server Contributor Or a custom role with exactly the actions above. Contributor at the resource group works and grants far more.

πŸ”’ The key permissions belong to the SERVER, not to your Terraform principal. The server's managed identity needs get, wrapKey and unwrapKey on the vault β€” a data-plane grant made with the Key Vault Crypto Service Encryption User role (or an access policy on a vault still using them). Nothing here can see whether that grant exists; the failure arrives at apply as a service error, not at plan.

ℹ️ Plan access is not key access here. This module references a key identifier and never reads key material, so no secret is accepted or emitted.


Azure Prerequisites

  • The Microsoft.Sql resource provider registered in the subscription.
  • An existing Azure SQL logical server. A managed instance is refused by this module's anchored ID check β€” it has its own encryption-protector resource.
  • For a customer-managed key only:
    • A Key Vault or Managed HSM with soft delete and purge protection enabled. Azure SQL will not accept a key from a vault without purge protection.
    • A versioned key identifier.
    • The SQL server configured with a managed identity, and that identity granted get, wrapKey and unwrapKey.
    • Network reachability from the SQL service to the vault, if the vault restricts network access.
  • Nothing at all for the empty call. With no key, the server keeps service-managed encryption.

πŸ“ Module Structure

terraform-azurerm-mssql-server-transparent-data-encryption/
β”œβ”€β”€ providers.tf     # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
β”œβ”€β”€ variables.tf     # 5 inputs, 6 validations, deeply-typed with the schema in the descriptions
β”œβ”€β”€ main.tf          # one keystone `this`; dynamic timeouts; try() on every optional field
β”œβ”€β”€ outputs.tf       # 46 outputs: id first, then identity, then the posture facts
β”œβ”€β”€ README.md        # this file
β”œβ”€β”€ SCOPE.md         # the cross-module contract
β”œβ”€β”€ LICENSE          # MIT
└── .gitignore

βš™οΈ Quick Start

The smallest real call keeps the Microsoft-managed key and simply brings the protector under management:

provider "azurerm" {
  features {}
}

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

  server_id = module.sql_server.id
}

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


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
server_id string terraform-azurerm-mssql-server β†’ id
key_vault_key_id string (optional) terraform-azurerm-key-vault β†’ key_ids["<key>"]
managed_hsm_key_id string (optional) terraform-azurerm-key-vault-managed-hardware-security-module-key β†’ versioned_id
auto_rotation_enabled bool (optional, false) caller
timeouts object (optional) caller

Emits

Output Consumed by
id audit tooling, policy exemptions
server_id, server_name, resource_group_name, subscription_id compositions and reporting
key_kind, this_server_uses_a_customer_managed_key posture reporting
vault_name, key_name, key_version key-rotation tooling
server_key_name, server_key_id audit and cleanup of the untracked server-key records
rotation_setting_is_inert, azure_rotates_past_the_pinned_version, rotating_the_key_means_changing_this_configuration drift review
the posture constants human readers

πŸ“š Example Library

1 Β· The empty call β€” service-managed encryption, brought under management
module "sql_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"

  server_id = module.sql_server.id
}

πŸ”’ This is already encrypted. Transparent data encryption is on with a Microsoft-managed key, and there is no configuration that turns it off.

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

  server_id        = module.sql_server.id
  key_vault_key_id = module.key_vault.key_ids["sql-tde"]
}

⚠️ The identifier must carry a version. module.key_vault.key_ids[...] is the key resource's id, which is versioned β€” that is why it works here.

3 Β· The versionless key that is refused β€” and why
module "sql_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"

  server_id        = module.sql_server.id
  key_vault_key_id = module.key_vault.key_versionless_ids["sql-tde"] # refused
}

πŸ”΄ Refused offline, before any Azure call, by this module's own version check β€” which names the versionless case specifically rather than reporting a generic shape error. On the managed-instance twin the same value would be legal whenever auto-rotation is on; the rule does not transfer. ℹ️ Worth knowing that this is the provider's restriction, not the platform's: Azure SQL Database accepts a versionless TDE protector and resolves the latest enabled version itself. That is also why auto_rotation_enabled = true behaves awkwardly here β€” see example 6.

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

  name           = "sql-tde"
  managed_hsm_id = var.managed_hsm_id
  key_type       = "RSA-HSM"
  key_size       = 2048 # required for RSA-HSM by the sibling module
  key_opts       = ["wrapKey", "unwrapKey"]
}

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

  server_id          = module.sql_server.id
  managed_hsm_key_id = module.hsm_key.versioned_id
}

⚠️ Supplying this argument prints Warning: Argument is deprecated at plan β€” it is deprecated in favour of key_vault_key_id and removed in the provider's 5.0 line. See example 5 for the migration.

5 Β· The same HSM key, without the deprecation warning
module "sql_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"

  server_id = module.sql_server.id

  # `key_vault_key_id` already accepts a Managed HSM identifier -- which is
  # exactly why the provider's read populates both fields with the same value.
  key_vault_key_id = module.hsm_key.versioned_id
}

πŸ’‘ This is the migration, and it is available today. It removes the warning and pre-empts the 5.0 removal.

6 Β· Auto-rotation β€” what it does not change, and the diff it buys
module "sql_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"

  server_id             = module.sql_server.id
  key_vault_key_id      = module.key_vault.key_ids["sql-tde"] # still versioned
  auto_rotation_enabled = true
}

output "still_needs_a_version" {
  value = module.sql_tde.the_key_must_always_be_versioned_here
}

output "and_will_therefore_drift" {
  value = module.sql_tde.azure_rotates_past_the_pinned_version # true
}

⚠️ On this resource the flag does not make a versionless identifier legal. The key must be versioned either way, and nothing in Terraform's validation depends on this flag. πŸ”΄ It does, however, guarantee drift in this exact combination. Azure will move the protector to a newer key version within 24 hours of one appearing, the provider reads that newer version back, and every later plan proposes returning to the version pinned above β€” which an apply will actually do. Either pin deliberately with rotation off, or keep this configuration away from any pipeline that fails on a non-empty plan.

7 Β· The flag set with no key at all β€” reported, not refused
module "sql_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"

  server_id             = module.sql_server.id
  auto_rotation_enabled = true
}

output "rotation_governs_nothing" {
  value = module.sql_tde.rotation_setting_is_inert # true
}

ℹ️ A service-managed key is rotated by Microsoft on its own schedule. The value is accepted and simply does not apply β€” so the module reports it rather than rejecting a value the provider allows.

8 Β· Both keys at once β€” refused, with the reason
module "sql_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"

  server_id          = module.sql_server.id
  key_vault_key_id   = module.key_vault.key_ids["sql-tde"]
  managed_hsm_key_id = module.hsm_key.versioned_id # rejected at plan
}

πŸ”΄ The provider declares these mutually exclusive. This module refuses the pairing offline with a message that says which one to keep.

9 Β· Reading the key back apart β€” vault, name and version
output "tde_key" {
  value = {
    kind    = module.sql_tde.key_kind    # "key_vault"
    vault   = module.sql_tde.vault_name  # "kv-data-platform"
    key     = module.sql_tde.key_name    # "sql-tde"
    version = module.sql_tde.key_version # never null under a customer key
  }
}

πŸ’‘ key_version is never null when a customer key is in effect, precisely because this resource demands the version. On the managed-instance twin it can be.

10 Β· Granting the server what it actually needs
module "sql_server" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server.git?ref=v1.0.0"

  name                = "sql-platform-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location

  azuread_administrator = {
    login_username = "sql-admins"
    object_id      = var.sql_admin_group_object_id
  }

  identity = { type = "SystemAssigned" }
}

module "tde_key_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.key_vault.id

  role_assignments = {
    sql_wrap_unwrap = {
      principal_id         = module.sql_server.identity_principal_id
      role_definition_name = "Key Vault Crypto Service Encryption User"
      principal_type       = "ServicePrincipal"
      description          = "Lets the SQL server wrap and unwrap its TDE protector key."
    }
  }
}

πŸ”’ This grant is the server's, not yours. Without it the key is set and the apply fails at the service β€” nothing in the plan shows the gap.

11 Β· The overlap that produces a permanent diff
# DO NOT DO THIS -- two resources claiming one setting.
module "sql_server" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server.git?ref=v1.0.0"

  name                = "sql-platform-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location

  azuread_administrator = {
    login_username = "sql-admins"
    object_id      = var.sql_admin_group_object_id
  }

  transparent_data_encryption_key_vault_key_id = module.key_vault.key_ids["sql-tde"] # <-- and
}

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

  server_id        = module.sql_server.id
  key_vault_key_id = module.key_vault.key_ids["sql-tde"] # <-- the same setting again
}

πŸ”΄ Choose one. Use the server argument when this configuration owns the server and a versioned Key Vault key is all you need. Use this module when you need a Managed HSM key or auto_rotation_enabled, or when the server is not yours to manage β€” then leave the server's argument unset, because a server whose configuration leaves it empty plans a change back to empty on every run once the protector is set elsewhere.

12 Β· Reporting the posture across a fleet
locals {
  servers = {
    platform  = module.sql_tde_platform
    reporting = module.sql_tde_reporting
    archive   = module.sql_tde_archive
  }
}

output "tde_posture" {
  value = {
    for name, m in local.servers : name => {
      key_kind          = m.key_kind
      customer_managed  = m.this_server_uses_a_customer_managed_key
      rotation_is_inert = m.rotation_setting_is_inert
      key_version       = m.key_version
    }
  }
}

πŸ’‘ A fleet report that names which servers took the key and which left it with Microsoft β€” the question an audit actually asks.

13 Β· Timeouts, including the one that is ignored
module "sql_tde" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-mssql-server-transparent-data-encryption.git?ref=v1.0.0"

  server_id        = module.sql_server.id
  key_vault_key_id = module.key_vault.key_ids["sql-tde"]

  timeouts = {
    create = "45m"
    read   = "10m"
    update = "45m"
    delete = "45m" # accepted and IGNORED -- the delete reads the create/update deadline
  }
}

⚠️ Two traps in four lines. delete does nothing here, and a misspelled key (deletes) is discarded silently by Terraform's object-type conversion β€” no error, no effect.

14 Β· πŸ—οΈ 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-data-platform-eastus"
  location = "eastus"
}

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

  name                = "kv-data-platform"
  resource_group_name = module.rg.name
  location            = module.rg.location
  tenant_id           = var.tenant_id

  # Azure SQL refuses a key from a vault without purge protection.
  purge_protection_enabled = true

  keys = {
    sql-tde = {
      key_type = "RSA"
      key_size = 2048
      key_opts = ["wrapKey", "unwrapKey"] # "get" is a VAULT permission, not a key operation
    }
  }
}

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

  name                = "sql-platform-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location

  azuread_administrator = {
    login_username = "sql-admins"
    object_id      = var.sql_admin_group_object_id
  }

  identity = { type = "SystemAssigned" }

  # Deliberately NOT set here -- the protector is owned by the module below.
  # transparent_data_encryption_key_vault_key_id = ...
}

module "tde_key_access" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.key_vault.id

  role_assignments = {
    sql_wrap_unwrap = {
      principal_id         = module.sql_server.identity_principal_id
      role_definition_name = "Key Vault Crypto Service Encryption User"
      principal_type       = "ServicePrincipal"
    }
  }
}

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

  server_id             = module.sql_server.id
  key_vault_key_id      = module.key_vault.key_ids["sql-tde"]
  auto_rotation_enabled = true
}

output "encryption" {
  value = {
    protector_id     = module.sql_tde.id
    key_kind         = module.sql_tde.key_kind
    key_version      = module.sql_tde.key_version
    customer_managed = module.sql_tde.this_server_uses_a_customer_managed_key
  }
}

πŸ”’ Every reference is a real output of a real sibling. The role assignment is what makes the apply succeed, and it is the piece most often missing.


πŸ“₯ Inputs

Identity β€” server_id (required, force-new) Key β€” key_vault_key_id, managed_hsm_key_id (mutually exclusive, both versioned-only) Behaviour β€” auto_rotation_enabled Tail β€” timeouts (no tags: the resource exposes none)

Full input schemas
variable "server_id" {
  type = string
  # Anchored to /subscriptions/<s>/resourceGroups/<rg>/providers/Microsoft.Sql/servers/<srv>
  # A managed-instance ID is refused -- it has its own encryption-protector resource.
}

variable "key_vault_key_id" {
  type    = string
  default = null
  # Must be https://<vault>/keys/<name>/<version> -- the VERSION IS ALWAYS REQUIRED here.
  # Conflicts with managed_hsm_key_id.
}

variable "managed_hsm_key_id" {
  type    = string
  default = null
  # Must be https://<hsm>.managedhsm.azure.net/keys/<name>/<version>.
  # DEPRECATED on this resource; supplying a value warns at plan.
}

variable "auto_rotation_enabled" {
  type    = bool
  default = false
  # Passed through. Does NOT change which key identifiers are legal on this resource --
  # but true plus a pinned version is the permanent-diff combination. See Architecture Notes.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string) # accepted and IGNORED
  })
  default = null
  # Every value is checked as a Go duration, so "1h30m", "1.5h", "500ms" and a bare "0"
  # are all accepted and a unitless "30" is refused at validate rather than at apply.
}

🧾 Outputs

Output Description Notes
id Resource ID of the protector. Always ends /encryptionProtector/current.
server_id The server this protector encrypts.
server_name, resource_group_name, subscription_id Parsed from the server ID.
key_vault_key_id The key identifier in effect. Populated for an HSM key too.
managed_hsm_key_id The HSM identifier as configured. Read from the variable, so consumers are not made to print a deprecation warning.
auto_rotation_enabled Whether the server follows the key as it rotates.
key_kind service_managed, key_vault or managed_hsm.
this_server_uses_a_customer_managed_key Boolean form of the above.
vault_name, key_name, key_version Parsed from the configured key. Null under a service-managed key.
server_key_name, server_key_id The untracked server-key record the provider registers before repointing the protector. Null under a service-managed key. Nothing removes these.
rotation_setting_is_inert Rotation asked for with no customer key. Conditional on the inputs.
azure_rotates_past_the_pinned_version Rotation on with a pinned version β€” the permanent-diff combination. Conditional on the inputs.
the_vault_needs_soft_delete_and_purge_protection Constant true. Azure validates both and fails setup if either is off.
the_provider_creates_a_second_arm_record_it_does_not_track Constant true. One per key version ever used.
managed_hsm_key_id_must_really_point_at_a_managed_hsm Constant true, and unenforced. A standard vault key passed here applies, then reads back as absent.
a_server_level_key_stops_databases_choosing_a_service_managed_key Constant true. The blast radius is every database on the server.
the_sql_server_resource_owns_this_same_setting Constant true. The overlap warning.
setting_the_key_in_both_places_does_not_fail_cleanly Constant true. What the overlap actually does.
the_server_argument_cannot_express_hsm_or_auto_rotation Constant true. Why this module exists alongside it.
the_key_must_always_be_versioned_here Constant true. The difference from the twin.
auto_rotation_does_not_change_which_key_ids_are_legal_here Constant true.
the_empty_call_is_already_encrypted Constant true.
a_customer_managed_key_is_a_control_transfer_not_an_upgrade Constant true.
this_server_uses_a_customer_managed_key Conditional.
losing_the_key_makes_every_database_unreadable Constant true.
the_server_needs_wrap_and_unwrap_on_the_vault Constant true.
rotating_the_key_means_changing_this_configuration True for a customer-managed key with rotation off β€” the only state in which the version written here is the version in force. Conditional on the inputs.
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.
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_is_formally_deprecated_on_this_resource Constant true.
the_managed_instance_twin_is_not_deprecated Constant true. The asymmetry.
using_managed_hsm_key_id_emits_a_provider_warning Conditional.
this_resource_carries_a_state_upgrader Constant true.
one_protector_per_server Constant true.
force_new_fields ["server_id"].
fields_that_can_change_after_creation The keys and the rotation flag.
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.
an_empty_string_is_not_the_same_as_an_unset_key Constant true β€” why both key arguments are rendered through a normalizing local.

πŸ”’ No output is sensitive, because none carries a secret. A key identifier is a reference, not key material.


🧠 Architecture Notes

The empty call is the safe call, and it is not the strong one. Transparent data encryption is on for every Azure SQL server whether or not this module sets a key. Supplying a customer-managed key does not add encryption β€” it changes who holds the key, and with it who can render every database on the server unreadable by deleting or disabling it. That is a control transfer and a responsibility. The module makes the caller type it, and says so plainly rather than presenting a customer key as a security upgrade.

The versioned-key rule is the module's most useful refusal, and mirroring it is not redundant. The provider validates both key arguments versioned-only with no dependency on anything else β€” but a provider schema rule fires against a literal value in a resource block, and a caller reaching this resource through a module never supplies one. Called this way, the provider's own check waits for plan. The mirror here fires during variable evaluation instead, so the failure arrives offline with no credentials, and the message can say why: it names the versionless case specifically, separates it from a wrong item type, and names the difference from the managed-instance twin, where the same value is legal under auto-rotation. Intra-family asymmetry is the norm in this provider, not the exception β€” and it is worth adding that the versioned-only rule is the provider's, not the service's: Azure SQL Database supports a versionless protector and resolves the latest enabled version itself.

auto_rotation_enabled is a pass-through whose default is kept for two reasons, and the second is the interesting one. This suite inverts a provider default when keeping it would make the feature the module is named for do nothing. Here the empty call is already encrypted, so there is nothing to rescue β€” that is the first reason. The second is that true is not the harmless setting it looks like on this resource. Azure's automated rotation, once on, moves the protector to the newest version of the key within 24 hours of it appearing; the provider requires a pinned version in the configuration and reads the live one back into state. The two are in direct tension, and the visible result is a plan that proposes rolling the key backwards on every run, for as long as the configuration and the vault disagree. The managed-instance twin resolves the same tension by accepting a versionless identifier and carrying a diff-suppression function for exactly this case β€” neither exists here. So false is kept because it is the quiet choice as well as the provider's, and the combination is reported through azure_rotates_past_the_pinned_version rather than refused, since the provider permits it and a caller may well want rotation more than a clean plan. Where the flag genuinely does nothing, with no customer key configured at all, rotation_setting_is_inert says so.

One apply, two ARM records, and only one of them tracked. Making a customer-managed key the protector is not a single write: the key is first registered on the server as a Microsoft.Sql/servers/keys record named <vault>_<key>_<version>, and only then is the protector repointed at that record by name. Terraform's state holds the protector alone. Nothing in the provider removes a registration β€” not switching keys, not terraform destroy, not the revert to a service-managed key β€” so a server accumulates one per key version it has ever used. That is defensible rather than a leak, because restoring an old backup needs the key version it was encrypted with, but it does mean the server's key list is not a picture of what is in use. server_key_id emits the record's ID for exactly that audit.

Lifecycle: only server_id is force-new, and destroy reverts. Changing the key or the rotation flag updates in place. Removing the resource issues a create-or-update that returns the server to a service-managed key β€” encryption is never turned off, and the databases stay readable. That is also why timeouts.delete is accepted and ignored: the delete path reads the create/update deadline, which matches what it actually does.

The overlap is the fact worth carrying away. azurerm_mssql_server writes the same encryption protector through its own optional argument. The two resources do not coordinate, and the failure mode is a permanent diff rather than an error: the server's read writes the live key into its own state unconditionally, and that argument is optional rather than computed. This module emits the overlap as a constant output because it is consequential, invisible in state, and inferable from nothing else in a plan.


🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Encryption at rest On, with a Microsoft-managed key. There is no configuration that turns it off. β€”
Key custody Microsoft holds the key. Supply key_vault_key_id or managed_hsm_key_id and accept the responsibility.
Key identifier precision Versioned only, mirroring the provider. None β€” the provider refuses the alternative.
Conflicting key sources Refused at plan. None β€” the provider declares them mutually exclusive.
Secrets None accepted, none emitted. A key identifier is a reference. β€”
Deprecated arguments Reported, not hidden, and the module's own outputs avoid imposing the warning on callers. β€”

πŸ”’ sensitive = true would redact plan output and would not encrypt state β€” which is why this module carries no secret at all rather than marking one.


πŸš€ Runbook

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

Pin the source at a tag β€” ?ref=v1.0.0 β€” never a branch. This module is authored and verified plan-only; a human applies from CI.


πŸ§ͺ Testing

What validate and fmt cover, with no credentials:

  • Every input validation: the anchored server ID, the versioned-only key rules, and the mutual exclusion. All four fire offline.
  • The provider's own schema validators, which run at the same stage.
  • The deprecation warning on managed_hsm_key_id β€” supplying a literal value prints it at plan, which is how the asymmetry with the managed-instance twin was confirmed rather than assumed.
  • HCL syntax and formatting.

What only plan or apply reaches:

  • Whether the server's identity actually has get, wrapKey and unwrapKey on the vault.
  • Whether the vault has purge protection enabled.
  • Whether the key exists and the version is current.
  • Whether the SQL service can reach a network-restricted vault.

⚠️ A validation failure blocks terraform destroy as well as apply, which is why this module refuses only what the provider itself refuses and reports everything else.


πŸ’¬ Example Output

Outputs:

encryption = {
  "customer_managed" = true
  "key_kind"         = "key_vault"
  "key_version"      = "9f4c1e2ab7d34f80a1c6e5d2b8f70931"
  "protector_id"     = "/subscriptions/8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8/resourceGroups/rg-data-platform-eastus/providers/Microsoft.Sql/servers/sql-platform-eastus/encryptionProtector/current"
}
key_name    = "sql-tde"
vault_name  = "kv-data-platform"
rotation_setting_is_inert = false

πŸ” Troubleshooting

Symptom Cause Fix
key_vault_key_id is a VERSIONLESS key identifier, and this resource requires the version… A versionless identifier. Legal on the managed-instance twin β€” and legal to Azure SQL Database itself β€” but not on this resource in this provider release. Use the key's versioned id β€” module.key_vault.key_ids["…"], not key_versionless_ids.
key_vault_key_id must be a Key Vault KEY identifier… Something that is not a key: a secrets/certificates identifier, an ARM Resource ID, or a path with too many segments. This is the shape check, which is separate from the version check so the two failures name themselves. Pass the key's own data-plane identifier, https://<vault>.vault.azure.net/keys/<name>/<version>.
every timeouts value must be a Go duration string… A unitless number such as 30, or prose such as 30 minutes. Use a duration: 30m, 1h30m, 1.5h, 500ms, or a bare 0.
both key_vault_key_id and managed_hsm_key_id are set… The provider declares them mutually exclusive. Keep the one matching where the key lives. To use an HSM key without the deprecation warning, pass it through key_vault_key_id.
server_id must be an Azure SQL SERVER Resource ID… A managed-instance ID. Use azurerm_mssql_managed_instance_transparent_data_encryption instead β€” it is a different resource with different rules.
Warning: Argument is deprecated on every validate managed_hsm_key_id carries the schema's deprecation flag. Expected. Pass the same identifier through key_vault_key_id to remove it β€” that argument already accepts an HSM key.
The SQL server plans a change to its transparent_data_encryption_key_vault_key_id on every run Both the server resource and this module own the protector. Leave the server's argument unset wherever this module is in play.
Apply fails with a key-access or wrap error at the service The server's identity lacks get/wrapKey/unwrapKey, or the server has no managed identity at all. Give the server an identity, then grant Key Vault Crypto Service Encryption User to module.sql_server.identity_principal_id at the vault.
Apply is refused as soon as the key is supplied, before any permission is even checked Azure SQL validates that the vault has soft delete AND purge protection, and fails protector setup if either is off. Enable both on the vault and apply again. Retrying without changing the vault will fail identically.
The key was rotated in the vault but the server still uses the old version auto_rotation_enabled is false, so nothing watches the vault and the version here is the version in force. Either update key_vault_key_id to the new versioned identifier and apply, or set auto_rotation_enabled = true β€” but read the next row first.
Every plan proposes changing key_vault_key_id back to an older version, and applying it really does roll the key back auto_rotation_enabled = true while the provider requires a pinned version. Azure moved the protector forward; Terraform is proposing to undo it. Expected on this resource, and reported as azure_rotates_past_the_pinned_version. Either update the pinned version to what Azure chose, or leave rotation off and rotate deliberately. There is no diff-suppression here as there is on the managed-instance twin.
The server's key list has grown far beyond the keys in use Each key version ever made the protector left a Microsoft.Sql/servers/keys registration behind, and nothing removes them. Expected β€” see server_key_id. Old registrations are what a restore of an old backup needs, so audit before pruning.
terraform destroy leaves encryption apparently unchanged Destroy reverts to a service-managed key rather than deleting anything. Expected. Encryption is never switched off.
A timeouts.delete value has no effect The delete function reads the create/update deadline. Set timeouts.create instead.

πŸ”— Related Docs


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