Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Key Vault Terraform Module

Manage a hardened Azure Key Vault with its keys, secrets, and certificates as one unit — RBAC-authorized, purge-protected, and private by default — on hashicorp/azurerm ~> 4.0.

Terraform azurerm module type resources

🧩 Overview

  • 🔐 Creates one azurerm_key_vault hardened by default: RBAC authorization, purge protection, 90-day soft delete, public network access off.
  • 🗝️ Manages azurerm_key_vault_key (generated server-side — no key material leaves Azure), azurerm_key_vault_secret, and azurerm_key_vault_certificate (import or policy-generated) as keyed maps.
  • 🚦 Optional network_acls default to Deny. (Certificate contacts is accepted but cannot be applied to a new vault — see example 9.)
  • 🔒 Secret values and imported certificate contents are provisioned out of band; no secret is ever emitted.

💡 Why it matters: the vault is the root of trust for a regulated workload. A vault that is RBAC-only, purge-protected, and private on the empty call means the safe posture is the default — every relaxation is a deliberate, reviewable opt-out.

❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!

🗺️ Where this fits in the family

flowchart TD
  RG["terraform-azurerm-resource-group"]
  KV["terraform-azurerm-key-vault"]
  AKV["azurerm_key_vault"]
  CHILD["keys / secrets / certificates (for_each)"]
  RA["terraform-azurerm-role-assignments (data-plane RBAC)"]
  PE["terraform-azurerm-private-endpoint"]
  CMK["Storage / SQL / Disk (CMK via key id)"]
  AP["terraform-azurerm-key-vault-access-policy"]
  CC["terraform-azurerm-key-vault-certificate-contacts"]
  CI["terraform-azurerm-key-vault-certificate-issuer"]

  RG -->|"resource_group_name + location"| KV
  KV --> AKV
  AKV --> CHILD
  RA -->|"grants data-plane roles"| KV
  KV -->|"id"| PE
  KV -->|"key versionless_id"| CMK
  KV -->|"key_vault_id"| AP
  KV -->|"key_vault_id"| CC
  KV -->|"key_vault_id"| CI

  classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
  classDef key fill:#004578,color:#ffffff,stroke:#004578;
  class KV this;
  class AKV key;
Loading

🧬 What this module builds

flowchart LR
  I1["name / location / tenant_id / sku_name"]
  I2["RBAC authZ, purge protection,<br/>soft-delete 90, public access off"]
  I3["network_acls (Deny) / contacts"]
  I4["keys / secrets / certificates (maps)"]

  KV["azurerm_key_vault.this"]
  K["azurerm_key_vault_key.this (for_each)"]
  S["azurerm_key_vault_secret.this (for_each)"]
  C["azurerm_key_vault_certificate.this (for_each)"]

  O1["id / name / vault_uri"]
  O2["key_ids / key_versionless_ids"]
  O3["secret_ids / certificate_ids"]

  I1 --> KV
  I2 --> KV
  I3 --> KV
  I4 --> K
  I4 --> S
  I4 --> C
  KV --> K
  KV --> S
  KV --> C
  KV --> O1
  K --> O2
  S --> O3

  classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
  classDef key fill:#004578,color:#ffffff,stroke:#004578;
  class KV key;
  class K this;
Loading

Resource inventory

Resource Cardinality Role
azurerm_key_vault.this 1 (keystone) The hardened vault.
azurerm_key_vault_key.this 0..N (for_each) Server-generated keys.
azurerm_key_vault_secret.this 0..N (for_each) Secrets (values out of band).
azurerm_key_vault_certificate.this 0..N (for_each) Imported or policy-generated certificates.

✅ Provider / Versions

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

Schema notes that bite

  • 🔴 contacts CANNOT BE SET ON A NEW VAULT. The provider's create function refuses the block outright; it exists on the 4.x line only so pre-existing vaults that already carry contacts can still be updated. Reported rather than refused here, because a validation {} cannot tell a create from an update and refusing would strand those callers.

  • 🔴 The vault NAME is globally unique across all of Azure, and a destroyed vault keeps it. Soft delete holds the name for the retention window, and with purge protection on — this module's default — the vault cannot be purged early to release it. Re-creating a vault with the same name inside that window fails with what looks like someone else having taken it.

  • 🔴 Purge protection cannot be turned off once on. Not force-new, not updatable, and not undone by destroying and re-creating the vault, because a soft-deleted vault retains the setting. Several Azure features (customer-managed keys on Storage, SQL, Machine Learning) require it, which is why this module defaults it on despite the cost.

  • ⚠️ sku_name is LOWERCASE and case-sensitive — "Standard" is refused. That is the opposite convention from most Azure SKU arguments in this provider.

  • ⚠️ Raising the SKU to premium does not convert existing keys to HSM-backed ones. Those must be created again as HSM types, and a key cannot be exported to be re-imported — so "upgrade to premium" means issuing new keys and rotating everything that uses them.

  • ⚠️ On the pinned 4.x line the provider carries BOTH rbac_authorization_enabled and the deprecated enable_rbac_authorization, each ConflictsWith the other. This module uses the new name; older examples use the old one, and setting both fails at plan.

  • ⚠️ soft_delete_retention_days is sent only when it is NOT 90, and the provider's read ASSUMES 90 when the API returns nothing for it — so a vault whose retention Azure does not report reads back as 90 regardless.

  • name is globally unique and immutable; resource_group_name, location, and tenant_id are immutable too.

  • With rbac_authorization_enabled = true (the default), access policies are not used — grant data-plane access with the role-assignments module (e.g. Key Vault Secrets User) at the vault scope.

  • purge_protection_enabled = true (default) cannot be disabled once enabled, and a purge-protected vault cannot be permanently deleted until its soft-delete window elapses — deliberate for production.

  • Managing secrets/keys/certs requires the deploying identity to hold the data-plane role (e.g. Key Vault Administrator) because RBAC authZ is on — a management-plane role alone is not enough.

  • Secret value is provider-sensitive (redacted in plan); imported certificate contents are secret material — provision both out of band.

🔑 Required Azure RBAC Roles / Permissions

  • Key Vault Contributor (management plane) on the target resource group to create/update the vault.
  • A data-plane role for the deploying identity when RBAC authZ is on: Key Vault Administrator (or the narrower Crypto/Secrets/Certificates Officer roles) at the vault scope, so Terraform can manage keys/secrets/certificates.

Azure Prerequisites

  • The Microsoft.KeyVault resource provider registered on the subscription.
  • An existing resource group and the target Entra ID tenant_id.
  • For private access, a subnet and a private endpoint + private DNS zone (privatelink.vaultcore.azure.net).
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; the module declares none of these.

📁 Module Structure

terraform-azurerm-key-vault/
├── providers.tf     # required_version + azurerm ~> 4.0; no provider block
├── variables.tf     # name, rg, location, tenant_id, sku, hardening toggles, network_acls, keys/secrets/certificates
├── main.tf          # azurerm_key_vault.this + for_each keys / secrets / certificates
├── outputs.tf       # id, name, vault_uri, key_ids, key_versionless_ids, secret_ids, certificate_ids
├── README.md        # this document
├── SCOPE.md         # cross-module contract
├── LICENSE          # MIT
└── .gitignore       # canonical Terraform ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "kv" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
  name                = "kv-platform-prod-eus2"
  resource_group_name = "rg-security-prod-eastus2"
  location            = "eastus2"
  tenant_id           = var.tenant_id
}

ℹ️ The empty call is RBAC-only, purge-protected, private. Pin the module by tag (?ref=v1.0.0), never a branch.

🔌 Cross-Module Contract

Consumes

Input Type From
resource_group_name string terraform-azurerm-resource-group (name)
location string caller / resource group (location)
tenant_id string caller (the Entra tenant)

Emits

Output Description Consumed by
id Vault Resource ID the key-vault-* config modules (access-policy, certificate-contacts, certificate-issuer, managed-storage-account), role assignments, private endpoints, diagnostics
name Vault name reference
vault_uri Data-plane URI SDK/app configuration
key_versionless_ids Map key key → versionless key ID CMK wiring (Storage/SQL/Disk)
secret_ids / certificate_ids child maps reference

📚 Example Library

1 · Minimal (hardened)
module "kv" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
  name                = "kv-min-eus2"
  resource_group_name = "rg-security-eastus2"
  location            = "eastus2"
  tenant_id           = var.tenant_id
}

🔒 RBAC authZ, purge protection, 90-day soft delete, no public access — all on by default.

2 · Premium (HSM-backed keys)
module "kv" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
  name                = "kv-hsm-eus2"
  resource_group_name = "rg-security-eastus2"
  location            = "eastus2"
  tenant_id           = var.tenant_id
  sku_name            = "premium"
}
3 · Network ACLs (allow specific subnets)
module "kv" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
  name                = "kv-acl-eus2"
  resource_group_name = "rg-security-eastus2"
  location            = "eastus2"
  tenant_id           = var.tenant_id
  network_acls = {
    virtual_network_subnet_ids = [var.app_subnet_id]
    ip_rules                   = ["203.0.113.0/24"]
  }
}

🔒 default_action defaults to Deny; only listed subnets/IPs are allowed.

4 · Generate an RSA key
keys = {
  cmk = {
    key_type = "RSA"
    key_size = 3072
    key_opts = ["wrapKey", "unwrapKey"]
  }
}
5 · Generate an EC key
keys = {
  signing = {
    key_type = "EC"
    curve    = "P-256"
    key_opts = ["sign", "verify"]
  }
}
6 · Store a secret (value out of band)
module "kv" {
  # ...
  secrets = {
    db-password = { value = var.db_password }   # populated from a secret store / random_password, never a literal
  }
}

🔒 value flows into a provider-sensitive attribute and is redacted from plan; provision it out of band.

7 · Self-signed certificate (policy-generated)
certificates = {
  app-tls = {
    certificate_policy = {
      issuer_parameters = { name = "Self" }
      key_properties    = { exportable = true, key_type = "RSA", reuse_key = true, key_size = 2048 }
      secret_properties = { content_type = "application/x-pkcs12" }
      x509_certificate_properties = {
        subject            = "CN=app.internal.contoso.com"
        validity_in_months = 12
        key_usage          = ["digitalSignature", "keyEncipherment"]
        subject_alternative_names = { dns_names = ["app.internal.contoso.com"] }
      }
      lifetime_action = [{
        action  = { action_type = "AutoRenew" }
        trigger = { days_before_expiry = 30 }
      }]
    }
  }
}
8 · Import a certificate (PFX out of band)
certificates = {
  imported = {
    certificate = {
      contents = var.pfx_base64   # provisioned out of band
      password = var.pfx_password
    }
  }
}
9 · Certificate contacts — what NOT to write on a new vault

❌ This cannot be applied to a vault this module creates.

contacts = [{ email = "pki@example.com", name = "PKI Team" }]

🔴 The provider refuses contact on any NEW key vault, from inside its create function: "contact field is not supported for new key vaults". The field survives on the pinned ~> 4.0 line only so that vaults which already carry contacts — deployed before it was withdrawn — can still be updated without being re-created. The whole feature sits behind an internal 5.0 flag and is going away.

ℹ️ So why does this module still accept the value? Because a validation {} block fires on every plan and cannot tell a create from an update. Refusing a non-empty list would stop a caller who already has contacts on a pre-existing vault from planning at all — and, since a failed validation blocks terraform destroy too, would leave that vault unmanageable through the module. It is reported through the contacts_cannot_be_set_on_a_new_vault output instead.

✅ Do this instead. Certificate expiry notification belongs in Azure Monitor or Event Grid, both of which observe the vault's certificate events without this field and can route to an action group you control.

10 · Enable for disk encryption
module "kv" {
  # ...
  enabled_for_disk_encryption = true
}
11 · Public access (opt-in)
module "kv" {
  # ...
  public_network_access_enabled = true
  network_acls = { default_action = "Allow" }
}

⚠️ Opening public access removes the private guardrail — use only with justification.

12 · for_each — a vault per environment
module "kv" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
  for_each            = toset(["dev", "prod"])
  name                = "kv-${each.key}-eus2"
  resource_group_name = "rg-security-eastus2"
  location            = "eastus2"
  tenant_id           = var.tenant_id
}
13 · Grant data-plane access via RBAC
module "kv" { # ... }

module "kv_roles" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
  scope  = module.kv.id
  role_assignments = {
    secrets-user = {
      role_definition_name = "Key Vault Secrets User"
      principal_id         = var.app_principal_id
    }
  }
}

🔒 With RBAC authZ on, data-plane access is granted by role assignment, not access policies.

14 · 🏗️ End-to-end composition

A resource group, a hardened vault, an identity granted secrets access, and a CMK key wired into a storage account.

provider "azurerm" {
  features {}
}

module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-security-prod-eastus2"
  location = "eastus2"
}

module "id" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"
  name                = "id-storage-cmk"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "kv" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
  name                = "kv-plat-prod-eus2"
  resource_group_name = module.rg.name
  location            = module.rg.location
  tenant_id           = var.tenant_id

  keys = {
    storage-cmk = { key_type = "RSA", key_size = 3072, key_opts = ["wrapKey", "unwrapKey"] }
  }
}

module "kv_roles" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
  scope  = module.kv.id
  role_assignments = {
    cmk-user = {
      role_definition_name = "Key Vault Crypto Service Encryption User"
      principal_id         = module.id.principal_id
    }
  }
}

# module.kv.key_versionless_ids["storage-cmk"] + module.id.id feed the storage account's customer_managed_key.

💡 The vault is RBAC-only; the identity is granted crypto access by role, and the key's versionless ID drives storage CMK — least privilege, end to end.

📥 Inputs

Name Type Required Default Description
name string ✅ — Vault name (3–24). Immutable.
resource_group_name string ✅ — Containing resource group. Immutable.
location string ✅ — Azure region. Immutable.
tenant_id string ✅ — Entra tenant ID.
sku_name string — "standard" standard / premium.
rbac_authorization_enabled bool — true Use RBAC (not access policies).
purge_protection_enabled bool — true Purge protection.
soft_delete_retention_days number — 90 7–90.
public_network_access_enabled bool — false Public reachability.
network_acls object — null ACLs (default_action Deny).
contacts list(object) — [] Certificate contacts.
keys / secrets / certificates map(object) — {} Child objects.
tags map(string) — {} Tags.
timeouts object — null Optional timeouts.
Full variable schemas (children)
keys = map(object({
  name = optional(string), key_type = string, key_opts = list(string),
  key_size = optional(number), curve = optional(string),
  expiration_date = optional(string), not_before_date = optional(string), tags = optional(map(string))
}))   # key_type ∈ RSA | RSA-HSM | EC | EC-HSM (validated)

secrets = map(object({
  name = optional(string), value = string, content_type = optional(string),
  expiration_date = optional(string), not_before_date = optional(string), tags = optional(map(string))
}))   # value is provider-sensitive; provision out of band

certificates = map(object({
  name        = optional(string)
  certificate = optional(object({ contents = string, password = optional(string) }))   # import
  certificate_policy = optional(object({ issuer_parameters, key_properties, secret_properties,
    x509_certificate_properties = optional(...), lifetime_action = optional(list(...)) }))  # generate
  tags = optional(map(string))
}))

🧾 Outputs

Output Description Notes
id Vault Resource ID Emitted first.
name Vault name —
location Azure region, in the canonical form Azure uses. Read from the resource, not var.location.
vault_uri Data-plane URI —
key_ids / key_versionless_ids Key maps Versionless for rotation-following CMK.
secret_ids / certificate_ids Child maps Values never emitted.

🧠 Architecture Notes

  • Hardened empty call. RBAC authZ, purge protection, 90-day soft delete, and no public access are the defaults; each is a documented opt-out.
  • RBAC, not access policies. The module deliberately omits access_policy; data-plane access is granted with role assignments at the vault scope, which is auditable and least-privilege.
  • Data-plane role needed to manage children. Because RBAC is on, the deploying identity must hold a data-plane role (e.g. Key Vault Administrator) to create keys/secrets/certificates.
  • Keys are server-side. azurerm_key_vault_key generates material in the vault; no private key leaves Azure. Secret values and imported certificate contents are provisioned out of band and never emitted.
  • features {} dependence. No provider {} block here; the caller configures provider "azurerm" { features {} } (Key Vault behavior such as purge-on-destroy is a provider features concern).

🧱 Design Principles

Concern Secure default (empty call) Opt-out
Authorization rbac_authorization_enabled = true set false (access policies)
Purge protection purge_protection_enabled = true set false
Soft delete soft_delete_retention_days = 90 lower to as few as 7
Public network access public_network_access_enabled = false set true
Network default action Deny (when network_acls set) Allow
Deployment access flags all false enable per need

🚀 Runbook

cd terraform-azurerm-key-vault
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinue

Pin the module by tag (?ref=v1.0.0), never a branch. Plan-only during authoring; a human runs plan/apply from CI.

🧪 Testing

The offline proof gate — terraform init -backend=false, terraform validate, terraform fmt -check — proves the configuration is type-correct against the pinned azurerm ~> 4.0 schema (including the name, sku, soft-delete, and key-type validations) and canonically formatted, with no cloud calls. What it does not exercise: whether the deploying identity holds the data-plane role, global name uniqueness, and certificate policy validity — those surface only under terraform plan/apply against real credentials from CI.

💬 Example Output

$ terraform output
id        = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-security-prod-eastus2/providers/Microsoft.KeyVault/vaults/kv-plat-prod-eus2"
name      = "kv-plat-prod-eus2"
vault_uri = "https://kv-plat-prod-eus2.vault.azure.net/"
key_versionless_ids = {
  "storage-cmk" = "https://kv-plat-prod-eus2.vault.azure.net/keys/storage-cmk"
}

🔍 Troubleshooting

Symptom Cause Fix
403 managing secrets/keys RBAC on but identity lacks a data-plane role Grant Key Vault Administrator (or scoped officer) at the vault.
Vault name rejected Not globally unique, or not 3–24 chars Choose a unique, valid name.
Cannot delete vault Purge protection + soft delete window Wait out the window, or plan for it in non-prod.
Access policy ignored RBAC authZ is on Use role assignments, not access policies.
Secret value visible worry — value is provider-sensitive and redacted; provision out of band.
Cert policy apply error Missing required policy sub-fields Provide issuer_parameters/key_properties/secret_properties.

🔗 Related Docs


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