Citadel Access Contracts - Use-case onboarding
Overview
Automate the onboarding of AI use cases to your APIM-based AI Gateway with a streamlined, infrastructure-as-code approach using Bicep parameter files (.bicepparam).
This package eliminates manual APIM configuration by providing:
- 📦 Automated Product Creation: Per-service APIM products with naming
<serviceCode>-<BU>-<UseCase>-<ENV> - 🧩 Mixed asset types: a single product can grant LLM + Tools (MCP) + Agents (A2A) together (prefix
MULTI-), with policies applied conditionally per asset type - 🔌 API Integration: Automatic API attachment to product with custom or default policies
- 🔑 Subscription Management: Auto-generated subscription with secure API keys
- 🔐 Flexible Secret Storage: Optional Azure Key Vault integration or direct credential output
- 🤖 Azure Microsoft Foundry Integration: Optional APIM connection creation for Foundry agents
- 📝 Declarative Configuration: Simple
.bicepparam&.xmlfiles for version control per use case - 🔐 JWT Authentication: Optional layered API Key + JWT Bearer token authentication per product
What Gets Created
| Resource | Naming Pattern | Description |
|---|---|---|
| APIM Product | {code}-{BU}-{UseCase}-{ENV} |
Product per service (e.g., LLM-Healthcare-PatientAssistant-DEV). code is the asset-type prefix: LLM / TOOL / AGENT for a single type, or MULTI when one product grants more than one type (e.g. MULTI-HR-ChatAgent-DEV) |
| APIM Subscription | {product}-SUB-01 |
Subscription with a single shared API key |
| Key Vault Secrets | {secretName} |
One shared key secret + one endpoint secret per asset (optional) |
| Foundry Connection | {prefix}-{code} |
APIM connection for Microsoft Foundry agents, wired to the LLM endpoint (optional) |
Key Features
✨ Simplified Parameters: No need for full resource IDs - just API names
🔄 Optional Key Vault: Choose between Key Vault storage or direct output
🤖 Optional Foundry Integration: Create APIM connections for AI agents
📋 Policy Templates: Pre-built policies for common use cases
🎯 Multi-Service Support: Onboard multiple AI services in one deployment
🔒 Secure by Default: Credentials stored in Key Vault or marked as secrets
📊 Production Ready: Designed for scale and aligned with DevOps practices
Business Continuity & Resiliency (optional)
A single access contract can optionally be mirrored across additional APIM gateways, Key Vaults, and Foundry instances so it keeps working if the primary instance or region becomes unavailable. The subscription key generated by the primary gateway is reused across all gateways, so client applications use the same api-key regardless of which gateway serves the request (ideal behind a global load balancer such as Azure Front Door or Traffic Manager).
- 🔁 Same key everywhere: primary gateway generates the key; additional gateways reuse it verbatim.
- 🌐 Optional global endpoint: point Key Vault/Foundry at a global load balancer URL (like Azure Traffic Manager endpoint).
- 🧩 Opt-in per instance: add secondary Key Vaults / Foundry projects with configurable endpoint source.
- 📦 Staged deployment: primary first → additional gateways → Key Vaults & Foundry.
✅ Fully backward compatible — all of these are optional parameters that default to empty. Existing access contracts continue to deploy and behave exactly as before with no changes.
👉 See the Business Continuity & Resiliency Guide for configuration, examples, deployment order, key rotation, and troubleshooting.
Governing Tools (MCP) & Agents (A2A) — mixed asset contracts
Assets published through the Publish Contract (Tools/MCP and Agents/A2A) are governed by an access contract exactly like LLM APIs. A single product can grant access to any mix of the three asset types; the product policy classifies each request and applies the right controls.
- Product prefix:
LLM/TOOL/AGENTfor a single asset type;MULTIwhen a contract grants two or more types (e.g.MULTI-HR-ChatAgent-DEV). Thecodefield on theservicesentry drives this — no bicep change needed. apiNameMapping[code]lists the union of granted APIs (LLM inference APIs + published Tool/Agent APIs).- Conditional policy:
TOOL/AGENT/MULTIproducts default topolicies/default-multi-product-policy.xml, which includes theset-asset-kindfragment and a<choose>that applies:- LLM → model RBAC (
validate-model-access) +llm-token-limit(TPM + quota) - Tool / Agent → request-based
rate-limit-by-key(calls/min) +quota-by-key(call quota) - Common (any type) → opt-in
llm-content-safety, alerting, etc.
- LLM → model RBAC (
- Asset-kind detection: APIM does not expose the API type to policy, so the product policy sets
contractToolApis/contractAgentApis(comma-separated API resource names) before includingset-asset-kind; anything unlisted defaults tollm. - One shared key, one endpoint per asset: the contract mints a single subscription key shared by every granted asset, and Key Vault publishes that key once plus one endpoint secret per asset (via
assetEndpointsorpublishAllAssetEndpoints). See Shared key, one endpoint secret per asset. - Foundry connects to the LLM endpoint of the contract (selected via
foundryApiName); pair it with Key Vault so the Tool/Agent endpoints are also available to the contract owner.
✅ Backward compatible —
LLMcontracts keep the existing LLM product policy untouched. See Access contracts policy snippets for the policy snippets.
Zero-downtime Key Rotation (optional)
Rotate a use case's APIM subscription key without any service interruption using a simple, deliberate 3-step flow — across the primary gateway and every mirrored gateway, Key Vault, and Foundry connection.
Each subscription has two keys (primary + secondary), both always valid. The contract hands consumers one active key and rotates the other:
- 🔀 Switch (
usePrimaryKey=false): push the secondary key everywhere while the primary stays valid — no downtime. - ✅ Confirm: use-case owners verify their apps are healthy on the new key.
- ♻️ Rotate (
usePrimaryKey=false+keyRotationEnabled=true): regenerate the now non-active key, invalidating the old value.
✅ Fully backward compatible — defaults (
usePrimaryKey=true,keyRotationEnabled=false) reproduce existing behavior exactly. ARM has no native "regenerate" action, so the non-active key is regenerated declaratively by assigning a fresh value, which also keeps keys in sync across all gateways.
👉 See the Key Rotation Guide for the full step-by-step procedure, secondary-key rotation, multi-gateway behavior, verification, and troubleshooting.
Deployment quick reference
For a condensed cheat-sheet of the folder conventions and deployment command, see the Quick Reference Guide.
below are the high-level steps to deploy the use case onboarding Bicep package before diving into the detailed documentation:
- Create a folder dedicated for use-cases contracts: a folder like
contractsin the citadel-access-contracts module to hold your use case specific files. The shippedbase-contracts/commonfolder holds the base default contract with shared Governance Hub coordinates (subscription, APIM name, and resource group) that you can customize and reuse. - Create a use-case contract folder: under the contracts folder, create a new folder for your use case following the pattern
<business-unit>/<use-case>(e.g.sales/assistant,hr/chat-agent). - Create an environment subfolder: under the use case folder, create a subfolder for each environment (e.g.
dev,test,prod). - Prepare Parameter File: Create new use case
.bicepparamfile (you can usemain.bicepparamas a base) under the environment folder. - Create/Customize APIM Policy: Use default or create a custom XML policy. For simplicity, policy file can be named
ai-product-policy.xmland placed in the same environment folder. - Deploy template with the prepared parameter file:
# This can be executed in CLI or through a DevOps pipeline az deployment sub create --name <use-case-contract-name> --location <location> --template-file main.bicep --parameters contracts/<business-unit>/<use-case>/<environment>/main.bicepparam
NOTE: Ensure that you are updating values according to your environment and folder structure.
Architecture Overview
Deployment Flow
flowchart TB
subgraph Input["📥 Inputs"]
P1[bicepparam file]
P5[Policy XML files]
end
subgraph Deploy["🚀 Deployment"]
D1[main.bicep]
D2[Create Products]
D3[Attach APIs to Products]
D4[Apply Policies]
D5[Create Subscriptions]
D6{Use Key Vault?}
D7[Store Secrets in KV]
D8[Output Credentials]
D9{Use Foundry?}
D10[Create Foundry Connections]
end
subgraph Output["📤 Outputs"]
O1[Products Created]
O2[Subscription Keys]
O3[KV Secret Names]
O4[Direct Credentials]
O5[Foundry Connections]
end
Input --> Deploy
D1 --> D2 --> D3 --> D4 --> D5 --> D6
D6 -->|Yes| D7 --> O3
D6 -->|No| D8 --> O4
D5 --> D9
D9 -->|Yes| D10 --> O5
D9 -->|No| O2
D2 --> O1
D5 --> O2
Runtime Request Flow
Below is a suggested flow for client applications (i.e. agents) interacting with the onboarded services via the Citadel Access Contracts:
sequenceDiagram
participant App as AI Agent/App
participant Entra as Microsoft Entra ID
participant KV as Azure Key Vault
participant Foundry as Microsoft Foundry
participant APIM as AI Gateway
participant AI as AI Services
alt Using Key Vault
App->>KV: Get endpoint + API key
KV-->>App: Return secrets
else Using Foundry Connection
App->>Foundry: Use APIM connection
Foundry->>Foundry: Get stored credentials
else Direct Credentials
Note over App: Use credentials from deployment output
end
opt JWT Authentication Enabled
App->>Entra: Request JWT token (client credentials)
Entra-->>App: Return Bearer token
end
App->>APIM: HTTPS request with api-key + optional Bearer token
APIM->>APIM: Validate subscription key (always required)
APIM->>APIM: Apply product policy (check jwtRequired)
opt JWT Required by Product
APIM->>APIM: Validate JWT via security-handler fragment
end
APIM->>AI: Forward to backend service
AI-->>APIM: Response
APIM->>APIM: Logs & metrics
APIM-->>App: Response with usage headers
Repository Structure
This is a submodule focused on Citadel Access Contracts. The folder structure is as follows:
citadel-access-contracts/
├── main.bicep # Main orchestration template
├── main.bicepparam # Base parameter file
├── modules/
│ ├── apimOnboardService.bicep # Product + subscription creation
│ ├── apimProduct.bicep # APIM product module
│ ├── apimSubscription.bicep # Subscription module
│ ├── kvSecrets.bicep # Key Vault secret storage
│ └── foundryConnection.bicep # Azure Microsoft Foundry connection module
├── policies/
│ └── default-ai-product-policy.xml # Default product policy
├── base-contracts/ # Base default contracts
│ └── common/ # Governance Hub coordinates (reusable base)
│ ├── main.bicepparam
│ └── ai-product-policy.xml
├── contracts/ # Use-case contracts folder for source control
│ └── <business-unit>/ # Business unit folder (e.g., sales)
│ └── <use-case>/ # Use case folder (e.g., assistant)
│ ├── dev/ # Environment subfolder
│ │ ├── main.bicepparam # Use case specific parameters
│ │ └── ai-product-policy.xml # Custom policy file
│ ├── test/ # Test environment
│ │ ├── main.bicepparam
│ │ └── ai-product-policy.xml
│ └── prod/ # Production environment
│ ├── main.bicepparam
│ └── ai-product-policy.xml
Parameter File Reference
Main Parameters (main.bicepparam)
| Parameter | Type | Required | Description | Example |
|---|---|---|---|---|
apim |
object | ✅ | APIM instance coordinates | { subscriptionId, resourceGroupName, name } |
keyVault |
object | ✅* | Key Vault for secrets (*required even if not used) | { subscriptionId, resourceGroupName, name } |
useTargetAzureKeyVault |
bool | ❌ | Store secrets in Key Vault (default: false) |
true or false |
useCase |
object | ✅ | Use case naming context | { businessUnit, useCaseName, environment } |
apiNameMapping |
object | ✅ | Map service codes to API names | { OAI: ["azure-openai-service-api"], ... } |
services |
array | ✅ | Services to onboard | See Services Schema below |
productTerms |
string | ❌ | Product terms of service | "By using this product..." |
JWT Authentication: JWT validation is configured per access contract via the
policies.jwtAuth.enabledfield in the Agent Access Contract Request JSON (see Base Access Contract Request). When enabled, the product policy setsjwtRequired=true, and thesecurity-handlerfragment enforces JWT Bearer token validation in addition to the API key.
| useTargetFoundry | bool | ❌ | Create Foundry connections (default: false) | true or false |
| foundry | object | ❌* | Microsoft Foundry coordinates (*required if useTargetFoundry=true) | { subscriptionId, resourceGroupName, accountName, projectName } |
| foundryConfig | object | ❌ | Foundry connection configuration | See Foundry Config below |
| globalGatewayUrl | string | ❌ | Global endpoint (e.g. Front Door) used for all KV/Foundry endpoints (default: '' = primary gateway). See Resiliency Guide | https://ai-gateway.contoso.com |
| additionalApimGateways | array | ❌ | Additional APIM gateways to mirror the contract into, reusing the primary key (default: []) | [{ subscriptionId, resourceGroupName, name }] |
| additionalKeyVaults | array | ❌ | Additional Key Vaults for endpoint + shared key, with endpointSource selector (default: []) | [{ subscriptionId, resourceGroupName, name, endpointSource }] |
| additionalFoundries | array | ❌ | Additional Foundry instances for APIM connections, with endpointSource selector (default: []) | [{ subscriptionId, resourceGroupName, accountName, projectName, endpointSource }] |
Key Rotation: Zero-downtime subscription key rotation is controlled by the optional
usePrimaryKey(defaulttrue) andkeyRotationEnabled(defaultfalse) parameters. Defaults preserve existing behavior. See the Key Rotation Guide.
| usePrimaryKey | bool | ❌ | Which subscription key is active (handed to consumers). true = primary (default), false = secondary. See Key Rotation Guide | true or false |
| keyRotationEnabled | bool | ❌ | Regenerate the non-active key, invalidating its old value (default: false) | true or false |
| rotationKeyOverride | string | ❌ | Explicit rotation key value for deterministic re-runs (default: '' = auto-generate) | <64-char key> |
Service Code mapping
Map service codes (which is a short acronym that represents the category of services fall under) to their API-id in APIM:
{
LLM: ["azure-openai-api", "universal-llm-api"]
OAIRT: ["openai-realtime-ws-api"]
DOC: ["document-intelligence-api", "document-intelligence-api-legacy"]
SRCH: ["azure-ai-search-index-api"]
// ... add more services
}
Note: API-id must already exist in your APIM instance. The deployment will fail if an API name is not found.
Above is the list of supported APIs that are provisioned by default by the accelerator.
Adding custom APIs
You can onboard any number of other APIs as well to support your custom services. Add the newly added API-ids to the mapping above accordingly.
Multi-service Bundles
Mapping currently is suggested to focus on a specific category of services (e.g., LLM, Document Intelligence, etc.)
You can create unique mappings that mix different service types under one bundle if needed (this will require the product policy to be aware of that mix to apply the correct policies based on the service type like using tokens-per-mint limits for LLM and request-per-min limits for Document Intelligence).
Use-case service assignment schema
Each service in the services array:
{
code: string // Service code (e.g., "LLM", "DOC", "SRCH", "MULTI")
apiKeySecretName: string // Name for the single shared API key secret in Key Vault
endpointSecretName: string // Optional: legacy single endpoint secret (first granted API path)
policyXml: string // Optional: Custom policy XML (empty = use default)
// --- Multi-asset endpoint publishing (optional, backward compatible) ---
assetEndpoints: array // Optional: one endpoint secret per asset
// [{ apiName: 'weather-tool', endpointSecretName?: '' }]
// empty endpointSecretName auto-generates a name
publishAllAssetEndpoints: bool // Optional: auto-publish an endpoint secret for EVERY
// API in apiNameMapping[code] (default false)
foundryApiName: string // Optional: which granted API is the LLM endpoint for the
// Foundry connection (defaults to first known LLM API)
}
This creates one APIM product + one subscription + one shared API key per service code, and applies the referenced policy XML for that product.
The services array lets you assign multiple independent service codes to a use case (for example granting both LLM and DOC access). Each service code gets its own product + subscription + key (LLM has a different key from Document Intelligence).
Shared key, one endpoint secret per asset
A single product (especially a MULTI- contract granting LLM + Tools/MCP + Agents/A2A) shares one subscription key across every granted asset. Because each asset has its own gateway path, Key Vault publishes:
- one key secret —
apiKeySecretName(shared by all assets in the contract), and - one endpoint secret per asset — configured via
assetEndpointsorpublishAllAssetEndpoints.
| Field | Effect |
|---|---|
endpointSecretName (legacy) |
Publishes a single endpoint secret for the first granted API. Still honored for backward compatibility; additive when combined with the fields below. |
assetEndpoints |
Publishes one endpoint secret per listed asset. Supply endpointSecretName per entry, or leave it empty to auto-generate <code>-<businessUnit>-<useCase>-<env>-<apiName>-endpoint. |
publishAllAssetEndpoints: true |
Publishes an endpoint secret for every API in apiNameMapping[code] (names auto-generated, or overridden by a matching assetEndpoints entry). |
foundryApiName |
Selects which granted API is the LLM inference endpoint for the Foundry connection (Foundry supports the LLM endpoint only). |
✅ Backward compatible — a service with only
code+apiKeySecretName+endpointSecretName(no new fields) publishes exactly the same two secrets as before.
What gets created
| Component | Scope | Naming | Notes |
|---|---|---|---|
| APIM Product | APIM | <serviceCode>-<BU>-<UseCase>-<ENV> |
One per service code you include |
| APIM Subscription | APIM | <product>-SUB-01 |
Single shared key captured into Key Vault |
| Key Vault key secret | KV | apiKeySecretName |
One shared key per contract |
| Key Vault endpoint secret(s) | KV | endpointSecretName / assetEndpoints[].endpointSecretName / auto-generated |
One per asset for multi-asset contracts |
| Foundry Connection | Microsoft Foundry | <prefix>-<serviceCode> |
One connection per service (LLM endpoint), if enabled |
Naming examples
- Product:
LLM-Retail-FinancialAssistant-DEV - Subscription:
LLM-Retail-FinancialAssistant-DEV-SUB-01 - Foundry Connection:
Retail-FinancialAssistant-DEV-LLM - Auto-generated endpoint secret:
multi-hr-chatagent-dev-weather-tool-endpoint
Prerequisites
Azure Resources
| Resource | Requirement | How to Verify |
|---|---|---|
| Citadel Compliant APIM Instance | with published APIs matching your apiNameMapping |
az apim api list -g <rg> -n <apim-name> |
| Azure Key Vault | Accessible with secret set permissions (if using KV) | az keyvault show -n <kv-name> |
| Azure Microsoft Foundry | Account and project must exist (if using Foundry) | az cognitiveservices account show -n <account-name> -g <rg> |
Permissions Required
The deployment identity needs:
| Scope | Role | Purpose |
|---|---|---|
| APIM Resource Group | API Management Service Contributor |
Create products and subscriptions |
| Target Key Vault (if used) | Key Vault Secrets Officer |
Write secrets |
| Microsoft Foundry Resource Group (if used) | Contributor |
Create connections |
| Subscription | Reader |
Reference existing resources |
Quick Start Guide
Step 1: Create use case folder with environment subfolder
Create a folder structure following the pattern contracts/<business-unit>/<use-case>/<environment>/ with copies of both the main.bicepparam and default policy as a base.
# Create the folder structure: contracts/healthcare/chatbot/dev/
mkdir -p bicep/infra/citadel-access-contracts/contracts/healthcare/chatbot/dev
cd bicep/infra/citadel-access-contracts/contracts/healthcare/chatbot/dev
# Copy template files (note: path goes up 4 levels due to business-unit/use-case/env nesting)
cp ../../../../main.bicepparam main.bicepparam
cp ../../../../policies/default-ai-product-policy.xml ai-product-policy.xml
Step 2: Configure Your Parameters
# Edit main.bicepparam in the environment folder
code main.bicepparam
Update these values:
using '../../../../main.bicep'
param apim = {
subscriptionId: 'YOUR-SUBSCRIPTION-ID' // ← Update
resourceGroupName: 'YOUR-APIM-RESOURCE-GROUP' // ← Update
name: 'YOUR-APIM-NAME' // ← Update
}
param keyVault = {
subscriptionId: 'YOUR-SUBSCRIPTION-ID' // ← Update
resourceGroupName: 'YOUR-KV-RESOURCE-GROUP' // ← Update
name: 'YOUR-KV-NAME' // ← Update
}
param useTargetAzureKeyVault = true // false to output credentials directly
param useCase = {
businessUnit: 'YourDepartment' // ← Update
useCaseName: 'YourUseCaseName' // ← Update
environment: 'DEV' // DEV, TEST, PROD
}
// Verify these API names exist in your APIM
param apiNameMapping = {
LLM: ['azure-openai-api', 'universal-llm-api']
DOC: ['document-intelligence-api', 'document-intelligence-api-legacy']
}
param services = [
{
code: 'LLM'
endpointSecretName: 'OPENAI-ENDPOINT'
apiKeySecretName: 'OPENAI-API-KEY'
policyXml: loadTextContent('ai-product-policy.xml') // or '' for default
}
// Add more services as needed
]
Step 3: Validate Configuration
# Preview what will be created (run from the environment folder)
az deployment sub what-if `
--location swedencentral `
--template-file ../../../../main.bicep `
--parameters main.bicepparam
Step 4: Deploy
# Deploy at subscription scope (run from the environment folder)
az deployment sub create `
--name healthcare-chatbot-dev-onboarding `
--location swedencentral `
--template-file ../../../../main.bicep `
--parameters main.bicepparam
# Or run from the citadel-access-contracts root folder:
az deployment sub create `
--name healthcare-chatbot-dev-onboarding `
--location swedencentral `
--template-file main.bicep `
--parameters contracts/healthcare/chatbot/dev/main.bicepparam
Step 5: Verify Deployment
# Check products created
az apim product list `
--resource-group YOUR-APIM-RG `
--service-name YOUR-APIM-NAME `
--query "[?contains(name, 'REPLACE-PRODUCT-NAME')].{Name:name, State:state}"
# If using Key Vault, check secrets
az keyvault secret list `
--vault-name YOUR-KV-NAME `
--query "[?contains(name, 'openai')].name"
Step 6: Use the Service
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
# Get credentials from Key Vault
credential = DefaultAzureCredential()
kv_client = SecretClient(
vault_url="https://YOUR-KV-NAME.vault.azure.net/",
credential=credential
)
endpoint = kv_client.get_secret("openai-endpoint").value
api_key = kv_client.get_secret("openai-api-key").value
# Use with your application
print(f"Endpoint: {endpoint}")
# api_key is ready to use as api-key header
Also you can use the citadel-access-contracts-tests notebook to validate end-to-end connectivity of the newly created access contract.
Secret Management Options
Access contract generates sensitive keys for each service onboarded. You have two options to manage these secrets:
Option 1: Use Key Vault (Recommended)
When to use: Production deployments, applications with managed identities
param useTargetAzureKeyVault = true
param keyVault = {
subscriptionId: 'YOUR-SUB-ID'
resourceGroupName: 'YOUR-KV-RG'
name: 'YOUR-KV-NAME'
}
Benefits:
- ✅ Centralized secret management
- ✅ Automatic rotation support
- ✅ Access auditing
- ✅ Integration with managed identities
Retrieval:
from azure.keyvault.secrets import SecretClient
kv_client = SecretClient(vault_url="https://<kv-name>.vault.azure.net/", credential=credential)
endpoint = kv_client.get_secret("llm-endpoint").value
Option 2: Direct Output (CI/CD)
When to use: CI/CD pipelines, serverless functions, non-Azure environments where these values needs to be stored in some other secret store
param useTargetAzureKeyVault = false
// keyVault still required but can use placeholders as values are not considered.
param keyVault = {
subscriptionId: '00000000-0000-0000-0000-000000000000'
resourceGroupName: 'placeholder'
name: 'placeholder'
}
Benefits:
- ✅ No Key Vault dependency
- ✅ Direct credential access
- ✅ Works in any environment
Retrieval from Deployment Output:
$output = az deployment sub show `
--name my-deployment `
--query properties.outputs.endpoints.value -o json | ConvertFrom-Json
$oaiEndpoint = ($output | Where-Object { $_.code -eq 'LLM' }).endpoint
$oaiKey = ($output | Where-Object { $_.code -eq 'LLM' }).apiKey
# Store in CI/CD variables (NOT RECOMMENDED. Just for demonstration)
Write-Host "##vso[task.setvariable variable=LLM_ENDPOINT;issecret=true]$oaiEndpoint"
Write-Host "##vso[task.setvariable variable=LLM_KEY;issecret=true]$oaiKey"
⚠️ Security Note: When using direct output, handle credentials as secrets in your CI/CD system.
Option 3: Use Microsoft Foundry Connections (Recommended for Agents)
When to use: Building AI agents in Microsoft Foundry that need APIM gateway access
param useTargetFoundry = true
param foundry = {
subscriptionId: 'YOUR-FOUNDRY-SUB-ID'
resourceGroupName: 'YOUR-FOUNDRY-RG'
accountName: 'YOUR-FOUNDRY-ACCOUNT'
projectName: 'YOUR-PROJECT-NAME'
}
param foundryConfig = {
connectionNamePrefix: '' // Empty = use useCase naming
authType: 'ProjectManagedIdentity' // Default: MI JWT + api-key header. 'ApiKey' = key only
managedIdentityAudience: 'https://cognitiveservices.azure.com' // MI token audience (validated by policy)
deploymentInPath: 'false' // Model in request body
isSharedToAll: false // Share with project users
inferenceAPIVersion: '' // APIM defaults
deploymentAPIVersion: '' // APIM defaults
staticModels: [] // Dynamic discovery
listModelsEndpoint: '' // APIM defaults
getModelEndpoint: '' // APIM defaults
deploymentProvider: '' // AzureOpenAI format
customHeaders: {} // No custom headers (api-key added automatically for MI)
authConfig: {} // Default api-key header
}
Benefits:
- ✅ Seamless integration with Microsoft Foundry agents
- ✅ Credentials stored securely in Foundry connection
- ✅ Supports the "Bring Your Own AI Gateway" pattern
- ✅ Automatic model discovery from APIM
- ✅ No need to manage secrets separately
Usage in Foundry Agent:
import os
from azure.ai.projects import AIProjectClient
from azure.identity import DefaultAzureCredential
# Connection name follows pattern: <prefix>-<serviceCode>
# Example: HR-ChatBot-DEV-LLM
connection_name = "HR-ChatBot-DEV-LLM"
model_deployment = f"{connection_name}/gpt-4o"
os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] = model_deployment
client = AIProjectClient(
credential=DefaultAzureCredential(),
endpoint="https://your-foundry.cognitiveservices.azure.com/"
)
# Create agent using the APIM connection
agent = client.agents.create_agent(
model=model_deployment,
name="my-hr-assistant",
instructions="You are a helpful HR assistant."
)
Foundry Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
connectionNamePrefix |
string | '' |
Custom prefix for connection names. Empty uses <BU>-<UseCase>-<ENV> |
authType |
string | 'ProjectManagedIdentity' |
Connection auth mode. ProjectManagedIdentity (default) = managed-identity JWT + api-key header; ApiKey = subscription key only (original behavior) |
managedIdentityAudience |
string | 'https://cognitiveservices.azure.com' |
Audience the project MI requests a token for (must match the JWT audience validated by the product policy). Only used when authType = ProjectManagedIdentity |
deploymentInPath |
string | 'false' |
'true': model in URL, 'false': model in body |
isSharedToAll |
bool | false |
Share connection with all project users |
inferenceAPIVersion |
string | '' |
API version for chat/embeddings (empty = APIM defaults) |
deploymentAPIVersion |
string | '' |
API version for discovery (empty = APIM defaults) |
staticModels |
array | [] |
Fixed model list (skips discovery) |
listModelsEndpoint |
string | '' |
Custom list endpoint (empty = /deployments) |
getModelEndpoint |
string | '' |
Custom get endpoint (empty = /deployments/{id}) |
deploymentProvider |
string | '' |
Discovery format (AzureOpenAI or OpenAI) |
customHeaders |
object | {} |
Additional headers for requests (the api-key header is added automatically for ProjectManagedIdentity) |
authConfig |
object | {} |
Custom auth header config (applies to the ApiKey mode) |
LLM endpoint selection: the Foundry connection always targets the LLM inference endpoint of the contract. Set
foundryApiNameon the service to pick which granted API is the LLM endpoint; when omitted the first known LLM API (universal-llm-api/azure-openai-api/unified-ai-api) is used, else the first API inapiNameMapping[code].
Foundry connection authentication (authType)
The Foundry connection supports two authentication modes. The APIM gateway itself supports api-key only or api-key + JWT, and the two connection modes map directly onto that:
authType |
What Foundry sends | APIM validates | Product policy needs |
|---|---|---|---|
ProjectManagedIdentity (default) |
Authorization: Bearer <MI token> and api-key: <subscription key> (custom header) |
The subscription key (api-key) and the JWT (audience https://cognitiveservices.azure.com) |
JWT validation enabled for that audience |
ApiKey (backward compatible) |
api-key: <subscription key> (stored in credentials.key) |
The subscription key only | Nothing extra (api-key is always validated) |
How ProjectManagedIdentity works:
- The connection is created with
authType = ProjectManagedIdentityand no stored key — the subscription key is instead injected intocustomHeadersasapi-key. - At runtime the Foundry project managed identity acquires an Entra ID token for
managedIdentityAudience(defaulthttps://cognitiveservices.azure.com) and sends it asAuthorization: Bearer …. - APIM validates the subscription key (api-key) and the JWT. The access contract's product policy enables JWT validation for the cognitive services audience by reusing the gateway's
security-handler(see Foundry ProjectManagedIdentity JWT validation).
Prerequisites for ProjectManagedIdentity:
- Enable a system-assigned or user-assigned managed identity on the Foundry project (Azure portal → Foundry resource → Projects → your project → Identity).
- The product policy must validate the JWT for the same audience. When you use the
base-access-contract-requestmodule or the validation notebook, this policy is generated automatically. For manual contracts, add the snippet from the policy guide. - The gateway validates audience + issuer by default; optionally restrict to the project MI's client ID for tighter security (see the policy guide).
✅ Backward compatible — set
authType = 'ApiKey'(and omitmanagedIdentityAudience) to reproduce the original subscription-key-only connection exactly. Existing contracts that want the old behavior should setauthType = 'ApiKey'explicitly.
Combined Targets Example
You can use Key Vault AND Foundry together:
// Store secrets in Key Vault for traditional apps
param useTargetAzureKeyVault = true
param keyVault = {
subscriptionId: 'YOUR-SUB-ID'
resourceGroupName: 'YOUR-KV-RG'
name: 'YOUR-KV-NAME'
}
// Also create Foundry connections for AI agents
param useTargetFoundry = true
param foundry = {
subscriptionId: 'YOUR-FOUNDRY-SUB-ID'
resourceGroupName: 'YOUR-FOUNDRY-RG'
accountName: 'YOUR-FOUNDRY-ACCOUNT'
projectName: 'YOUR-PROJECT-NAME'
}
This creates:
- APIM products and subscriptions
- Key Vault secrets for traditional applications
- Foundry APIM connections for AI agents
All using the same subscription keys, ensuring consistent governance.
🧩 Foundry supports the LLM endpoint only. For a multi-asset (
MULTI-) contract the Foundry connection is wired to the LLM inference endpoint (chosen viafoundryApiName, defaulting to the first known LLM API). The Tool (MCP) and Agent (A2A) endpoints in the same contract are not carried by the Foundry connection.Recommendation: for multi-asset contracts, enable Key Vault alongside Foundry (both
useTargetAzureKeyVault = trueanduseTargetFoundry = true). Foundry gives agents the LLM connection, while Key Vault publishes the shared key and every asset's endpoint (LLM + Tools + Agents) so the contract owner has everything needed to reach all granted asset types using the sameapi-key.
Creating Custom Policies
Using Default Policy
The simplest approach - omit policyXml or set it to empty string:
param services = [
{
code: 'LLM'
endpointSecretName: 'LLM-ENDPOINT'
apiKeySecretName: 'LLM-KEY'
policyXml: '' // Uses policies/default-ai-product-policy.xml
}
]
Default policy includes:
- Model restrictions (GPT-4o, deepseek-r1)
- Token limits (300 tokens/min + 10,000 tokens/month)
- Content safety checks
Creating Custom Policy
Step 1: Create policy XML file in your use case folder
You can use the default-ai-product-policy.xml as a base policy for LLM and extend/modify it as needed.
Additional policy capabilities can be found in Access contracts policy snippets for more options and patterns that can be leveraged.
Note: APIM has power policy engine that can be used even beyond what is provided in Citadel Governance Hub. Leverage agentic development like GitHub Copilot to help you create custom policies based on your requirements.
Step 2: Reference policy in bicepparam
param services = [
{
code: 'LLM'
endpointSecretName: 'LLM-ENDPOINT'
apiKeySecretName: 'LLM-KEY'
policyXml: loadTextContent('my-custom-policy.xml')
}
]
Advanced Scenarios
Multi-asset contract (one product, LLM + Tool + Agent, endpoint per asset)
A single MULTI- product grants LLM + Tools/MCP + Agents/A2A under one shared key, and Key Vault receives one endpoint secret per asset plus the shared key. Enable Key Vault (for all endpoints) alongside Foundry (for the LLM connection):
param useTargetAzureKeyVault = true
param useTargetFoundry = true
param apiNameMapping = {
MULTI: ['universal-llm-api', 'weather-tool', 'hr-chat-agent']
}
param services = [
{
code: 'MULTI'
apiKeySecretName: 'HR-CHATAGENT-KEY' // single shared key for all assets
foundryApiName: 'universal-llm-api' // Foundry connection targets the LLM endpoint
assetEndpoints: [
{ apiName: 'universal-llm-api', endpointSecretName: 'HR-CHATAGENT-LLM-ENDPOINT' }
{ apiName: 'weather-tool' } // auto-named endpoint secret
{ apiName: 'hr-chat-agent' } // auto-named endpoint secret
]
policyXml: loadTextContent('ai-product-policy.xml')
}
]
Result: 1 product, 1 subscription (shared key), and in Key Vault: 1 key secret (hr-chatagent-key) + 3 endpoint secrets (hr-chatagent-llm-endpoint, multi-hr-chatagent-dev-weather-tool-endpoint, multi-hr-chatagent-dev-hr-chat-agent-endpoint). The Foundry connection points at the LLM endpoint. To publish an endpoint for every granted API without listing them, set publishAllAssetEndpoints: true instead of assetEndpoints.
Multiple Services in One Use Case
Onboard multiple AI services simultaneously:
param apiNameMapping = {
LLM: ['azure-openai-api', 'universal-llm-api']
DOC: ['document-intelligence-api', 'document-intelligence-api-legacy']
SRCH: ['azure-ai-search-index-api']
OAIRT: ['openai-realtime-ws-api']
}
param services = [
{
code: 'LLM'
endpointSecretName: 'MULTI-LLM-ENDPOINT'
apiKeySecretName: 'MULTI-LLM-KEY'
policyXml: loadTextContent('llm-policy.xml')
}
{
code: 'DOC'
endpointSecretName: 'MULTI-DOC-ENDPOINT'
apiKeySecretName: 'MULTI-DOC-KEY'
policyXml: loadTextContent('doc-policy.xml')
}
{
code: 'SRCH'
endpointSecretName: 'MULTI-SEARCH-ENDPOINT'
apiKeySecretName: 'MULTI-SEARCH-KEY'
policyXml: '' // Use default
}
]
Result: Creates 3 APIM products, 3 subscriptions, 6 Key Vault secrets (endpoint + key for each service).
Deployment Outputs
After deployment, the following outputs are available:
When Using Key Vault (useTargetAzureKeyVault = true)
| Output | Type | Description | Example |
|---|---|---|---|
apimGatewayUrl |
string | APIM gateway base URL | https://apim-gateway.azure-api.net |
useKeyVault |
bool | Always true |
true |
products[] |
array | Created products | [{ productId: "OAI-Healthcare-...", displayName: "..." }] |
subscriptions[] |
array | KV secret names. keyVaultApiKeySecretName is the shared key; keyVaultEndpointSecretName is the first endpoint (legacy); keyVaultEndpointSecretNames[] lists every per-asset endpoint secret |
[{ name: "MULTI-...-SUB-01", keyVaultApiKeySecretName: "hr-chatagent-key", keyVaultEndpointSecretNames: ["hr-chatagent-llm-endpoint", "multi-hr-chatagent-dev-weather-tool-endpoint"], ... }] |
Access secrets from Key Vault:
$subs = (az deployment sub show --name my-deployment --query properties.outputs.subscriptions.value -o json | ConvertFrom-Json)
# Shared key
$key = az keyvault secret show --vault-name <kv-name> --name ($subs[0].keyVaultApiKeySecretName) --query value -o tsv
# Every per-asset endpoint
foreach ($name in $subs[0].keyVaultEndpointSecretNames) {
$ep = az keyvault secret show --vault-name <kv-name> --name $name --query value -o tsv
Write-Host "$name = $ep"
}
When NOT Using Key Vault (useTargetAzureKeyVault = false)
| Output | Type | Description | Contains Secrets |
|---|---|---|---|
apimGatewayUrl |
string | APIM gateway base URL | No |
useKeyVault |
bool | Always false |
No |
products[] |
array | Created products | No |
endpoints[] |
array | Direct credentials | ⚠️ YES |
Endpoints output structure:
[
{
"code": "OAI",
"productId": "OAI-Healthcare-PatientAssistant-DEV",
"subscriptionName": "OAI-Healthcare-PatientAssistant-DEV-SUB-01",
"endpoint": "https://apim-gateway.azure-api.net/openai",
"apiKey": "abc123...xyz" // ⚠️ Sensitive
}
]
Extract credentials:
$output = az deployment sub show --name my-deployment --query properties.outputs.endpoints.value -o json | ConvertFrom-Json
$oaiCreds = $output | Where-Object { $_.code -eq 'OAI' }
# Store securely in CI/CD
Write-Host "##vso[task.setvariable variable=OAI_ENDPOINT;issecret=true]$($oaiCreds.endpoint)"
Write-Host "##vso[task.setvariable variable=OAI_KEY;issecret=true]$($oaiCreds.apiKey)"
⚠️ Security Warning: The endpoints output contains sensitive API keys when not using Key Vault. Always:
- Mark as secrets in CI/CD systems
- Never log or display in plaintext
- Store in secure secret management systems
- Rotate keys regularly
JWT Authentication for Access Contracts
Access Contracts support layered authentication: API Key is always required, with optional JWT Bearer token validation per product. This is configured during contract creation, not at deployment time.
Prerequisites
- The Citadel Governance Hub must be deployed with
entraAuth=trueso that APIM JWT named values (JWT-TenantId,JWT-AppRegistrationId,JWT-Issuer,JWT-OpenIdConfigUrl) and thesecurity-handlerpolicy fragment are provisioned - An Entra ID App Registration exists (auto-provisioned by the
entra-idBicep module or provided manually) - Client secret is stored in Key Vault as
ENTRA-APP-CLIENT-SECRET
How It Works
- The product policy snippet
jwt-auth.xmlsetsjwtRequired=truein the APIM context - The
security-handlerfragment detects the auth method (api-key,jwt,api-key-jwt) - When
jwtRequired=true, the fragment validates the JWT Bearer token against the Entra ID OpenID configuration - Requests without a valid JWT are rejected with
401 Unauthorized
Authentication Flow
| Scenario | Headers Required | Result |
|---|---|---|
| API Key only (JWT not enabled) | api-key: {key} |
✅ Allowed |
| API Key + JWT (JWT enabled) | api-key: {key} + Authorization: Bearer {token} |
✅ Allowed |
| API Key only (JWT enabled) | api-key: {key} |
❌ 401 - JWT required |
| JWT only (no API Key) | Authorization: Bearer {token} |
❌ 401 - API key required |
| Invalid JWT (JWT enabled) | api-key: {key} + Authorization: Bearer {invalid} |
❌ 401 - Invalid token |
Enabling JWT via Agent Access Contract Request
When using the base-access-contract-request module, enable JWT in the contract JSON:
{
"contractInfo": {
"businessUnit": "Security",
"useCaseName": "SecureAgent",
"environment": "DEV"
},
"policies": {
"jwtAuth": { "enabled": true },
"modelAccess": {
"enabled": true,
"allowedModels": ["gpt-4o", "gpt-4o-mini"]
}
},
"services": [
{
"code": "LLM",
"endpointSecretName": "SECURE-AGENT-ENDPOINT",
"apiKeySecretName": "SECURE-AGENT-KEY"
}
]
}
Enabling JWT via Custom Product Policy
For manual access contracts (not using the agent request module), add the JWT snippet to your product policy XML:
<policies>
<inbound>
<base />
<!-- Enable JWT requirement for this product -->
<set-variable name="jwtRequired" value="true" />
<!-- Other policy snippets (model access, capacity, etc.) -->
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>
Acquiring a JWT Token
Use the client credentials flow to obtain a JWT token from Microsoft Entra ID:
POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id={entra-app-client-id}
&client_secret={entra-app-client-secret}
&scope={audience}/.default
Then include the token in API requests:
POST https://{apim-gateway}/openai/deployments/gpt-4o/chat/completions?api-version=2024-02-15-preview
api-key: {subscription-key}
Authorization: Bearer {jwt-token}
Content-Type: application/json
Validation
Use the JWT Access Contract Validation Notebook to test end-to-end JWT authentication with access contracts.
Support
For issues or questions:
- GitHub Issues: Report bugs or request features