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). Targetshashicorp/azurerm ~> 4.0.
- 🤝 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_keysis, 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_idandservice_endpointsare 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_endpointsandstorage_endpointsare always empty by the provider's own note. Useservice_endpoints.⚠️ Onlytagsupdates 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.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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;
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;
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. |
| 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_keyandsecondary_keyare computed and sensitive, so they enter state on every refresh. - 🔴
storage_skuis lowercase:standard,basic. ⚠️ orderer_endpointsandstorage_endpointsare documented as deprecated in a future Fluid Relay version and always empty today.- 🔴
customer_managed_keyis 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.typecombined value is the literal"SystemAssigned, UserAssigned"— comma and space.⚠️ Onlytagsupdates in place; replacing the server issues new keys and endpoints.lifecycleis not valid inside amoduleblock, so a caller cannot addprevent_destroy.
| 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
plancan read them out of the state file — soReaderhere 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).
- An existing resource group in a supported region — near the users, since the workload is latency-sensitive.
- The
Microsoft.FluidRelayresource 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).
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
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.
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 |
— |
🔒 The access keys are never emitted.
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_idandservice_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_idis 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
planagainst this configuration can read the keys out of the state file.Readeron 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_accessis emitted as a constanttrueso 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.
⚠️ Asensitive = truemarking 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.
Standardis 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.
💡
basicis 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_idappears inidentity.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. Composeterraform-azurerm-role-assignmentsfor 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_endpointsandfrs_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_emptyis 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_idsis required withUserAssigned, and rejected without it. Both directions, because IDs that silently do nothing read like configuration that took effect.
⚠️ identity_idstakes 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_idis the value to grant roles to — notid, notfrs_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
tagsunusually 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_credis a constanttrue, 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).
🔒
cmkis a genuine choice, andfalseis not a failure. Platform-managed encryption is still encryption;truemeans you have taken custody of the key and accepted the force-new constraint that comes with it (example 5).
⚠️ cmk_keyis worth reading for a version segment. A pinned version means rotation requires replacing the server.
⚠️ identitymust not benull. 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_skuexactly, 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.
⚠️ Restatecustomer_managed_keyif 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_skuis lowercase and one of the two legal values,identity.typeis one of the three legal strings,identity_idsis 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 consoleinside the module, which does fire root-module variable validations — unliketerraform validateon a calling configuration."Standard"fires the lowercase check, and acustomer_managed_keynaming 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
CanNotDeletemanagement 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 — andlifecycleis not valid inside amoduleblock, soprevent_destroyis 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_onbecause 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_onis 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 casedepends_onis for.
| 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 ..."
}
}| 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 |
no |
No key output exists. Not sensitive-marked, not present.
-
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_accessappears 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:Readerhere is effectively a credential grant. -
The module is explicit that
sensitive = truewould 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_endpointsandcustomer_managed_key_vault_key_idare 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, soStandardis close to inevitable — and the generic "not a valid value" error would not point at the reason. -
customer_managed_key.user_assigned_identity_idis validated againstidentity.identity_idsspecifically 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 thatfalsedoes 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.
| 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.
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.
- ✅
tagsupdates 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.
⚠️ Restatestorage_skuin lowercase before importing, or the plan replaces the server.⚠️ A customer-managed key needs its Key Vault grant to exist first —depends_onis appropriate.- 💡 Record key rotation in a tag, since Terraform neither rotates nor sees it.
terraform validate and terraform fmt -check are the offline gate. They confirm:
name,resource_group_nameandlocationare non-empty;storage_sku, when supplied, is lowercasestandardorbasic;identity.typeis exactly one ofSystemAssigned,UserAssigned,SystemAssigned, UserAssigned;identity_idsis present when the type includesUserAssigned, and absent when it does not;- every
identity_idsentry is aMicrosoft.ManagedIdentity/userAssignedIdentitiesResource ID; customer_managed_key.key_vault_key_idlooks like a Key Vault key URI;customer_managed_key.user_assigned_identity_idis a user-assigned identity ID and one ofidentity_ids;- no key is emitted as an output at all;
- the module declares no
providerblock.
💡 These were proved by evaluating the conditions in
terraform consoleinside the module — which does fire root-module variable validations, unliketerraform validateon a calling configuration."Standard"fires the lowercase check;type = "UserAssigned"with noidentity_idsfires the requirement; and acustomer_managed_keynaming an identity outsideidentity_idsfires 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 readsvar.identity, so a configuration that also breaksidentitymay report only theidentityerror.
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.
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 = trueis what a consumer gets; the key itself comes from the resource or a secret store.
🔴
plan_access_is_credential_access = trueis a constant, and it is a prompt. The keys are in state even though they are not here (example 3).
⚠️ orderer_endpointsandstorage_endpointsare empty, and that is correct — the constant above them explains why (example 6).
✅
storage_sku = "standard"in lowercase, anduses_customer_managed_key = truewith a versionless key URI — rotation will not replace the server (examples 4 and 5).
| 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). |
azurerm_fluid_relay_server— provider documentation, including the always-empty endpoint note of example 6.- Azure Fluid Relay overview — what the service does.
- Fluid Relay version compatibility — the endpoint deprecation the provider links to (example 6).
- Customer-managed keys for Azure Fluid Relay — the encryption model of example 5.
- Sibling modules:
terraform-azurerm-resource-group,terraform-azurerm-user-assigned-identity,terraform-azurerm-key-vault,terraform-azurerm-role-assignments. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."

