- name
- citadel-spoke-onboarding
- description
- Onboard a Foundry project as a spoke into an AI Citadel Governance Hub. Covers Access Contracts, APIM connections, product policies, JWT auth. USE FOR: citadel spoke onboarding, access contract, connect foundry to citadel, APIM connection, bring your own ai gateway, govern agent, citadel JWT auth, citadel product policy, unified ai api, citadel AGT, vnet-isolated citadel spoke, foundry-vnet-deploy spoke onboarding. DO NOT USE FOR: deploying the Citadel hub itself, APIM infrastructure, hub networking, hub provisioning, hub sizing, llm backend onboarding, deploying model backends, apim backend pools, hub policy fragment deployment, spoke-side VNet/peering creation (use foundry-vnet-deploy).
- metadata
- {"version":"2.0.0"}
# Citadel Spoke Onboarding — Reference Guide
How to connect a GenAI application or Microsoft Foundry project to an
**existing** AI Citadel Governance Hub. Controls cover the traffic actually routed
through the gateway, not every agent action or a compliance certification.
> **Threadlight integration**: This skill is the **opt-in Phase 7** of
> `threadlight-deploy`. It runs ONLY when SPEC § 11b sets
> `governance_hub.required: yes` (the SPEC field is generic; the AI
> Citadel hub is one reference implementation). The base agent deploy
> (Phase 5 + 6) lands in the customer's tenant first; this skill
> onboards it as a hub spoke afterwards as an additive step. Read
> SPEC § 11b for the per-process governance posture (hub endpoint,
> access contracts, JWT requirements, secret wiring).
>
> **Threadlight pilots MUST use Option B (Foundry Connection)** — see
> `Consuming the Gateway from Your App` below. Option A (Key Vault
> secret pull) violates the keyless-by-mandate posture: it requires
> the agent to hold an APIM subscription key and read it from KV at
> runtime. Option B threads the call through a Foundry APIM connection
> so the agent does not retrieve the subscription key. The pinned connection
> uses `authType: ApiKey`; this is **not end-to-end keyless**. Caller-to-Foundry
> Entra authentication, connection-managed secret custody and downstream APIM
> authentication are separate boundaries. JWT requires its own approved setup
> and positive/negative runtime evidence; no MI-to-APIM JWT flow is implied.
> If a customer insists on
> Option A for a non-threadlight reason, document the deviation in
> SPEC § 11b explicitly.
> **Source:** [exact hub/spoke revision](https://github.com/Azure-Samples/ai-hub-gateway-solution-accelerator/tree/63f0f812474e713916dc909494d655246783a1d9).
> The [pin record](references/upstream-pin.md) separates source comparison from
> live compatibility. `citadel-v1` is a freshness signal, not a deployment input.
> **Quick link:** <https://aka.ms/ai-hub-gateway>
---
## Key Concepts
| Term | Meaning |
|------|---------|
| **Citadel Governance Hub** | Central control plane with Azure API Management (APIM) acting as the unified AI gateway. Already deployed — not your concern here. |
| **Spoke** | An isolated workload environment (Foundry project, Container App, Function, etc.) that consumes AI services **through** the hub gateway. |
| **Access Contract** | A Bicep parameter file (`.bicepparam`) + optional policy XML declaring what AI services a spoke needs, with what policies. Deployed as IaC. |
| **Foundry Connection** | An APIM-type connection inside an Azure AI Foundry project that routes model calls through the Citadel gateway. |
| **Service Code** | Short acronym mapping a category of AI services to APIM API IDs (e.g. `LLM`, `DOC`, `SRCH`, `OAIRT`). |
---
## What Gets Created Per Access Contract
| Resource | Naming Pattern | Description |
|----------|----------------|-------------|
| **APIM Product** | `{code}-{BU}-{UseCase}-{ENV}` | One per service code, with attached APIs and policies |
| **APIM Subscription** | `{product}-SUB-01` | Subscription with API key |
| **Key Vault Secrets** (optional) | `{secretName}` | Endpoint URL + API key stored in KV |
| **Foundry Connection** (optional) | `{prefix}-{code}` | APIM connection for Foundry agents |
---
## Prerequisites (Spoke Side)
| Requirement | Details |
|-------------|---------|
| Running Citadel Hub | APIM deployed with published APIs matching your `apiNameMapping` |
| Azure CLI + Bicep | Latest version with `az deployment sub create` support |
| Permissions | `API Management Service Contributor` on APIM RG, `Key Vault Secrets Officer` on target KV (if used), `Contributor` on Foundry RG (if using Foundry connections) |
| Foundry Project | Must exist if you want APIM connections inside Foundry |
Before any operator Azure call, apply
[`azure-tenant-isolation`](../azure-tenant-isolation/SKILL.md): paired isolated
CLI/AZD context and the approved exact tenant/subscription. Assert the target
immediately before writes. Read-only inventory grants no repair authority.
---
## Step-by-Step: Create an Access Contract
### 1. Scaffold the Contract Folder
Materialize the exact reviewed hub revision, then follow
`contracts/<businessunit-usecasename>/<environment>/`. Do not overwrite an existing
checkout, deployed contract or policy to align a pin.
```bash
PINNED_SHA="63f0f812474e713916dc909494d655246783a1d9"
git clone --filter=blob:none --no-checkout https://github.com/Azure-Samples/ai-hub-gateway-solution-accelerator.git
cd ai-hub-gateway-solution-accelerator
git fetch --depth 1 origin "$PINNED_SHA"
git checkout --detach "$PINNED_SHA"
test "$(git rev-parse HEAD)" = "$PINNED_SHA"
cd bicep/infra/citadel-access-contracts
# Create contract folder
mkdir -p contracts/myteam-myagent/dev
cd contracts/myteam-myagent/dev
# Copy templates
cp ../../../main.bicepparam main.bicepparam
cp ../../../policies/default-ai-product-policy.xml ai-product-policy.xml
```
> 📂 Full contract folder structure and module reference:
> [citadel-access-contracts/](https://github.com/Azure-Samples/ai-hub-gateway-solution-accelerator/tree/citadel-v1/bicep/infra/citadel-access-contracts)
>
> ⚠️ Sample contracts were removed from the repo. Use `main.bicepparam` as your template base.
> Review `allowedModels` explicitly: this revision's default LLM policy allows
> `gpt-4.1,gpt-5.4-mini`, unlike the older policy's broader list. Existing
> approved policies are not replaced automatically. Leave optional key rotation
> and additional-gateway settings disabled unless separately approved.
### 2. Configure the Parameter File
Edit `main.bicepparam`:
```bicep
using '../../../main.bicep'
// ── Hub coordinates (get these from your platform team) ──
param apim = {
subscriptionId: '<HUB-SUBSCRIPTION-ID>'
resourceGroupName: '<HUB-APIM-RG>'
name: '<HUB-APIM-NAME>'
}
// ── Secret storage ──
param useTargetAzureKeyVault = true // false → credentials in deployment output
param keyVault = {
subscriptionId: '<SPOKE-SUBSCRIPTION-ID>'
resourceGroupName: '<SPOKE-KV-RG>'
name: '<SPOKE-KV-NAME>'
}
// ── Use-case identity ──
param useCase = {
businessUnit: 'MyTeam'
useCaseName: 'MyAgent'
environment: 'DEV' // DEV | TEST | PROD
}
// ── Map service codes → APIM API IDs ──
// ⚠️ Order matters: endpoint secret stores the gateway URL for the FIRST API.
// Put the API matching your SDK first (e.g. azure-openai-api for AzureOpenAI SDK).
param apiNameMapping = {
LLM: ['azure-openai-api', 'universal-llm-api', 'unified-ai-api']
}
// ── Services to onboard ──
param services = [
{
code: 'LLM'
endpointSecretName: 'MYAGENT-LLM-ENDPOINT'
apiKeySecretName: 'MYAGENT-LLM-KEY'
policyXml: loadTextContent('ai-product-policy.xml') // '' → use default
}
]
// ── Foundry integration (optional) ──
param useTargetFoundry = true // false if not using Foundry agents
param foundry = {
subscriptionId: '<FOUNDRY-SUBSCRIPTION-ID>'
resourceGroupName: '<FOUNDRY-RG>'
accountName: '<FOUNDRY-ACCOUNT>'
projectName: '<FOUNDRY-PROJECT>'
}
param foundryConfig = {
connectionNamePrefix: '' // empty → auto from useCase naming
deploymentInPath: 'false' // model name in request body
isSharedToAll: false
inferenceAPIVersion: '' // empty → APIM defaults
deploymentAPIVersion: ''
staticModels: []
listModelsEndpoint: ''
getModelEndpoint: ''
deploymentProvider: ''
customHeaders: {}
authConfig: {}
}
```
### 3. Customise the Product Policy (Optional)
The [default policy](https://github.com/Azure-Samples/ai-hub-gateway-solution-accelerator/blob/citadel-v1/bicep/infra/citadel-access-contracts/policies/default-ai-product-policy.xml) includes model restrictions, token limits, and content safety.
For custom policies, edit `ai-product-policy.xml`. Full policy reference:
[citadel-access-contracts-policy.md](https://github.com/Azure-Samples/ai-hub-gateway-solution-accelerator/blob/citadel-v1/bicep/infra/citadel-access-contracts/citadel-access-contracts-policy.md)
**Recommended policy ordering in `<inbound>`:**
```xml
<inbound>
<base />
<!-- 1. JWT Authentication (optional) -->
<set-variable name="jwtRequired" value="true" />
<!-- 2. App Role Authorization (optional, requires JWT) -->
<set-variable name="requiredRoles" value="Models.Read" />
<!-- 3. Model extraction and access control -->
<include-fragment fragment-id="set-llm-requested-model" />
<set-variable name="allowedModels" value="gpt-5.4-mini,gpt-5.4-nano" />
<include-fragment fragment-id="validate-model-access" />
<!-- 4. Capacity management (subscription level) -->
<!-- ⚠️ For Foundry agents with MCP tools, use ≥100K TPM.
A single CI query with 10-20 tool calls consumes 50-80K tokens.
10K TPM causes server_error after the first tool call completes. -->
<llm-token-limit counter-key="@(context.Subscription.Id)"
tokens-per-minute="5000"
estimate-prompt-tokens="false"
tokens-consumed-header-name="consumed-tokens"
remaining-tokens-header-name="remaining-tokens"
token-quota="100000"
token-quota-period="Monthly"
retry-after-header-name="retry-after" />
<!-- 5. Usage attribution (optional) -->
<set-variable name="appId" value="@(context.Request.Headers.GetValueOrDefault("x-app-id", context.Subscription?.Id ?? "Portal-Admin"))" />
<set-variable name="customDimension1" value="@(context.Request.Headers.GetValueOrDefault("x-sub-agent-id", "general-agent"))" />
<set-variable name="customDimension2" value="@(context.Request.Headers.GetValueOrDefault("x-enduser-id", "anonymous-enduser"))" />
<!-- 6. PII Anonymization (optional) -->
<set-variable name="piiAnonymizationEnabled" value="true" />
<!-- 7. Content Safety (optional) -->
<llm-content-safety backend-id="content-safety-backend" shield-prompt="true">
<categories output-type="EightSeverityLevels">
<category name="Hate" threshold="3" />
<category name="Violence" threshold="3" />
</categories>
</llm-content-safety>
<!-- 8. Response debug headers (dev/test only) -->
<set-variable name="enableResponseHeaders" value="@(true)" />
</inbound>
```
**Per-model capacity limits** (instead of flat subscription-level):
```xml
<include-fragment fragment-id="set-llm-requested-model" />
<choose>
<when condition="@((string)context.Variables["requestedModel"] == "gpt-5.4-mini")">
<llm-token-limit counter-key="@(context.Subscription.Id + "-gpt-5.4-mini")"
tokens-per-minute="10000" token-quota="100000" token-quota-period="Monthly"
estimate-prompt-tokens="false" />
</when>
<when condition="@((string)context.Variables["requestedModel"] == "DeepSeek-R1")">
<llm-token-limit counter-key="@(context.Subscription.Id + "-DeepSeek-R1")"
tokens-per-minute="2000" token-quota="10000" token-quota-period="Weekly"
estimate-prompt-tokens="false" />
</when>
<otherwise>
<llm-token-limit counter-key="@(context.Subscription.Id + "-default")"
tokens-per-minute="1000" token-quota="5000" token-quota-period="Monthly"
estimate-prompt-tokens="false" />
</otherwise>
</choose>
```
**Throttling alerts** (in `<on-error>` section):
```xml
<on-error>
<base />
<set-variable name="productName" value="@(context.Product?.Name?.ToString() ?? "Portal-Admin")" />
<set-variable name="deploymentName" value="@((string)context.Variables.GetValueOrDefault<string>("requestedModel", "DefaultModel"))" />
<set-variable name="appId" value="@((string)context.Variables.GetValueOrDefault<string>("appId", context.Subscription?.Id ?? "Portal-Admin-Sub"))" />
<include-fragment fragment-id="raise-throttling-events" />
</on-error>
```
### 4. Validate and Deploy
```powershell
# Preview (what-if)
az deployment sub what-if `
--location <REGION> `
--template-file ../../../main.bicep `
--parameters main.bicepparam
# Deploy
az deployment sub create `
View on GitHub