Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure NetApp Account Encryption Terraform Module

Links a NetApp account to a customer-managed key (azurerm_netapp_account_encryption) — deciding who holds the key, not whether the data is encrypted. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources


🧩 Overview

  • 🔐 Links a NetApp account to a customer-managed key, as a keystone resource named this.
  • 🧭 States the distinction that gets missed: this does not decide whether the data is encrypted — NetApp Files data at rest is encrypted either way — it decides who holds the key.
  • ✅ Mirrors the provider's ConflictsWith on the two identity fields, so at most one is supplied — and deliberately does NOT require one of them, because the provider accepts neither being set and refusing a legal call would also block terraform destroy. That case is reported through names_no_identity instead.
  • ⚠️ Names the grant this module cannot make: the identity still needs Get / Wrap Key / Unwrap Key on the key, or the volumes become unreadable.
  • 🔑 Accepts a key URI reference, never key material — and deliberately does not emit it back.
  • 📤 Emits identity_mode and is_cross_tenant_key, because "which identity holds the key path" and "is another tenant involved" are the two facts a governance review needs.

💡 Why it matters: A customer-managed key is usually adopted as a compliance checkbox and is actually an availability dependency. Once the account points at your key, the Key Vault sits on the read path of every volume under it: revoke, delete, or lose access to that key and the data is unreadable. That is the intended power of holding your own key — and it is the reason to enable purge protection on the vault before pointing an account at it, not after.

❤️ Support this project

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


🗺️ Where this fits in the family

flowchart LR
  rg["terraform-azurerm-resource-group"]
  kv["terraform-azurerm-key-vault"]
  vnet["terraform-azurerm-virtual-network: subnet delegated to Microsoft.NetApp/volumes"]
  acct["terraform-azurerm-netapp-account"]
  enc["terraform-azurerm-netapp-account-encryption: who holds the key"]
  pool["terraform-azurerm-netapp-pool: the BILLED capacity"]
  vol["terraform-azurerm-netapp-volume: what clients mount"]
  snap["terraform-azurerm-netapp-snapshot: one on-demand, in the pool"]
  spol["terraform-azurerm-netapp-snapshot-policy: the schedule"]
  vault["terraform-azurerm-netapp-backup-vault: the destination"]
  bpol["terraform-azurerm-netapp-backup-policy: the retention"]
  vghana["terraform-azurerm-netapp-volume-group-sap-hana"]
  vgora["terraform-azurerm-netapp-volume-group-oracle"]

  rg -->|"resource_group_name, location"| acct
  kv -->|"encryption_key URI"| enc
  acct -->|"id, BY ID"| enc
  acct -->|"account_name, BY NAME"| pool
  acct -->|"account_name, BY NAME"| vault
  acct -->|"account_name, BY NAME"| bpol
  acct -->|"account_name, BY NAME"| spol
  acct -->|"account_name, BY NAME"| vghana
  acct -->|"account_name, BY NAME"| vgora
  pool -->|"pool_name, BY NAME"| vol
  pool -->|"capacity_pool_id, BY ID"| vghana
  pool -->|"capacity_pool_id, BY ID"| vgora
  vnet -->|"subnet_id, must be delegated"| vol
  vol -->|"name plus pool_name, BY NAME"| snap
  spol -->|"snapshot_policy_id, BY ID"| vol
  vault -->|"backup_vault_id, BY ID"| vol
  bpol -->|"backup_policy_id, BY ID"| vol

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class acct keystone;
  class enc,pool,vol,snap,spol,vault,bpol,vghana,vgora me;
  class rg,kv,vnet sib;
Loading

🧬 What this module builds

flowchart TB
  acct["netapp_account_id: force-new, BY ID unlike the rest of the family"]
  key["encryption_key: a Key Vault key URI, NOT key material"]
  version["must be VERSIONLESS: a versioned URI is accepted and read back versionless, so it never converges"]
  notenc["this does NOT decide whether data is encrypted, only WHO HOLDS THE KEY"]
  avail["the vault joins the availability path of every volume: revoke the key and they are unreadable"]
  sysid["system_assigned_identity_principal_id"]
  uaid["user_assigned_identity_id: preferred, because the vault grant can be in place FIRST"]
  xor["at most one, mirroring ConflictsWith. Neither is legal and is reported, not refused"]
  grant["the identity still needs Get plus Wrap Key plus Unwrap Key on the key: a SEPARATE grant"]
  cross["cross_tenant_key_vault_resource_id plus federated_client_id: both or neither, and only with a user-assigned identity"]
  this["terraform-azurerm-netapp-account-encryption"]
  res["azurerm_netapp_account_encryption.this"]
  out["outputs: id, identity_mode, is_cross_tenant_key. The key URI is NOT emitted."]

  acct -->|"the account"| this
  key -->|"the reference"| this
  version -->|"rotation behaviour"| key
  notenc -->|"read this first"| key
  avail -->|"the operational risk"| notenc
  sysid -->|"one option"| xor
  uaid -->|"the other"| xor
  xor -->|"enforced"| this
  grant -->|"without it the volumes cannot be read"| xor
  cross -->|"only for another tenant's vault"| this
  this -->|"creates"| res
  res -->|"emits"| out

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class res keystone;
  class acct,key,version,notenc,avail,sysid,uaid,xor,grant,cross,out sib;
Loading

Resource inventory

Resource Count Role
azurerm_netapp_account_encryption.this 1 The keystone encryption configuration, with its optional timeouts block. A 1:1 record for the account — no name, no tags.

✅ Provider / Versions

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

Schema notes that bite (verified against the live provider schema):

  • netapp_account_id is force-new. The resource is a 1:1 record for the account, so it has no name and no tags surface.
  • It takes the account's Resource ID — unlike the pool, volume, snapshot, snapshot-policy, and backup modules in this family, which all address the account by name.
  • 🔴 system_assigned_identity_principal_id IS NEVER SENT TO THE API. The provider's expand builds the encryption identity from user_assigned_identity_id alone; when that is empty the identity object goes out empty and this principal ID is not referenced at all. It is written to state and read back from the account's own identity block, which is all it does — what actually selects the system-assigned identity is the identity block on the NetApp account.
  • Both identity arguments are optional and NEITHER being set is a legal call. The provider carries ConflictsWith on the pair, which this module mirrors at terraform validate; it does NOT add an at-least-one-of rule, because that would refuse a legal shape and a failing validation {} blocks terraform destroy as well as apply. names_no_identity reports the case.
  • ConflictsWith, RequiredWith and the rest are invisible in the binary schema. They are read from the provider's Go schema, and ConflictsWith tests whether a key is PRESENT in the configuration rather than what its value is.
  • The identity still needs Key Vault permission to use the key. Naming it here grants nothing.
  • The account must already carry the identity being named; this resource does not attach one.
  • cross_tenant_key_vault_resource_id and federated_client_id are needed together — the provider carries RequiredWith in both directions, enforced at terraform validate, offline and without credentials.
  • 🔴 federated_client_id is only ever sent alongside a USER-ASSIGNED identity. The provider attaches it to the encryption identity only inside the branch that handles user_assigned_identity_id, and no schema keyword expresses that. A cross-tenant arrangement configured with the account's system-assigned identity passes every provider check and sends no federated client ID at all. This module refuses the combination; the rule is mirrored from the provider's code, not from its schema.
  • 🔴 encryption_key must be VERSIONLESS, and an earlier version of this README had it backwards. A versioned URI is accepted by the provider's validator and then read back in versionless form, so the configuration and the state never match and every plan proposes the same change again. It does not pin the key; it produces a diff that cannot converge. Versionless is also what you want — the service follows the current version, so a rotation in Key Vault needs no Terraform change.
  • 🔴 Changing only cross_tenant_key_vault_resource_id applies nothing. The update function issues a request only when one of user_assigned_identity_id, system_assigned_identity_principal_id, encryption_key or federated_client_id has changed; this argument is absent from that list. The plan shows a change, the apply reports success, and Azure is unchanged.
  • 🔴 The Terraform ID is the NetApp account's Resource ID. Only one of these configurations is meaningful per account, and a second one pointed at the same account silently overwrites the first. The id and netapp_account_id outputs therefore carry the same string.
  • The import guard asks an unusual question. Instead of checking whether a resource exists, it fetches the account and refuses only if the key source is already Microsoft.KeyVault — so a CMK account must be imported while a platform-key account is adopted by the create path.
  • A destroy does not delete anything. It issues a PATCH clearing the encryption block, asking the service to move back to platform-managed keys — a re-key of live data, which is why delete carries the longest default timeout of the four.
  • The provider's timeout defaults are long and asymmetric: create 90m, update 120m, delete 120m, read 5m. The account is locked for the duration.
  • Revoking or deleting the key makes the volumes unreadable. The vault joins the availability path of every volume under the account.
  • A volume additionally carries its own encryption_key_source and key_vault_private_endpoint_id — this account-level resource does not replace those.

🔑 Required Azure RBAC Roles / Permissions

  • Contributor on the resource group holding the NetApp account, or a custom role covering Microsoft.NetApp/netAppAccounts/write.
  • Read access on the Key Vault key so Terraform can resolve the reference.
  • Separately, and not made by this module: the identity named here needs Get / Wrap Key / Unwrap Key on the key — the Key Vault Crypto Service Encryption User role, or an access policy with those permissions, depending on the vault's authorization model. Its absence produces a configuration that applies and volumes that cannot be read.
  • For a cross-tenant key, permission in the other tenant to use the vault via the federated application.

Azure Prerequisites

  • An existing NetApp account with an identity — system-assigned, or a user-assigned identity already attached to it.
  • An existing Key Vault key. Enable purge protection and soft delete on that vault first — once the account depends on the key, losing it makes the volumes unreadable.
  • The Key Vault grant described above, ideally in place before this resource is applied.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription.

📁 Module Structure

terraform-azurerm-netapp-account-encryption/
├── providers.tf   # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf   # key URI reference, identity XOR, cross-tenant pair, timeouts tail (no tags)
├── main.tf        # keystone azurerm_netapp_account_encryption.this
├── outputs.tf     # id, identity_mode, is_cross_tenant_key — the key URI deliberately withheld
├── README.md      # this document
├── SCOPE.md       # cross-module contract
├── LICENSE        # MIT
└── .gitignore     # canonical library ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "anf_encryption" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-account-encryption.git?ref=v1.0.0"

  netapp_account_id = module.anf_account.id # BY ID, unlike the rest of the family

  # A key URI reference — no material passes through Terraform.
  # No version segment, so a rotation in Key Vault needs no code change.
  encryption_key = "https://kv-anf-eastus2.vault.azure.net/keys/netapp-cmk"

  # Exactly one identity. The account's own is the simpler path.
  system_assigned_identity_principal_id = module.anf_account.principal_id
}

⚠️ This applies cleanly and the volumes still cannot be read until that identity has Get / Wrap Key / Unwrap Key on the key — see example 4.

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


🔌 Cross-Module Contract

Consumes

Input Type Source module
netapp_account_id string terraform-azurerm-netapp-account (id)
encryption_key string (key URI) terraform-azurerm-key-vault — key provisioned out of band
system_assigned_identity_principal_id string terraform-azurerm-netapp-account (principal_id)
user_assigned_identity_id string terraform-azurerm-user-assigned-identity (id)

Emits

Output Description Consumed by
id Encryption configuration Resource ID (first) audit inventories
netapp_account_id The account this applies to composition wiring
identity_mode "UserAssigned", "SystemAssigned", or "AccountDefault" when neither argument is supplied governance review — which identity reaches the key. The third value used to be unreachable, because the module refused that call; only "UserAssigned" reflects something the provider actually transmits
user_assigned_identity_id The user-assigned identity, or null composition wiring
is_cross_tenant_key Whether the key lives in another Entra tenant governance review — true means another tenant's administrators control this data's availability

No secret is emitted, and the key URI is deliberately not emitted either — it is a reference rather than material, but re-emitting it invites a consumer to treat this module as the source of truth for the key when Key Vault is.


📚 Example Library

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

variable "kv_pe_id" {
  description = "id of an existing kv pe that these examples reference but do not create."
  type        = string
}
1 · The system-assigned path
module "anf_encryption" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-account-encryption.git?ref=v1.0.0"

  netapp_account_id = module.anf_account.id
  encryption_key    = "https://kv-anf-eastus2.vault.azure.net/keys/netapp-cmk"

  system_assigned_identity_principal_id = module.anf_account.principal_id
}

💡 The simpler of the two identity options: the identity's lifecycle is the account's, and there is nothing extra to create. The trade-off is in example 3.

2 · What this does and does not change
Without this module:  data at rest IS encrypted — with a platform-managed key.
With this module:     data at rest IS encrypted — with YOUR key.

⚠️ The difference is custody, not encryption. Adopting a customer-managed key does not close a gap where data was previously unencrypted; it moves the key into your control — and with it, the ability to revoke access. Treat it as an availability dependency you are taking on, not a box being ticked.

3 · Why a user-assigned identity is often better
user_assigned_identity_id = module.anf_identity.id

💡 A user-assigned identity can be granted Key Vault access before the account exists, which removes the window in which the account is pointed at a key it cannot yet read. It also outlives the account and can be shared. ⚠️ The same identity must also be attached to the NetApp account itself — naming it here does not attach it.

4 · The grant this module does not make
# RBAC-authorization vault:
module "kv_crypto_grant" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.anf_kv.id

  role_assignments = {
    netapp_cmk = {
      role_definition_name = "Key Vault Crypto Service Encryption User"
      principal_id         = module.anf_account.principal_id
    }
  }
}
# Access-policy vault, instead:
module "kv_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-access-policy.git?ref=v1.0.0"

  key_vault_id    = module.anf_kv.id
  tenant_id       = var.tenant_id
  object_id       = module.anf_account.principal_id
  key_permissions = ["Get", "WrapKey", "UnwrapKey"]
}

⚠️ Without one of these, this configuration applies cleanly and the volumes cannot be read. Which form you need depends on the vault's authorization model, which this module cannot know — so it documents the requirement rather than guessing. This is the most common cause of a green apply and unreadable data.

5 · At most one identity — and why "neither" is allowed
# ✅ Neither identity supplied. This is LEGAL, and it is the ordinary shape for an account
#    using its own system-assigned identity.
module "anf_encryption" {
  source            = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-account-encryption.git?ref=v1.0.0"
  netapp_account_id = module.anf_account.id
  encryption_key    = var.key_uri
}

ℹ️ An earlier version of this module REFUSED that call, on the reasoning that the service would be left with no identity. It was wrong twice over. The provider makes both arguments optional and sends an empty identity object when neither is set — the account's own identity block decides. And system_assigned_identity_principal_id is never transmitted anyway, so requiring it required a value with no effect. The module now reports the case through names_no_identity instead of refusing it, because a failing validation {} blocks terraform destroy as well as apply.

# ❌ Both supplied.
system_assigned_identity_principal_id = module.anf_account.principal_id
user_assigned_identity_id             = module.anf_identity.id
Error: system_assigned_identity_principal_id and user_assigned_identity_id are mutually
exclusive - the provider carries ConflictsWith on the pair, which tests whether each key
is PRESENT in the configuration rather than what its value is, and which does not appear
in the binary schema. Supply at most one.

💡 The mutual-exclusion check lives on the user_assigned_identity_id variable and reads the system-assigned field one-directionally, because Terraform rejects two validations that reference each other as a cycle. A cross-field rule has to be placed, not gathered.

6 · The key URI must be versionless — a versioned one never converges
# ✅ Versionless. The service follows the current key, so a rotation in Key Vault needs no
#    Terraform change at all.
encryption_key = "https://kv-anf-eastus2.vault.azure.net/keys/netapp-cmk"
# ❌ Versioned. Refused by this module - and NOT because pinning is discouraged.
encryption_key = "https://kv-anf-eastus2.vault.azure.net/keys/netapp-cmk/abcdef1234567890"
Error: encryption_key must be VERSIONLESS - drop the trailing version segment. The provider
accepts a versioned URI at terraform validate and then reads the value back in versionless
form, so a versioned URI produces a plan that proposes the same change on every run and
never converges.

⚠️ An earlier version of this README recommended the versioned form as a way to pin the key. It does not pin anything. The provider's validator accepts it, the read path normalises whatever comes back to the versionless form, and the configuration and the state then disagree forever — a plan that proposes the same change on every run, with no warning from the provider or from terraform validate. The provider's own field description calls this argument "the versionless encryption key url".

🔒 This is one of the few places where refusing input is right rather than presumptuous: the value is accepted everywhere and works nowhere. key_is_versionless is emitted as a constant true for the same reason.

7 · Purge protection is a prerequisite, not a nicety
module "anf_kv" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"

  name                       = "kv-anf-eastus2"
  resource_group_name        = module.anf_rg.name
  location                   = module.anf_rg.location
  tenant_id                  = var.tenant_id
  sku_name                   = "standard"
  purge_protection_enabled   = true # ← before pointing an account at it
  soft_delete_retention_days = 90
}

🔒 Once the account depends on this key, losing the key means losing the data. Purge protection prevents a deleted vault or key from being permanently destroyed inside the retention window, which is the only recovery path there is. Enable it before the dependency exists — retrofitting it does not help with a key already gone.

8 · A cross-tenant key
cross_tenant_key_vault_resource_id = var.customer_vault_id
federated_client_id                = var.multi_tenant_app_client_id
federated_client_id = var.multi_tenant_app_client_id # ❌ without the vault ID
Error: federated_client_id is only meaningful together with
cross_tenant_key_vault_resource_id.

⚠️ A cross-tenant key means another tenant's administrators hold the availability of your data. That is occasionally exactly the point — a customer holding their own key over data you process for them — and it should be a documented decision rather than a convenience. is_cross_tenant_key is emitted so it shows up in a review.

9 · The account is addressed by ID here
netapp_account_id = module.anf_account.id   # ✅ this module: BY ID
account_name = module.anf_account.name      # the pool, volume, snapshot, backup modules: BY NAME

ℹ️ Worth noticing when wiring a composition: this is one of only two places in the netapp family that takes the account's Resource ID (the other being the volume groups' capacity_pool_id for pools). The ID form creates a real Terraform dependency, so ordering is implicit here in a way it is not for the name-based modules.

10 · Reading the posture from state
output "cmk_posture" {
  description = "The two facts a governance review needs about key custody."
  value = {
    identity   = module.anf_encryption.identity_mode
    cross_tenant = module.anf_encryption.is_cross_tenant_key
  }
}

💡 Both are derived outputs. identity_mode tells you whether the key path depends on an identity that dies with the account; is_cross_tenant_key tells you whether anyone outside your tenant can cut it. Neither is readable from the resource's own attributes, which is why they are computed here.

11 · Why the key URI is not an output

This module accepts encryption_key as an input and deliberately emits no corresponding output — there is no key_uri, and no other output carries the key's URI. A caller that needs the URI downstream should read it from the same Key Vault this module was pointed at.

ℹ️ The URI is a reference, not material, so withholding it is not about secrecy. It is about custody: Key Vault is the source of truth for the key, and re-emitting the URI invites downstream configuration to treat this module as the authority instead — which then drifts the moment the key is rotated or replaced.

12 · Account-level and volume-level encryption are different fields
# This module — account level:
module "anf_encryption" { netapp_account_id = module.anf_account.id, ... }
# The volume module — per volume:
module "anf_volume" {
  encryption_key_source         = "Microsoft.KeyVault"
  key_vault_private_endpoint_id = var.kv_pe_id
}

ℹ️ The two are complementary, not alternatives. This resource configures the account's key relationship; a volume separately declares its own encryption_key_source and the private endpoint it reaches Key Vault through. Setting one does not set the other.

13 · Removing the configuration
# Removing the module reverts the account to platform-managed keys.

⚠️ Confirm what that means for your compliance position before doing it — and note the reverse direction is the dangerous one: an accidental removal changes key custody without any data becoming unreadable, so nothing breaks loudly and the drift can go unnoticed until an audit. Treat this resource's presence as part of the compliance baseline.

14 · Rotation, revocation, and the availability path
Rotate the key (no version pinned)  → transparent; nothing to change.
Rotate with a version pinned        → update `encryption_key`; a Terraform change.
Revoke the identity's key access    → volumes become unreadable.
Delete the key                      → volumes become unreadable; recovery only inside
                                       the vault's soft-delete window.

🔒 This is the whole operational story of a customer-managed key in four lines. The first is routine, the second is a process, and the last two are the power you took on — deliberately available to you, and equally available to a mistake.

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

# 1 · The resource group.
module "anf_rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-anf-eastus2"
  location = "eastus2"
}

# 2 · The vault. Purge protection ON before anything depends on the key.
module "anf_kv" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"

  name                       = "kv-anf-eastus2"
  resource_group_name        = module.anf_rg.name
  location                   = module.anf_rg.location
  tenant_id                  = var.tenant_id
  sku_name                   = "standard"
  purge_protection_enabled   = true
  soft_delete_retention_days = 90
}

# 3 · A user-assigned identity — chosen so the vault grant in step 4 can exist BEFORE
#     the encryption configuration in step 6 is applied.
module "anf_identity" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"

  name                = "id-anf-cmk"
  resource_group_name = module.anf_rg.name
  location            = module.anf_rg.location
}

# 4 · The grant this module does NOT make. In place first, deliberately.
module "kv_crypto_grant" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.anf_kv.id

  role_assignments = {
    netapp_cmk = {
      role_definition_name = "Key Vault Crypto Service Encryption User"
      principal_id         = module.anf_identity.principal_id
    }
  }
}

# 5 · The NetApp account, carrying the identity from step 3.
module "anf_account" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-account.git?ref=v1.0.0"

  name                = "anf-prod-eastus2"
  resource_group_name = module.anf_rg.name
  location            = module.anf_rg.location

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

# 6 · The encryption link — this module. BY ID, one identity, no key version pinned.
module "anf_encryption" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-account-encryption.git?ref=v1.0.0"

  netapp_account_id = module.anf_account.id

  # No version segment: rotation in Key Vault needs no code change.
  encryption_key = "${module.anf_kv.vault_uri}keys/netapp-cmk"

  user_assigned_identity_id = module.anf_identity.id

  # The grant from step 4 is not in this resource's dependency graph, so say it.
  depends_on = [module.kv_crypto_grant]
}

# 7 · Capacity and a volume, now encrypted with your key.
module "anf_pool" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-pool.git?ref=v1.0.0"

  name                = "pool-prod-01"
  resource_group_name = module.anf_rg.name
  account_name        = module.anf_account.name # BY NAME — note the contrast with step 6
  location            = module.anf_rg.location

  service_level = "Premium"
  size_in_tb    = 4
}

output "cmk_posture" {
  description = "Key custody, readable from state."
  value = {
    identity     = module.anf_encryption.identity_mode
    cross_tenant = module.anf_encryption.is_cross_tenant_key
  }
}

💡 The ordering of steps 3, 4 and 6 is the point of this composition. A user-assigned identity is created first so its vault grant can be applied before the account is pointed at the key — which closes the window where the account depends on a key it cannot read. depends_on is explicit because a role assignment is not in this resource's dependency graph. And step 7 shows the family's split convention in the same file: this module takes the account by ID, the pool takes it by name. Output names on sibling modules are illustrative; match them to the versions you pin.


📥 Inputs

Required: netapp_account_id, encryption_key, and exactly one of system_assigned_identity_principal_id / user_assigned_identity_id.

Cross-tenant (both or neither): cross_tenant_key_vault_resource_id, federated_client_id.

Universal tail: timeouts. This resource supports no tags and has no name of its own.

Full object() schemas
variable "netapp_account_id" {
  # force-new. Takes the account's RESOURCE ID — unlike the pool, volume, snapshot,
  # snapshot-policy and backup modules, which address the account by NAME.
  type = string
}

variable "encryption_key" {
  # A Key Vault key URI. 🔒 A REFERENCE, not key material — nothing secret passes through
  # Terraform configuration or state.
  # ⚠️ Whoever controls this key controls access to the data. The vault joins the availability
  #    path of every volume under the account: revoke or delete the key and they are unreadable.
  #    Enable purge protection and soft delete on the vault BEFORE pointing an account at it.
  # Include the version to pin it (rotation becomes a Terraform change); omit it so the service
  # follows the current version — usually preferable.
  type = string
}

variable "system_assigned_identity_principal_id" {
  # The account's own system-assigned identity. Simpler: its lifecycle is the account's.
  # ⚠️ Naming it here grants nothing — it still needs Get / Wrap Key / Unwrap Key on the key.
  type    = string
  default = null
}

variable "user_assigned_identity_id" {
  # PREFERRED where the grant should exist first: a user-assigned identity can be given vault
  # access before the account exists, closing the window where the account cannot read its key.
  # It must also be ATTACHED to the account; naming it here does not attach it.
  # VALIDATED here, reading the system-assigned field one-directionally (Terraform rejects
  # validations that reference each other):
  #   - at least one identity must be set — both are optional to the provider, so NEITHER is accepted
  #   - the two are mutually exclusive
  type    = string
  default = null
}

variable "cross_tenant_key_vault_resource_id" {
  # Only for a Key Vault in a DIFFERENT Entra tenant. ⚠️ That means another tenant's
  # administrators hold the availability of your data — a deliberate, documented decision.
  type    = string
  default = null
}

variable "federated_client_id" {
  # The multi-tenant application used to reach a cross-tenant vault.
  # Required with cross_tenant_key_vault_resource_id and meaningless without it; both
  # directions validated on this variable.
  type    = string
  default = null
}

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

# There is NO `tags` variable and no `name`: this is a 1:1 configuration record for the account,
# not a taggable object.

🧾 Outputs

Output Description Notes
id The Resource ID of the encryption configuration. Emitted first. It IS the NetApp account's own Resource ID — the provider sets the Terraform ID directly to it, so only one of these configurations is meaningful per account and a second silently overwrites the first.
netapp_account_id The account this applies to. The same string as id, for the reason above. Emitted under both names so a consumer finds whichever it looks for.
identity_mode "UserAssigned", "SystemAssigned", or "AccountDefault". Derived — which identity reaches the key. The third value used to be unreachable; only "UserAssigned" reflects something the provider transmits.
user_assigned_identity_id The user-assigned identity, or null. The only identity argument the provider actually sends.
names_no_identity Whether neither identity argument was supplied. Derived, known at plan time. Replaces a validation {} that used to refuse a legal call.
is_cross_tenant_key Whether the key lives in another Entra tenant. Derived — true means another tenant's administrators control this data's availability.
key_vault_is_resolved_by_a_live_lookup Whether the provider must find the vault by a live read at apply. True on the same-tenant path; the lookup can fail for reasons no offline check anticipates.
key_is_versionless Always true. The module refuses a versioned URI, because the provider accepts one and reads it back versionless — a plan that never converges.
grants_no_key_access Always true. Naming a key grants nothing. Without Get / Wrap Key / Unwrap Key, the apply is clean and the volumes are unreadable.
destroy_reverts_the_key_source Always true. A destroy issues a PATCH clearing the encryption block — a re-key of live data back to platform-managed keys, not a deletion.
changing_only_the_cross_tenant_vault_applies_nothing Always true. That argument is absent from the update function's change list, so the apply reports success and Azure is unchanged.
does_not_decide_whether_data_is_encrypted Always true. The data is encrypted either way. This decides who holds the key.
federated_client_id_is_ignored_without_a_user_assigned_identity Always true. Mirrored from the provider's code rather than its schema — no keyword expresses it.

No secret is emitted, and the key URI is deliberately withheld: Key Vault is the source of truth for the key, not this module.

🧠 Architecture Notes

  • This changes key custody, not encryption, and the module says so repeatedly because the misreading is consequential. NetApp Files data at rest is encrypted with or without this resource; adopting a customer-managed key moves the key into your control and, with it, the power to revoke access. Treated as a compliance checkbox it looks free; treated accurately it is an availability dependency on a Key Vault.
  • A standalone module rather than a block on the account, matching this library's established pattern for customer-managed keys (storage-account-customer-managed-key, eventhub-namespace-customer-managed-key, servicebus-namespace-customer-managed-key, mssql-server-transparent-data-encryption). The key relationship has its own lifecycle and its own permission story, and folding it into the account module would mean editing a shipped module to add it.
  • The identity XOR exists because the provider has no such rule. Both identity fields are optional, so a configuration naming neither applies cleanly and leaves the service with nothing to authenticate to Key Vault with. Both directions are checked on one variable, since Terraform rejects validations that reference each other.
  • The grant is the most common invisible failure. The identity named here needs Get / Wrap Key / Unwrap Key on the key, and which form that takes — an RBAC role or an access policy — depends on the vault's authorization model, which this module cannot know. So it documents the requirement, the composition applies the grant before the encryption configuration, and depends_on is explicit because a role assignment is not in this resource's dependency graph.
  • A user-assigned identity is preferred for a reason worth stating: it can be granted vault access before the account exists, closing the window in which the account is pointed at a key it cannot yet read. A system-assigned identity cannot, because it does not exist until the account does.
  • The key URI is not emitted, and not because it is secret. It is about custody: Key Vault is the authority for the key, and re-emitting the URI invites downstream configuration to treat this module as the authority instead — which drifts the moment the key is rotated.
  • identity_mode and is_cross_tenant_key are derived outputs because neither fact is readable from the resource's own attributes, and both are what a governance review actually asks: does the key path depend on an identity that dies with the account, and can anyone outside our tenant cut it.
  • Version pinning is a rotation policy in disguise. Omitting the version segment lets Key Vault rotation take effect transparently; including it makes rotation a code change. Neither is wrong, but the choice should be deliberate.
  • Account-level and volume-level encryption are different fields. A volume separately declares encryption_key_source and key_vault_private_endpoint_id; this resource does not set those.
  • Accidental removal fails quietly. Removing this resource reverts the account to platform-managed keys — nothing becomes unreadable, so nothing breaks loudly, and the compliance position changes without an alarm. Treat its presence as part of the baseline.
  • features {} dependence. The module carries no provider {} block. If it appears not to initialize in isolation, the cause is a missing caller-side provider "azurerm" { features {} }.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Key material never accepted — a URI reference only — (structural)
Key re-publication URI not emitted — (no opt-out)
Missing identity exactly-one enforced at plan — (no opt-out; the provider allows neither)
Vault grant documented as a separate, required step, applied first in the composition — (structural)
Key loss documented: purge protection and soft delete before the dependency exists skip it (unrecoverable)
Rotation version-less URI recommended, so rotation is transparent pin a version
Cross-tenant custody is_cross_tenant_key emitted set the cross-tenant pair
Custody visibility identity_mode emitted — (no opt-out)

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module with ?ref=v1.0.0 — never a branch.
  • This library is plan-only during authoring; a human runs terraform plan / apply from CI against real credentials.
  • Before applying: confirm purge protection and soft delete on the vault, and that the identity's key grant already exists.
  • After apply, read a volume. A successful apply proves the link was recorded, not that the service can unwrap the key.
  • Treat a plan that removes this resource as a compliance change, not a cleanup — it silently reverts the account to platform-managed keys.

🧪 Testing

  • terraform validate proves the configuration is internally consistent and type-correct against the pinned provider schema, and exercises all four validation {} blocks: the identity at-least-one and mutual-exclusion checks, and the cross-tenant pair in both directions.
  • terraform fmt -check enforces canonical formatting.
  • Neither command calls Azure. Only terraform plan (run by a human, from CI) exercises the ARM API — the module ships without any cloud apply.
  • What only apply exercises: whether the key URI resolves, and whether the account exists.
  • What nothing exercises — and it is the one that matters: whether the identity can actually use the key. No check here can see a Key Vault role assignment or access policy, and its absence produces a configuration that applies cleanly and volumes that cannot be read. Reading a volume after apply is the actual test.

💬 Example Output

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

id                        = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-anf-eastus2/providers/Microsoft.NetApp/netAppAccounts/anf-prod-eastus2/encryption/default"
netapp_account_id         = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-anf-eastus2/providers/Microsoft.NetApp/netAppAccounts/anf-prod-eastus2"
identity_mode             = "UserAssigned"
user_assigned_identity_id = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-anf-eastus2/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id-anf-cmk"
is_cross_tenant_key       = false

🔍 Troubleshooting

Symptom Cause Fix
Provider configuration not present / features error No caller-side provider "azurerm" { features {} }. Add the provider block with features {} in the root module.
Applies cleanly, volumes cannot be read The identity has no Get / Wrap Key / Unwrap Key on the key. Grant Key Vault Crypto Service Encryption User, or an access policy with those permissions.
Plan error: supply either identity Neither identity field was set. The provider would have accepted that. Supply exactly one.
Plan error: mutually exclusive Both identity fields were set. Supply exactly one.
Run-time failure: identity not found The identity named is not attached to the NetApp account. Attach it in the account's identity block; this resource does not attach one.
Apply fails resolving the key The key URI is wrong, or the deploying identity cannot read it. Correct the URI; grant read on the key.
Volumes became unreadable after a Key Vault change The key was deleted, disabled, or the grant was revoked. Restore the key inside the soft-delete window, or re-grant access.
Rotation had no effect A key version is pinned in encryption_key. Remove the version segment so the service follows the current key.
Plan error: federated_client_id is only meaningful together with... One half of the cross-tenant pair. Supply both, or neither.
Compliance drift with nothing broken The resource was removed, reverting the account to platform-managed keys. Re-apply it; treat its presence as part of the baseline.
Confusion with per-volume encryption A volume's encryption_key_source is a separate field. Set both where a volume needs its own CMK wiring.

🔗 Related Docs


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