Upstream issues (proposed, not patched)
Discipline rule 1: dependencies are used as-is. Anything that looks like a bug or gap in an emulator or OpenMetadata is recorded here with a repro and routed around.
Confirmed working, for contrast (no gap): entra-emulator’s managed-identity endpoint (/msi/token, App Service protocol) issues tokens for every audience asked; OBO (jwt-bearer + requested_token_use=on_behalf_of) returns a token carrying the user’s oid; fabric-emulator’s TDS front then enforces workspace role assignments against that principal — alice (Viewer) reads, bob (no role) is refused at login. The per-user authorization chain is real, end to end.
| # | Component | Observation | Repro | Expected | Workaround here | Status |
|---|---|---|---|---|---|---|
| 1 | azure-apim-emulator | No MCP OAuth discovery (/.well-known/oauth-protected-resource, RFC 9728) or per-tool policy scoping (both listed pending in its parity.md) |
— | Gateway serves resource metadata and can scope tools | Our service serves the discovery documents; agent enforces tool allow-list; OM bot is read-only | open |
| 2 | fabric-emulator 0.32.0 | Warehouse properties.connectionString echoes the REST request host (fabric-emulator:1433), so a client using the documented path — advertised connectionString + database by display name — fails with workspace "fabric-emulator,1433" not found, because the TDS router takes the workspace from the login server name’s first DNS label. Real Fabric advertises <workspace-guid>.datawarehouse.fabric.microsoft.com. |
GET /v1/workspaces/{ws}/warehouses/{wh} then Server=<connectionString>;Database=<displayName> |
connectionString encodes the workspace (e.g. <ws-id>.<echoed-host>), or the router accepts an unlabelled server when the display name is unambiguous |
compose DNS alias <workspace-name>.datawarehouse.fabric.microsoft.com on fabric-emulator + per-source tds_server override (env only; blank in prod) |
open |
| 3 | entra-emulator 0.9.0 | Graph GET /applications?$filter=identifierUris/any(u:u eq '…') → 400 unsupported $filter (real Graph supports the lambda) |
see seed/common.py graph_ensure_resource_app |
filter honoured | fall back to listing + client-side match | open |
| 4 | entra-emulator 0.9.0 | https://database.windows.net is not a seeded first-party resource (unlike vault.azure.net), so client_credentials for the SQL audience fails AADSTS500011 until an app with that identifierUri exists |
token request with scope=https://database.windows.net/.default on a fresh emulator |
well-known Azure SQL resource issuable out of the box | seed registers it via Graph POST /applications (no-op on a real tenant) |
open |
| 5 | entra-emulator 0.9.0 | Graph POST/PATCH /applications accepts api.oauth2PermissionScopes (204) but does not persist it; POST /applications/{id}/addPassword is 404; an application’s confidential/public nature has no Graph field |
see seed/apps.py |
Graph writes persist scopes and credentials as real Graph does | one-time TENANT SETUP falls back to the tenant admin surface (/admin/api/apps/{id}/scopes, /secrets, PATCH isConfidential), guarded by a Graph postcondition check so a real tenant never reaches it; never used by services/, agent/ or harnesses |
open |
| 6 | entra-emulator 0.9.0 | Secretless OBO (client assertion from a federated identity credential whose issuer is the emulator itself) fails AADSTS700213 … fetch issuer metadata: tls: failed to verify certificate — the validator does not trust the emulator’s own self-signed cert when fetching its own JWKS |
mint MI token for api://AzureADTokenExchange, use as client_assertion in the OBO request |
self-federation validates (real Entra federates to itself for MI-backed FICs) | documented parity gap: OBO uses a client secret fetched from Key Vault with the managed identity (never an env var). The FIC is still created, so prod runs secretless with no code change | open |
| 7 | azure-apim-emulator 0.4.0 | The gateway’s validate-jwt accepts ONLY ARM-audience tokens: Validator.Validate (wired in as the policy token validator) hardcodes armAudience(claims.Audience), so a policy declaring <audiences><audience>api://my-api</audience></audiences> still rejects a valid token for that audience. <openid-config> is also unsupported in this release (unsupported policy: <validate-jwt/openid-config>), though HEAD has it. |
policy with <issuers> + <audiences> for a non-ARM API; send a valid token for that audience → 401 |
the policy’s declared audience decides | DAS_APIM_VALIDATE_JWT=false locally (true is the default and the production shape). The executor validates the same bearer against the tenant’s JWKS — issuer, audience, scope — before any data is read, and the OpenMetadata route additionally requires an APIM subscription key because its own credential is applied at the gateway |
open |
| 8 | azure-apim-emulator 0.4.0 | REST→MCP tool calls cannot carry the caller’s identity: callMCPBackend builds a fresh http.NewRequestWithContext and copies no inbound headers, so Authorization never reaches the backend. Any on-behalf-of design behind a synthesised MCP API therefore sees an anonymous request. (MCP passthrough does copy every header, and is what this repo uses.) |
publish a REST API as type: mcp, call a tool with a bearer, observe the backend receives none |
the caller’s headers reach the backend, as they do for a normal proxied operation | our executor speaks MCP itself and the gateway proxies it (mcpMode: passthrough) — also the shape Azure documents for fronting your own MCP server |
open |
| 9 | entra-emulator 0.9.0 | An app role assigned to a USER does not appear in that user’s delegated token: roles is emitted for app-only tokens only (documented in docs/20-stateful-directory.md), while real Entra puts a user’s assigned app roles in the roles claim of the delegated token. Graph PATCH /applications/{id} also does not persist appRoles (same shape as #5). |
assign an app role via POST /servicePrincipals/{id}/appRoleAssignedTo with a user principalId, then mint a user token |
the token carries roles: ["Data.Analyst"] |
the executor prefers the roles claim and falls back to a Graph lookup of appRoleAssignedTo, cached — the same fallback a real deployment needs for role/group overage, so no code changes in production. This is also why the catalog’s per-role bot is chosen in the executor rather than by a gateway <choose> on the claim: locally the claim is never there |
open |
| 10 | azure-apim-emulator 0.4.0 | GET {gateway}/.well-known/oauth-authorization-server is answered by the gateway itself for an API of type: mcp, by fetching the configured issuer’s openid-configuration — and that fetch does not honour APIM_ENTRA_TLS_INSECURE, so against a development issuer it fails 500 PolicyExecutionFailure … tls: failed to verify certificate. The sibling path /.well-known/oauth-protected-resource is proxied normally. |
publish any type: mcp API and GET that path |
either the built-in answer honours the same TLS switch as every other outbound call, or the operation defined on the API wins | we publish only the protected-resource document at the gateway; clients follow authorization_servers to the issuer’s own metadata, which is the RFC 9728 flow and cannot go stale |
open |
| 11 | azure-apim-emulator 0.4.0 | llm-token-limit and llm-emit-token-metric read OpenAI-shaped usage (prompt_tokens, completion_tokens, total_tokens). A response using Anthropic’s field names (input_tokens, output_tokens) is counted as zero tokens, so the ceiling never fires for that provider — measured: X-Tokens-Consumed: 200 for the OpenAI shape and 0 for the Anthropic shape from the same stub. |
POST through an llm API to a backend returning each shape; compare the consumed header |
either shape is counted, or the policy reports that it could not account for the response | request-rate governance (rate-limit-by-key) is applied alongside and works for any provider; token spend is metered from the model’s own response in the agent. Whether real APIM parses Anthropic’s schema is unverified — docs/parity.md says “not yet” |
open |
| 12 | databricks-emulator 0.2.9 | A successful statement returns its rows as a JSON string at result.text, and manifest carries no schema. The documented Statement Execution API with format: JSON_ARRAY returns manifest.schema.columns[] and result.data_array[][]; statementJSON (internal/server/sql.go) sets data_array only when stdout is EMPTY, so a query that returns rows never populates it. A client written against the documented shape gets zero columns and zero rows from every successful query. |
POST /api/2.0/sql/statements {"statement":"SELECT COUNT(*) AS n FROM contoso.gold.fct_revenue_summary","warehouse_id":"wh-1"} → status.state=SUCCEEDED, result.text="{\"data\":[[119]],\"schema\":{\"fields\":[…]}}", no result.data_array, no manifest.schema. Our adapter’s _rows() on that response returns ([], []) while the row [[119]] sits in the payload |
manifest.schema.columns[].name and result.data_array as the API documents |
None that keeps the code prod-identical. Teaching the adapter result.text would be parsing a shape real Databricks never emits — an emulator-only path, which scripts/check_prod_paths.py --strict exists to forbid. Databricks therefore stays “unwitnessed” in docs/16-go-parity.md: the adapter is written against the documented API and cannot be proved against this emulator |
open |
| 13 | databricks-emulator 0.2.9 | information_schema.tables is a stub: it answers with a fixed five-column shape (table_name, table_type, file_format, table_owner, databricks_table_type), ignores the SELECT list, and returns zero rows for a catalog whose schema SHOW TABLES lists nine tables in. Table discovery through the standard catalog view finds nothing. |
SELECT table_schema, table_name, table_type FROM information_schema.tables with catalog=contoso → {"schema":{"fields":[table_name, table_type, file_format, table_owner, databricks_table_type]},"data":[]}, while SHOW TABLES IN contoso.gold returns nine rows |
the projected columns, and a row per table | none applied — list_tables against this source returns an empty list, and issue 12 blocks the source anyway. SHOW TABLES is not a substitute: it is not the query the adapter runs on real Databricks, so switching to it would be the emulator-only path again |
open |
| 14 | databricks-emulator 0.2.9 | A two-part schema.table reference is not resolved against the request’s catalog: the body’s catalog field does not set the session’s current catalog (its schema field does set the current database). A guard that qualifies statements to schema.table — which ours does, because the allowed-schema check requires it — therefore cannot address the data at all. |
{"statement":"SELECT COUNT(*) AS n FROM gold.fct_revenue_summary","warehouse_id":"wh-1","catalog":"contoso"} → FAILED … TABLE_OR_VIEW_NOT_FOUND: fct_revenue_summary; adding "schema":"gold" does not help; the three-part contoso.gold.fct_revenue_summary succeeds |
catalog in the request body scopes name resolution, as the API documents |
none applied. Fully qualifying to catalog.schema.table would work here AND on real Databricks, so it is a legitimate future change rather than an emulator accommodation — but it is pointless while issue 12 makes the rows unreadable |
open |
| 15 | contoso-data-product-databricks-jobs (sibling product repo) | steps/govern.py registers the DOMAIN, the databaseService and the six metrics, then writes "fqn": "contoso-databricks.contoso.gold.fct_revenue_summary" into catalog.json as a hard-coded string and prints catalogued contoso-analytics as <that FQN>. No database, schema or table entity is ever created, so nothing resolves at that FQN. A consumer grounding on this catalog finds the service and nothing under it. |
build the product, then against its OpenMetadata (:18585): GET /api/v1/tables → [], GET /api/v1/databases → [], GET /api/v1/dataProducts → [], GET /api/v1/tables/name/contoso-databricks.contoso.gold.fct_revenue_summary → 404 — while GET /api/v1/services/databaseServices returns contoso-databricks and /metrics returns all six |
the tables the product publishes are discoverable under the service it registers, at the FQN catalog.json advertises |
none needed here yet: issue 12 blocks reading the product’s rows anyway. When that clears, a consumer wanting catalog grounding for this product must either ingest the tables itself or use the source’s own list_tables — om_service_fqn on a DAS_SOURCES entry already points at whichever service holds them |
open |
| 16 | fabric-emulator 0.32.0 | The TDS surface resolves the workspace from the first DNS label of the server name (workspaceRef, internal/server/warehouse.go), so a consumer that reaches the emulator by any other hostname is rejected at login with Invalid authorization specification … workspace "host" (from server name "host.docker.internal,11433") not found. Real Fabric’s connection strings do carry the workspace in the hostname, so this is faithful — but it means a second Fabric stack on the same host cannot be addressed by host.docker.internal, and two products whose workspaces share a name cannot both be reached by hostname from one consumer. |
from outside the emulator’s compose network: connect TDS to host.docker.internal,11433 with a valid FedAuth token → the login is refused before the database is considered |
either the hostname or an explicit workspace field addresses the workspace | Addressed in configuration, no code change: resolveSQLItem tries GetItemByID(database) first, so a DAS_SOURCES entry whose database is the warehouse’s ITEM ID connects over any hostname. Witnessed that way — {"database": "<warehouse guid>", "tds_server": "host.docker.internal:11433"} returned the product’s real rows through both executors |
open |
| 17 | contoso-data-product-fabric-notebook-pipelines (sibling product repo) | steps/govern.py reads OM_URL with the default http://localhost:8585/api/v1, and the product’s steps run on the HOST rather than in the platform’s network. On a host where a DIFFERENT OpenMetadata is published on 8585, the govern step silently catalogues the whole product into that stranger’s catalog and reports success — catalogued the medallion: 25 tables, 34 lineage edges. Its own platform’s OpenMetadata (published on 18587) is left empty. |
run the product against fabric-platform-notebook-pipelines while another OpenMetadata answers on localhost:8585; step 14 reports 25 tables; GET :18587/api/v1/tables → []; the 25 tables, a contoso-analytics data product, an apiService and a messagingService appear in the OTHER catalog |
the platform passes OM_URL for its own OpenMetadata, or the default is the platform’s published port rather than a bare 8585 |
none needed by us — but this repo’s OpenMetadata WAS the stranger, and the 25 tables plus four other entities were removed from it by hand afterwards. Anyone running a sibling product on this machine should expect it and check GET /services/databaseServices |
open |