# Citadel Spoke Onboarding

> >

- **Type:** Skill
- **Install:** `agentstack add skill-aiappsgbb-awesome-gbb-citadel-spoke-onboarding`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [aiappsgbb](https://agentstack.voostack.com/s/aiappsgbb)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [aiappsgbb](https://github.com/aiappsgbb)
- **Source:** https://github.com/aiappsgbb/awesome-gbb/tree/main/skills/citadel-spoke-onboarding

## Install

```sh
agentstack add skill-aiappsgbb-awesome-gbb-citadel-spoke-onboarding
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Citadel Spoke Onboarding — Reference Guide

How to connect a GenAI application or Microsoft Foundry project to an
**existing** AI Citadel Governance Hub so that all AI traffic is governed,
observable, and compliant.

> **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's UAMI is the only credential, and APIM enforces JWT
> validation on the project's MI token. If a customer insists on
> Option A for a non-threadlight reason, document the deviation in
> SPEC § 11b explicitly.

> **Source repo:** [Azure-Samples/ai-hub-gateway-solution-accelerator](https://github.com/Azure-Samples/ai-hub-gateway-solution-accelerator/tree/citadel-v1) (branch `citadel-v1`)
> **Quick link:** 

---

## 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 |

---

## Step-by-Step: Create an Access Contract

### 1. Scaffold the Contract Folder

Clone or init the accelerator, then follow the pattern `contracts///`:

```powershell
# Option A: clone the accelerator
git clone -b citadel-v1 https://github.com/Azure-Samples/ai-hub-gateway-solution-accelerator.git
cd ai-hub-gateway-solution-accelerator/bicep/infra/citadel-access-contracts

# Option B: if using azd
azd init --template Azure-Samples/ai-hub-gateway-solution-accelerator -e my-citadel --branch citadel-v1

# 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.

### 2. Configure the Parameter File

Edit `main.bicepparam`:

```bicep
using '../../../main.bicep'

// ── Hub coordinates (get these from your platform team) ──
param apim = {
  subscriptionId: ''
  resourceGroupName: ''
  name: ''
}

// ── Secret storage ──
param useTargetAzureKeyVault = true        // false → credentials in deployment output
param keyVault = {
  subscriptionId: ''
  resourceGroupName: ''
  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: ''
  resourceGroupName: ''
  accountName: ''
  projectName: ''
}
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 ``:**

```xml

    

    
    

    
    

    
    
    
    

    
    
    

    
    
    
    

    
    

    
    
        
            
            
        
    

    
    

```

**Per-model capacity limits** (instead of flat subscription-level):

```xml

    
        
    
    
        
    
    
        
    

```

**Throttling alerts** (in `` section):

```xml

    
    
    ("requestedModel", "DefaultModel"))" />
    ("appId", context.Subscription?.Id ?? "Portal-Admin-Sub"))" />
    

```

### 4. Validate and Deploy

```powershell
# Preview (what-if)
az deployment sub what-if `
  --location  `
  --template-file ../../../main.bicep `
  --parameters main.bicepparam

# Deploy
az deployment sub create `
  --name myteam-myagent-dev `
  --location  `
  --template-file ../../../main.bicep `
  --parameters main.bicepparam
```

### 5. Verify

```powershell
# Check APIM product
az apim product list `
  --resource-group  `
  --service-name  `
  --query "[?contains(name, 'MyTeam')].{Name:name, State:state}"

# Check Key Vault secrets (if using KV)
az keyvault secret list `
  --vault-name  `
  --query "[?contains(name, 'MYAGENT')].name"
```

---

## Consuming the Gateway from Your App

### Option A: Key Vault (Traditional Apps — NOT for threadlight pilots)

> **Threadlight pilots: do NOT use Option A.** Pulling an APIM subscription
> key from Key Vault means the agent holds a long-lived secret at
> runtime, which violates the keyless-by-mandate posture. Use Option B
> (Foundry Connection) below — APIM still authorizes via the project
> MI token, and the agent never sees a key. Option A remains documented
> for traditional non-Foundry apps that don't have a project-level
> connection surface.

> **Secret name normalization:** The Bicep module lowercases names and replaces
> underscores with hyphens. E.g. `MYAGENT-LLM-ENDPOINT` → `myagent-llm-endpoint`.
> Use the normalized name when retrieving secrets.

```python
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

credential = DefaultAzureCredential()
kv = SecretClient(vault_url="https://.vault.azure.net/", credential=credential)

endpoint = kv.get_secret("myagent-llm-endpoint").value   # normalized name
api_key  = kv.get_secret("myagent-llm-key").value

# Use with Azure OpenAI SDK (requires azure-openai-api FIRST in apiNameMapping)
from openai import AzureOpenAI
client = AzureOpenAI(azure_endpoint=endpoint, api_key=api_key, api_version="2024-12-01-preview")
response = client.chat.completions.create(model="gpt-5.4-mini", messages=[{"role":"user","content":"Hello"}])
```

### Option B: Foundry Connection (Foundry Agents)

The `connectionName/modelName` pattern routes LLM calls through the APIM gateway.
This works at the **agent level** — not via raw `oai.chat.completions.create()`.

**Hosted Agents (FoundryChatClient):**

Set `MODEL_DEPLOYMENT_NAME` in `agent.yaml` to `connectionName/modelName`:

```yaml
# agent.yaml
environment_variables:
  - name: MODEL_DEPLOYMENT_NAME
    value: Hub-MyTeam-MyAgent-DEV-LLM/gpt-5.4
```

The `FoundryChatClient` in `container.py` resolves the connection automatically:

```python
client = FoundryChatClient(
    project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
    model=os.environ["MODEL_DEPLOYMENT_NAME"],  # "connectionName/gpt-5.4"
    credential=DefaultAzureCredential(),
)
```

**Prompt Agents (PromptAgentDefinition):**

```python
from azure.ai.projects import AIProjectClient
from azure.ai.projects.models import PromptAgentDefinition
from azure.identity import DefaultAzureCredential

client = AIProjectClient(
    credential=DefaultAzureCredential(),
    endpoint="https://.services.ai.azure.com/api/projects/",
    allow_preview=True,
)

# Connection name from access contract output
model_deployment = "Hub-MyTeam-MyAgent-DEV-LLM/gpt-5.4"

agent = client.agents.create_version(
    agent_name="my-agent",
    definition=PromptAgentDefinition(
        model=model_deployment,
        instructions="You are a helpful assistant.",
    ),
)

# Chat via get_openai_client(agent_name=...) + responses.create()
oai = client.get_openai_client(agent_name="my-agent")
response = oai.responses.create(input="Hello", stream=False)
```

> **⚠️ CRITICAL: `connectionName/model` does NOT work with raw OpenAI API calls.**
> Calling `oai.chat.completions.create(model="connName/gpt-5.4")` returns
> `404 DeploymentNotFound`. The routing only works through:
> - `FoundryChatClient(model="connName/model")` (hosted agents)
> - `PromptAgentDefinition(model="connName/model")` (prompt agents)
> - NOT via `oai.chat.completions.create()` or `oai.responses.create()` directly

> **`isSharedToAll` quirk:** The REST API ignores `isSharedToAll=true` on PUT/PATCH
> — it always stays `false`. This does NOT block hosted agent routing (the agent
> identity resolves the connection via `FoundryChatClient`). It may affect prompt
> agents depending on how the caller authenticates.

### Option C: Direct Output (CI/CD Pipelines)

When not using Key Vault, set `useTargetAzureKeyVault = false` but still provide
a placeholder `keyVault` object (Bicep validation requires it):

```bicep
param useTargetAzureKeyVault = false
param keyVault = {
  subscriptionId: '00000000-0000-0000-0000-000000000000'
  resourceGroupName: 'placeholder'
  name: 'placeholder'
}
```

Retrieve credentials from deployment output:

```powershell
$output = az deployment sub show `
  --name myteam-myagent-dev `
  --query properties.outputs.endpoints.value -o json | ConvertFrom-Json

$creds = $output | Where-Object { $_.code -eq 'LLM' }
# $creds.endpoint and $creds.apiKey are available (handle as secrets!)
```

---

## JWT Authentication (Optional Layer)

When the hub is deployed with `entraAuth=true`, you can require JWT on top of the API key.

### Enable in Product Policy

Add to your `ai-product-policy.xml`:

```xml

    
    

```

### Authentication Matrix

| Scenario | Headers Required | Result |
|----------|-----------------|--------|
| API Key only (JWT disabled) | `api-key: {key}` | ✅ |
| API Key + JWT (JWT enabled) | `api-key: {key}` + `Authorization: Bearer {token}` | ✅ |
| API Key only (JWT enabled) | `api-key: {key}` | ❌ 401 |
| JWT only (no API Key) | `Authorization: Bearer {token}` | ❌ 401 |

### Acquiring the JWT

Two distinct identities are involved:
- **Gateway audience** (``): The Entra app registration configured in the hub's APIM. The hub team provides this.
- **Spoke client identity**: Your app's own service principal or managed identity, which must be granted access to the gateway app role.

**Service principal client:**

```python
from azure.identity import ClientSecretCredential

credential = ClientSecretCredential(
    tenant_id="",
    client_id="",           # your app's identity
    client_secret=""         # your app's secret
)
token = credential.get_token("api:///.default").token
# Pass as: Authorization: Bearer {token}
```

**Managed identity client (recommended on Azure):**

```python
from azure.identity import DefaultAzureCredential

credential = DefaultAzureCredential()
token = credential.get_token("api:///.default").token
```

> ⚠️ Your spoke identity must be granted the required app role (e.g. `Models.Read`)
> on the gateway app registration. Ask the platform team to assign this via Entra ID.
>
> **Guides:**
> - [JWT Authentication Guide](https://github.com/Azure-Samples/ai-hub-gateway-solution-accelerator/blob/citadel-v1/guides/entraid-auth-validation.md)
> - [JWT Client Identity & Permissions](https://github.com/Azure-Samples/ai-hub-gateway-solution-accelerator/blob/citadel-v1/guides/jwt-client-identity-permissions.md)

### Custom Identity Provider Override

Access contracts can override gateway JWT defaults per product:

```xml

    
    
    
    
    
    

```

| Variable | Falls Back To (APIM Named Value) |
|----------|----------------------------------|
| `jwtAudience` | `JWT-AppRegistrationId` |
| `jwtIssuer` | `JWT-Issuer` |
| `jwtOpenIdConfigUrl` | `JWT-OpenIdConfigUrl` |

### App Role Authorization

Require specific Entra app roles (enforced after JWT validation, OR logic):

```xml

```

Available gateway app roles: `Task.ReadWrite`, `Models.Read`, `MCP.Read`, `Agent.Read`.

---

## Foundry APIM Connection (Standalone)

If you only need to wire a Foundry project to the APIM gateway **without** a full
Access Contract (e.g. the product/subscription already exists), use the
[`foundry-integration/main.bicep`](https://github.com/Azure-Samples/ai-hub-gateway-solution-accelerator/tree/citadel-v1/bicep/infra/foundry-integration) template:

```bash
cd bicep/infra/foundry-integration
cp main.bicepparam my-connection.bicepparam
# Edit my-connection.bicepparam with your values

az account set --subscription 
az deployment group create \
  --name foundry-apim-conn \
  --resource-group  \
  --template-file main.bicep \
  --parameters my-connection.bicepparam
```

Key parameters (`foundry-integration/main.bicepparam`):

| Parameter | Description |
|-----------|-------------|
| `aiFoundryAccountName` | Name of the AI Foundry account |
| `aiFoundryProjectName` | Name of the AI Foundry project |
| `connectionName` | Name for the connection (e.g. `citadel-hub-connection`) |
|

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [aiappsgbb](https://github.com/aiappsgbb)
- **Source:** [aiappsgbb/awesome-gbb](https://github.com/aiappsgbb/awesome-gbb)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** yes
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-aiappsgbb-awesome-gbb-citadel-spoke-onboarding
- Seller: https://agentstack.voostack.com/s/aiappsgbb
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
