Add APIM + Microsoft Purview DLP AI Gateway lab (Foundry + Bedrock) - #390
Conversation
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.
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).
Alex Vieira (vieiraae)
left a comment
There was a problem hiding this comment.
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
apim-purview-dlp.ipynbcell 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.- Broken image reference — the notebook points at
../../images/apim-purview-dlp.svg, but the PR addsapim-purview-dlp.png.README.MDcorrectly uses.png. - 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. main.whatif.parameters.jsoncontains a real-looking tenant GUID committed to the repo, and no other lab in this repo ships a*.whatif.parameters.json.
Correctness / security
- The Foundry leg does not strip
X-User-Token, while the Bedrock leg does — contradicting theREADME.MDfrontmatter claim "The backends never see the user token." - The DLP verdict fails open (
catch { return "ALLOWED"; }). X-User-Tokenis never validated (validate-jwt), so audit attribution inAIGatewayContent_CLis spoofable and the rate limit is trivially resettable.- Blocked PII is written to
AIGatewayContent_CLanyway, becauseemit-content-logruns before the 403. Reasonable by design, but it should be an explicit callout inREADME.MD. Ocp-Apim-Subscription-Keyis 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'smain.bicep). purview-processcontent.fragment.xml's named-value list omitspurview-graph-host, which the fragment uses twice.Create-LabDlpPolicy.ps1says "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
foundryProjectEndpointshape 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.
…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).
|
yes, the PR description says "service-level |
2432f73
into
Azure-Samples:main
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 viabedrock-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 GraphdataSecurityAndGovernance/processContentAPI on-behalf-of the signed-in user.Two gates:
uploadText: the prompt is inspected by Purview before it leaves the gateway. Real PII (Luhn-valid CC, U.S. SSN) → 403PURVIEW_DLP_PROMPT.downloadText: the model response is inspected before it leaves the gateway. A demo simulator (stripe test credit cardorfictional hr documentationtrigger phrases) replaces the model text with a canned SIT-detectable string so the block is reproducible without asking a model to emit PII → 403PURVIEW_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
README.MDfoundry-hrpolicy.policy.xmlbedrock-expense.policy.xmlpurview-processcontent.fragment.xmldataSecurityAndGovernance/processContentcall →policyActions[*].actionverdict →pv-blocked+pv-block-reasonemit-content-log.fragment.xmlAIGatewayContent_CLvia DCE/DCRmain.bicep<diagnostic verbosity="verbose">on each API soTraceRecordspopulatesetup/Create-LabDlpPolicy.ps1microsoft/purview-api-samples/DLPforCustomAIApps— creates the lab DLP policy end-to-end viaNew-DlpCompliancePolicy+New-DlpComplianceRule(Applicationenforcement plane,RestrictAccessBlock on UploadText + DownloadText, SITs = Credit Card + U.S. SSN)aigw-content-audit.kqlaigw-payg-purview-forecast.kqlapim-purview-dlp.ipynbclean-up-resources.ipynbDeployment parameters are supplied via a local, git-ignored
params.json(matched by the repo-root**/params.jsonrule). 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:
PURVIEW_DLP_PROMPTPURVIEW_DLP_RESPONSEPURVIEW_DLP_RESPONSEPURVIEW_DLP_RESPONSEPURVIEW_DLP_RESPONSEKQL verified against Log Analytics — both
AIGatewayContent_CL(prompt + response rows paired byCorrelationId, blocked rows carryPurviewBlockReason) and the trace-based forecast query (ApiManagementGatewayLogsfiltered onTraceRecords.source == 'AIGateway') return rows keyed to the signed-in UPN.Non-obvious design decisions (documented in code comments)
https://ai.azure.com(Foundry Responses API surface), NOThttps://cognitiveservices.azure.com.Accept-Encoding: identityon the backend call so the outboundJObject.Parsecan read the response.output[0].content[0].text, not top-leveloutput_text— the fragment usesSelectTokento pull it out.rewrite-urikeeps raw:in the model id; SigV4 canonical usesUri.EscapeDataStringso both sides match after APIM's on-wire encoding./beta/me/dataSecurityAndGovernance/processContent, not/beta/me/processContent, and requiresContent.Process.User(1d787a13-…) in addition toProtectionScopes.Compute.User+ContentActivity.Write.contentToProcesswithprotectedAppMetadata.applicationLocation.value = <appId>— without that field the API returns400 empty ContentToProcess.$.policyActions[*].action(the response'sprotectionScopeStateis a string, not an object).obo-v3-) so stale cached tokens are invalidated when the required scope list or key-derivation changes; the key itself isSHA-256(jwt)to bound cache-key length.<diagnostic verbosity="verbose">is provisioned in Bicep on each of the two APIs so<trace source="AIGateway">populatesApiManagementGatewayLogs.TraceRecordsend-to-end without a portal step.Cost & clean-up
README.MDandclean-up-resources.ipynbcall this out explicitly.processContentcalls ($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 theDLPforCustomAIAppsfolder. Otherwise it stays underlabs/apim-purview-dlp/setup/.