Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Fluid Relay Server Terraform Module

Manages an Azure Fluid Relay Server — the backend for Fluid Framework collaborative editing, whose access keys are computed attributes and therefore land in Terraform state (azurerm_fluid_relay_server). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Caveat

🧩 Overview

  • 🤝 The backend for Fluid Framework applications — collaborative multi-user editing, where every keystroke round-trips.
  • 🔴 Plan access is credential access. The two access keys are computed attributes, so Terraform reads them into state on every refresh. A principal who can plan this configuration can read the keys out of state.
  • ✅ The keys are deliberately never emitted. Only has_access_keys is, because re-emitting a credential copies it into every consuming configuration's state for no benefit.
  • ✅ What is emitted is everything a client legitimately needs — frs_tenant_id and service_endpoints are identifiers, not secrets.
  • 🔴 storage_sku's values are lowercase (standard, basic), unusually for an Azure SKU. The capitalised spelling is rejected by the API.
  • 🔴 A customer-managed key needs a user-assigned identity, so it cannot work with the default system-assigned one — and the pairing is validated here rather than failing at Key Vault.
  • ⚠️ orderer_endpoints and storage_endpoints are always empty by the provider's own note. Use service_endpoints.
  • ⚠️ Only tags updates in place. Replacing the server issues new keys and endpoints.

💡 Why it matters: This is a credential-bearing resource whose credentials nobody asked for. Because the keys are computed rather than configured, they arrive in state whether or not you ever reference them — which turns the state backend into part of this server's security perimeter and makes "who can run a plan" a security question.

❤️ Support this project

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


🗺️ Where this fits in the family

flowchart TB
  rg["terraform-azurerm-resource-group"]
  frs["terraform-azurerm-fluid-relay-server"]
  uai["terraform-azurerm-user-assigned-identity, required for a customer-managed key because the block will not work with a system-assigned identity alone"]
  kv["terraform-azurerm-key-vault and its key, holding the customer-managed key. Prefer a VERSIONLESS id so rotation does not replace the server."]
  ra["terraform-azurerm-role-assignments, granting the identity wrap, unwrap and get on the key. Without this grant the server is created and cannot decrypt."]
  app["the FLUID FRAMEWORK APPLICATION, which needs frs_tenant_id, a service endpoint and an access key"]
  keys["TWO ACCESS KEYS, computed and sensitive. This module does NOT emit them - and because they are computed, Terraform reads them into state on every refresh, so plan access is credential access."]

  rg -->|"resource_group_name and location"| frs
  uai -->|"identity_ids and user_assigned_identity_id"| frs
  kv -->|"key_vault_key_id"| frs
  uai -->|"identity_principal_id"| ra
  ra -->|"grants wrap, unwrap and get on"| kv
  frs -->|"frs_tenant_id and service_endpoints"| app
  frs -->|"holds"| keys
  keys -->|"read from the resource or a secret store, never a module output"| app

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class frs mine;
  class keys keystone;
  class rg,uai,kv,ra,app sib;
Loading

🧬 What this module builds

flowchart TB
  ids["name, resource_group_name, location and storage_sku: force-new. So is the whole customer_managed_key block. tags is the ONLY field that updates in place."]
  sku["storage_sku's values are LOWERCASE - standard and basic - which is unusual for an Azure SKU. The capitalised spelling every other resource uses is rejected by the API, so the module rejects it offline."]
  ident["identity: the empty call yields a SystemAssigned identity. The combined value is the literal string SystemAssigned, UserAssigned with a comma and a space."]
  cmk["customer_managed_key needs a USER-assigned identity, so it cannot work with the default system-assigned one - and the identity must appear in identity_ids, which the module validates rather than letting Key Vault report it."]
  grant["The identity ALSO needs wrap, unwrap and get on the key, granted at the key vault. This module cannot make that grant, and without it the server is created and cannot decrypt."]
  keys["TWO ACCESS KEYS, computed and sensitive. NOT EMITTED by this module, because re-emitting a credential copies it into every consumer's state for no benefit."]
  plan["And because they are COMPUTED, Terraform reads them into state on every refresh - so plan access is credential access, and the state backend is part of this server's security."]
  empty["orderer_endpoints and storage_endpoints are documented as deprecated and ALWAYS EMPTY. An empty list is correct, not a failure. Use service_endpoints and frs_tenant_id."]
  this["azurerm_fluid_relay_server.this"]
  outputs["id, frs_tenant_id, service_endpoints, identity_principal_id, uses_customer_managed_key, has_access_keys, plan_access_is_credential_access, orderer_and_storage_endpoints_are_always_empty"]

  ids -->|"identity and lifecycle"| sku
  sku -->|"validated offline"| this
  ident -->|"managed identity by default"| cmk
  cmk -->|"validated pairing"| grant
  grant -->|"outside this module"| this
  keys -->|"presence only"| plan
  plan -->|"documented in the RBAC table"| this
  empty -->|"documented"| this
  this -->|"exports"| outputs

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this mine;
  class ident,cmk mine;
  class keys,plan,sku keystone;
  class ids,grant,empty,outputs sib;
Loading

Resource inventory

Resource Count Notes
azurerm_fluid_relay_server.this 1 The keystone. Only tags updates in place.
identity block 0..1 Present by default — system-assigned.
customer_managed_key block 0..1 🔒 Force-new. Needs a user-assigned identity.
timeouts block 0..1 All four operations exist.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource provider Microsoft.FluidRelay (fluidRelayServers)
Provider block None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication.

Schema notes that bite — confirmed against the live provider schema and its documentation:

  • 🔴 primary_key and secondary_key are computed and sensitive, so they enter state on every refresh.
  • 🔴 storage_sku is lowercase: standard, basic.
  • ⚠️ orderer_endpoints and storage_endpoints are documented as deprecated in a future Fluid Relay version and always empty today.
  • 🔴 customer_managed_key is force-new in its entirety and requires a user-assigned identity.
  • ⚠️ The identity also needs wrap/unwrap/get on the key — a Key Vault grant this module cannot make.
  • ⚠️ identity.type combined value is the literal "SystemAssigned, UserAssigned" — comma and space.
  • ⚠️ Only tags updates in place; replacing the server issues new keys and endpoints.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy.

🔑 Required Azure RBAC Roles / Permissions

Operation Role Scope
Create, update or delete the server Contributor the resource group
Read the server — which reads its access keys Reader the server
List or regenerate the keys Contributor the server
Let a customer-managed key work Key Vault Crypto Service Encryption User for the user-assigned identity the key vault key
Assign that identity to the server Managed Identity Operator the identity

🔴 Plan access is credential access, and this is the row that matters. The keys are computed, so Terraform reads both into state on every refresh. A principal able to run plan can read them out of the state file — so Reader here is not a read-only grant in the sense people usually mean, and the state backend's access controls are part of this server's security (example 3).

✅ The Key Vault grant belongs to the identity, not the caller. A caller with Contributor on the resource group can create the server; only the identity needs to reach the key (example 5).

Azure Prerequisites

  • An existing resource group in a supported region — near the users, since the workload is latency-sensitive.
  • The Microsoft.FluidRelay resource provider registered on the subscription.
  • 🔴 An encrypted, access-controlled state backend, because the keys land in state (example 3).
  • For a customer-managed key: a Key Vault key (preferably versionless), a user-assigned identity assigned to this server, and a wrap/unwrap/get grant for that identity on the key (example 5).

📁 Module Structure

terraform-azurerm-fluid-relay-server/
├── providers.tf   # required_version + the pinned azurerm provider. No provider block.
├── variables.tf   # name, resource_group_name, location, storage_sku, identity,
#                  # customer_managed_key, tags, timeouts
├── main.tf        # the keystone `this` + dynamic identity / customer_managed_key / timeouts
├── outputs.tf     # id first, then client-facing identifiers, then the credential-posture flags
├── README.md      # this document
├── SCOPE.md       # the cross-module contract
├── LICENSE        # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

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

  name                = "frs-collab-docs"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # storage_sku omitted — Azure chooses. Note the values are LOWERCASE (example 4).
  # identity defaults to SystemAssigned.

  tags = { app = "collab-docs", owner = "productivity" }
}

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

🔴 Before applying this anywhere real, read example 3: the access keys will be in your state file whether you want them or not.

🔌 Cross-Module Contract

Consumes

Input Type Source module
name string caller — force-new
resource_group_name / location string terraform-azurerm-resource-group
storage_sku string caller — force-new, lowercase, or omitted
identity object(...) caller — defaults to system-assigned
customer_managed_key.key_vault_key_id string the Key Vault key's versionless URI
customer_managed_key.user_assigned_identity_id string terraform-azurerm-user-assigned-identity output id
tags map(string) caller — the only in-place field
timeouts object(...) caller — all four operations exist

Emits

Output Description Consumed by
id The server's Resource ID. RBAC, review
name / resource_group_name / location Identity. Force-new. review
frs_tenant_id ✅ An identifier, not a credential. Fluid clients
service_endpoints The endpoints clients connect to. Fluid clients
storage_sku The tier, lowercase. review
identity_principal_id Grant roles to this. terraform-azurerm-role-assignments
identity_tenant_id / identity_type Identity detail. review
uses_customer_managed_key 🔒 Derived, positive. security review
customer_managed_key_vault_key_id The key URI, not the key. key-rotation review
has_access_keys 🔒 Always true — instead of the keys. security review
plan_access_is_credential_access 🔴 Always true. access review
orderer_and_storage_endpoints_are_always_empty Always true. expectation setting
orderer_endpoints / storage_endpoints ⚠️ Always empty. —

🔒 The access keys are never emitted.

📚 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_id" {
  description = "id of an existing kv that these examples reference but do not create."
  type        = string
}

variable "kv_key_versionless_id" {
  description = "versionless id of an existing kv key that these examples reference but do not create."
  type        = string
}
1 · What a Fluid Relay Server is for
A Fluid Framework application needs three things from Azure:

  frs_tenant_id      ← an identifier   ✅ emitted by this module
  a service endpoint ← an identifier   ✅ emitted by this module
  an access key      ← a CREDENTIAL    🔒 deliberately NOT emitted (example 2)

ℹ️ Fluid Framework is Microsoft's library for real-time collaborative editing — several people in one document, with the service reconciling their edits. The Relay Server is the backend that does the reconciling.

⚠️ It is latency-sensitive in an unusual way. Every keystroke round-trips, so the region matters more than for most backends: put it near the users, not near the rest of the estate.

💡 It is an application dependency rather than platform infrastructure, which is why the tagging advice is to name the application that needs it. A Relay Server with no application is pure cost.

ℹ️ Most of this module's interest is in the credential handling (examples 2 and 3) rather than in the resource's own configuration, which is small: a name, a region, a storage tier and optional encryption.

2 · 🔒 Why the keys are not outputs
# ❌ This module does NOT do this:
# output "primary_key" { value = azurerm_fluid_relay_server.this.primary_key, sensitive = true }

# ✅ It does this:
output "has_access_keys" { value = true }

🔒 Re-emitting a secret copies it into every consuming configuration's state, not just this one's. A module output is read by the caller, whose own state then contains the key — so one credential becomes two copies, for no benefit.

✅ So the module emits presence only, and tells consumers to read the key from the resource itself or from the secret store it is replicated into. That is the same place this module would have read it from.

✅ Everything a client legitimately needs is emitted: frs_tenant_id and service_endpoints. Both are identifiers rather than credentials — knowing them grants nothing — so the distinction the module draws is between "needed to connect" and "sufficient to authenticate".

⚠️ customer_managed_key_vault_key_id is emitted for the same reason. A key URI is an identifier; the key material never leaves the vault.

🔴 But note that not emitting the keys does not keep them out of state — see example 3, which is the harder problem.

3 · 🔴 Plan access is credential access
primary_key   ← computed attribute
secondary_key ← computed attribute

  so Terraform reads BOTH into state on every refresh,
  whether or not any configuration references them.

🔴 This is the fact that makes the resource security-relevant, and it is not a choice this module can undo. Because the keys are computed rather than configured, they arrive in state as part of an ordinary refresh. Not outputting them (example 2) prevents a second copy; it does not prevent the first.

🔴 Consequently, a principal who can run plan against this configuration can read the keys out of the state file. Reader on this resource is a credential grant in effect, which is not what "read-only" usually implies.

✅ What follows in practice:

  • The state backend must be encrypted and access-controlled — never a local file in a repository.
  • Who may run a plan against this configuration is a security decision, not just an operational one.
  • Consider isolating this resource in its own state, so a broad plan permission elsewhere does not include it.

✅ plan_access_is_credential_access is emitted as a constant true so the fact appears in a review rather than being discovered after somebody is granted plan rights. This library's rule is that where reading a resource means reading its keys, that belongs in the permissions table — so it is also in the RBAC section above.

⚠️ A sensitive = true marking would not help. Sensitivity redacts plan output; it does not encrypt state. Saying "the keys are sensitive" without saying "and they are plaintext in state" would be worse than saying nothing, because it invites relaxing the control that actually matters.

4 · 🔴 The lowercase SKU
storage_sku = "standard" # ✅
storage_sku = "basic"    # ✅
storage_sku = "Standard" # ❌ rejected — and this is the spelling every other Azure SKU uses

🔴 Almost every SKU in Azure is capitalised, and this one is not. Standard is what muscle memory types and the API rejects it, so the module rejects it offline with a message naming the trap:

Error: Invalid value for variable

  storage_sku must be "standard" or "basic" — note the LOWERCASE, which is unusual for
  an Azure SKU. "Standard" and "Basic" are rejected by the API. Omit the variable
  entirely to let Azure choose.

✅ Omitting it is this module's default, because the field is computed as well as optional — so Azure chooses, and the module does not invent a tier the provider does not default. The output reports whatever was chosen.

🔴 It is force-new. Changing the storage tier replaces the server, which issues new keys and new endpoints — so every client has to pick them up. That makes the tier a create-time decision rather than something to tune.

💡 basic is the cheaper tier and adequate for development. Choosing it deliberately for a non-production server is reasonable; discovering later that production is on it is not, and there is no in-place fix.

5 · 🔒 A customer-managed key needs a user-assigned identity
identity = {
  type         = "UserAssigned"      # 🔴 SystemAssigned alone will NOT work
  identity_ids = [module.uai.id]
}

customer_managed_key = {
  key_vault_key_id          = var.kv_key_versionless_id # 💡 versionless
  user_assigned_identity_id = module.uai.id                # must be in identity_ids above
}

✅ First, what this does and does not change. Data is encrypted at rest either way — omitting the block means platform-managed keys, not plaintext. The reason to want a customer-managed key is custody and revocation, not encryption.

🔴 The identity that unwraps the key must be user-assigned and must be assigned to this server. The module validates that user_assigned_identity_id appears in identity.identity_ids, because otherwise the failure arrives at apply as a Key Vault access error — sending the reader to the vault instead of to the identity list three lines above:

Error: Invalid value for variable

  customer_managed_key.user_assigned_identity_id names an identity that is not in
  identity.identity_ids, so it is not assigned to this server and cannot reach Key
  Vault. [...] Left as-is, this fails at apply as a key-access error pointing at Key
  Vault rather than at the identity list.

⚠️ The identity also needs wrap, unwrap and get on the key — a Key Vault grant this module cannot make. Compose terraform-azurerm-role-assignments for it. Without that grant the server is created and cannot decrypt, which is a working apply and a broken service.

🔴 The whole block is force-new. Adding, changing or removing a customer-managed key replaces the server, and therefore its keys and endpoints.

💡 Use a versionless key ID. A pinned version means key rotation requires replacing the server — which the module recommends against but does not forbid, because pinning is occasionally intended.

6 · ⚠️ Two outputs that are always empty
service_endpoints  → populated ✅  use this
orderer_endpoints  → ALWAYS EMPTY ⚠️
storage_endpoints  → ALWAYS EMPTY ⚠️

⚠️ The provider documents both as deprecated in a future Fluid Relay version and always empty today. An empty list from either is correct behaviour, not a failure — but it looks exactly like a failure if you do not know.

✅ So the module emits them anyway, with a constant flag explaining why. Omitting them would leave a reader wondering where they went; emitting them silently would leave someone debugging an empty list for an afternoon.

✅ Use service_endpoints and frs_tenant_id — those are what a Fluid client actually needs (example 1).

💡 This is a small instance of a general habit worth having: when a provider tells you a field is vestigial, say so at the point somebody will read it, rather than passing the confusion straight through.

ℹ️ orderer_and_storage_endpoints_are_always_empty is the constant that carries the explanation.

7 · The identity, and its one unusual string
identity = { type = "SystemAssigned" }                                       # the default
identity = { type = "UserAssigned", identity_ids = [module.uai.id] }
identity = { type = "SystemAssigned, UserAssigned", identity_ids = [module.uai.id] }

✅ The empty call yields a system-assigned identity, this suite's preferred alternative to embedded credentials, and the same shape its other identity-bearing modules use — so the two read alike at a call site.

⚠️ "SystemAssigned,UserAssigned" without the space is rejected, and the provider's own error is not especially clear about why. The module validates the exact set of three legal strings.

✅ identity_ids is required with UserAssigned, and rejected without it. Both directions, because IDs that silently do nothing read like configuration that took effect.

⚠️ identity_ids takes the identity's Resource ID, not its principal or client ID. All three are GUID-ish values people copy interchangeably, so the module checks the path.

💡 identity_principal_id is the value to grant roles to — not id, not frs_tenant_id. Getting those confused produces a role assignment against nothing recognisable.

8 · What updates in place: almost nothing
# ✅ In place:
tags = { app = "collab-docs", key_rotated = "2026-09-01" }

# 🔴 Force-new — and each replacement issues NEW KEYS and NEW ENDPOINTS:
name                 = "frs-collab-docs-v2"
location             = "westus2"
storage_sku          = "basic"
customer_managed_key = { ... }

🔴 Replacing this server is a client-visible event. New access keys and new endpoints mean every Fluid application has to pick up new configuration — so a casual rename is an application deployment.

✅ Which makes tags unusually load-bearing, as it is the only mutable field. It is the place to record which application depends on the server, who owns it, and when the keys were last rotated.

⚠️ Plan a replacement as a migration, not an edit: stand up the new server, move clients, retire the old one. There is no in-place path for any of the four force-new fields.

💡 Key rotation is not a Terraform operation. Azure regenerates the keys out of band; this module neither rotates nor emits them. Recording the rotation date in a tag is the cheapest way to make it visible at all.

9 · What a review should assert
output "fluid_relay_review" {
  value = {
    id          = module.fluid_relay.id
    tenant      = module.fluid_relay.frs_tenant_id
    endpoints   = module.fluid_relay.service_endpoints
    cmk         = module.fluid_relay.uses_customer_managed_key
    cmk_key     = module.fluid_relay.customer_managed_key_vault_key_id
    identity    = module.fluid_relay.identity_principal_id # must not be null
    keys_exist  = module.fluid_relay.has_access_keys                  # always true
    plan_is_cred = module.fluid_relay.plan_access_is_credential_access # always true
  }
}

🔴 plan_is_cred is a constant true, and it is a prompt rather than a value. It means: confirm the state backend is encrypted and access-controlled, and confirm who can run a plan against this configuration (example 3).

🔒 cmk is a genuine choice, and false is not a failure. Platform-managed encryption is still encryption; true means you have taken custody of the key and accepted the force-new constraint that comes with it (example 5).

⚠️ cmk_key is worth reading for a version segment. A pinned version means rotation requires replacing the server.

⚠️ identity must not be null. A server with no identity cannot use a customer-managed key and has nothing to grant roles to.

💡 What no output can tell you is whether the keys have ever been rotated, or who has read them out of state. Both are outside Terraform.

10 · Importing, and the offline gate
terraform import 'module.fluid_relay.azurerm_fluid_relay_server.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-collab/providers/Microsoft.FluidRelay/fluidRelayServers/frs-collab-docs"

🔴 The moment you import, the access keys are in your state. That is worth knowing before running the command rather than after (example 3) — so confirm the backend first.

🔴 Restate storage_sku exactly, in lowercase. It is force-new, so a mismatch — including a capitalisation mismatch that the module would otherwise reject — produces a plan that replaces the server and reissues its keys.

⚠️ Restate customer_managed_key if the live server has one, for the same reason. Omitting it is a replacement, not a no-op.

The offline gate

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

✅ What it proves: storage_sku is lowercase and one of the two legal values, identity.type is one of the three legal strings, identity_ids is present exactly when the type requires it and every entry is a user-assigned identity path, the Key Vault key ID looks like a key URI, and the customer-managed key's identity is one of the assigned identities.

💡 Proved by evaluating the conditions in terraform console inside the module, which does fire root-module variable validations — unlike terraform validate on a calling configuration. "Standard" fires the lowercase check, and a customer_managed_key naming an unassigned identity fires the pairing check.

⚠️ What it cannot prove: that the Key Vault grant exists, that the region is near the users, or anything at all about the keys.

11 · What a destroy removes
terraform destroy on this module:
  removes the SERVER          ->  its access keys and endpoints are gone
                              ->  every Fluid client using it stops working immediately
  the Key Vault key survives  ->  and so does the user-assigned identity
  the keys remain in old state versions  ->  🔒 which is worth remembering

🔴 This destroy is immediately client-visible. There is no soft delete and no grace period: the endpoints stop answering and collaborative sessions end.

🔒 And note the last line. The access keys were in state, so they are in whatever state history your backend keeps — versioned blobs, snapshots, backups. Destroying the server does not remove them from there, and the keys are no longer rotatable because the server is gone. If that matters, the state history is what needs attention.

✅ The Key Vault key and the identity survive, which is the right blast radius — they are shared, longer-lived resources this module only references.

💡 A CanNotDelete management lock is worth considering for a production server, given how abrupt the failure mode is. Note that a lock prevents deletion, not the force-new replacement of example 8 — and lifecycle is not valid inside a module block, so prevent_destroy is not available to a caller.

12 · 🏗️ 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-collab"
  location = "eastus" # 🔴 near the USERS — every keystroke round-trips (example 1)
}

# ── A user-assigned identity, because a customer-managed key requires one ────
module "uai" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"

  name                = "uai-fluid-relay"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

# ── The identity needs wrap/unwrap/get on the key. This module cannot grant it ──
# ℹ️ module.kv and module.kv_key are the caller's key vault and key, elided here.
module "cmk_grant" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = var.kv_id

  role_assignments = {
    fluid_relay_cmk = {
      role_definition_name = "Key Vault Crypto Service Encryption User"
      principal_id         = module.uai.principal_id
    }
  }
}

# ── This module: the server ─────────────────────────────────────────────────
module "fluid_relay" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-fluid-relay-server.git?ref=v1.0.0"

  name                = "frs-collab-docs"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # 🔴 LOWERCASE, and force-new (example 4).
  storage_sku = "standard"

  # 🔴 A customer-managed key needs a USER-assigned identity (example 5).
  identity = {
    type         = "UserAssigned"
    identity_ids = [module.uai.id]
  }

  customer_managed_key = {
    key_vault_key_id          = var.kv_key_versionless_id # 💡 versionless
    user_assigned_identity_id = module.uai.id                # in identity_ids above
  }

  # The only mutable field, so it carries what changes (example 8).
  tags = {
    app         = "collab-docs"
    owner       = "productivity"
    key_rotated = "2026-09-01"
  }

  depends_on = [module.cmk_grant] # the grant must exist before the server needs the key
}

output "fluid_posture" {
  value = {
    # ✅ What a Fluid client needs — identifiers, not credentials (examples 1 and 2).
    tenant    = module.fluid_relay.frs_tenant_id
    endpoints = module.fluid_relay.service_endpoints
    # 🔒 Posture.
    cmk          = module.fluid_relay.uses_customer_managed_key          # true here
    plan_is_cred = module.fluid_relay.plan_access_is_credential_access   # always true
    # 🔒 The keys are NOT here, deliberately (example 2).
  }
}

🔒 What the composition gets right: a user-assigned identity because the customer-managed key requires one, the Key Vault grant made explicitly with depends_on because the server needs it before it can decrypt, a versionless key so rotation does not replace the server, the region chosen for the users rather than the estate, the lowercase SKU stated explicitly, and an output that publishes the identifiers a client needs while publishing no credential.

⚠️ What no plan will tell you: whether the state backend is encrypted — which matters more here than in most modules, because the keys are in it (example 3).

💡 depends_on is used here deliberately, and it is the one place in this composition where an attribute reference would not express the dependency: the server does not consume anything from the role assignment, but it needs the grant to exist. That is exactly the case depends_on is for.

📥 Inputs

Input Type Default Notes
name string — Required. Force-new.
resource_group_name / location string — Required. Force-new. Region near the users.
storage_sku string null Force-new. 🔴 Lowercase standard / basic. Omit to let Azure choose.
identity object(...) {} Defaults to SystemAssigned. identity_ids required with UserAssigned.
customer_managed_key object(...) null 🔒 Force-new. Requires a user-assigned identity from identity_ids.
tags map(string) {} ✅ The only field that updates in place.
timeouts object(...) null All four operations exist.
Full schemas
variable "storage_sku" {
  type    = string
  default = null # computed as well as optional, so omitting it lets Azure choose
  # LOWERCASE, unusually for an Azure SKU — "Standard" is what muscle memory types and the API rejects it.
  # See example 4.
  validation {
    condition     = var.storage_sku == null || contains(["standard", "basic"], coalesce(var.storage_sku, ""))
    error_message = "storage_sku must be \"standard\" or \"basic\" — note the LOWERCASE ..."
  }
}

variable "customer_managed_key" {
  type = object({
    key_vault_key_id          = string   # versionless keeps service-side auto-rotation; a pinned version turns it off
    user_assigned_identity_id = string
  })
  default = null
  # The identity that unwraps the key must be ASSIGNED to the server. Checked here because a validation condition
  # must reference its own variable, which is why the message names `identity` — a variable the caller may not have
  # edited. Without this check the failure arrives at apply as a Key Vault access error, pointing at the wrong
  # resource. See example 5.
  validation {
    condition = (
      var.customer_managed_key == null ||
      contains(
        [for i in coalesce(try(var.identity.identity_ids, null), []) : lower(i)],
        lower(try(var.customer_managed_key.user_assigned_identity_id, ""))
      )
    )
    error_message = "customer_managed_key.user_assigned_identity_id names an identity that is not in identity.identity_ids ..."
  }
}

🧾 Outputs

Output Description Sensitive
id The server's Resource ID. no
name / resource_group_name / location Identity. Force-new. no
frs_tenant_id ✅ An identifier a Fluid client needs. no
service_endpoints The endpoints clients connect to. no
storage_sku The tier, lowercase. no
identity_principal_id Grant roles to this. no
identity_tenant_id / identity_type Identity detail. no
uses_customer_managed_key 🔒 Derived, positive. no
customer_managed_key_vault_key_id The key URI — an identifier. no
has_access_keys 🔒 Always true — instead of the keys. no
plan_access_is_credential_access 🔴 Always true. no
orderer_and_storage_endpoints_are_always_empty Always true. no
orderer_endpoints / storage_endpoints ⚠️ Always empty. no

No key output exists. Not sensitive-marked, not present.

🧠 Architecture Notes

  • The access keys are not emitted, and that is only half the problem. Not outputting them prevents a second copy in the caller's state; it does not prevent the first, because the keys are computed attributes that Terraform reads into state on every refresh. Both halves are documented, because a reader who sees "the keys are not output" may reasonably conclude they are not in state — and that conclusion is wrong.

  • plan_access_is_credential_access appears both as a constant output and as a row in the permissions table. This library's rule is that where reading a resource means reading its keys, the permissions section must say so rather than leaving it to be discovered after someone is granted plan rights. This is a clean instance of that rule: Reader here is effectively a credential grant.

  • The module is explicit that sensitive = true would not fix it. Sensitivity redacts plan output and does not encrypt state, so claiming the keys are "sensitive" without that caveat would invite relaxing the control that matters — an encrypted, access-controlled backend, and deliberate limits on who can plan this configuration.

  • The distinction between an identifier and a credential is drawn deliberately and repeatedly. frs_tenant_id, service_endpoints and customer_managed_key_vault_key_id are all emitted because knowing them grants nothing; the access keys are not, because knowing them grants everything. A module that emitted neither would be safe and useless.

  • storage_sku's lowercase values get their own validation and their own message. Every other Azure SKU in this library is capitalised, so Standard is close to inevitable — and the generic "not a valid value" error would not point at the reason.

  • customer_managed_key.user_assigned_identity_id is validated against identity.identity_ids specifically to relocate a misleading error. Unchecked, naming an unassigned identity fails at apply with a Key Vault access error, which sends the reader to the vault's permissions rather than to the identity list three lines above.

  • The two always-empty endpoint outputs are emitted with a constant flag explaining them. The provider documents both as vestigial; omitting them would leave a reader hunting for them, and emitting them silently would leave someone debugging an empty list. Saying so at the point of use is the honest option.

  • uses_customer_managed_key's description states that false does not mean unencrypted. Platform-managed encryption is still encryption, and a flag that implied otherwise would push callers toward customer-managed keys for a reason that is not true — the real reasons being custody and revocation.

  • The destroy note mentions state history. The keys were in state, so they are in whatever versioning or backups the backend keeps — and after the server is destroyed they can no longer be rotated. That is an unusual consequence and worth naming.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Duplicating credentials into consumers keys never emitted; presence only —
Undisclosed credential exposure plan_access_is_credential_access emitted; in the RBAC table —
False secrecy "redacts plans, does not encrypt state" stated explicitly —
Embedded credentials a system-assigned managed identity by default identity = null
Silently ineffective identity config identity_ids without UserAssigned rejected —
Misleading apply-time errors the CMK/identity pairing checked offline —
Capitalised SKU rejected by the API lowercase enforced, with the reason —
Rotation that forces a replacement versionless key IDs recommended pin a version, knowingly
Outputs that look broken always-empty endpoints flagged as such —
"Not CMK" read as "not encrypted" stated explicitly in the output's description —
  • Before applying anywhere real: the keys will be in state. Check the backend.
  • Before granting plan rights: that is a credential grant here.
  • Before choosing a SKU: lowercase, and force-new.
  • Before a customer-managed key: a user-assigned identity, plus a Key Vault grant this module cannot make.
  • Before renaming or moving: every client gets new keys and endpoints.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the source to a tag — ?ref=v1.0.0 — never a branch.
  • Plan-only from here. A human applies from CI.
  • ✅ tags updates in place. Nothing else does.
  • 🔴 The access keys are in state after any refresh. Encrypted backend only.
  • 🔴 Replacing the server issues new keys and endpoints — plan it as a client migration.
  • ⚠️ Restate storage_sku in lowercase before importing, or the plan replaces the server.
  • ⚠️ A customer-managed key needs its Key Vault grant to exist first — depends_on is appropriate.
  • 💡 Record key rotation in a tag, since Terraform neither rotates nor sees it.

🧪 Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • name, resource_group_name and location are non-empty;
  • storage_sku, when supplied, is lowercase standard or basic;
  • identity.type is exactly one of SystemAssigned, UserAssigned, SystemAssigned, UserAssigned;
  • identity_ids is present when the type includes UserAssigned, and absent when it does not;
  • every identity_ids entry is a Microsoft.ManagedIdentity/userAssignedIdentities Resource ID;
  • customer_managed_key.key_vault_key_id looks like a Key Vault key URI;
  • customer_managed_key.user_assigned_identity_id is a user-assigned identity ID and one of identity_ids;
  • no key is emitted as an output at all;
  • the module declares no provider block.

💡 These were proved by evaluating the conditions in terraform console inside the module — which does fire root-module variable validations, unlike terraform validate on a calling configuration. "Standard" fires the lowercase check; type = "UserAssigned" with no identity_ids fires the requirement; and a customer_managed_key naming an identity outside identity_ids fires the pairing check.

⚠️ A harness property worth knowing before concluding a check is missing: Terraform skips a validation whose referenced variable has already failed. The CMK pairing check reads var.identity, so a configuration that also breaks identity may report only the identity error.

What only plan and apply exercise:

  • whether the resource group exists and the caller may create the server in it.

What no Terraform command checks at any stage:

  • 🔴 whether the state backend is encrypted — the only thing protecting the keys it now holds;
  • 🔴 whether the user-assigned identity has wrap/unwrap/get on the key — without it the server is created and cannot decrypt;
  • 🔴 whether the keys have ever been rotated, or who has read them out of state;
  • whether the region is anywhere near the users, which for this workload is a real performance property.

💬 Example Output

Outputs:

customer_managed_key_vault_key_id              = "https://kv-collab.vault.azure.net/keys/fluid-relay-cmk"
frs_tenant_id                                  = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
has_access_keys                                = true
id                                             = "/subscriptions/00000000-.../providers/Microsoft.FluidRelay/fluidRelayServers/frs-collab-docs"
identity_principal_id                          = "11111111-2222-3333-4444-555555555555"
identity_tenant_id                             = "99999999-8888-7777-6666-555555555555"
identity_type                                  = "UserAssigned"
location                                       = "eastus"
name                                           = "frs-collab-docs"
orderer_and_storage_endpoints_are_always_empty = true
orderer_endpoints                              = []
plan_access_is_credential_access               = true
resource_group_name                            = "rg-collab"
service_endpoints                              = [
  "https://eastus.fluidrelay.azure.com",
]
storage_endpoints                              = []
storage_sku                                    = "standard"
uses_customer_managed_key                      = true

🔒 There is no key in this output, and that is the point (example 2). has_access_keys = true is what a consumer gets; the key itself comes from the resource or a secret store.

🔴 plan_access_is_credential_access = true is a constant, and it is a prompt. The keys are in state even though they are not here (example 3).

⚠️ orderer_endpoints and storage_endpoints are empty, and that is correct — the constant above them explains why (example 6).

✅ storage_sku = "standard" in lowercase, and uses_customer_managed_key = true with a versionless key URI — rotation will not replace the server (examples 4 and 5).

🔍 Troubleshooting

Symptom Cause Fix
Plan rejects storage_sku Capitalised Standard. The values are lowercase (example 4).
Plan rejects identity.type "SystemAssigned,UserAssigned" missing the space. Use the exact string (example 7).
Plan says identity_ids is required The type includes UserAssigned. Supply at least one ID (example 7).
Plan says identity_ids would have no effect The type does not include UserAssigned. Remove them or change the type (example 7).
Plan rejects the CMK identity It is not in identity.identity_ids. Add it there (example 5).
Apply fails with a Key Vault access error The identity lacks wrap/unwrap/get on the key. Grant it — this module cannot (example 5).
The server exists but cannot decrypt The same missing grant. Grant it, then re-apply (example 5).
orderer_endpoints is empty It is always empty. Use service_endpoints (example 6).
No key output exists Deliberate. Read it from the resource or a secret store (example 2).
The keys turned up in a state file They are computed attributes. Expected — secure the backend (example 3).
A plan proposes replacing the server A force-new field differs, often the SKU's case after an import. Restate it in lowercase (example 10).
Clients broke after a change A replacement reissued keys and endpoints. Plan it as a migration (example 8).
Collaborative editing feels slow The region is far from the users. Force-new; a new server closer to them (example 1).
Wanted prevent_destroy lifecycle is not valid inside a module block. Use a CanNotDelete lock (example 11).

🔗 Related Docs

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