Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🗄️ azapi Data Plane Resource Terraform Module

Manage a data-plane object — an App Configuration key-value, a Key Vault secret, an AI Foundry agent — on a parent resource owned elsewhere. Reaches the service's data plane directly (where ARM and azurerm cannot), with write-only secret values and export-nothing defaults. Wraps the azapi_data_plane_resource primitive. Built for the Azure azapi provider v2.x (~> 2.10).

Terraform azapi module type azapi blocks authoring


🧩 Overview

This module wraps the azapi_data_plane_resource primitive — for objects that live in a service's data plane, not in ARM:

  • 🗄️ Data-plane objects — App Configuration key-values & feature flags, Key Vault secrets, AI Foundry agents, and similar — under a required parent_id.
  • 🔐 Write-only secret values — secret data-plane values go in sensitive_body, merge-patched at apply and never persisted to state.
  • 🚫 Export nothing by defaultresponse_export_values = [].
  • 🪪 Data-plane RBAC — access is governed by data-plane roles (e.g. App Configuration Data Owner), not only control-plane RBAC — prefer Entra ID over keys.

💡 Why it matters: not everything in Azure is an ARM resource. Configuration key-values live inside an App Configuration store; secrets live inside a Key Vault's data plane. azurerm often cannot manage them. This module reaches the data plane directly — and because those objects frequently hold config secrets, it routes secret values through write-only sensitive_body.

⚠️ This primitive has no tags / location / identity / schema_validation_enabled / ignore_null_property. parent_id is required. The type carries a data-plane version (often @1.0), not a control-plane ARM api-version.


❤️ Support this project

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

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


🗺️ Where this fits in the family

flowchart LR
 caller["Caller root config<br/>provider azapi + data-plane auth"]:::ext
 azurerm["azurerm / other team<br/>owns the parent"]:::sib
 parent["Parent ARM resource<br/>e.g. App Configuration store"]:::keystone
 subgraph tier1["Tier-1 azapi primitives (sibling modules)"]
 direction TB
 res["terraform-azapi-resource<br/>creates the parent"]:::sib
 upd["terraform-azapi-update-resource<br/>patch existing"]:::sib
 act["terraform-azapi-resource-action<br/>control-plane action"]:::sib
 dp["terraform-azapi-data-plane-resource<br/>THIS · data-plane object"]:::this
 end
 caller --> dp
 res -->|"id as parent_id"| dp
 azurerm -->|"id as parent_id"| dp
 dp -->|"manage object in data plane of"| parent
 classDef this fill:#0078D4,color:#fff,stroke:#004578,stroke-width:3px;
 classDef keystone fill:#004578,color:#fff,stroke:#0078D4,stroke-width:2px;
 classDef sib fill:#E6F2FB,color:#073B6B,stroke:#0078D4,stroke-width:1px;
 classDef ext fill:#f2f2f2,color:#333333,stroke:#999999,stroke-width:1px;
Loading

ℹ️ Blue = this module. It sits alongside the other three tier-1 azapi primitives (terraform-azapi-resource, terraform-azapi-update-resource, terraform-azapi-resource-action). It consumes the id of a parent resource (created by terraform-azapi-resource, azurerm, or another team) as its parent_id, and manages an object in that parent's data plane. update-resource and resource-action are shown as family members only — this module has no direct data flow with them.


🧬 What this module builds

flowchart TD
 subgraph inputs["Typed inputs (the contract)"]
 direction TB
 t["type @ data-plane version<br/>parent_id (required) · name"]:::ext
 b["body (data-plane object)"]:::ext
 s["sensitive_body (write-only)"]:::secret
 beh["behavior tail<br/>no schema_validation / null"]:::ext
 end
 this["azapi_data_plane_resource.this<br/>one data-plane object"]:::this
 parent["Parent resource data plane<br/>e.g. App Config store"]:::keystone
 out["Outputs<br/>id · name · output"]:::keystone
 t --> this
 b --> this
 s -. "merge-patched, never in state".-> this
 beh --> this
 this -->|"create / update object in"| parent
 this --> out
 classDef this fill:#0078D4,color:#fff,stroke:#004578,stroke-width:3px;
 classDef keystone fill:#004578,color:#fff,stroke:#0078D4,stroke-width:2px;
 classDef secret fill:#FCE8E6,color:#8B1A10,stroke:#D13438,stroke-width:1px;
 classDef ext fill:#f2f2f2,color:#333333,stroke:#999999,stroke-width:1px;
Loading

Resource inventory (1 azapi_* block):

  • azapi_data_plane_resource.this — one data-plane object of type@version under the required parent_id; routes secret values through write-only sensitive_body.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
azapi provider ~> 2.10 (validated against v2.10.0)
Provider config None inside the module — the caller configures provider "azapi" + auth.

Schema notes that bite (verified against the azapi v2.10.0 binary schema):

  • 🧱 parent_id is required and immutable (force-new); name is also force-new.
  • 🔢 The type version is a data-plane version (e.g. @1.0 for App Configuration key-values), not a control-plane ARM api-version.
  • ➖ No schema_validation_enabled / ignore_null_property / tags / location / identity — they do not exist on this primitive. The typed inputs and the service's own validation are the body guards.
  • 🔐 Secret values go in write-only sensitive_body; keep response_export_values = [] so read-backs do not pull secret data-plane values into state.

🔑 Required Azure RBAC Permissions

This manages a data-plane object, so the apply identity needs the relevant data-plane role on the parent, plus enough control-plane access to resolve it:

  • App Configuration key-values → App Configuration Data Owner (data-plane) on the store.
  • Key Vault secrets → a Key Vault data-plane secrets role (e.g. Key Vault Secrets Officer).
  • AI Foundry / Cognitive Services objects → the service's data-plane data role.
  • Control-plane: Microsoft.<RP>/<resource>/read on the parent, and RP registration for Microsoft.<RP>.

⚠️ Prefer Entra ID (data-plane RBAC) over access keys, and disable local/key auth on the parent where possible. Route role design through Cloud Security / IAM.


🧰 Azure Prerequisites

  • The parent resource already exists and its data plane is reachable from the runner (network / private endpoint as applicable).
  • Data-plane authentication is configured (Entra ID preferred).
  • The chosen type@version (a data-plane version such as 1.0) is valid for the parent service.
  • Terraform >= 1.12; azapi ~> 2.10; caller sets enable_preflight = true (recommended).
  • Auth configured by the caller — OIDC / workload-identity federation preferred.

📁 Module Structure

terraform-azapi-data-plane-resource/
├── providers.tf # required_providers (Azure/azapi ~> 2.10) + required_version >= 1.12.0; no provider block
├── variables.tf # type, parent_id (required), name, body, sensitive_body, behavior tail
├── main.tf # one keystone azapi_data_plane_resource.this; total renderer; dynamic timeouts
├── outputs.tf # id (primary), name, output (opt-in)
├── examples/
│ └── basic/ # an App Configuration key-value
├── README.md # you are here
└── SCOPE.md # lightweight cross-module contract

⚙️ Quick Start

module "feature_flag" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-casey/providers/Microsoft.AppConfiguration/configurationStores/appcs-casey"
  name      = "FeatureFlags:NewUI"

  body = {
    properties = { value = "enabled", contentType = "text/plain" }
  }
}

provider "azapi" {
  enable_preflight = true
}

⚠️ For a secret value, pass it via sensitive_body instead of body. Pin the module by tag (?ref=v1.0.0), never a branch.


🔌 Cross-Module Contract

Consumes

Input Type Source
parent_id string (parent ARM ID) the module that OWNS the parent (terraform-azapi-resource, an azurerm App Config store / Key Vault)

Emits

Output Description Consumed by
id ID of the data-plane resource downstream references
name Name (identifier segment) of the object downstream config
output Opt-in read-back of behavior.response_export_values ({} by default) downstream config

📚 Example Library

1 · Minimal — an App Configuration key-value
module "kv" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = "Service:Timeout"
  body      = { properties = { value = "30" } }
}
2 · Key-value with a content type
module "kv_json" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = "Service:Limits"
  body = {
    properties = {
      value       = jsonencode({ maxItems = 100, maxBytes = 1048576 }) # value is a string; jsonencode is fine HERE for the VALUE
      contentType = "application/json"
    }
  }
}

ℹ️ jsonencode here builds the string value of a key — that is not the v1 body idiom (the body itself is still native HCL).

3 · Feature flag (App Configuration)
module "feature_flag" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = ".appconfig.featureflag/NewUI"
  body = {
    properties = {
      contentType = "application/vnd.microsoft.appconfig.ff+json;charset=utf-8"
      value       = jsonencode({ id = "NewUI", enabled = false })
    }
  }
}
4 · Labeled key-value (per-environment override)
module "kv_prod" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = "Service:Timeout$prod" # "key$label"
  body      = { properties = { value = "10" } }
}

ℹ️ App Configuration encodes the label in the identifier as key$label.

5 · Secret value via sensitive_body
module "kv_secret_ref" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = "Service:ApiToken"

  # Secret value is write-only — never stored in state.
  sensitive_body = {
    properties = { value = var.api_token } # mark this variable sensitive in the caller
  }
}

🔒 Secret data-plane values belong in sensitive_body, never in body.

6 · Key Vault secret (data-plane)
module "vault_secret" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.KeyVault/vaults/secrets@7.5" # data-plane version
  parent_id = module.key_vault.id
  name      = "db-connection-string"

  sensitive_body = {
    value = var.db_connection_string # write-only; mark the variable sensitive
  }
}

🔒 Requires a Key Vault data-plane secrets role (e.g. Key Vault Secrets Officer) on the vault.

7 · Read back a non-secret value via response_export_values
module "kv_read" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = "Service:Region"
  body      = { properties = { value = "eastus2" } }

  behavior = {
    response_export_values = { etag = "properties.eTag" } # non-secret metadata
  }
}

output "kv_etag" {
  value = module.kv_read.output.etag
}

⚠️ Never export a secret path — output lands in state.

8 · Force replacement with replace_triggers_external_values
module "kv_versioned" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = "Service:Schema"
  body      = { properties = { value = var.schema_payload } }

  replace_triggers_external_values = [var.schema_version]
}
9 · Transient-error retry policy
module "kv_retry" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = "Service:Flag"
  body      = { properties = { value = "on" } }

  behavior = {
    retry = {
      error_message_regex  = ["TooManyRequests", "ServiceUnavailable"]
      interval_seconds     = 10
      max_interval_seconds = 120
    }
  }
}
10 · Custom request headers (request_options)
module "kv_headers" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = "Service:Mode"
  body      = { properties = { value = "active" } }

  request_options = {
    create_headers = { "x-ms-client-request-id" = "casey-bootstrap" }
  }
}

⚠️ Do not place secrets in headers.

11 · for_each at scale — a whole config namespace
locals {
  settings = {
    "Service:Region"     = "eastus2"
    "Service:Timeout"    = "30"
    "Service:MaxRetries" = "3"
    "Service:LogLevel"   = "info"
  }
}

module "config" {
  source   = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
  for_each = local.settings

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = each.key
  body      = { properties = { value = each.value } }
}

💡 The for_each lives on the caller's module call; each instance is one key-value, keyed by its name.

12 · Drift-handling tuning (ignore_casing / ignore_missing_property)
module "kv_strict" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id
  name      = "Service:Mode"
  body      = { properties = { value = "active" } }

  behavior = {
    ignore_casing           = false # surface casing drift (default)
    ignore_missing_property = true  # un-echoed (e.g. secret) props don't show as drift (default)
  }
}
13 · 🏗️ End-to-end composition (mandatory) — create the store, then seed it
# 1) Create the App Configuration store (control plane, via terraform-azapi-resource)
module "app_config" {
  source    = "git::https://github.com/microsoftexpert/terraform-azapi-resource?ref=v1.0.0"
  type      = "Microsoft.AppConfiguration/configurationStores@2024-05-01"
  name      = "appcs-casey-orders"
  parent_id = module.resource_group.id
  location  = "eastus2"
  body = {
    sku = { name = "standard" }
    properties = {
      disableLocalAuth    = true # Entra-only data-plane access
      publicNetworkAccess = "Disabled"
    }
  }
}

# 2) Seed its data plane with key-values (this module, parent_id wired from output)
module "config" {
  source   = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
  for_each = { "Service:Region" = "eastus2", "Service:Timeout" = "30" }

  type      = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id = module.app_config.id # ← wired from output
  name      = each.key
  body      = { properties = { value = each.value } }
}

# 3) A secret value goes through the write-only channel
module "api_token" {
  source         = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
  type           = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
  parent_id      = module.app_config.id
  name           = "Service:ApiToken"
  sensitive_body = { properties = { value = var.api_token } }
}

🏗️ Control plane creates the store with disableLocalAuth = true; the data-plane module then seeds it over Entra ID — no keys, secrets write-only.


📥 Inputs

Coretype (required), parent_id (required), name. Bodybody (the data-plane object), sensitive_body, sensitive_body_version. Behavior tailbehavior (casing/missing/export/locks/retry — no schema_validation / null), timeouts. Lifecycle / advancedreplace_triggers_external_values, replace_triggers_refs, request_options.

Full object schemas
type = string # "<Namespace>/<resource>@<data-plane version>"; validated ^.+/.+@.+$; resource-type portion force-new
parent_id = string # the parent resource whose data plane this object lives in; REQUIRED; force-new
name = optional(string) # identifier segment; force-new
body = optional(any, {}) # native HCL data-plane object; NO secrets here
sensitive_body = optional(any) # sensitive = true; write-only; secret values only
sensitive_body_version = optional(map(string), {})

behavior = optional(object({
 ignore_casing = optional(bool, false)
 ignore_missing_property = optional(bool, true)
 response_export_values = optional(any, []) # export NOTHING by default
 locks = optional(list(string), [])
 retry = optional(object({
 error_message_regex = list(string)
 interval_seconds = optional(number, 10) # 1..120
 max_interval_seconds = optional(number, 180) # 1..300
 }))
}), {})

timeouts = optional(object({ create, read, update, delete = optional(string) })) # default null
replace_triggers_external_values = optional(any) # default null
replace_triggers_refs = optional(list(string), [])
request_options = optional(object({ # per-operation headers/query params
 create_headers/read_headers/update_headers/delete_headers = optional(map(string), {})
 create_query_parameters/.../delete_query_parameters = optional(map(list(string)), {})
}), {})

ℹ️ body is typed any because this primitive manages arbitrary data-plane types (the documented exception).


🧾 Outputs

Output Description Sensitive
id Primary. ID of the data-plane resource. no
name Name (identifier segment). no
output Opt-in read-back of behavior.response_export_values ({} by default). no — never export secret paths

🧠 Architecture Notes

Control plane vs data plane. ARM (and azurerm) manage the resource — the App Configuration store, the Key Vault. This module manages objects inside that resource's data plane — the key-values, the secrets. parent_id is the bridge: the ARM ID of the parent whose data plane you are writing into. The parent must already exist and its data plane must be reachable from the runner (mind private endpoints).

Data-plane auth is distinct from control-plane RBAC. A 403 here usually means the runner lacks the data-plane role (e.g. App Configuration Data Owner), even if it has plenty of control-plane rights. Prefer Entra ID data-plane RBAC over access keys, and set disableLocalAuth = true on the parent (see the end-to-end example) so key auth is off entirely.

Secrets and the data-plane version. Secret values (a Key Vault secret's value, a secret config setting) go in write-only sensitive_body; keep response_export_values = [] so read-backs do not pull them into state. Note the type version is a data-plane version (e.g. @1.0, @7.5), not an ARM control-plane api-version — a frequent source of confusion.

What this primitive lacks. No schema_validation_enabled (no embedded validator on the data plane), no ignore_null_property, no tags/location/identity. The typed variables.tf and the service's own validation are the body guards. main.tf is a total renderer — dynamic timeouts, null-guarded retry, direct passthrough for the behavior controls and replace_triggers_*.


🧱 Design Principles — the empty call is the safe call

Concern Secure default Opt-out
Secrets routed via sensitive_body (write-only, never in state) — (placing secrets in body is forbidden)
Missing-property drift behavior.ignore_missing_property = true set false
Output exposure behavior.response_export_values = [] (export nothing) pass explicit non-secret paths
Casing drift behavior.ignore_casing = false set true
Concurrency behavior.locks = [] available populate with ARM IDs
Data-plane auth Entra ID data-plane RBAC (caller-configured) access keys (discouraged)
Body shape native HCL body (never jsonencode the body)

🚀 Runbook

Authoring is plan-only (regulated-FI posture) — a human applies from a controlled CI context:

cd terraform-azapi-data-plane-resource
terraform init -backend=false
terraform validate
terraform fmt -check -recursive
# NO `terraform apply` here. plan/apply against an environment is a separate, human-reviewed CI step.
rm -rf.terraform.terraform.lock.hcl

⚠️ Consume the module pinned to a tag — ?ref=v1.0.0, never a branch.


🧪 Testing

terraform init -backend=false # azapi ~> 2.10 only
terraform validate # "Success! The configuration is valid."
terraform fmt -check -recursive # zero formatting differences

ℹ️ Validate runs offline. Data-plane reachability and auth only exercise against a live parent at plan/apply — a separate, human-reviewed step.


💬 Example Output

id     = "<parent data-plane object ref>"
name   = "Service:Region"
output = {} # nothing exported by default

🔍 Troubleshooting

Symptom Cause Fix
403 / auth error at apply Runner lacks the data-plane role on the parent, or the data plane is unreachable Grant the data-plane role (e.g. App Configuration Data Owner); check network / private endpoint
A secret value landed in state Secret placed in body Pass it via sensitive_body; keep behavior.response_export_values = []
Wrong version in type Used a control-plane ARM api-version Use the service's data-plane version (e.g. @1.0, @7.5)
Re-apply wants to replace You changed parent_id or name (both force-new), or a replace_triggers_* value Expected for those changes

🔗 Related Docs

  • Terraform azapi provider — azapi_data_plane_resource resource reference
  • Azure App Configuration — key-value / feature-flag data-plane reference (Microsoft Learn)
  • Azure Key Vault — secret data-plane reference (Microsoft Learn)
  • Sibling modules — terraform-azapi-resource (creates the parent), terraform-azapi-resource-action
  • SCOPE.md (this module)

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

Releases

Packages

Contributors

Languages