AI Citadel Governance Hub - APIM Policy Architecture — Terraform
Scope: How every API Management policy, fragment, and named value in this repo is modeled, wired up, and applied at runtime. Covers the core inference APIs (
universal-llm-api,azure-openai-api), the wildcardunified-ai-api, operation-level policies, product-level policies (including thecitadel-access-contractsadd-on), and the MCP sample APIs.Related files: modules/apim/policy-fragments.tf · modules/apim/main.tf · modules/apim/extra-apis.tf · modules/apim/named-values-extras.tf · modules/apim/backends.tf · modules/access-contracts/main.tf · citadel-access-contracts/main.tf · llm-backend-onboarding/main.tf
The two standalone root modules
citadel-access-contracts/andllm-backend-onboarding/apply the same product/fragment/named-value model against an already-deployed APIM (viadatasources) for day-2 onboarding. See §9.
1. Mental model
APIM policies are XML documents that run at one of four scopes. Each scope maps 1:1 to a distinct Terraform resource type:
| Scope | Terraform resource | What it controls |
|---|---|---|
| Global | azurerm_api_management_policy |
All APIs across the service. Not currently used in this port. |
| Product | azurerm_api_management_product_policy |
All APIs attached to a given product. Used for access contracts + unified-ai. |
| API | azurerm_api_management_api_policy |
One API's inbound/backend/outbound/on-error pipeline. Used by every inference API. |
| Operation | azurerm_api_management_api_operation_policy |
A single operation inside an API. Used for deployments + deployment-by-name. |
Policy fragments are separate, reusable chunks that live alongside those
four scopes. A fragment is a standalone APIM resource
(azurerm_api_management_policy_fragment) that is uploaded once and
then referenced from any policy document with:
<include-fragment fragment-id="security-handler" />
APIM validates every <include-fragment> tag and every {{named-value}}
reference at the moment the policy is saved, so the fragments + named values
must exist before any policy that references them. Terraform enforces
this via depends_on.
2. How .tf files become a module
Terraform automatically loads every .tf file in a module directory and
merges them into one configuration — there is no include or import
directive, and file names are purely organizational. For the APIM module,
these files are all parsed together as one unit:
modules/apim/
main.tf # APIM service, loggers, diagnostic, named values, universal-llm + azure-openai APIs
policy-fragments.tf # ~25 fragments (static + dynamic)
backends.tf # LLM backends, pools, content safety, AI search, embeddings
extra-apis.tf # unified-ai, ai-search, doc-intel, ai-model-inference, openai-realtime, weather, MCP
named-values-extras.tf # JWT-* named values, PII key, operation policies
api-center-onboarding.tf # API Center registration (optional)
foundry-subscription.tf # Dedicated APIM subscription for Foundry (optional)
The resources in policy-fragments.tf plug into the dependency graph via:
- Shared
local.*blocks — e.g.local.all_poolsdefined in backends.tf is consumed bylocal.backend_pools_codein policy-fragments.tf. - Direct resource references — e.g.
azurerm_api_management.citadel.idin the fragment resource'sapi_management_id. - Explicit
depends_on— everyazurerm_api_management_api_policythat uses<include-fragment>lists the fragment resources in itsdepends_onso APIM's server-side validation finds them.
3. Fragments (the reusable building blocks)
All fragments are declared in
modules/apim/policy-fragments.tf and
their XML bodies live in modules/apim/policies/
prefixed with frag-. There are three kinds.
3.1 Static fragments (unconditional)
Mirrored from Bicep's policy-fragments.bicep. Declared as a map and
created with for_each:
resource "azurerm_api_management_policy_fragment" "static" {
for_each = local.all_static_fragments
api_management_id = azurerm_api_management.citadel.id
name = each.key
format = "rawxml"
description = each.value.description
value = file("${path.module}/policies/${each.value.file}")
depends_on = [ /* all named values */ ]
}
The map local.all_static_fragments = merge(local.static_fragments, local.unified_ai_fragments):
| Sub-map | Always on? | Fragment IDs |
|---|---|---|
static_fragments |
Yes | set-backend-authorization, set-target-backend-pool, set-llm-usage, set-llm-requested-model, validate-model-access, ai-usage, raise-throttling-events, throttling-events, security-handler, entra-auth, aad-auth, aad-auth-custom, ai-foundry-deployments, llm-usage, openai-usage, openai-usage-streaming, ai-foundry-compatibility, set-response-headers, responses-id-security, responses-id-cache-store, strip-backend-headers |
unified_ai_fragments |
var.enable_unified_ai_api = true |
central-cache-manager, request-processor, path-builder |
PII fragments are no longer part of this
for_eachmerge. Thepii-anonymization/pii-deanonymization/pii-state-savingfragments (gated onvar.enable_pii_anonymization) are now managed byazapi_resource.pii_fragment(a direct idempotent PUT) because theazurermfragment resource hit an LRO polling bug (404 PolicyFragment not found duringCreateOrUpdate).scripts/migrate-pii-fragments.shdrops the oldazurermstate entries without destroying the fragments.
responses-id-security/responses-id-cache-storeenforce per-subscription ownership of Responses API objects;strip-backend-headersremoves browser / App Service (ARR) /X-Forwarded-*headers before the request is forwarded to an AI backend.
3.2 Dynamic fragments (computed from var.llm_backend_config + var.model_aliases)
Mirrored from Bicep's llm-policy-fragments.bicep. Each one takes a
placeholder-based XML template from
modules/apim/policies/ and injects generated code
into it at plan time via replace(). They are always created (even
when llm_backend_config is empty) because the core API policies reference
them unconditionally and APIM validates fragment IDs at policy-save time.
| Fragment | Source XML | Placeholder | Injected content |
|---|---|---|---|
set-backend-pools |
frag-set-backend-pools.xml |
//{backendPoolsCode} |
C# JObject literals for every pool in local.all_pools |
get-available-models |
frag-get-available-models.xml |
//{modelDeploymentsCode} |
C# JObject literals for every model across all backends plus an alias deployment entry per var.model_aliases |
metadata-config |
frag-metadata-config.xml |
//{modelsConfigCode} / //{modelAliasesCode} |
JSON mapping model → {pool, apiVersion, timeout} + alias-to-model mappings |
resolve-model-alias |
frag-resolve-model-alias.xml |
//{inlineAliasesCode} |
C# alias→underlying-model lookup generated from var.model_aliases (own resource azurerm_api_management_policy_fragment.resolve_model_alias, always created) |
The generators live in locals { … } blocks at the top of
policy-fragments.tf:
local.backend_pools_codeiterateslocal.all_pools(which is derived in backends.tf by groupingvar.llm_backend_configby supported model).local.model_deployments_codeiterates a flattened list of all models across all backends.local.metadata_models_codeproduces amodel → poollookup using the first backend that advertises support for each model.
Each replace(file(...), "//{placeholder}", local.generated) produces the
final XML string stored in local.set_backend_pools_xml,
local.get_available_models_xml, and local.metadata_config_xml, which is
what azurerm_api_management_policy_fragment.{set_backend_pools,
get_available_models, metadata_config} upload.
3.3 Named values (variables fragments consume)
Named values are APIM's {{key}} substitutions. They must exist before any
policy or fragment that references them. Declared in two files:
| Named value | File | Populated from |
|---|---|---|
uami-client-id |
main.tf | var.managed_identity_client_id |
piiServiceUrl |
main.tf | var.pii_service_endpoint (count-gated on PII) |
contentSafetyServiceUrl |
main.tf | var.content_safety_endpoint (count-gated on content safety) |
tenant-id, client-id, audience, entra-auth |
main.tf | var.entra_* with safe placeholder fallbacks |
JWT-TenantId, JWT-AppRegistrationId, JWT-Issuer, JWT-OpenIdConfigUrl |
named-values-extras.tf | var.jwt_* or not-configured |
aws-access-key, aws-secret-key, aws-region |
named-values-extras.tf | Non-secret NOT_CONFIGURED placeholders — always created so the set-backend-authorization fragment compiles even without an AWS Bedrock backend. lifecycle.ignore_changes lets llm-backend-onboarding own the real values (Key Vault references) |
backend_api_key (per-backend) |
llm-backend-onboarding/main.tf | One per backend whose auth_config.named_value_key is set; a secret Key Vault reference when key_vault_secret_uri is supplied, otherwise an explicit secret_value (testing only, non-compliant) or a non-secret NOT_CONFIGURED placeholder |
Secret named values are only ever created as Key Vault references (resolved
via the APIM user-assigned identity) to satisfy the Azure Policy API
Management secret named values should be stored in Azure Key Vault; the
sole exception is the testing-only backend_api_key secret_value path.
Every static-fragment resource declares depends_on on all named
values above so APIM can resolve {{…}} tokens at fragment-create time.
4. Policy scopes in this repo
4.1 API-level policies
Each API loads a full policy document and wires in behavior by
<include-fragment>ing the fragments above.
| API resource | Policy XML | Uses fragments |
|---|---|---|
azurerm_api_management_api_policy.universal_llm (main.tf) |
policies/universal-llm-api-policy-v2.xml | security-handler, set-llm-requested-model, validate-model-access, set-backend-pools, set-target-backend-pool, set-backend-authorization, set-llm-usage, ai-foundry-compatibility, set-response-headers, raise-throttling-events |
azurerm_api_management_api_policy.azure_openai (main.tf) |
policies/azure-open-ai-api-policy.xml | security-handler, set-llm-requested-model, validate-model-access, set-backend-pools, set-target-backend-pool, set-backend-authorization, set-llm-usage, set-response-headers, raise-throttling-events |
azurerm_api_management_api_policy.unified_ai (extra-apis.tf) |
policies/unified-ai-api-policy.xml | security-handler, central-cache-manager, request-processor, path-builder, set-backend-pools, set-target-backend-pool, set-backend-authorization, set-response-headers |
azurerm_api_management_api_policy.ai_search |
policies/ai-search-index-api-policy.xml | security-handler, ai-usage |
azurerm_api_management_api_policy.doc_intelligence_legacy + doc_intelligence |
policies/doc-intelligence-api-policy.xml | security-handler, ai-usage |
azurerm_api_management_api_policy.ai_model_inference |
policies/ai-model-inference-api-policy.xml | security-handler, ai-usage |
azapi_resource.openai_realtime_policy |
policies/openai-realtime-policy.xml | WebSocket auth only |
azurerm_api_management_api_policy.weather |
sample/weather/policy.xml | None (sample) |
azapi_resource.weather_mcp_policy + ms_learn_mcp_policy |
policies/mcp-default-policy.xml | MCP default auth |
Every one of these API-policy resources declares:
depends_on = [
azurerm_api_management_policy_fragment.static,
azurerm_api_management_policy_fragment.set_backend_pools,
azurerm_api_management_policy_fragment.get_available_models,
azurerm_api_management_policy_fragment.metadata_config,
]
so APIM finds every <include-fragment> target at validation time.
4.2 Operation-level policies
Declared in named-values-extras.tf.
| Operation | API | Policy XML |
|---|---|---|
deployments (GET /deployments) |
universal-llm-api |
policies/universal-llm-api-deployments-policy.xml |
deployment-by-name (GET /deployments/{id}) |
universal-llm-api |
policies/universal-llm-api-deployment-by-name-policy.xml |
openai-deployments (GET /deployments) |
azure-openai-api |
policies/universal-llm-api-deployments-policy.xml |
openai-deployment-by-name (GET /deployments/{id}/info) |
azure-openai-api |
policies/universal-llm-api-deployment-by-name-policy.xml |
deployments + deployment-by-name (unified-AI) |
unified-ai-api |
policies/unified-ai-api-deployments-policy.xml, policies/unified-ai-api-deployment-by-name-policy.xml |
All four deployments / deployment-by-name operation policies
<include-fragment fragment-id="get-available-models" />, which means they
are count-gated on length(var.llm_backend_config) > 0 — if no LLM
backends are configured, the dynamic fragment isn't created and these
operation policies aren't attached.
4.3 Product-level policies
| Product | Policy XML | Where |
|---|---|---|
default-ai-access |
None (inline policy not set here) | main.tf |
unified-ai-product |
policies/unified-ai-product-subscription.xml | extra-apis.tf |
| Per-use-case access-contract products | Per-service policy_xml, or citadel-access-contracts/policies/default-ai-product-policy.xml when blank |
citadel-access-contracts/main.tf (standalone) / modules/access-contracts/main.tf |
Access-contract product policies set context variables (e.g. the
allowedModels set-variable and enableResponseHeaders) and include the
set-llm-requested-model, validate-model-access, and set-response-headers
fragments to enforce per-product rules. See §9.1.
5. End-to-end request flow (Universal LLM API)
What happens at runtime when a client calls
POST /models/chat/completions:
1. APIM matches request → universal-llm-api (policy: universal-llm-api-policy-v2.xml)
2. inbound {
<base/> // inherits global + product policy
<include-fragment id="security-handler"/> // API-key + optional JWT
<include-fragment id="set-llm-requested-model"/> // reads body.model
<set-variable name="allowedBackendPools" .../> // per-instance RBAC
<set-variable name="defaultBackendPool" .../>
<include-fragment id="validate-model-access"/> // enforces contract allowedModels
<include-fragment id="set-backend-pools"/> // DYNAMIC: injects pool defs from llm_backend_config
<include-fragment id="set-target-backend-pool"/> // picks pool for requested model
<include-fragment id="set-backend-authorization"/> // MI token, key, or OAuth
<include-fragment id="set-llm-usage"/> // captures token usage for EH
<include-fragment id="ai-foundry-compatibility"/> // CORS
}
3. backend {
<retry count="2" condition="..."> // retries 429 + 5xx (not pool-exhausted)
<forward-request buffer-request-body="true"/>
</retry>
}
4. outbound {
<base/>
<include-fragment id="set-response-headers"/> // UAIG-* diagnostic headers
}
5. on-error {
<base/>
<include-fragment id="raise-throttling-events"/> // pushes 429 metrics to Monitor
<include-fragment id="set-response-headers"/>
}
The two dynamic fragments (set-backend-pools, get-available-models) are
what make this pipeline generic — re-apply with a new llm_backend_config
and the routing logic updates without touching any XML.
6. Apply-time ordering (why depends_on matters)
Terraform's graph resolves to roughly this order inside the APIM module:
1. azurerm_api_management.citadel
2. Named values (uami, pii, content-safety, entra-*, JWT-*, aws-*)
3. azurerm_api_management_policy_fragment.static (for_each map)
azurerm_api_management_policy_fragment.set_backend_pools (dynamic)
azurerm_api_management_policy_fragment.get_available_models
azurerm_api_management_policy_fragment.metadata_config
azurerm_api_management_policy_fragment.resolve_model_alias
azapi_resource.pii_fragment (PII, when enabled)
4. azurerm_api_management_backend.* / azapi_resource.llm_backend / pool / content-safety / embeddings
5. azurerm_api_management_api.{universal_llm, azure_openai, unified_ai, ...}
azurerm_api_management_api_operation.* (chat-completions, deployments, deployment-by-name, ...)
6. azurerm_api_management_api_policy.* ← validates <include-fragment> + {{named-value}}
azurerm_api_management_api_operation_policy.* ← validates <include-fragment>
7. azurerm_api_management_product.*
azurerm_api_management_product_policy.*
azurerm_api_management_product_api.*
If you ever see a 400 at apply time like
"Policy reference is not resolved: The fragment 'xxx' cannot be found" or
"Named value 'yyy' is not defined", the fix is always to add the missing
entry to the depends_on of the policy resource — the graph doesn't know
about <include-fragment> text inside an XML file.
7. Feature-flag matrix
| Variable | Effect on fragments | Effect on policies |
|---|---|---|
enable_unified_ai_api |
Creates 4 unified-AI fragments (central-cache-manager, request-processor, path-builder, set-response-headers) |
Creates unified-AI API + its policy + 2 op policies + product + product policy |
enable_pii_anonymization |
Creates 3 PII fragments via azapi_resource.pii_fragment |
No direct policy; referenced from universal-llm + unified-ai |
enable_pii_redaction |
— | Creates piiServiceUrl named value + pii-usage-eventhub-logger (Language service auth uses the APIM managed identity) |
enable_content_safety |
— | Creates contentSafetyServiceUrl named value + content-safety backend |
enable_jwt_auth |
— | Populates 4 JWT-* named values (else placeholders) |
enable_azure_ai_search |
— | Creates azure-ai-search-index-api + its policy + ai_search backends |
enable_document_intelligence |
— | Creates two document intelligence APIs + policies |
enable_ai_model_inference |
— | Creates ai-model-inference-api + policy |
enable_openai_realtime |
— | Creates WebSocket API + policy via azapi |
is_mcp_sample_deployed |
— | Creates weather-api + weather-mcp + ms-learn-mcp + 3 policies |
length(var.llm_backend_config) > 0 |
Creates the dynamic fragments | Enables 4 operation policies on universal-llm / azure-openai |
length(var.model_aliases) > 0 |
Injects alias deployments into get-available-models / metadata-config and populates resolve-model-alias |
Aliases resolve to underlying models at request time |
Standalone citadel-access-contracts/ (var.services + var.use_case) |
— | Creates per-use-case products + product-API links + subscription + policy (+ optional KV secrets / Foundry connection) against existing APIM |
8. Where to change things
| Task | Edit this file |
|---|---|
| Add a new reusable policy snippet (available in all APIs) | Add XML to modules/apim/policies/ + add entry to local.static_fragments in policy-fragments.tf |
| Change which fragments an API uses | Edit the API's policy XML (e.g. universal-llm-api-policy-v2.xml); no Terraform changes needed |
| Add a new API with its own policy | Add azurerm_api_management_api + azurerm_api_management_api_policy in extra-apis.tf with depends_on on the fragments used |
| Add a new named value | Add resource in main.tf or named-values-extras.tf + append to the depends_on list of azurerm_api_management_policy_fragment.static |
| Add a new backend pool routing rule | Add to var.llm_backend_config — dynamic fragments regenerate automatically |
| Add a per-use-case access contract | Add a service entry to var.services (+ var.api_name_mapping) in the standalone citadel-access-contracts/ module (no XML edits unless you need custom per-service policy_xml) |
| Onboard an LLM backend to a live APIM | Add to var.llm_backend_config in the standalone llm-backend-onboarding/ module |
9. Standalone onboarding modules
Two root modules re-use the same policy model but target an already-running
APIM via data sources (no APIM creation). They are for day-2 onboarding and
are not wired from the core deployment.
9.1 Access contracts onboarding
citadel-access-contracts/main.tf onboards a
single use-case. Inputs are var.apim, var.use_case
({ business_unit, use_case_name, environment }), var.api_name_mapping
(service code → existing APIM API names), and var.services (list of
{ code, endpoint_secret_name, api_key_secret_name, policy_xml }). Per service
code it creates:
- An APIM product
<code>-<business_unit>-<use_case_name>-<environment>. - Product→API links from
var.api_name_mapping[code]. - A product policy (
policy_xml, elsepolicies/default-ai-product-policy.xml). - A subscription
<…>-SUB-01. - Optional Key Vault secrets for the endpoint + key (
var.use_target_key_vault; secret names lower-cased,_→-). - An optional Foundry connection (
var.use_target_foundry) viaazapi_resource.foundry_connection, typeMicrosoft.CognitiveServices/accounts/projects/connections@2026-03-01(authApiKey, metadata fromvar.foundry_config).
This replaces the older
enable_access_contracts+var.access_contractswiring.modules/access-contracts/is the equivalent in-graph module and uses the samevar.services/var.use_caseshape but is not wired from the root in the current deployment.
9.2 LLM backend onboarding
llm-backend-onboarding/main.tf registers LLM
backends + routing against an existing APIM (data.azurerm_api_management.citadel).
It creates:
azapi_resource.llm_backend(Microsoft.ApiManagement/service/backends@2024-06-01-preview) per backend, with circuit-breaker rules gated onvar.configure_circuit_breaker, and<model>-backend-pools for any model served by 2+ backends.- The 3 dynamic fragments (
set-backend-pools,get-available-models,metadata-config) plusresolve-model-alias, all withvar.model_aliasessupport. - A focused static-fragment set:
set-backend-authorization,set-target-backend-pool,set-llm-requested-model,set-llm-usage,validate-model-access,responses-id-security,responses-id-cache-store. - Named values
aws-access-key/aws-secret-key(Key Vault references viaaws_access_key_secret_uri/aws_secret_key_secret_uri, otherwise non-secretNOT_CONFIGUREDplaceholders) andaws-region(AWS Bedrock auth), and a per-backendbackend_api_keynamed value (Key Vault reference or explicit value) for each backend withauth_config.named_value_key.