Skip to content
Kumar Chandrachooda
Azure & DevOps

APIM API Provisioning: Governance as Code, One for_each at a Time

The catalog's finale: three child modules fanned out over locals maps turn AAD groups, APIM products and OpenAPI-imported APIs into reviewable code, map keys double as URL routing taxonomy, and policy XML gets environment-templated by a blunt string replace - plus a retrospective on what fifteen modules taught. Part 15 of the Terraform Module Catalog.

By Kumar Chandrachooda 13 May 2026 6 min read
Groups, then products, then APIs - governance in dependency order

Every module so far in this catalog provisions infrastructure — compute, storage, edges, names. The last one provisions rules. API Management is where an estate decides who may call what, how often, with which credentials, through which URL — and in most organizations those decisions live in a portal, edited by hand, unreviewable and undiffable. This template's premise is that governance is just configuration, and configuration should be code.

This is part 15 — the final part — of the Terraform Module Catalog, companion to the main Terraform on Azure series, whose chassis (workspace maps, platform JSON, pinned modules) every template here rides on. Fittingly for a finale, this template is the one that composes the most: three child modules, fanned out with for_each, chained with explicit depends_on, feeding on a folder tree of XML and OpenAPI files.

Three maps, three modules, one dependency chain

APIM's permission model is layered: AAD groups grant humans visibility, products bundle APIs behind subscription keys and approval workflows, and APIs expose actual operations. The template mirrors that layering as three locals maps and three module calls (fresh illustrative code, genericized throughout):

aad_groups_map = {
  api-consumers = {
    aad_group_obj_id = local.json_data.dev_platform_consumers_group_id
    aad_group_name   = "platform-api-consumers"
  }
}

product_map = {
  OrdersDev = {
    product_name        = "OrdersDev"
    approval_required   = false
    published           = true
    subscriptions_limit = "2"
    aad_group_obj_id    = local.json_data.dev_platform_admin_group_id
    aad_group_name      = local.json_data.dev_platform_admin_group_name
    product_policy      = "./product-policy/orders-dev-policy.xml"
  }
}

openapi_file_map = {
  "commercial/api/dev/orders" = {
    spec          = "./api-spec/exp-orders.json"
    products_list = ["OrdersDev"]
    revision      = "1"
    policy        = "./api-policy/orders-api-dev-policy.xml"
  }
}
module "product_provision" {
  source   = "../../../infra-repo/product-provision"
  for_each = local.product_map

  api_management_name      = local.api_management_name
  resource_group_name      = local.resource_group_name
  product_name             = each.value.product_name
  approval_required        = each.value.approval_required
  published                = each.value.published
  subscriptions_limit      = each.value.subscriptions_limit
  product_policy_file_path = each.value.product_policy
}

module "api_provision" {
  source   = "../../../infra-repo/api-provision"
  for_each = local.openapi_file_map

  api_management_name = local.api_management_name
  resource_group_name = local.resource_group_name
  openapi_file_path   = each.value.spec
  policy_file_path    = each.value.policy
  products_list       = each.value.products_list
  revision            = each.value.revision
  path                = each.key          # the map key IS the URL path

  depends_on = [module.product_provision]
}

Two things carry the design. First, module-level for_each (this library adopted Terraform 0.13 partly for it): adding an API to the platform is adding one map entry, and the plan shows exactly one subtree of new resources. Onboarding an API becomes a pull request a governance reviewer can actually read — a product's approval flag, its subscription limit, its policy file, all in ten lines of diff.

Second, the explicit depends_on. Terraform cannot infer that an API's product binding needs the product to exist, because the linkage travels through APIM's control plane as strings, not through resource references. The template pins the order — groups, then products, then APIs — turning APIM's implicit object model into an explicit, enforced provisioning sequence. It is a blunt instrument (every API waits for every product), but at this scale bluntness reads as clarity.

The deployed stacks built from this template add a fourth link: a small glue module applying per-operation policy XML — one map entry per operation (GetQuote, Purchase, CancelPolicy), each pointing at its own XML file, depends_on the API import. Group, product, API, operation: the whole governance hierarchy, in dependency order, in code.

The map key is a URL: taxonomy as routing

The subtlest decision in the template is easy to miss: path = each.key. The key of the openapi_file_map is not a label — it is the API's URL path segment under the central APIM instance's gateway. And in the deployed estate, those keys followed a strict grammar:

<business-line>/api/<env>/<product>     # dev, qa, integration
<business-line>/api/<product>           # staging and prod: the clean path

So commercial/api/dev/orders and commercial/api/orders are the same API at two lifecycle stages. The taxonomy earns its keep three ways. Consumers can read an endpoint and know its owner and maturity. Pre-production environments coexist on a shared APIM instance without collision, because the environment lives in the path. And promotion to production is, from the consumer's perspective, dropping one path segment — the URL a partner integrates against contains no environment token that would have to survive go-live.

The cost is the same one the naming article in the main series charges everywhere: the grammar is convention, not schema. Nothing validates that a key matches the pattern, and a fat-fingered key is not a broken name — it is a live, wrongly-routed URL. When a map key doubles as an interface, typos ship.

Policy XML: a folder tree and a blunt replace

APIM policies — rate limits, header injection, backend routing, URL rewrites — are XML documents, and the template treats them as first-class files. The library ships samples (a pass-through base policy, a rate-limit-by-key example keyed on subscription ID); deployed stacks grow a full tree:

apim-orders/
├── api-spec/           exp-orders.json          # the OpenAPI contract
├── api-policy/         orders-api-dev-policy.xml, orders-api-qa-policy.xml, ...
├── product-policy/     orders-dev-policy.xml, ...
└── operation-policy/   purchase-policy.xml, get-quote-policy.xml

Governance-as-files has real virtues: policies are diffed in review like any code, the plan shows a policy change as a resource update, and an auditor can reconstruct the platform's rules from Git history alone.

Then comes the environment problem, and the estate's solution deserves both credit and critique. Operation policies must point at environment-specific backends — azfun-orders-dev-purchase in dev, azfun-orders-prod-purchase in prod. Rather than six copies of every operation policy, the glue module templated one file per operation like this:

resource "azurerm_api_management_api_operation_policy" "this" {
  # ...
  xml_content = replace(
    file(var.operation_policy_file_path),
    "environment",                    # every occurrence of this substring...
    var.environment_name              # ...becomes "dev", "staging", "prod"
  )
}

The XML on disk carries the literal word environment inside its backend IDs — backend-id="azfun-orders-environment-purchase" — and replace() swaps it at plan time. Crude? Thoroughly. replace() substitutes every occurrence of the substring, anywhere: an XML comment saying “test in each environment first”, an error message, a backend genuinely named environments-api — all silently rewritten. The token has no sigil marking it as a placeholder, so the file reads as valid XML that happens to contain a trap word, and nothing fails when the token is absent — a policy missing its placeholder deploys pointing at a literal environment backend. templatefile() with an explicit ${env} marker would have cost the same one line and made intent, absence and escaping all explicit.

And yet — credit where due — the crude version shipped, worked, and kept six environments' policies to one reviewable file per operation. Meanwhile the API- and product-level policies took the other path: six hand-maintained near-identical XML files per policy, which is the drift generator you would predict. One stack, both strategies, and the templated one aged better. Honest defects, as ever, anonymized: the glue module's folder and locals carry a frozen typo (opertaion) that every caller must now spell identically forever, and the deployed AAD-groups map was empty except for a comment — “already added by other APIM code” — cross-stack ownership resolved by folklore instead of by code.

What fifteen modules taught

That closes the catalog, so let me close the series. Walking one template per part, a few convictions hardened. The chassis holds: one set of conventions — workspace maps, platform JSON, pinned module tags, boring providers — carried services as different as AKS and a DNS zone, and every template that deviated from it (Front Door's private provider, Private DNS's missing everything) paid for the deviation. Rich surfaces want to be object lists, and names-as-foreign-keys is an honest price for mirroring how Azure actually joins things. Placeholders are a safety system, not filler — the difference between 10.999.24.5 and UPDATE is the difference between a template that cannot ship unfinished and one that must. Documentation is the interface when your inputs live in locals; the DNS Zone README proved it can be done and the empty terraform-docs stubs proved it will not happen by tooling. And above all: a golden template is a release mechanism with no recall. Its virtues and its typos propagate with equal fidelity, so the highest-leverage engineering hours in a platform team are the ones spent finishing, linting and stranger-testing the sixteen folders everyone else will copy.

The main series' finale, What Two Hundred Copies Teach You, draws those threads into the estate-wide retrospective — the museum of versions, the frozen typos, the upgrade list. The catalog was the evidence; that is the verdict. Thanks for walking the library with me.