Architecture
MCP client (agent, Claude Code, Cursor, …) │ Bearer: the USER's token (api://data-agent-service / access_as_user) ▼ API Management ── /warehouse/mcp ──► warehouse-query (mcpMode: passthrough) └─ /om/mcp ──► warehouse-query /om/mcp ──► OpenMetadata /mcp (passthrough; the executor picks the role's bot) /warehouse-rest ─► warehouse-query (REST, for non-MCP clients & load tests) │ ▼ warehouse-query (the executor) 1. validates the bearer against the tenant's JWKS (issuer, audience, scope) 2. SQL guard: parse → single read-only SELECT → allowed schemas → row ceiling 3. managed identity ──► on-behalf-of ──► data-plane token carrying THE USER 4. TDS to the Fabric Warehouse; the SOURCE applies that user's permissionsWhy the executor speaks MCP
Section titled “Why the executor speaks MCP”The gateway can synthesise MCP tools from a REST API, but a synthesised call is
a new request that carries none of the caller’s headers, so the user’s identity
is lost (docs/upstream-issues.md #8). Since acting as the asking user is the
whole point, the service implements MCP itself and the gateway proxies it —
also the shape Azure documents for putting API Management in front of your own
MCP server. Owning the tool surface additionally means the tool descriptions
say what an analyst needs (“describe before you query”), which is what the
model reads.
Two surfaces, one contract
Section titled “Two surfaces, one contract”A source declares whether it speaks SQL or HTTP, and list_sources reports it
so a client knows which verbs apply.
| SQL source | HTTP source | |
|---|---|---|
| Discovery | list_tables, describe_table |
list_operations, describe_operation |
| Execution | run_query |
call_operation |
| The allow-list | schemas, and the parse tree | the OpenAPI document |
| Access rules | schema.table.column |
collection.operation.field — the same matcher |
call_operation is a second surface rather than an overload of run_query.
Passing a JSON body pretending to be a statement would keep the contract’s
shape and lose its meaning.
An enterprise knowledge base is an HTTP source. Most retrieval APIs are
POST /search with a JSON body, which the guard admits only when the spec
marks the operation x-read-only — a POST that changes state and a POST that
runs a search are indistinguishable otherwise. What that buys is the property
enterprise retrieval usually cannot state about itself: the source says whether
it applies the caller’s document permissions or a service identity’s, that
answer is recorded in every audit line, and a field a role may not read is
stripped from retrieved documents exactly as it is from warehouse columns.
Where each guardrail lives
Section titled “Where each guardrail lives”| Guardrail | Enforced in | Why there |
|---|---|---|
| Rate limit per caller | APIM policy | the chokepoint every call passes |
| Token validation | executor (and APIM in production) | cannot be bypassed by reaching the service directly |
| Read-only SQL, schema scope, row ceiling | sqlguard.py, in the executor process |
a guard beside the cursor cannot be routed around |
| Safe methods, declared parameters, item and body ceilings | httpguard.py, same process |
the HTTP counterpart; an API call has no parse tree, so every property was translated rather than ported |
| Who may see which rows | the data source itself, via the OBO token — except where the engine has no identity, see below | the database is the authority, not our code, wherever it can be |
| Catalog reach and catalog writes | OpenMetadata’s own policy on the bot matching the caller’s role | the catalog decides what its bot may see and do; the executor only chooses which bot, by the role it resolved for the data path |
The engines that cannot be the authority
Section titled “The engines that cannot be the authority”The row above is the load-bearing one, and there is a class of source where it
is false. authz_tier names which case a source is in, and it is recorded in
every audit line rather than inferred:
user— the engine authorises the caller. A Fabric Warehouse or an Azure Database for PostgreSQL takes the caller’s on-behalf-of token, and its own grants decide what comes back. Our access rules narrow that; they do not replace it.service— the engine cannot tell its callers apart. Every request arrives as one principal, and per-user authorization then rests entirely on the gateway’s roles andDAS_ACCESS_RULES.
For a PostgreSQL with no Entra trust, service is a property of that
deployment — point it at Azure Database for PostgreSQL and it becomes user.
For an embedded engine it is a property of the engine. DuckDB is a library
reading a file: there is no session, no principal, no GRANT to a directory
identity, and nothing to exchange a token for. A DuckDB source is service
tier permanently, and the honest description is not “one of three layers of
authorization” but “the gateway’s roles and the access rules are the entire
control”. The executor refuses at start-up a DuckDB source that claims
authz_tier: user, because that claim cannot be made true by configuration.
That is the trade for what an embedded engine buys — a source with no server, no credential and no network, which is why it is the right shape for local work, for querying lake files directly, and for exercising this design against a fourth SQL dialect at almost no cost.
Identity, hop by hop
Section titled “Identity, hop by hop”| Hop | Mechanism | Identity downstream |
|---|---|---|
| user → client | authorization code + PKCE (device code for CLIs) | the user |
| client → gateway | bearer for api://data-agent-service |
the user |
| gateway → executor | passthrough forwards every header | the user |
| executor → tenant | managed identity (App Service protocol) → OBO | the user, for database.windows.net |
| executor → warehouse | TDS with that token | the user |
gateway → executor /om/mcp |
passthrough forwards every header | the user |
| executor → OpenMetadata | the read-only bot for the caller’s role, its JWT read from Key Vault | the role bot — OpenMetadata’s audit names the bot; the executor’s audit line names the human and the bot together |
The executor holds no secret in its environment: its managed identity reads what it needs from Key Vault.
Two executors
Section titled “Two executors”DAS_EXECUTOR=py|go chooses the implementation compose builds. Both satisfy
services/contract/openapi.json, proved by services/conformance/run.py (21
checks), so the gateway, the agent and the evals cannot tell them apart.
Go is ~8× the throughput at a thirtieth of the image size; Python is where new behaviour is easiest to write. The measurements and the reasoning are in adr/0001-two-executors.md — including the finding that the gateway looks free in front of the slow executor and costs 28% of throughput in front of the fast one.