Skip to content

Add APIM + Microsoft Purview DLP AI Gateway lab (Foundry + Bedrock) - #390

Merged
Alex Vieira (vieiraae) merged 7 commits into
Azure-Samples:mainfrom
inwafula:apim-purview-dlp-lab
Aug 7, 2026
Merged

Alex Vieira (vieiraae) merged 7 commits into
Azure-Samples:mainfrom
inwafula:apim-purview-dlp-lab

Conversation

@inwafula

@inwafula inwafula commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new lab under labs/apim-purview-dlp/ that puts Azure API Management in front of two AI backends — Microsoft Foundry (gpt-4.1) and Amazon Bedrock (Nova 2 Lite via bedrock-runtime.InvokeModel, SigV4-signed inside the APIM policy — no Lambda) — and enforces Microsoft Purview DLP on both prompts and responses via a shared policy fragment that calls the Microsoft Graph dataSecurityAndGovernance/processContent API on-behalf-of the signed-in user.

Two gates:

  • Gate 1 — inbound uploadText: the prompt is inspected by Purview before it leaves the gateway. Real PII (Luhn-valid CC, U.S. SSN) → 403 PURVIEW_DLP_PROMPT.
  • Gate 3 — outbound downloadText: the model response is inspected before it leaves the gateway. A demo simulator (stripe test credit card or fictional hr documentation trigger phrases) replaces the model text with a canned SIT-detectable string so the block is reproducible without asking a model to emit PII → 403 PURVIEW_DLP_RESPONSE.

Prompts and responses are also written to a granular-RBAC-protected Log Analytics custom table (AIGatewayContent_CL), and a single KQL rolls up model PAYG spend + Purview PAYG spend per user, per model, per backend across both clouds.

What's in the lab

File Purpose
README.MD Full Purview, Entra, Bedrock, Foundry prereqs; cost table (~$23/day APIM StandardV2 dominant); clean-up
foundry-hrpolicy.policy.xml Foundry leg policy — Gate 1, Gate 3, six-header telemetry contract, simulator, trace
bedrock-expense.policy.xml Bedrock leg policy — same contract, plus SigV4 signing in-policy
purview-processcontent.fragment.xml Shared fragment: OBO exchange → cached Graph token → dataSecurityAndGovernance/processContent call → policyActions[*].action verdict → pv-blocked + pv-block-reason
emit-content-log.fragment.xml Writes each turn to AIGatewayContent_CL via DCE/DCR
main.bicep APIM StandardV2, LAW + Application Insights, DCE + DCR + custom table, RBAC assignments, named values, both APIs + operations, API-level <diagnostic verbosity="verbose"> on each API so TraceRecords populate
setup/Create-LabDlpPolicy.ps1 Wrapper around microsoft/purview-api-samples/DLPforCustomAIApps — creates the lab DLP policy end-to-end via New-DlpCompliancePolicy + New-DlpComplianceRule (Application enforcement plane, RestrictAccess Block on UploadText + DownloadText, SITs = Credit Card + U.S. SSN)
aigw-content-audit.kql 4 views: chronological feed, per-turn join, blocked-only, content ↔ trace
aigw-payg-purview-forecast.kql 30-day cost forecast per user × model × backend, with token→bytes→chunking model
apim-purview-dlp.ipynb 6-scenario walkthrough: Foundry+Bedrock happy paths, Foundry Gate 1 real-PII prompt block, Foundry Gate 3 simulator block, Bedrock Gate 3 simulator block, then cost + audit KQL views
clean-up-resources.ipynb RG teardown + reminder to delete the Entra app

Deployment parameters are supplied via a local, git-ignored params.json (matched by the repo-root **/params.json rule). Every secret (AWS access key/secret, Entra client secret, subscription IDs) stays out of the repo.

End-to-end validation

All six scenarios verified against a live deploy:

Scenario HTTP X-Purview-Blocked X-Purview-Block-Reason Prompt/Response Calls Tokens
Foundry happy 200 0 none 1 / 1 8 / 10
Foundry Gate 1 (real Luhn CC in prompt) 403 1 PURVIEW_DLP_PROMPT 1 / 0 0 / 0
Foundry Gate 3 (simulator CC) 403 1 PURVIEW_DLP_RESPONSE 1 / 1 0 / 0
Foundry Gate 3 (simulator SSN) 403 1 PURVIEW_DLP_RESPONSE 1 / 1 0 / 0
Bedrock happy 200 0 none 1 / 1 47 / 12
Bedrock Gate 3 (simulator CC) 403 1 PURVIEW_DLP_RESPONSE 1 / 1 0 / 0
Bedrock Gate 3 (simulator SSN) 403 1 PURVIEW_DLP_RESPONSE 1 / 1 0 / 0

KQL verified against Log Analytics — both AIGatewayContent_CL (prompt + response rows paired by CorrelationId, blocked rows carry PurviewBlockReason) and the trace-based forecast query (ApiManagementGatewayLogs filtered on TraceRecords.source == 'AIGateway') return rows keyed to the signed-in UPN.

Non-obvious design decisions (documented in code comments)

  • Foundry MI audience is https://ai.azure.com (Foundry Responses API surface), NOT https://cognitiveservices.azure.com.
  • Foundry needs Accept-Encoding: identity on the backend call so the outbound JObject.Parse can read the response.
  • Foundry response text lives at output[0].content[0].text, not top-level output_text — the fragment uses SelectToken to pull it out.
  • Bedrock rewrite-uri keeps raw : in the model id; SigV4 canonical uses Uri.EscapeDataString so both sides match after APIM's on-wire encoding.
  • Graph URL is /beta/me/dataSecurityAndGovernance/processContent, not /beta/me/processContent, and requires Content.Process.User (1d787a13-…) in addition to ProtectionScopes.Compute.User + ContentActivity.Write.
  • Request body wraps everything in contentToProcess with protectedAppMetadata.applicationLocation.value = <appId> — without that field the API returns 400 empty ContentToProcess.
  • Verdict path is top-level $.policyActions[*].action (the response's protectionScopeState is a string, not an object).
  • OBO cache key includes a version suffix (obo-v3-) so stale cached tokens are invalidated when the required scope list or key-derivation changes; the key itself is SHA-256(jwt) to bound cache-key length.
  • API-level <diagnostic verbosity="verbose"> is provisioned in Bicep on each of the two APIs so <trace source="AIGateway"> populates ApiManagementGatewayLogs.TraceRecords end-to-end without a portal step.

Cost & clean-up

  • APIM StandardV2 is the dominant cost (~$23 / day) whether or not the lab is being used. README.MD and clean-up-resources.ipynb call this out explicitly.
  • Traffic-driven costs are per-turn: model PAYG + 2 × processContent calls ($0.00005/call × 2 = $0.0001/turn) + 1 × audit record ($0.000015/turn).

Related upstream PRs (planned, follow-ups)

  • microsoft/purview-api-samples — if the maintainers want the lab-specific DLP script upstreamed, we'll open a companion PR from the DLPforCustomAIApps folder. Otherwise it stays under labs/apim-purview-dlp/setup/.

New lab under labs/apim-purview-dlp/ that puts Azure API Management in front of two AI backends — Microsoft Foundry (gpt-4.1) and Amazon Bedrock (Nova 2 Lite) — and enforces Microsoft Purview DLP on both prompts (Gate 1, uploadText) and responses (Gate 3, downloadText) via a shared APIM policy fragment that calls the Microsoft Graph /beta/me/dataSecurityAndGovernance/processContent API on-behalf-of the signed-in user.

What's included:

- foundry-hrpolicy.policy.xml + bedrock-expense.policy.xml — full APIM policies with the six-header telemetry contract (X-Purview-Prompt-Calls, X-Purview-Response-Calls, X-Purview-Blocked, X-Purview-Block-Reason, X-Model-Input-Tokens, X-Model-Output-Tokens) and a demo Gate-3 simulator triggered by 'stripe test credit card' / 'fictional hr documentation' phrases so the response-lane block is reproducible without asking a model to emit PII.

- purview-processcontent.fragment.xml — shared fragment implementing OBO exchange, Graph processContent call with the correct dataSecurityAndGovernance path + contentToProcess body shape + top-level policyActions verdict parsing, and cached OBO tokens.

- emit-content-log.fragment.xml + AIGatewayContent_CL custom table + DCE/DCR — prompt/response audit trail via granular RBAC.

- aigw-content-audit.kql + aigw-payg-purview-forecast.kql — audit and cost-forecast queries (model tokens joined with Purview call counts).

- apim-purview-dlp.ipynb — 6-scenario walkthrough: Foundry+Bedrock happy paths, Foundry Gate 1 real-PII prompt block, Foundry Gate 3 simulator-driven response block, Bedrock Gate 3 simulator-driven response block, plus KQL cost + audit views.

- setup/Create-LabDlpPolicy.ps1 — wrapper around microsoft/purview-api-samples/DLPforCustomAIApps that creates the lab DLP policy (Application enforcement plane, RestrictAccess Block on UploadText + DownloadText, SITs = Credit Card + U.S. SSN).

- main.bicep + main.local.parameters.sample.json + main.whatif.parameters.json — one-command deploy.

- README.MD — full Purview + Entra + Bedrock + Foundry prereqs, cost table (~\/day APIM StandardV2 dominant), and clean-up.

Validated end-to-end: all six scenarios green, both audit and forecast KQLs return rows keyed to the signed-in UPN, Purview calls billed per turn per backend, and diagnostic setting verbosity is set to verbose in Bicep so TraceRecords ingestion survives redeploys.
@inwafula
inwafula marked this pull request as ready for review August 2, 2026 00:39
Repo vocabulary doesn't include Multi-Cloud AI. Keep Governance & Responsible AI which is the established umbrella for DLP/guardrails/safety labs (content-safety, token-rate-limiting, access-controlling, etc.).
Not in repo vocabulary. Keep Governance & Responsible AI (the umbrella for DLP/guardrails labs).

@vieiraae Alex Vieira (vieiraae) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really strong lab — the two-gate (uploadText / downloadText) design, the shared fragment, the in-policy SigV4 so there's no Lambda, and the level of "why" documented in the comments are all excellent. main.bicep compiles clean against the repo modules (only the pre-existing appinsights.bicep BCP037 warning).

Requesting changes for a few blockers and a set of correctness / doc mismatches. Details are inline; summary here.

Blockers

  1. apim-purview-dlp.ipynb cell 16 has a Python syntax error — a literal \n", leaked into the source line, so the Foundry Gate-3 scenario can't run (SyntaxError: unexpected character after line continuation character). I compiled every code cell; this is the only failure.
  2. Broken image reference — the notebook points at ../../images/apim-purview-dlp.svg, but the PR adds apim-purview-dlp.png. README.MD correctly uses .png.
  3. OBO cache key uses String.GetHashCode() — a 32-bit non-cryptographic hash keying a cache of user Graph tokens. A collision serves user A user B's OBO token.
  4. main.whatif.parameters.json contains a real-looking tenant GUID committed to the repo, and no other lab in this repo ships a *.whatif.parameters.json.

Correctness / security

  1. The Foundry leg does not strip X-User-Token, while the Bedrock leg does — contradicting the README.MD frontmatter claim "The backends never see the user token."
  2. The DLP verdict fails open (catch { return "ALLOWED"; }).
  3. X-User-Token is never validated (validate-jwt), so audit attribution in AIGatewayContent_CL is spoofable and the rate limit is trivially resettable.
  4. Blocked PII is written to AIGatewayContent_CL anyway, because emit-content-log runs before the 403. Reasonable by design, but it should be an explicit callout in README.MD.
  5. Ocp-Apim-Subscription-Key is forwarded to both backends, including to AWS.

Doc / comment mismatches

  • foundry-hrpolicy.policy.xml: "All three native LLM guardrails are enabled" — the next comment says two are omitted.
  • Both policies reference infra/platform.bicep, which doesn't exist (it's main.bicep).
  • purview-processcontent.fragment.xml's named-value list omits purview-graph-host, which the fragment uses twice.
  • Create-LabDlpPolicy.ps1 says "CC/SSN/IBAN" in three places but only configures CC + SSN.
  • aigw-payg-purview-forecast.kql's header is stale in several ways (see inline).
  • Notebook step 5 says responses carry X-Purview-Called: 1 — no such header exists.
  • PR description says a service-level <diagnostic>; the Bicep provisions API-level diagnostics on each API.

Questions

  • foundryProjectEndpoint shape is ambiguous across the sample params, the whatif params, and the policy comment — these produce different backend URLs.
  • Should the simulator ship as-is? A Luhn-valid card number and an SSN-shaped string hardcoded in the repo may trip secret/DLP scanners on the repo itself. An env- or notebook-supplied trigger string would avoid that.

Comment thread labs/apim-purview-dlp/apim-purview-dlp.ipynb Outdated
Comment thread labs/apim-purview-dlp/apim-purview-dlp.ipynb Outdated
Comment thread labs/apim-purview-dlp/purview-processcontent.fragment.xml Outdated
Comment thread labs/apim-purview-dlp/purview-processcontent.fragment.xml Outdated
Comment thread labs/apim-purview-dlp/purview-processcontent.fragment.xml
Comment thread labs/apim-purview-dlp/bedrock-expense.policy.xml
Comment thread labs/apim-purview-dlp/main.whatif.parameters.json Outdated
Comment thread labs/apim-purview-dlp/main.local.parameters.sample.json Outdated
Comment thread labs/apim-purview-dlp/setup/Create-LabDlpPolicy.ps1
Comment thread labs/apim-purview-dlp/aigw-payg-purview-forecast.kql Outdated
…y S5-S10, Docs D1-D6, Cosmetic C1-C2)

Blockers: fix notebook syntax error (B1), .svg -> .png (B2), SHA-256 OBO cache key (B3), remove committed sample params w/ real tenant GUID + labs/**/*.local* gitignore patterns (B4). Security: strip Ocp-Apim-Subscription-Key alongside X-User-Token (S5); propagate pv-block-reason variable through 403 responses w/ UNKNOWN fail-closed (S6); validate-jwt on X-User-Token + rate-limit keyed on oid claim (S7); README callout on AIGatewayContent_CL audit trail containing blocked content (S8); safe content-user from validated JWT claims, no base64 decode (S10). Docs: reword native-guardrail claim (D1), infra/platform.bicep -> main.bicep (D2), purview-graph-host in NV list (D3), drop IBAN from Create-LabDlpPolicy (D4), Purview-wired forecast header (D5), X-Purview-*-Calls header names (D6). Cosmetic: split collapsed set-header line (C1), reword Bearer comment (C2).
Notebook hint + Bicep param @description now show the canonical shape: <foundryResource>.services.ai.azure.com/api/projects/<projectName> (host + path, no scheme, no trailing slash).
@inwafula

inwafula commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

yes, the PR description says "service-level <diagnostic>" but the Bicep actually provisions API-level diagnostics (Microsoft.ApiManagement/service/apis/diagnostics@2024-06-01-preview on each of the two APIs, main.bicep lines ~433 and ~485). That's what feeds AzureDiagnostics + ApiManagementGatewayLogs for just these two APIs without blanket-logging every other API on the same APIM instance. I'll edit the PR description to say API-level,

@vieiraae
Alex Vieira (vieiraae) merged commit 2432f73 into Azure-Samples:main Aug 7, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants