Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

2 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

🩹 azapi Update Resource Terraform Module

Patch a subset of properties on an existing ARM resource without owning its lifecycle β€” set the one property azurerm cannot express, flip a preview flag, or manage a slice of a resource another team created. Wraps the azapi_update_resource primitive with the secure defaults; on destroy it does nothing, so it never fights the owner. Built for the Azure azapi provider v2.x (~> 2.10).

Terraform azapi module type azapi blocks authoring


🧩 Overview

This module wraps the azapi_update_resource primitive β€” a surgical patch tool for resources you do not own:

  • 🩹 Patch, don't own β€” manages only the properties present in body; everything else on the resource is left untouched. Create and delete are never performed.
  • 🧨 Destroy is a no-op β€” removing the module stops managing those properties; their last-applied values remain. It never deletes the target.
  • 🎯 Single, unambiguous target β€” by resource_id. (The provider treats name/parent_id as mutually exclusive with resource_id, so this module exposes the one clear mode.)
  • πŸ” Write-only secrets β€” rotate a secret property via sensitive_body, never persisted to state.
  • 🚫 Export nothing by default β€” response_export_values = [].

πŸ’‘ Why it matters: platform teams constantly need to set one property on a resource someone else owns β€” enforce minimumTlsVersion = "TLS1_2" on an account another team created, or set a field the typed provider hasn't caught up to. azapi_update_resource patches exactly what you list and leaves the rest alone, so two teams can co-manage one resource without a tug-of-war.

⚠️ This primitive has no tags / location / identity / schema_validation_enabled / ignore_null_property β€” they are absent from the provider schema and therefore from this module.


❀️ 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 + OIDC auth"]:::ext
 res["terraform-azapi-resource<br/>creates resources"]:::sib
 azurerm["azurerm / other team<br/>owns the target"]:::sib
 upd["terraform-azapi-update-resource<br/>THIS Β· patch existing"]:::this
 target["Existing ARM resource<br/>lifecycle owned elsewhere"]:::keystone
 caller --> upd
 res -->|"id as resource_id"| upd
 azurerm -->|"id as resource_id"| upd
 upd -->|"PATCH selected properties"| target
 upd -. "destroy = no-op".-> target
 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 consumes the id of a resource created by terraform-azapi-resource, azurerm, or another team, and patches a property slice of it.


🧬 What this module builds

flowchart TD
 subgraph inputs["Typed inputs (the contract)"]
 direction TB
 t["type @ api-version<br/>resource_id (target)"]:::ext
 b["body (property subset to patch)"]:::ext
 s["sensitive_body (write-only)"]:::secret
 beh["behavior tail<br/>no schema_validation / null"]:::ext
 end
 this["azapi_update_resource.this<br/>patch only Β· destroy = no-op"]:::this
 ex["Existing ARM resource<br/>owned elsewhere"]:::keystone
 out["Outputs<br/>id Β· resource_id Β· output"]:::keystone
 t --> this
 b --> this
 s -. "merge-patched, never in state".-> this
 beh --> this
 this -->|"PATCH listed properties only"| ex
 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_update_resource.this β€” targets the existing resource by resource_id and PATCHes only the properties present in body; routes secret properties 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):

  • 🧨 destroy performs no operation. The patched properties remain on the resource; the module does not revert them. Removing it from config simply stops managing those properties.
  • 🎯 name/parent_id are mutually exclusive with resource_id β€” and an explicit name = null still counts as "set." This module targets by resource_id only; compose the ID from pieces with provider::azapi::build_resource_id(parent_id, type, name).
  • βž– No schema_validation_enabled / ignore_null_property / tags / location / identity on this primitive β€” they do not exist in the schema.
  • 🧱 Changing type or resource_id re-targets (force-new) the update resource.

πŸ”‘ Required Azure RBAC Permissions

Type-dependent (the target type is caller-supplied). The apply identity needs, on the target resource:

  • Microsoft.<RP>/<resource>/read and /write β€” to read current state and PATCH it. No create or delete is performed by this module.
  • RP registration: Microsoft.<RP> already registered (the target exists, so normally it is).
  • Least-privilege: a custom role limited to read + write on the target type (or a service-specific Contributor), scoped to the target resource β€” never subscription Owner.

⚠️ Route role design to Cloud Security / IAM. This module assigns no roles.


🧰 Azure Prerequisites

  • The target resource already exists (this module does not create it).
  • The chosen type api-version is valid for the target in its region.
  • 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-update-resource/
β”œβ”€β”€ providers.tf # required_providers (Azure/azapi ~> 2.10) + required_version >= 1.12.0; no provider block
β”œβ”€β”€ variables.tf # type, resource_id (target), body (patch subset), sensitive_body, behavior tail
β”œβ”€β”€ main.tf # one keystone azapi_update_resource.this; total renderer; dynamic timeouts
β”œβ”€β”€ outputs.tf # id (primary), resource_id, output (opt-in)
β”œβ”€β”€ examples/
β”‚ └── basic/ # enforce TLS 1.2 on an existing storage account
β”œβ”€β”€ README.md # you are here
└── SCOPE.md # lightweight cross-module contract

βš™οΈ Quick Start

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

  type        = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-casey/providers/Microsoft.Storage/storageAccounts/stcaseyexample001"

  # Patch ONLY this property; everything else on the account is left untouched.
  body = {
    properties = { minimumTlsVersion = "TLS1_2" }
  }
}

provider "azapi" {
  enable_preflight = true
}

⚠️ Pin the module by tag (?ref=v1.0.0), never a branch.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
resource_id string (ARM ID) the module/team that OWNS the target (terraform-azapi-resource, an azurerm resource)

Emits

Output Description Consumed by
id ARM resource ID of the patched resource downstream references
resource_id The resolved target ARM ID auditing / chaining
output Opt-in read-back of behavior.response_export_values ({} by default) downstream config

πŸ“š Example Library

1 Β· Minimal β€” enforce TLS 1.2 on an existing storage account
module "enforce_tls" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type        = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = "/subscriptions/.../resourceGroups/rg-casey/providers/Microsoft.Storage/storageAccounts/stcasey001"
  body        = { properties = { minimumTlsVersion = "TLS1_2" } }
}
2 Β· Lock network access to default-deny
module "net_deny" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type        = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = module.storage.id
  body = {
    properties = {
      publicNetworkAccess = "Disabled"
      networkAcls         = { defaultAction = "Deny", bypass = "AzureServices" }
    }
  }
}

πŸ”’ Only publicNetworkAccess and networkAcls are managed here β€” the account's other properties stay owned by whoever created it.

3 Β· Disable shared-key auth (Entra-first)
module "entra_only" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type        = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = module.storage.id
  body        = { properties = { allowSharedKeyAccess = false } }
}
4 Β· Patch tags on an existing resource
module "retag" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type        = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = module.storage.id

  # ARM tags are a top-level property; patch them through body.
  body = {
    tags = {
      data_classification = "internal"
      cost_center         = "CC-1042"
    }
  }
}

ℹ️ azapi_update_resource has no dedicated tags argument β€” set body.tags (the listed keys are merged; unlisted tags are left untouched).

5 Β· Rotate a secret property via sensitive_body
module "rotate_admin_password" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type        = "Microsoft.Sql/servers@2023-08-01-preview"
  resource_id = module.sql_server.id

  sensitive_body = {
    properties = { administratorLoginPassword = var.new_sql_password } # write-only; mark var sensitive
  }

  # Bump the version to force the rotated value to re-send.
  sensitive_body_version = { "properties.administratorLoginPassword" = "v2" }
}

πŸ”’ The new password is merge-patched at apply and never stored in state.

6 Β· Target by composed ID (build_resource_id)
module "patch_by_pieces" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = provider::azapi::build_resource_id(module.resource_group.id,
    "Microsoft.Storage/storageAccounts",
  "stcasey001", )
  body = { properties = { minimumTlsVersion = "TLS1_2" } }
}

πŸ’‘ The provider function composes a full ARM ID from parent_id + type + name β€” the clean way to target without hardcoding the whole ID string.

7 Β· Read back a patched value via response_export_values
module "patch_and_read" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type        = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = module.storage.id
  body        = { properties = { minimumTlsVersion = "TLS1_2" } }

  behavior = {
    response_export_values = { tls = "properties.minimumTlsVersion" }
  }
}

output "effective_tls" {
  value = module.patch_and_read.output.tls
}

⚠️ Never export a secret path β€” output lands in state.

8 Β· Re-apply the patch on external change (replace_triggers_external_values)
module "patch_versioned" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type        = "Microsoft.Network/publicIPAddresses@2024-05-01"
  resource_id = module.public_ip.id
  body        = { properties = { idleTimeoutInMinutes = var.idle_timeout } }

  # Force a fresh patch when the policy version changes.
  replace_triggers_external_values = [var.idle_timeout, var.policy_version]
}
9 Β· Transient-error retry policy
module "patch_with_retry" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type        = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = module.storage.id
  body        = { properties = { minimumTlsVersion = "TLS1_2" } }

  behavior = {
    retry = {
      error_message_regex  = ["TooManyRequests", "Conflict"]
      interval_seconds     = 15
      max_interval_seconds = 120
    }
  }
}
10 Β· Tune drift handling (ignore_casing / ignore_missing_property)
module "patch_strict" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type        = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = module.storage.id
  body        = { properties = { minimumTlsVersion = "TLS1_2" } }

  behavior = {
    ignore_casing           = false # surface casing drift (default)
    ignore_missing_property = true  # un-echoed props (e.g. rotated secrets) don't show as drift (default)
  }
}
11 Β· Patch a child resource (subnet property)
module "subnet_pe_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"

  type        = "Microsoft.Network/virtualNetworks/subnets@2024-05-01"
  resource_id = "${module.vnet.id}/subnets/snet-apps"
  body = {
    properties = { privateEndpointNetworkPolicies = "Disabled" }
  }
}
12 Β· for_each at scale β€” patch many resources
locals {
  accounts = {
    app  = module.storage_app.id
    logs = module.storage_logs.id
    data = module.storage_data.id
  }
}

module "enforce_tls" {
  source   = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
  for_each = local.accounts

  type        = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = each.value
  body        = { properties = { minimumTlsVersion = "TLS1_2", allowSharedKeyAccess = false } }
}

πŸ’‘ One patch policy, applied across many resources β€” the for_each lives on the caller's module call.

13 Β· πŸ—οΈ End-to-end composition (mandatory) β€” create then patch
# 1) Create a storage account (owned by terraform-azapi-resource)
module "storage" {
  source    = "git::https://github.com/microsoftexpert/terraform-azapi-resource?ref=v1.0.0"
  type      = "Microsoft.Storage/storageAccounts@2025-06-01"
  name      = "stcaseyorders001"
  parent_id = module.resource_group.id
  location  = "eastus2"
  body      = { sku = { name = "Standard_LRS" }, kind = "StorageV2" }
}

# 2) A governance team enforces a property on it WITHOUT owning its lifecycle (this module)
module "enforce_tls" {
  source      = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
  type        = "Microsoft.Storage/storageAccounts@2025-06-01"
  resource_id = module.storage.id # ← wired from output
  body        = { properties = { minimumTlsVersion = "TLS1_2", allowSharedKeyAccess = false } }
}

πŸ—οΈ The creating module and the patching module can live in different states / teams β€” they meet only at resource_id. On destroy of the patch module, the enforced properties simply stay put.


πŸ“₯ Inputs

Core β€” type (required), resource_id (required). Body β€” body (the property subset to patch), sensitive_body, sensitive_body_version. Behavior tail β€” behavior (casing/missing/export/locks/list-controls/retry β€” no schema_validation / null), timeouts. Lifecycle / advanced β€” replace_triggers_external_values, request_options (read + update only).

Full object schemas
type = string # "<Namespace>/<resource>@<api-version>"; validated ^.+/.+@.+$
resource_id = string # full ARM ID, must start with "/"; force-new
body = optional(any, {}) # the property SUBSET to patch; native HCL; NO secrets here
sensitive_body = optional(any) # sensitive = true; write-only; secrets 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), [])
 ignore_other_items_in_list = optional(list(string), [])
 list_unique_id_property = optional(map(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
request_options = optional(object({ # read + update only (no create/delete of the target)
 read_headers/update_headers = optional(map(string), {})
 read_query_parameters/update_query_parameters = optional(map(list(string)), {})
}), {})

ℹ️ body is typed any because this primitive patches arbitrary types (the documented exception).


🧾 Outputs

Output Description Sensitive
id Primary. ARM resource ID of the patched resource. no
resource_id The resolved target ARM ID. no
output Opt-in read-back of behavior.response_export_values ({} by default). no β€” never export secret paths

🧠 Architecture Notes

Patch semantics β€” own the properties, not the resource. azapi_update_resource manages exactly the keys present in body and nothing else. This is the right tool when a resource's lifecycle is owned elsewhere (another module, another team, click-ops) but you must enforce or set a few properties. On destroy it performs no operation β€” the last-applied values stay on the resource, so removing the module never deletes or reverts the target. If you need to revert a property, patch it back explicitly.

Single targeting mode, by design. The provider treats name/parent_id as mutually exclusive with resource_id, and the framework counts an explicit name = null as "set" β€” so a module that wrote all three would fail validation. This module exposes the single unambiguous mode (resource_id). To target from pieces, compose the ID with provider::azapi::build_resource_id(parent_id, type, name) (Example 6).

Secrets and drift. Secret properties go in write-only sensitive_body; ignore_missing_property defaults true so the API not echoing the secret back does not register as drift. Bump sensitive_body_version to force a rotated value to re-send.

What this primitive lacks. No schema_validation_enabled (the embedded validator is not available on update), no ignore_null_property, no tags/location/identity. Patch tags through body.tags; the typed variables.tf and the service's own validation are the body guards.


🧱 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 paths / a JMESPath map
Casing drift behavior.ignore_casing = false set true
Concurrency behavior.locks = [] available populate with ARM IDs
Targeting by resource_id only (unambiguous) compose the ID via provider::azapi::build_resource_id
Body shape native HCL body; only listed props managed β€”

πŸš€ Runbook

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

cd terraform-azapi-update-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. The patch behavior (and "destroy is a no-op") only exercises against a real existing target at plan/apply β€” a separate, human-reviewed step.


πŸ’¬ Example Output

id          = "/subscriptions/.../providers/Microsoft.Storage/storageAccounts/stcasey001"
resource_id = "/subscriptions/.../providers/Microsoft.Storage/storageAccounts/stcasey001"
output      = {} # nothing exported by default

πŸ” Troubleshooting

Symptom Cause Fix
terraform destroy left the property changed Expected β€” update_resource destroy is a no-op The last-applied patch remains; revert it explicitly if required
Only one of name or resource_id can be set name/parent_id set somewhere alongside resource_id This module targets by resource_id only (they're mutually exclusive, even when null)
A secret/PII value landed in state or an output Exported via behavior.response_export_values Set it back to []
Secret shows as drift Secret placed in body, or ignore_missing_property = false Move it to sensitive_body; keep ignore_missing_property = true
Re-apply wants to replace You changed type or resource_id (re-targeting), or a replace_triggers_external_values value Expected for re-targeting

πŸ”— Related Docs

  • Terraform azapi provider β€” azapi_update_resource resource reference
  • azapi provider function β€” build_resource_id (compose a target ARM ID from pieces)
  • Azure Resource Manager template reference β€” learn.microsoft.com/azure/templates/<RP>/<resource>
  • Sibling modules β€” terraform-azapi-resource (creates the target), terraform-azapi-resource-action
  • SCOPE.md (this module)

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."

Releases

Packages

Contributors

Languages