|
| 1 | +# APIM Policy Analysis: `entra-jwt-policy.xml` |
| 2 | + |
| 3 | +This is an **Azure API Management (APIM) policy** that acts as a gateway layer between client applications and Azure OpenAI. It implements authentication, authorization, quota enforcement, request transformation, usage logging, and error handling. The policy executes across all four APIM policy sections: **inbound**, **backend**, **outbound**, and **on-error**. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## 1. `<inbound>` — Request Processing (Pre-Backend) |
| 8 | + |
| 9 | +The inbound section performs six distinct operations before the request reaches Azure OpenAI: |
| 10 | + |
| 11 | +### 1.1 JWT Validation |
| 12 | + |
| 13 | +```xml |
| 14 | +<validate-jwt header-name="Authorization" ...> |
| 15 | + <openid-config url="https://login.microsoftonline.com/common/.well-known/openid-configuration" /> |
| 16 | + <required-claims> |
| 17 | + <claim name="aud" match="all"> |
| 18 | + <value>{{ExpectedAudience}}</value> |
| 19 | + </claim> |
| 20 | + </required-claims> |
| 21 | +</validate-jwt> |
| 22 | +``` |
| 23 | + |
| 24 | +- Validates the Bearer token from the `Authorization` header against Entra ID's OpenID Connect metadata. |
| 25 | +- Uses `/common/` endpoint — this means it's **multi-tenant**, accepting tokens from any Entra directory. |
| 26 | +- Requires the `aud` (audience) claim to match `{{ExpectedAudience}}` (a named value configured in APIM). This ensures tokens are scoped to the correct resource. |
| 27 | +- Returns **HTTP 401** on failure with a configurable error message. |
| 28 | + |
| 29 | +### 1.2 Claim Extraction |
| 30 | + |
| 31 | +Extracts three JWT claims into APIM context variables: |
| 32 | + |
| 33 | +| Variable | Claim | Purpose | |
| 34 | +|----------|-------|---------| |
| 35 | +| `tenantId` | `tid` | Identifies the caller's Entra tenant/organization | |
| 36 | +| `clientAppId` | `azp` (delegated) or `appid` (client-credentials) | Identifies the calling application — handles both user-interactive and service-to-service flows | |
| 37 | +| `audience` | `aud` | Captured for logging purposes | |
| 38 | + |
| 39 | +The `clientAppId` extraction is notable: it prefers `azp` (set in delegated/on-behalf-of tokens) and falls back to `appid` (set in client-credentials tokens). This supports both authentication flows. |
| 40 | + |
| 41 | +### 1.3 Deployment ID Resolution |
| 42 | + |
| 43 | +``` |
| 44 | +deploymentId = path segment from /deployments/{id}/... |
| 45 | + OR request body "model" field |
| 46 | + OR last URL segment |
| 47 | +``` |
| 48 | + |
| 49 | +Three-tier fallback: |
| 50 | + |
| 51 | +1. **URL path** — extracts from `/deployments/{id}/...` (standard Azure OpenAI chat/completions route) |
| 52 | +2. **Request body** — reads the `model` field from JSON (for routes like `/responses`, `/models` that use the Responses API) |
| 53 | +3. **Last URL segment** — generic fallback |
| 54 | + |
| 55 | +This `deploymentId` is used for per-model quota tracking and usage attribution. |
| 56 | + |
| 57 | +### 1.4 Pre-authorization Check (Precheck) |
| 58 | + |
| 59 | +```xml |
| 60 | +<authentication-managed-identity resource="{{ContainerAppAudience}}" ... /> |
| 61 | +<send-request ... response-variable-name="precheckResponse"> |
| 62 | + <set-url>{{ContainerAppUrl}}/api/precheck/{clientAppId}/{tenantId}?deploymentId={deploymentId}</set-url> |
| 63 | +</send-request> |
| 64 | +``` |
| 65 | + |
| 66 | +**This is the core rate-limiting and quota gate.** Before the request reaches OpenAI: |
| 67 | + |
| 68 | +1. APIM acquires a token via its **managed identity** for the Container App audience — no secrets are exchanged. |
| 69 | +2. Calls the Chargeback API's `/api/precheck` endpoint, which checks: |
| 70 | + - Client is registered and has a plan assigned |
| 71 | + - Monthly token quota is not exhausted |
| 72 | + - RPM/TPM rate limits are within bounds |
| 73 | +3. Response handling: |
| 74 | + - **401** → Client not authorized (no plan assigned) → returns 401 to caller |
| 75 | + - **429** → Quota/rate limit exceeded → returns 429 to caller |
| 76 | + - **Non-200** → Returns 500 to caller |
| 77 | + - **200** → Request proceeds to OpenAI |
| 78 | + |
| 79 | +This **blocks unauthorized or over-quota requests before they incur OpenAI costs** — a key architectural decision. |
| 80 | + |
| 81 | +### 1.5 Backend Configuration & Authentication |
| 82 | + |
| 83 | +```xml |
| 84 | +<set-backend-service backend-id="openAiBackend" /> |
| 85 | +<authentication-managed-identity resource="https://cognitiveservices.azure.com/" /> |
| 86 | +``` |
| 87 | + |
| 88 | +- Routes the request to the pre-configured `openAiBackend` backend (defined in `infra/bicep/apimOaiApi.bicep`). |
| 89 | +- Authenticates to Azure OpenAI via APIM's **managed identity** with the Cognitive Services resource scope — **no API keys are used**. |
| 90 | + |
| 91 | +### 1.6 Request Body Transformation |
| 92 | + |
| 93 | +```xml |
| 94 | +<set-body>@{ |
| 95 | + ... |
| 96 | + if (requestBody["stream"] == true) { |
| 97 | + requestBody["stream_options"] = { "include_usage": true }; |
| 98 | + } |
| 99 | + return requestBody.ToString(); |
| 100 | +}</set-body> |
| 101 | +``` |
| 102 | + |
| 103 | +If the request has `"stream": true`, it injects `"stream_options": {"include_usage": true}`. This forces Azure OpenAI to include token usage data in the final streaming chunk, which is essential for accurate billing of streaming responses. |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +## 2. `<backend>` — Pass-Through |
| 108 | + |
| 109 | +```xml |
| 110 | +<backend> |
| 111 | + <base /> |
| 112 | +</backend> |
| 113 | +``` |
| 114 | + |
| 115 | +No custom logic — inherits default behavior. The request is forwarded to Azure OpenAI as configured in the inbound section. |
| 116 | + |
| 117 | +--- |
| 118 | + |
| 119 | +## 3. `<outbound>` — Response Processing (Post-Backend) |
| 120 | + |
| 121 | +### 3.1 Response Parsing |
| 122 | + |
| 123 | +The policy handles both **non-streaming** and **streaming (SSE)** responses: |
| 124 | + |
| 125 | +- **Non-streaming**: Parses the entire response body as JSON. |
| 126 | +- **Streaming**: Identifies `data:` prefixed lines (Server-Sent Events), finds the **last chunk** containing a JSON payload (excluding `[DONE]`), and parses it. This last chunk is where Azure OpenAI places the `usage` object (enabled by the inbound `stream_options` injection). |
| 127 | + |
| 128 | +### 3.2 Fire-and-Forget Usage Logging |
| 129 | + |
| 130 | +```xml |
| 131 | +<send-one-way-request mode="new"> |
| 132 | + <set-url>{{ContainerAppUrl}}/api/log</set-url> |
| 133 | + ... |
| 134 | +</send-one-way-request> |
| 135 | +``` |
| 136 | + |
| 137 | +Sends a **one-way (fire-and-forget)** POST to the Chargeback API's `/api/log` endpoint with: |
| 138 | + |
| 139 | +| Field | Source | |
| 140 | +|-------|--------| |
| 141 | +| `tenantId` | JWT `tid` claim | |
| 142 | +| `clientAppId` | JWT `azp`/`appid` claim | |
| 143 | +| `audience` | JWT `aud` claim | |
| 144 | +| `requestBody` | Original client request | |
| 145 | +| `responseBody` | Parsed OpenAI response (with usage data) | |
| 146 | +| `deploymentId` | Extracted model/deployment name | |
| 147 | + |
| 148 | +**Key design choice**: `send-one-way-request` means the client gets the OpenAI response immediately without waiting for logging to complete. This eliminates latency overhead on the response path. |
| 149 | + |
| 150 | +--- |
| 151 | + |
| 152 | +## 4. `<on-error>` — Error Handling |
| 153 | + |
| 154 | +Captures detailed error information into HTTP response headers: |
| 155 | + |
| 156 | +| Header | Value | |
| 157 | +|--------|-------| |
| 158 | +| `ErrorSource` | Which component failed | |
| 159 | +| `ErrorReason` | Error reason code | |
| 160 | +| `ErrorMessage` | Human-readable error message | |
| 161 | +| `ErrorScope` | Policy scope where error occurred | |
| 162 | +| `ErrorSection` | Which section (inbound/outbound/backend) | |
| 163 | +| `ErrorPath` | URL path that triggered the error | |
| 164 | +| `ErrorPolicyId` | Specific policy element that failed | |
| 165 | +| `ErrorStatusCode` | HTTP status code | |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +## 5. Security Analysis |
| 170 | + |
| 171 | +| Aspect | Assessment | |
| 172 | +|--------|-----------| |
| 173 | +| **Authentication** | Strong — Entra ID JWT validation with audience restriction | |
| 174 | +| **Multi-tenant** | `/common` endpoint supports any Entra tenant; tenant isolation is enforced at the data layer via `tenantId` | |
| 175 | +| **No API keys** | All backend calls use managed identity — no secrets in policy or config | |
| 176 | +| **Pre-authorization** | Requests are gated before reaching OpenAI, preventing cost abuse | |
| 177 | +| **APIM-to-backend auth** | Managed identity token for Container App audience — zero-trust between components | |
| 178 | + |
| 179 | +### Considerations |
| 180 | + |
| 181 | +- The `on-error` section exposes detailed error metadata in HTTP headers (source, path, policyId). This is useful for debugging but in production you may want to limit this to avoid leaking internal implementation details to external callers. |
| 182 | +- The fire-and-forget log payload is constructed via string interpolation (`$"{{...}}"`). If `requestBody` or `parsedResponseString` contain unescaped JSON, the payload is well-formed because they're already JSON strings. However, if either is empty or malformed, the `/api/log` endpoint receives invalid JSON — the backend handles this gracefully with a `BadRequest` response. |
| 183 | +- The `validate-jwt` policy does not restrict `iss` (issuer) claims beyond what `/common` provides. Any Entra tenant can authenticate — access control is delegated to the precheck endpoint which verifies the `clientAppId:tenantId` pair is registered. |
| 184 | + |
| 185 | +--- |
| 186 | + |
| 187 | +## 6. Named Values (APIM Configuration) |
| 188 | + |
| 189 | +The policy references the following `{{named values}}` that must be configured in the APIM instance: |
| 190 | + |
| 191 | +| Named Value | Purpose | |
| 192 | +|-------------|---------| |
| 193 | +| `{{ExpectedAudience}}` | The `aud` claim value required in JWT tokens (typically the APIM app registration's Application ID URI) | |
| 194 | +| `{{ContainerAppAudience}}` | The resource/audience used when acquiring a managed identity token for the Container App | |
| 195 | +| `{{ContainerAppUrl}}` | Base URL of the Chargeback API running on Azure Container Apps | |
| 196 | + |
| 197 | +--- |
| 198 | + |
| 199 | +## 7. Flow Summary |
| 200 | + |
| 201 | +``` |
| 202 | +Client Request |
| 203 | + │ |
| 204 | + ▼ |
| 205 | +┌─────────────────────────────────────────┐ |
| 206 | +│ INBOUND │ |
| 207 | +│ 1. Validate JWT (Entra ID, audience) │ |
| 208 | +│ 2. Extract claims (tid, appid/azp) │ |
| 209 | +│ 3. Resolve deploymentId │ |
| 210 | +│ 4. Precheck → Container App │ |
| 211 | +│ ├─ 401 → block (not authorized) │ |
| 212 | +│ ├─ 429 → block (over quota) │ |
| 213 | +│ └─ 200 → continue │ |
| 214 | +│ 5. Set backend → openAiBackend │ |
| 215 | +│ 6. Auth via managed identity │ |
| 216 | +│ 7. Inject stream_options if streaming │ |
| 217 | +└───────────────┬─────────────────────────┘ |
| 218 | + ▼ |
| 219 | + Azure OpenAI |
| 220 | + │ |
| 221 | + ▼ |
| 222 | +┌─────────────────────────────────────────┐ |
| 223 | +│ OUTBOUND │ |
| 224 | +│ 1. Parse response (SSE or JSON) │ |
| 225 | +│ 2. Fire-and-forget POST to /api/log │ |
| 226 | +│ (tenantId, clientAppId, usage) │ |
| 227 | +└───────────────┬─────────────────────────┘ |
| 228 | + ▼ |
| 229 | + Response to Client (unmodified) |
| 230 | +``` |
| 231 | + |
| 232 | +This is a chargeback/metering gateway policy. The inbound precheck blocks unauthorized or over-quota requests before they incur OpenAI costs, and the outbound fire-and-forget logging ensures usage tracking doesn't add latency to client responses. |
0 commit comments