Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure API Management Policy Terraform Module

Owns the global policy of an Azure API Management service β€” the document that applies to every API, at the broadest of the five policy scopes. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Blast radius


🧩 Overview

  • 🌐 Owns the global policy of an API Management service β€” one document, applying to every API.
  • 🧨 States the behaviour no other module in this family has: creating it overwrites whatever global policy already existed, with no warning at plan or apply.
  • πŸ†” Corrects a reasonable assumption: this resource's Resource ID is the service's own ID, byte for byte.
  • πŸšͺ Surfaces the bypass β€” a narrower policy that drops <base /> silently opts out of everything here β€” and names the Azure Policy definition that can enforce it.
  • πŸ”— Explains that xml_link is fetched once and never read back, so it is not a live link.
  • πŸ”’ Emits no policy content β€” only a fingerprint, a length and a match flag.
  • 🏷️ Carries no tags β€” the resource has none. The universal tail is timeouts only.

πŸ’‘ Why it matters: a global policy is where organisations put the controls they most want to be universal β€” authentication, logging, rate limiting, IP filtering. Two things undermine that: the first apply replaces whatever was already there, and any API team can step out of it by deleting one element. Neither is visible in a plan, so both are stated here as outputs.


❀️ 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 TB
  rg["terraform-azurerm-resource-group"]
  apim["terraform-azurerm-api-management"]
  nv["terraform-azurerm-api-management-named-value"]
  frag["terraform-azurerm-api-management-policy-fragment"]
  cache["terraform-azurerm-api-management-redis-cache"]
  this["terraform-azurerm-api-management-policy"]
  api["terraform-azurerm-api-management-api"]
  pa["terraform-azurerm-subscription-policy-assignment"]

  rg -->|"name"| apim
  apim -->|"id, which is also this record's own id"| this
  nv -->|"referenced as a double-brace token"| this
  frag -->|"name, pasted into an include-fragment element"| this
  cache -->|"makes an external cache available to a cache-store element"| this
  this -->|"applies to every API, unless one drops the base element"| api
  api -->|"an API policy without base bypasses this one entirely"| this
  pa -->|"audits or denies a missing base element"| api

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef keystone fill:#004578,stroke:#002b4d,color:#ffffff;
  classDef sib fill:#eef3f8,stroke:#b9c7d6,color:#1b2a3a;
  class this me;
  class apim keystone;
  class rg,nv,frag,cache,api,pa sib;
Loading

The API Management family is large, so this diagram names only this module's direct neighbours rather than every api-management-* module in the library. Note the edge running back from the API module: an API-scoped policy that omits <base /> bypasses this document entirely, which makes the relationship bidirectional in effect even though only one direction is a Terraform reference. The policy-assignment edge is the only mechanism that turns this global policy into a floor rather than a default.


🧬 What this module builds

flowchart TB
  subgraph inputs["Inputs"]
    svc["api_management_id, the only force-new field"]
    content["xml_content, the document inline"]
    link["xml_link, fetched once by Azure"]
  end

  this["azurerm_api_management_policy.this"]
  prop["the service's global policy, one per service"]

  subgraph outputs["Outputs"]
    oid["id, which IS the service id"]
    ofp["policy_fingerprint, policy_length_in_characters, policy_matches_configuration"]
    oshape["has_policies_root, contains_an_inert_base_element, declares_forward_request_in_backend"]
    ofact["creating_this_overwrites_any_existing_global_policy, a_narrower_policy_without_base_bypasses_this_one"]
  end

  svc --> this
  content -->|"exactly one of the two"| this
  link -->|"exactly one of the two"| this
  this -->|"replaces whatever was there"| prop
  this --> oid
  this --> ofp
  this --> oshape
  this --> ofact

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef keystone fill:#004578,stroke:#002b4d,color:#ffffff;
  classDef sib fill:#eef3f8,stroke:#b9c7d6,color:#1b2a3a;
  class this me;
  class prop keystone;
  class svc,content,link,oid,ofp,oshape,ofact sib;
Loading

Resource inventory

Resource Count Notes
azurerm_api_management_policy.this 1 The keystone, and the only resource. One per service; its Resource ID is the service's own.

βœ… Provider / Versions

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

Schema notes that bite

  • No import-exists check, by design. Every other resource in this family refuses to adopt an existing object; this one replaces it. The provider's own source comment explains why: the API always returns a policy, so "unset" and "set by someone else" are indistinguishable.
  • The Resource ID is the service's Resource ID. id and api_management_id are the same string.
  • api_management_id is the only force-new field. Both document fields update in place.
  • xml_content and xml_link are ExactlyOneOf and ConflictsWith; the create function additionally errors with either `xml_content` or `xml_link` must be set at apply when neither is given.
  • xml_link is never read back. Azure downloads the document and stores its content; xml_content is Optional and Computed, so it fills with whatever was downloaded.
  • Switching from xml_link to xml_content clears the link inside the update function, with a d.Set("xml_link", "") on a non-new resource.
  • The diff suppression strips all whitespace and unescapes the five XML entities before comparing β€” so reformatting and re-escaping are both invisible.
  • Read applies HTML unescaping, which is wider than XML's five entities.
  • SchemaVersion is 3, with three state upgraders for state written by older provider versions.
  • No tags, no location. The universal tail is timeouts only.

πŸ”‘ Required Azure RBAC Roles / Permissions

  • Microsoft.ApiManagement/service/policies/* β€” write and delete, scoped to the parent API Management service. API Management Service Contributor on the service is the smallest built-in role that covers it; Contributor on the resource group covers it more broadly than necessary.
  • Microsoft.ApiManagement/service/read on the service, which the read path uses before fetching the policy.

πŸ”’ Plan access is policy-content access. Refreshing this resource returns the whole global policy document, which routinely carries backend URLs and named-value references. That is a reason to keep credentials out of the document β€” reference a named value β€” rather than a reason to restrict the plan.


Azure Prerequisites

  • An existing API Management service.
  • Knowledge of the service's current global policy. Creating this resource replaces it without warning, so a service managed by hand or by another pipeline must have its policy exported and reconciled into the configuration first.
  • Any named value or policy fragment the document references must already exist on the same service. Nothing validates either β€” a missing one fails at request time.
  • For xml_link, a URL Azure can reach anonymously.
  • The caller configures provider "azurerm" { features {} }, authentication and subscription.

πŸ“ Module Structure

terraform-azurerm-api-management-policy/
β”œβ”€β”€ providers.tf    # required_version + pinned azurerm; no provider block
β”œβ”€β”€ variables.tf    # deeply-typed inputs; the unambiguous document mistakes rejected at plan time
β”œβ”€β”€ main.tf         # the single keystone resource
β”œβ”€β”€ outputs.tf      # id first (with its caveat), then the fingerprint, then the silent behaviours
β”œβ”€β”€ README.md       # this document
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore      # the canonical library ignore set

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "apim_global_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-policy.git?ref=v1.0.0"

  api_management_id = module.apim.id
  xml_content       = file("${path.module}/policies/global.xml")
}

ℹ️ The caller configures the provider, including the mandatory features {} block. This module declares none.

⚠️ This replaces the service's existing global policy on the first apply. Export the current one first if the service was not created by this configuration.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
api_management_id string terraform-azurerm-api-management output id
xml_content string a local .xml file read with file()
xml_link string a publicly reachable URL, hosted outside this library
fragment names inside xml_content string terraform-azurerm-api-management-policy-fragment output include_fragment_snippet
named-value tokens inside xml_content string terraform-azurerm-api-management-named-value β€” referenced by name in the XML, not wired

Emits

Output Consumed by
policy_fingerprint cross-environment comparison and out-of-band change detection
policy_matches_configuration drift review
a_narrower_policy_without_base_bypasses_this_one control review β€” the reason a global policy is a default, not a floor
azure_policy_can_enforce_the_base_element terraform-azurerm-subscription-policy-assignment, which can assign the built-in definition

πŸ“š Example Library

1 Β· Minimal global policy
module "global_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-policy.git?ref=v1.0.0"

  api_management_id = module.apim.id
  xml_content       = file("${path.module}/policies/global.xml")
}

πŸ’‘ Read the document from a file rather than a heredoc. Terraform's own interpolation syntax collides with policy expressions, and file() interpolates nothing at all.

2 Β· The document itself
<policies>
  <inbound>
    <ip-filter action="allow">
      <address-range from="10.0.0.0" to="10.255.255.255" />
    </ip-filter>
  </inbound>
  <backend>
    <forward-request />
  </backend>
  <outbound />
  <on-error />
</policies>

ℹ️ A global policy is a full document with the <policies> root and its four sections β€” unlike a policy fragment, which carries bare statements. Note the absence of <base />: at the global scope it does nothing, because there is no parent scope to inherit from.

⚠️ <forward-request /> is what Azure puts in the global backend section by default. A document that omits it changes how every request reaches its backend.

3 Β· Including a policy fragment
module "cors_fragment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-policy-fragment.git?ref=v1.0.0"

  name              = "cors-standard"
  api_management_id = module.apim.id
  value             = file("${path.module}/fragments/cors-standard.xml")
}

module "global_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-policy.git?ref=v1.0.0"

  api_management_id = module.apim.id

  xml_content = <<-XML
    <policies>
      <inbound>
        ${module.cors_fragment.include_fragment_snippet}
      </inbound>
      <backend><forward-request /></backend>
      <outbound />
      <on-error />
    </policies>
  XML
}

πŸ’‘ Interpolating include_fragment_snippet is the one way to make the fragment a genuine Terraform dependency β€” otherwise the include is a bare string and nothing orders the two.

4 Β· Referencing a named value instead of a literal secret
module "global_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-policy.git?ref=v1.0.0"

  api_management_id = module.apim.id

  # The XML references {{backend-api-key}} rather than carrying the key.
  xml_content = file("${path.module}/policies/global.xml")

  depends_on = [module.backend_key]
}

πŸ”’ Policy XML is stored in Terraform state in plaintext, and marking a value sensitive would redact plan output without encrypting state. Reference a named value β€” ideally Key Vault-backed β€” and keep the secret out of the document. references_a_named_value reports whether a document does this.

5 Β· Fetching the document from a URL
module "global_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-policy.git?ref=v1.0.0"

  api_management_id = module.apim.id
  xml_link          = "https://policies.contoso.com/apim/global.xml"
}

⚠️ This is a fetch-once instruction, not a live link. Azure downloads the document at apply time and stores its content; changing the file afterwards changes nothing until Terraform applies again, and no plan will propose that apply.

πŸ”’ The URL must be reachable by Azure anonymously, which means the document β€” and whatever backend topology it reveals β€” is public. Prefer xml_content with the document held alongside the Terraform configuration.

6 Β· Adopting a service whose policy already exists
# Export the current global policy BEFORE the first apply. This resource has no
# import-exists check: applying without this step replaces it silently.
az apim api policy show --resource-group rg-platform-apim \
  --service-name apim-platform-prod --api-id '*' 2>/dev/null

terraform import 'module.global_policy.azurerm_api_management_policy.this' \
  "/subscriptions/$SUB/resourceGroups/rg-platform-apim/providers/Microsoft.ApiManagement/service/apim-platform-prod"

⚠️ Note the import ID: it is the service's Resource ID, with no policy segment. That is not a shorthand β€” it is genuinely this resource's identifier.

⚠️ creating_this_overwrites_any_existing_global_policy states the risk as a constant so it survives in every instance's output.

7 Β· Detecting an out-of-band change
output "global_policy_matches_terraform" {
  value = module.global_policy.policy_matches_configuration
}

output "global_policy_fingerprint" {
  value = module.global_policy.policy_fingerprint
}

πŸ’‘ policy_matches_configuration compares Azure's document against the configuration the way the provider does β€” whitespace stripped and entities unescaped β€” so it reports a substantive change rather than a reformatting. The fingerprint is what to compare across environments.

8 Β· Comparing the policy across environments
output "policy_fingerprints_by_environment" {
  value = { for k, m in module.global_policies : k => m.policy_fingerprint }
}
policy_fingerprints_by_environment = {
  "dev"  = "b1e4...c92a"
  "prod" = "b1e4...c92a"
  "uat"  = "7f30...11de"   # <- UAT has drifted
}

πŸ’‘ The document itself is never emitted, so this comparison leaks nothing β€” which is what makes it safe to publish as a CI output.

9 Β· Reviewing document shape across an estate
output "policies_missing_a_policies_root" {
  value = [for k, m in module.global_policies : k if m.has_policies_root == false]
}

output "policies_with_an_inert_base_element" {
  value = [for k, m in module.global_policies : k if m.contains_an_inert_base_element == true]
}

output "policies_not_forwarding_requests" {
  value = [for k, m in module.global_policies : k if m.declares_forward_request_in_backend == false]
}

⚠️ All three read null on a xml_link policy, because the document is not visible from the configuration β€” hence the explicit == false and == true comparisons rather than a bare truthiness test.

10 Β· Enforcing that narrower scopes cannot bypass this policy
module "enforce_base_element" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subscription-policy-assignment.git?ref=v1.0.0"

  name                 = "apim-policies-must-inherit-base"
  subscription_id      = data.azurerm_subscription.current.id
  policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/d5448c98-e503-4fdd-bcd2-784960c00d04"
  display_name         = "API Management policies should inherit parent scope policies using <base />"
}

πŸ”’ This is the only mechanism that makes a global policy a floor rather than a default. Microsoft describes omitting <base /> as leading to "bypassing shared rules such as authentication, logging, rate limits, and other critical controls", and ships the built-in definition above with Audit, Deny and Disabled effects.

11 Β· Custom timeouts
module "global_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-policy.git?ref=v1.0.0"

  api_management_id = module.apim.id
  xml_content       = file("${path.module}/policies/global.xml")

  timeouts = {
    create = "45m"
    read   = "10m"
    update = "45m"
    delete = "45m"
  }
}

⚠️ A key that is not create, read, update or delete is silently discarded by Terraform's type conversion β€” no error, and no timeout. Note that the write here is fast: Microsoft's guidance for the global scope is that saving propagates to the gateway immediately. The risk is not duration; it is that the change takes effect on every API at once.

12 Β· for_each across several services
variable "api_management_ids" {
  description = "Service Resource IDs, keyed by environment."
  type        = map(string)
}

module "global_policies" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-policy.git?ref=v1.0.0"
  for_each = var.api_management_ids

  api_management_id = each.value
  xml_content       = file("${path.module}/policies/global.xml")
}

πŸ’‘ One document, several services. Because api_management_id is force-new and a destroy reverts a service to its default policy, removing an entry from this map is a control change on that service β€” not a bookkeeping change.

13 Β· πŸ—οΈ 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-platform-apim"
  location = "eastus2"
}

module "apim" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management.git?ref=v1.0.0"

  name                = "apim-platform-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  publisher_name      = "Platform Engineering"
  publisher_email     = "platform-engineering@example.com"
  sku_name            = "Developer_1"
}

module "backend_key" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-named-value.git?ref=v1.0.0"

  name                = "backend-api-key"
  resource_group_name = module.rg.name
  api_management_name = module.apim.name
  display_name        = "backend-api-key"
  secret              = true
  value               = var.backend_api_key
}

module "cors_fragment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-policy-fragment.git?ref=v1.0.0"

  name              = "cors-standard"
  api_management_id = module.apim.id
  value             = file("${path.module}/fragments/cors-standard.xml")
  description       = "Standard CORS. Included by the global policy."
}

module "global_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-api-management-policy.git?ref=v1.0.0"

  api_management_id = module.apim.id

  xml_content = <<-XML
    <policies>
      <inbound>
        ${module.cors_fragment.include_fragment_snippet}
        <set-header name="X-Backend-Key" exists-action="override">
          <value>{{backend-api-key}}</value>
        </set-header>
      </inbound>
      <backend><forward-request /></backend>
      <outbound />
      <on-error />
    </policies>
  XML

  depends_on = [module.backend_key]
}

# Without this, any API can step out of the global policy by deleting <base />.
module "enforce_base_element" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subscription-policy-assignment.git?ref=v1.0.0"

  name                 = "apim-policies-must-inherit-base"
  subscription_id      = data.azurerm_subscription.current.id
  policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/d5448c98-e503-4fdd-bcd2-784960c00d04"
  display_name         = "API Management policies should inherit parent scope policies using <base />"
}

πŸ”’ The backend key never appears in the policy document β€” it lives in a named value marked secret and is referenced by token, which keeps it out of the document, out of state, and out of every plan that refreshes this resource.

⚠️ depends_on is needed because the named-value reference is a string inside XML, not a Terraform reference. The fragment include, by contrast, gets its ordering for free from the interpolation.

⚠️ The policy assignment is not decoration. Without it, the controls in this document are a default that any API team can decline.


πŸ“₯ Inputs

Group Variables
Identity (force-new) api_management_id
The document (exactly one) xml_content, xml_link
Universal tail timeouts
Full input schemas
api_management_id = string   # force-new; the SERVICE id -- and also this resource's own id

# Exactly one of the two. Neither is force-new.
xml_content = optional(string)   # the full <policies> document, inline
xml_link    = optional(string)   # a publicly reachable URL; fetched once, never read back

timeouts = optional(object({ create, read, update, delete }))

See variables.tf for the full descriptions and every validation.


🧾 Outputs

Output Description Notes
id Resource ID of the global policy Equals the service's own ID
api_management_id Resource ID of the parent service Same string as id
api_management_name Name of the parent service Derived from the ID
resource_group_name Resource group holding the parent service Derived from the ID
policy_source xml_content or xml_link Derived
policy_fingerprint SHA-256 of the document as Azure holds it The document is not emitted
policy_length_in_characters Length of the document as Azure holds it Derived
policy_matches_configuration True when Azure's document matches the configuration Null on the xml_link path
xml_link The configured URL Azure never reads it back
xml_link_uses_plaintext_http True when fetched over HTTP Derived
has_policies_root True when the document carries a <policies> root Null on the xml_link path
contains_an_inert_base_element True when <base /> appears, where it does nothing Null on the xml_link path
declares_forward_request_in_backend True when the document forwards requests Null on the xml_link path
references_a_named_value True when the document uses a {{...}} token Null on the xml_link path
creating_this_overwrites_any_existing_global_policy Constant true The adoption risk
id_is_the_api_management_service_id Constant true
this_record_is_a_singleton_per_service Constant true
a_narrower_policy_without_base_bypasses_this_one Constant true The control risk
azure_policy_can_enforce_the_base_element Constant true The remedy
xml_link_is_fetched_once_and_never_read_back Constant true
whitespace_and_entity_changes_never_produce_a_diff Constant true Cuts both ways
policy_xml_is_stored_in_state_in_plaintext Constant true
destroying_this_reverts_the_service_to_its_default_policy Constant true
force_new_fields The single force-new field
fields_azure_returns_on_read The only fields in which drift can be detected
this_resource_supports_no_azure_resource_tags Constant true

No policy content is emitted β€” only a fingerprint, a length and a match flag. This follows the convention the api-management-api-operation module already sets for policy XML.


🧠 Architecture Notes

The first apply is the dangerous one. Every other resource in this family refuses to adopt an existing object and tells you to import it. This one replaces it, and the provider's own source comment explains that it has no choice: the API always returns a policy β€” a default one when nobody has set one β€” so "unset" and "set by someone else" are indistinguishable. Export the current global policy before adopting a service you did not create.

The Resource ID is not an identifier for this record. It is the API Management service's ID, byte for byte, because there is one global policy per service and it has no identity of its own. Using it as a policy identifier, or as a lock scope intended for the policy, silently addresses the whole service.

A global policy is a default, not a floor. Microsoft: "If you remove the base element at the API scope, only policies configured at the API scope will be applied. Policies configured at product and broader scopes won't be applied." So an API team can step out of global authentication, logging and rate limiting by deleting one element β€” no failure, no warning, and nothing here reporting which scopes have done so. The built-in Azure Policy definition that audits or denies a missing <base /> is the only mechanism that closes it.

<base /> in this document is inert. Microsoft again: "A globally scoped policy has no parent scope, and using the base element in it has no effect." Harmless, and worth surfacing, because its presence suggests inheritance that is not happening.

A linked document is fetched once. Azure downloads it at apply time and stores the content; the link is never read back, and no plan depends on the file's contents. Drift between the URL and the service is invisible in both directions, which makes xml_link a poor fit for anything that changes.

The diff suppression is generous in both directions. It parses both sides as XML, then falls back to a string comparison with all whitespace stripped and the five XML entities unescaped. Reformatting produces no plan, which is convenient. A change purely in escaping, or in literal whitespace inside a value, is equally invisible β€” which is not.

The content stays out of the outputs. Policy documents carry backend URLs, subscription keys and authorization headers. This module emits a fingerprint, a length and a match flag, which support every comparison a consumer needs without copying the document into another configuration's state.


🧱 Design Principles

Concern Default in this module Opt-out
Adoption safety none available β€” the provider has no import-exists check, so the module states the risk as an output and documents the export-first workflow instead none
Document source not defaulted; every example uses xml_content, and xml_link carries an explicit public-exposure warning use xml_link
Link scheme not defaulted; xml_link_uses_plaintext_http reports HTTP rather than refusing it none
Document shape a missing <policies> root, an inert <base /> and a missing <forward-request /> are reported through outputs, not refused, because the provider validates nothing and a validation failure blocks destroy none
Content emission no policy content is emitted β€” fingerprint, length and match flag only none
Secrets not withheld by marking sensitive, which would redact plan output without encrypting state; the module says plainly that credentials belong in a named value none
Tagging no tags variable β€” the resource exposes none tag the parent API Management service

πŸš€ Runbook

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

Pin the module by immutable tag β€” ?ref=v1.0.0 β€” never a branch. This module is plan-only from the library's point of view: a human applies from CI against a reviewed plan. Treat the first apply against an existing service as a control change, not a bookkeeping one.


πŸ§ͺ Testing

terraform validate covers the service-ID shape (including an ID that addresses a narrower scope), the exactly-one-of rule, and the document checks β€” non-empty, contains XML, is not plainly a file path β€” plus the link checks: it must be an http/https URL, and must not be a policy document pasted into the wrong field.

A variables-only terraform plan fires the same validations against a .tfvars file and is also the cheapest way to confirm the derived flags. Drive both directions, and note the third case this resource needs: an xml_link fixture, where every content-derived output must read null rather than false. Terraform skips a validation whose referenced variable has already failed, so a short error list is not proof a check is missing.

What only a real plan or apply reaches: whether the service exists, and whether Azure accepts the XML. What nothing reaches, at any stage: what the service's global policy was before this configuration replaced it, whether any narrower scope has dropped <base />, and whether a referenced named value or fragment exists.


πŸ’¬ Example Output

id                                                  = "/subscriptions/.../providers/Microsoft.ApiManagement/service/apim-platform-prod"
api_management_id                                   = "/subscriptions/.../providers/Microsoft.ApiManagement/service/apim-platform-prod"
api_management_name                                 = "apim-platform-prod"
resource_group_name                                 = "rg-platform-apim"
policy_source                                       = "xml_content"
policy_fingerprint                                  = "b1e4c0a97f2d5e8143aa6c0b7e91d2f4c3358a10bb47e6d9f0c2a5b83e71d94c"
policy_length_in_characters                         = 412
policy_matches_configuration                        = true
xml_link                                            = null
xml_link_uses_plaintext_http                        = false
has_policies_root                                   = true
contains_an_inert_base_element                      = false
declares_forward_request_in_backend                 = true
references_a_named_value                            = true
force_new_fields                                    = [
  "api_management_id",
]
creating_this_overwrites_any_existing_global_policy = true
id_is_the_api_management_service_id                 = true
a_narrower_policy_without_base_bypasses_this_one    = true

πŸ” Troubleshooting

Symptom Cause Fix
The service's existing global policy vanished on the first apply This resource has no import-exists check; it replaces whatever was there Export the policy before adopting a service. creating_this_overwrites_any_existing_global_policy states it
A control in the global policy is not applying to one API That API's policy omits <base />, which drops every broader scope Add <base /> to the API policy, and assign the built-in Azure Policy definition to catch the next one
<base /> in the global policy appears to do nothing It genuinely does nothing β€” a globally scoped policy has no parent Remove it. contains_an_inert_base_element reports it
Requests stopped reaching the backend after a policy change The new document's <backend> section omits <forward-request />, which Azure puts there by default at the global scope Restore it, or make the alternative deliberate. declares_forward_request_in_backend reports it
The file at xml_link changed and nothing happened The link is fetched once; Azure stores the content, and no plan depends on the file Apply again, or move the document to xml_content where Terraform can see it change
xml_link shows as null in state after an apply The provider never reads it back β€” Azure stores the downloaded content, not the link Expected. The module emits the configured value so it is recorded somewhere
Using id as a lock scope locked the whole service This resource's ID is the service's ID Use it knowingly, or scope the lock deliberately
A reformatted policy file produces no plan The diff suppression strips all whitespace and unescapes entities Expected. Note it also hides a genuine whitespace or escaping change
Apply fails with either `xml_content` or `xml_link` must be set Neither was supplied; the provider raises this in the create function, so the plan looked clean Supply exactly one. This module refuses the same case at plan time, so seeing this means the call bypassed the module
A {{named-value}} in the policy fails at request time The named value does not exist on the service; nothing validates the reference Create it with the named-value module and order it with depends_on

πŸ”— Related Docs


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