Skip to content
mcp-data-platform composable mcp data platform
v1.x ·· UTC part of txn2 ↗

Threat Model

This document states the security model of mcp-data-platform as a whole: the trust boundaries, the attackers each mechanism is designed to stop, and, just as important, what is explicitly out of scope. It complements SECURITY.md (vulnerability reporting, supported versions) and the Authentication Overview (the fail-closed model in operational detail).

Every mitigation claim below carries a package or configuration citation that a reviewer can verify against the source. Aspirational protections are not listed. Where the platform cannot defend against a class of attack, this document says so plainly.

Reviewed at v1.101.x. See Maintenance contract for when this document must be revisited.

How to read this document

  • System context diagrams every trust boundary in one view.
  • Trust boundaries enumerates the inbound surfaces, identity mechanisms, outbound dependencies, and at-rest stores.
  • Attacker analysis works through six attacker personas, applying STRIDE-style reasoning to the boundaries each one can reach.
  • Mitigations is a threat-to-mechanism table with citations.
  • Non-goals and residual risks states what is deliberately not defended, and why.

Conventions: hostnames use example.com; the platform is vendor-neutral and no deployment names appear here.

System context

The platform sits between MCP clients (and a browser-based portal) on one side and a set of data and identity services on the other. Trust changes at each boundary crossing. Everything inside the platform box runs with the platform's own process trust; everything outside is either an authenticated caller whose identity the platform establishes, or an outbound dependency the platform authenticates to with operator-supplied credentials.

graph TB
    subgraph Callers[Untrusted callers]
        StdioClient[MCP client over stdio<br/>local process]
        HTTPClient[MCP client over HTTP<br/>streamable + SSE]
        Browser[Browser: portal UI<br/>and public share-link viewer]
        NetAttacker[Unauthenticated network]
    end

    subgraph Platform[mcp-data-platform trust boundary]
        subgraph Inbound[Inbound surfaces]
            Transport[MCP transports<br/>stdio / HTTP]
            OAuthEP[OAuth 2.1 endpoints<br/>authorize / token / register / metadata]
            AdminAPI[Admin REST API]
            PortalAPI[Portal API + public viewer]
            GatewayHTTP[Gateway REST shim]
            ObsProxy[Observability PromQL proxy]
            Health[Health endpoints]
            Resources[Managed resource upload/download]
        end

        subgraph Identity[Identity and authorization]
            OIDC[OIDC bearer validation]
            APIKeys[API keys]
            OAuthSrv[Built-in OAuth 2.1 server<br/>HS256 kid ring]
            Cookies[Browser cookie sessions]
            Persona[Persona default-deny]
        end

        subgraph Chain[Middleware chain]
            AuthMW[Auth + authz]
            GateMW[Search-first gate]
            AuditMW[Audit]
            EnrichMW[Cross-enrichment]
        end
    end

    subgraph AtRest[At rest]
        PG[(Postgres<br/>hashed tokens, encrypted secrets,<br/>audit rows)]
        Blobs[(S3 blobs)]
    end

    subgraph Outbound[Outbound dependencies]
        Trino[Trino]
        DataHub[DataHub]
        S3[S3]
        UpstreamMCP[Upstream MCP servers]
        UpstreamAPI[Upstream HTTP APIs]
        IdP[Upstream IdP / OIDC discovery]
        Embed[Embedding provider]
    end

    StdioClient --> Transport
    HTTPClient --> Transport
    Browser --> PortalAPI
    Browser --> Cookies
    NetAttacker -.attempts.-> OAuthEP
    NetAttacker -.attempts.-> Health
    NetAttacker -.attempts.-> PortalAPI

    Transport --> Identity
    OAuthEP --> OAuthSrv
    AdminAPI --> Identity
    PortalAPI --> Identity
    GatewayHTTP --> Identity
    ObsProxy --> Identity
    Resources --> Identity

    Identity --> Chain
    OIDC --> IdP
    Chain --> Outbound
    Chain --> AtRest

    GatewayHTTP --> UpstreamMCP
    Outbound --> Trino
    Outbound --> DataHub
    Outbound --> S3
    Outbound --> UpstreamAPI
    Outbound --> Embed

Trust boundaries

Inbound surfaces

All HTTP surfaces are assembled in one composition root (internal/httpserver/server.go, Serve). The transport selection happens in cmd/mcp-data-platform/main.go (startServer): stdio runs mcpServer.Run(ctx, &mcp.StdioTransport{}) directly, while http/sse runs httpserver.Serve.

Surface Route(s) Authentication
MCP stdio transport n/a (process pipe) None at the transport. stdio is local-process trust; see non-goals.
MCP streamable HTTP / Bearer or X-API-Key, enforced by MCPAuthGateway (pkg/http/authmiddleware.go) when auth.allow_anonymous is false.
MCP SSE /sse, /message Same gate via RequireAuthWithOAuth (pkg/http/authmiddleware.go).
OAuth 2.1 endpoints /authorize, /token, /register, /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource Public by design. /token and /register are rate-limited (see below); /register (DCR) is disabled unless configured.
Admin REST API /api/v1/admin/ admin.RequirePersona (pkg/admin/middleware.go): unauthenticated returns 401, cookie path enforces CSRF, and the resolved persona must equal the configured admin persona.
Portal API /api/v1/portal/ Authenticated mux (pkg/portal/handler.go).
Portal public viewer /portal/view/ Unauthenticated by design, gated by share token only and rate-limited (pkg/portal/handler.go, pkg/portal/ratelimit.go). Revoked/expired tokens return 410.
Gateway REST shim /api/v1/gateway/{connection}/invoke Wrapped by httpauth.RequireAuth when auth is enabled; the request runs through an in-memory MCP session so persona and audit apply (pkg/gatewayhttp/handler.go).
Observability PromQL proxy /api/v1/observability/query, /query_range Requires authentication and the observability:read capability; unauthenticated 401, unauthorized 403 (pkg/observability/proxy/handler.go).
Health /healthz, /readyz Unauthenticated by design (pkg/health/health.go); liveness/readiness only, no data.
Managed resources POST /api/v1/resources, GET /api/v1/resources/{id}/content, and CRUD Every handler calls authenticate first (401 on failure, 403 on CSRF); upload checks CanWriteScope, download checks CanReadResource; uploads are size-bounded via MaxBytesReader (pkg/resource/handler.go).

On the HTTP transports, a missing token yields 401 with a WWW-Authenticate: Bearer challenge, and a present-but-invalid token yields 401 with error="invalid_token" (pkg/http/authmiddleware.go, oauthgate). The gate fails open only on a transient validation outage (ErrValidationUnavailable, e.g. a JWKS fetch failure), never on an invalid token.

Identity and authorization

Mechanism Package Notes
OIDC bearer validation pkg/auth/oidc.go RSA-only signature check, requires kid, validates iss/aud/exp/nbf/iat. The JWKS cache is self-healing: on a kid miss it triggers a single-flight, throttled refresh (refreshForLookup, refreshGroup.DoChan) and re-checks, distinguishing a transient fetch failure (fail-open) from a definitive unknown key (fail-closed).
API keys pkg/auth/apikey.go Config keys are matched with subtle.ConstantTimeCompare; database-issued keys are bcrypt-hashed and verified with bcrypt.CompareHashAndPassword.
Built-in OAuth 2.1 server pkg/oauth/server.go, pkg/oauth/signkey/signkey.go Access tokens are HS256 with a kid header. The kid derives from SHA-256 of the signing key; a key ring keeps verify-only previous keys so rotation does not invalidate live tokens. Unknown kid is rejected.
PKCE pkg/oauth/server.go, pkg/oauth/pkce.go S256 required; any other code_challenge_method is rejected at /authorize, verification hard-codes S256, and metadata advertises S256 only. plain is not implemented.
Browser cookie sessions pkg/browsersession/cookie.go Cookies are HttpOnly, Secure (default on), SameSite=Lax by default; the value is an HS256-signed JWT (signing key required to be at least 32 bytes) and validation rejects non-HMAC algorithms.
Persona default-deny pkg/persona/filter.go On every axis (tools, connections, API routes) a nil persona denies, deny rules are evaluated first and win, an explicit allow is required, and the final fallthrough denies. FilterTools returns nil for a nil persona.

Outbound dependencies

Outbound credentials are operator-authored per connection, not derived from the calling user's identity.

Dependency Package Credential / control
Trino pkg/toolkits/trino/, pkg/query/trino/ Username/password from connection config. Optional read-only mode (read_only, default off) rejects write SQL via ReadOnlyInterceptor (delegates to trinotools.IsWriteSQL); this is a write-verb rejection, not a SELECT-only allowlist.
DataHub pkg/semantic/datahub/adapter.go Static bearer token from connection config.
S3 pkg/storage/s3/adapter.go Static access-key/secret from connection config; the adapter carries a ReadOnly flag.
Upstream MCP servers (gateway toolkit) pkg/toolkits/gateway/ Remote tool descriptions and responses are re-exposed under a namespaced name. Descriptions and non-error response content flow through with enrichment applied only to structured content; there is no description sanitization. See the malicious upstream analysis.
Upstream HTTP APIs (apigateway toolkit) pkg/toolkits/apigateway/invoke.go Requests target the operator-authored base_url; method is restricted to a closed allowlist and per-call timeout is capped. This path targets operator-configured hosts and does not run the catalog SSRF dialer guard.
OAuth-to-upstream pkg/connoauth/exchange.go One shared upstream identity per connection (#374). The token-exchange client refuses redirects, caps the response body, and enforces a hard timeout; tokens are attached by authRoundTripper in the gateway client.
Upstream IdP / OIDC discovery pkg/oidcdiscovery/, pkg/auth/oidc.go Discovery documents and JWKS are fetched from the configured issuer.
Embedding provider pkg/embedding/ollama.go Outbound POST to the operator-configured URL; input is byte-capped before send and error bodies are read under a limit reader.

At rest

Store Package Protection
OAuth authorization codes and refresh tokens pkg/oauth/postgres/store.go, pkg/oauth/storage.go Stored as SHA-256(token) hex digests (HashToken), looked up by hash, and consumed atomically with DELETE ... RETURNING so replay after use returns not-found.
OAuth client secrets pkg/oauth/dcr.go, pkg/oauth/server.go bcrypt-hashed at registration; the plaintext secret is returned to the client once and never stored.
Connection secrets pkg/platform/fieldcrypt/fieldcrypt.go AES-256-GCM with a single symmetric key from ENCRYPTION_KEY (32 bytes), random nonce per operation, stored as an enc:-prefixed base64 value. This is symmetric field encryption, not a DEK/KEK envelope scheme. When no key is configured, values are stored in plaintext (encryption disabled).
Audit rows pkg/audit/postgres/store.go, pkg/middleware/mcp_audit.go Tool-call arguments are stored in the parameters column. Configured top-level keys are replaced with [REDACTED] in the middleware before the event leaves the request path (redact_keys), and log_parameters: false drops arguments entirely. A baseline sanitizer masks a fixed sensitive-key set (pkg/audit/event.go).
S3 blobs pkg/storage/s3/ Stored in the operator's object store under the connection's credentials; blob-at-rest protection is a deployment responsibility (bucket encryption, access policy).

Attacker analysis

Each section names an attacker, the boundaries they can reach, and the mechanisms that stop or bound them. STRIDE categories are noted where they apply.

Unauthenticated network attacker

Reachable surface: OAuth endpoints, health endpoints, the portal public viewer, and (before authentication) the HTTP MCP transports.

  • Denial of service / resource exhaustion on /token and /register (Elevation, DoS). Both endpoints are rate-limited default-on with a per-IP token bucket and a global backstop bucket sized at ten times the per-IP rate (pkg/oauth/ratelimit.go, pkg/ratelimit/http.go); over-limit callers get 429 slow_down. DCR is disabled unless explicitly configured, and the bcrypt cost of /register is throttled by the same limiter.
  • Spoofed X-Forwarded-For to defeat per-IP limits (Spoofing). The client-IP resolver consults X-Forwarded-For only when the direct peer is a configured trusted proxy, then walks the header right-to-left to the last untrusted hop; with no trusted proxies configured the header is ignored entirely (pkg/ratelimit/clientip.go).
  • Public viewer scraping (Information disclosure). The /portal/view/ path is share-token-gated and rate-limited; revoked or expired tokens return 410 (pkg/portal/public.go, pkg/portal/handler.go). Only content the sharer chose to publish is reachable.
  • Token forgery against the MCP transports (Spoofing). A missing or invalid bearer/API key is rejected with 401 before any handler runs (pkg/http/authmiddleware.go); OAuth access tokens are HS256-signed and verified against the key ring.
  • Health endpoints (Information disclosure). /healthz and /readyz are unauthenticated but return only liveness/readiness, no data (pkg/health/health.go).

Authenticated low-privilege persona

Reachable surface: the tools their persona allows, plus probing of surfaces they should not reach.

  • Tool-allowlist escape (Elevation). Authorization is default-deny on every axis; deny rules win over allow, and a tool with no matching allow is denied (pkg/persona/filter.go). FilterTools never returns a tool the persona was not granted.
  • Cross-persona or cross-connection data access (Information disclosure). Connection access is a separate default-deny axis (IsConnectionAllowed); a persona cannot reach a connection it was not granted.
  • Admin-surface probing (Elevation). The admin REST API requires the resolved persona to equal the configured admin persona and enforces CSRF on the cookie path (pkg/admin/middleware.go). The observability proxy requires the observability:read persona capability, gated through the same default-deny persona tool filter (pkg/platform/observability.go, pkg/observability/proxy/handler.go): a persona must explicitly allow it (or match it via a wildcard), so in a typical configuration only the admin persona (allow: ["*"]) holds it.
  • Large-scan or PII access (Information disclosure, DoS). The Trino toolkit can prompt for consent before a large scan (cost estimation via EXPLAIN IO above a row threshold) or before accessing PII columns (pkg/toolkits/trino/elicitation.go). These are user-facing elicitation prompts, not hard blocks, and are specific to the Trino toolkit.

Malicious or compromised upstream

Reachable surface: whatever the gateway toolkit (upstream MCP server) or apigateway toolkit (upstream HTTP API) returns, which flows into cross-enrichment and on to the LLM client.

  • Tool-description injection (Tampering, and prompt injection reaching the client). The gateway toolkit re-exposes an upstream tool's own description verbatim under a namespaced name (pkg/toolkits/gateway/toolkit.go); there is no description sanitization. An operator who connects a malicious upstream MCP server is trusting that server's tool descriptions. The mitigation is operational: connections are operator-authored, credentials are per-connection and encrypted, and every proxied call is audited. The platform does not neutralize adversarial content inside an upstream tool description.
  • Response poisoning into the LLM (Tampering). Upstream responses flow back through the forwarder with enrichment applied only to structured content of non-error results. The platform cannot verify the semantic truthfulness of a response from a compromised upstream; see the malicious query data section, which describes the same residual limit.

Malicious data in query results

Reachable surface: the content of rows returned by Trino, DataHub, or S3, carried to the LLM client through cross-enrichment.

  • Prompt-injection content in data reaching the client (Tampering). Query results and their enriched context are returned to the calling LLM client. DataHub semantic metadata (descriptions, tags, glossary terms, column context) that flows through cross-enrichment is sanitized before exposure: control characters are removed, known injection patterns are stripped by default, strings are length-capped, and detected attempts are logged (pkg/semantic/sanitize.go, pkg/semantic/injection_logger.go). Raw query row values are a different matter: the platform does not and cannot scrub adversarial natural-language content out of legitimate data values, so a cell that contains attacker-controlled text reaches the client. What the platform does provide there is bounding and provenance: read-only mode can prevent the model from acting on injected instructions to mutate data (pkg/toolkits/trino/readonly.go), the search-first gate and persona allowlists bound which tools the model can reach at all, and every tool call is audited. Defending the client's own reasoning against injected data values is a shared responsibility with the client; for raw data the platform's contribution is blast-radius reduction, not content sanitization.

Database reader

Reachable surface: a Postgres backup, replica, or operator with read access to the platform database.

  • Credential theft from the database (Information disclosure, Elevation). OAuth authorization codes and refresh tokens are stored as SHA-256 hashes, not plaintext, and are single-use (pkg/oauth/postgres/store.go); client secrets are bcrypt-hashed (pkg/oauth/dcr.go); connection secrets are AES-256-GCM encrypted with a key held outside the database in ENCRYPTION_KEY (pkg/platform/fieldcrypt/fieldcrypt.go). A database reader does not obtain usable bearer tokens or connection credentials from these columns.
  • What a database reader still obtains. Audit rows contain tool-call arguments (including SQL) subject to redact_keys and the log_parameters opt-out but not otherwise encrypted, semantic metadata, portal content, and knowledge records. If ENCRYPTION_KEY is not configured, connection secrets are stored in plaintext. Protecting the audit and metadata content of a compromised database is a deployment responsibility (at-rest encryption, backup access control).

Compromised downstream credential

Reachable surface: the blast radius of a leaked Trino, S3, or DataHub service account.

  • Downstream account misuse (Elevation, Information disclosure). Because outbound credentials are per-connection and operator-authored, a compromised credential is bounded by what that service account can do in the downstream system, not by the platform's own privileges. Trino read-only mode, when enabled, prevents write SQL through that connection (pkg/toolkits/trino/readonly.go), and the S3 adapter carries a read-only flag. The platform cannot constrain a downstream account beyond what the downstream system's own authorization enforces; scoping the service account to least privilege in Trino/S3/DataHub is a deployment responsibility.

Mitigations

Each row maps a threat to the mechanism that addresses it and the code or configuration that implements it.

Threat Mechanism Citation
Unauthenticated call to an HTTP MCP transport Bearer/API-key gate, 401 + WWW-Authenticate, fail-closed on invalid token pkg/http/authmiddleware.go (MCPAuthGateway, RequireAuthWithOAuth, oauthgate)
Token forgery HS256 access tokens signed and verified against a kid key ring pkg/oauth/server.go, pkg/oauth/signkey/signkey.go
PKCE downgrade to plain S256 required and hard-coded; other methods rejected pkg/oauth/server.go, pkg/oauth/pkce.go
Authorization-code or refresh-token replay Single-use via atomic DELETE ... RETURNING on the hashed value pkg/oauth/postgres/store.go (ConsumeAuthorizationCode, ConsumeRefreshToken)
/token and /register flooding Default-on per-IP token bucket plus a global backstop; 429 slow_down pkg/oauth/ratelimit.go, pkg/ratelimit/http.go
Spoofed X-Forwarded-For Trusted-proxy-aware resolver; header ignored unless the peer is trusted pkg/ratelimit/clientip.go
DCR abuse / unbounded client growth DCR disabled unless configured; registration rate-limited pkg/oauth/server.go (RegisterClient), pkg/oauth/ratelimit.go
Tool-allowlist escape / cross-connection access Persona default-deny on tools, connections, and API routes pkg/persona/filter.go
Admin-surface access by a non-admin Admin persona required, CSRF enforced on the cookie path pkg/admin/middleware.go
Observability data access by a non-admin observability:read capability required; 401/403 pkg/observability/proxy/handler.go
Public share-link scraping Share-token gate, rate limiting, 410 on revoked/expired pkg/portal/public.go, pkg/portal/handler.go, pkg/portal/ratelimit.go
The model acting on injected instructions to mutate data Trino read-only mode rejects write SQL pkg/toolkits/trino/readonly.go (opt-in via read_only)
Premature or over-broad tool use Search-first gate refuses query tools until search runs; SEARCH_REQUIRED short-circuit pkg/middleware/mcp_workflow_gate.go
Unbounded large scans / unconsented PII access Cost-estimation and PII-consent elicitation prompts (Trino toolkit) pkg/toolkits/trino/elicitation.go
SSRF via catalog spec fetch by URL https-only, DNS pre-check plus dial-time re-check blocking private/link-local/CGNAT ranges and the metadata endpoint, redirects disabled, body capped pkg/toolkits/apigateway/catalog/fetch.go, applied in pkg/admin/catalog_handler.go
Outbound OAuth token-exchange abuse Redirects refused, response body capped, hard timeout pkg/connoauth/exchange.go
Token/credential theft from the database Tokens SHA-256-hashed, client secrets bcrypt-hashed, connection secrets AES-256-GCM encrypted pkg/oauth/postgres/store.go, pkg/oauth/dcr.go, pkg/platform/fieldcrypt/fieldcrypt.go
Sensitive tool arguments retained in audit redact_keys masking and log_parameters opt-out applied in-middleware pkg/middleware/mcp_audit.go, pkg/platform/config.go
Prompt injection in DataHub metadata Semantic metadata sanitized (control-char removal, injection-pattern stripping, length cap) with injection-attempt logging pkg/semantic/sanitize.go, pkg/semantic/injection_logger.go
Loss of accountability Every tool call audited through a bounded, drained async writer pkg/middleware/mcp_audit.go, pkg/audit/asyncwriter.go
IdP key rotation breaking authentication Self-healing JWKS cache with single-flight throttled refresh pkg/auth/oidc.go

Non-goals and residual risks

These are stated plainly. Omitting them would misrepresent the security posture.

  • stdio transport trusts the invoking process. When the platform runs over stdio, there is no MCP-layer authentication and no in-process sandboxing; the server acts with the credentials the local user configured (cmd/mcp-data-platform/main.go, docs/auth/overview.md). stdio is for local, single-user use.
  • The platform does not defend against a malicious platform administrator. An admin can author connections, read audit content, and configure personas by design. ENCRYPTION_KEY protects secrets against a database reader, not against an operator who holds the key.
  • Audit delivery is best-effort asynchronous by default. Under a sustained store outage, queued events are dropped (and counted via audit_events_dropped_total) rather than retained; sync delivery trades latency for durability. The full loss model is documented in Delivery semantics; it is not restated here.
  • API-gateway connections use one shared upstream identity per connection by design (#374). The platform does not perform per-user token exchange to upstream APIs; all callers of a given connection share its upstream credential, and per-user attribution comes from the audit trail, not from distinct upstream identities.
  • Content sanitization is partial, not comprehensive. DataHub semantic metadata is sanitized on the enrichment path (pkg/semantic/sanitize.go), but raw query row values and gateway-upstream tool descriptions and responses are not scrubbed; adversarial natural-language content in those reaches the LLM client. The platform reduces blast radius (read-only mode, persona allowlists, the search-first gate, audit) but does not neutralize injected content in raw data or upstream responses; defending the client's reasoning is a shared responsibility.
  • TLS termination, network segmentation, and Postgres transport security are deployment responsibilities. The platform assumes HTTP transports are fronted by TLS (strongly recommended in docs/auth/overview.md), that the database connection is secured by the operator, and that S3 blob-at-rest and bucket policy are configured in the object store. If ENCRYPTION_KEY is unset, connection secrets are stored in plaintext.

Maintenance contract

This document was reviewed at v1.101.x. It describes the code at that version; citations are to package and file paths that a reviewer can open and verify.

Update this document when any of the following change, because they alter a trust boundary or a mechanism cited here:

  • pkg/auth (identity validation, JWKS handling, API keys).
  • pkg/oauth (the built-in OAuth 2.1 server, token storage, PKCE, rate limiting).
  • pkg/middleware (the protocol middleware chain: auth, authz, the search-first gate, audit, enrichment).
  • pkg/persona (the authorization model).
  • The transports or the HTTP composition root (internal/httpserver, cmd/mcp-data-platform/main.go), or any new inbound surface.
  • pkg/platform/fieldcrypt, pkg/ratelimit, or pkg/connoauth (at-rest encryption, rate limiting, outbound OAuth).

When updating, re-verify each affected citation against the current source and bump the reviewed-at version at the top of this document.