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 (internal/httpserver/httpauth/authmiddleware.go) when auth.allow_anonymous is false. |
| MCP SSE | /sse, /message |
Same gate via RequireAuthWithOAuth (internal/httpserver/httpauth/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), then the persona gate (internal/httpserver/accessgate). Authentication proves identity; the gate decides access. A caller whose roles map to no persona is refused 403 before any handler runs — a browser navigation gets a branded page naming the refused account, everything else gets RFC 9457 Problem Details. Without it, any account an identity provider will issue a token for would read every org-shared knowledge page and the whole federated search surface, neither of which scopes on roles. The SPA shell at /portal/ applies the same check to a session cookie so a refused person gets the page instead of an application shell (internal/httpserver/mounts.go). |
| Portal public viewer | /portal/view/ |
Deliberately outside the persona gate (share links are for people with no account). Rate-limited, then gated on the share's access mode by publicShareGate (pkg/portal/share_access.go, pkg/portal/shareaccess), which wraps every route on the public mux. Anonymous access only for public shares; authenticated requires any signed-in user and restricted only the named recipient or the share's creator, both 403 otherwise. Revoked/expired tokens return 410 (internal/portal/viewerlimit). A refused browser navigation renders a branded landing page; email-share recipients without an account can request a single-use view link (pkg/portal/shareguest): sent only to the stored recipient address, SHA-256-hashed at rest, claimed atomically (15-minute expiry), opening a view-only guest session as an HMAC-signed cookie scoped to that one share, with the key domain-separated from the browser-session signing key so a guest cookie can never pose as a portal session. Share revocation is checked before guest admission, so it cuts off live guest sessions. The request endpoint answers uniformly regardless of share state (no share-existence oracle) and is capped per share per hour against mailbombing, inside the same per-IP limiter. The gate also sets the response's cache policy, since a per-caller verdict cached under a per-URL key is the same bypass by another route: every response on the surface carries Vary: Cookie, responses for any mode but public are Cache-Control: private, refusals are no-store, and only a fully public share's thumbnail is offered to shared caches, as public with max-age clamped to the smaller of an hour and the share's remaining life so no stored copy outlives the token. Revocation is the residual: it is not a time the response can be written against, so a copy a shared cache already holds is served until it goes stale, which is why that one window is an hour. |
| Notification unsubscribe | /portal/notifications/unsubscribe |
Public by design so recipients without an account can opt out of notification emails. The tok parameter is an HMAC over the recipient address under a key derived from the browser-session signing key; an invalid token writes nothing, and a valid one can only set delivery mode off for the address it names. GET performs no mutation (it renders a confirmation page), so mail-scanner URL prefetch cannot opt a recipient out; the opt-out records only on POST, either the confirmation form or the RFC 8058 one-click body (internal/httpserver/unsubhttp/unsubscribe.go). |
| Share resubscribe | POST /portal/view/{token}/resubscribe |
Public by design (its audience is opted-out recipients the share gate refuses); rate limited with the other public share routes. Answers uniformly regardless of share state (no share-existence oracle), acts only on a live, non-public share's stored recipient address, and can only restore the immediate-delivery default; it can never opt anyone out or touch category toggles (pkg/portal/shareguest). |
| 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 (internal/httpserver/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 (internal/httpserver/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). This surface authenticates through the portal authenticator but not through the portal handler, so it applies the persona gate in its own claims builder: a caller belonging to no persona is refused 403 (internal/httpserver/mounts.go, buildResourceClaims). upload checks CanWriteScope, and the by-id reads (get, download, patch, delete) check CanAccessResource — the caller's visible scopes, OR current write authority over the scope (platform admin, or that persona's admin), so an admin can manage material they may create but do not belong to, while a revoked role revokes the access with it (the grant is re-derived from current claims, never from the stored uploader); 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" (internal/httpserver/httpauth/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-provider outage¶
Validation has three outcomes, not two: valid, definitively invalid, and
undetermined. The third arises when the identity provider cannot be reached, so
the signing keys needed to check a token are unavailable. The platform
distinguishes it with the ErrValidationUnavailable sentinel
(pkg/middleware/auth.go), which pkg/auth/oidc.go wraps only when a JWKS
fetch fails while the cache is expired — never for a key the issuer does not
have.
The decision: the HTTP edge passes an undetermined credential through, and the protocol layer refuses the call. These are one control, not two independent behaviors:
- The edge (
oauthGate) does not answer401. A401asserts the credential is bad, which during an outage is false, and it would drive every valid user into an OAuth re-authentication flow that cannot complete until the provider returns. - The protocol layer re-validates on every tool call and, on the same sentinel,
answers
feature_unavailablewith a retryable hint rather thanunauthenticated(pkg/middleware/mcp.go,authenticateAndAuthorize). No tool handler runs.
Access is therefore never granted on an unvalidated credential. What the fail-open costs is depth: during a provider outage an unauthenticated request reaches the protocol layer before it is refused, so the refusal there is load-bearing for every request shape that can reach it.
What a client sees during an outage: HTTP requests succeed at the transport
level, the MCP session establishes, and every tool call returns an in-band error
result carrying code: feature_unavailable, the message authentication
temporarily unavailable, and a hint stating that this is a transient
server-side condition rather than a credential problem. Nothing signals re-auth,
so a well-behaved client retries rather than prompting the user to sign in
again. Once the provider is reachable the next call succeeds with no client
action.
Both halves are pinned end to end by
TestStreamableHTTP_ValidationUnavailable_EdgePassesThroughProtocolRefuses
(internal/httpserver/validation_outage_test.go), which drives a real HTTP
handler, gate, middleware chain and MCP client. It fails if the edge starts
rejecting, if the protocol layer stops refusing, or if the refusal stops being
categorized as retryable. Its companion,
TestStreamableHTTP_DefinitiveRejection_EdgeFailsClosed, holds the boundary:
pass-through applies only to an undetermined verdict, never to a credential the
platform can definitively reject.
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. It applies per connection: the interceptor resolves the connection the call names, or the default when it names none, and permits writes only on a connection configured write-capable — an unresolved or unconfigured connection is refused. The interceptor runs on the toolkit's SQL tools; pkg/query/trino/ is the enrichment read path, which issues its own fixed metadata queries and does not run the interceptor. |
| 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
/tokenand/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 get429 slow_down. DCR is disabled unless explicitly configured, and the bcrypt cost of/registeris throttled by the same limiter. - Spoofed
X-Forwarded-Forto defeat per-IP limits (Spoofing). The client-IP resolver consultsX-Forwarded-Foronly 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
(
internal/httpserver/httpauth/authmiddleware.go); OAuth access tokens are HS256-signed and verified against the key ring. - Health endpoints (Information disclosure).
/healthzand/readyzare unauthenticated but return only liveness/readiness, no data (internal/httpserver/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).FilterToolsnever 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 theobservability:readpersona 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 inENCRYPTION_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_keysand thelog_parametersopt-out but not otherwise encrypted, semantic metadata, portal content, and knowledge records. IfENCRYPTION_KEYis 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. - The connection is also the unit of containment, by design. Access is scoped at the connection rather than the end user (see Authorization model), so an operator sizes blast radius by deciding what each credential can reach and which personas are granted it, before any call happens. Several connections may front the same downstream system under different credentials at different permission levels; a compromise of the read-only one does not reach what the write-capable one can do. That containment is only as good as the split: a single broad credential shared by every persona makes the boundary a formality.
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 |
internal/httpserver/httpauth/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, internal/portal/viewerlimit |
| Guest-link abuse (mailbombing a recipient, probing share tokens, replaying emailed links) | Uniform response regardless of share state; per-share issue cap plus per-IP rate limit; single-use atomic claim of a hashed, 15-minute token; guest session scoped to one share and view-only | pkg/portal/shareguest |
| Forged unsubscribe (opting someone else out) | Footer token is an HMAC over the recipient address under a key derived from the browser-session signing key; only a holder of the emailed link can opt that address out | internal/httpserver/unsubhttp/unsubscribe.go |
| Silent unsubscribe by mail-scanner prefetch (Safe Links, Proofpoint, and similar GETting footer URLs) | GET renders a confirmation page and mutates nothing; the opt-out records only on the confirmation form POST or the RFC 8058 one-click POST, which providers fire only on a real user action | internal/httpserver/unsubhttp/unsubscribe.go |
| The model acting on injected instructions to mutate data | Trino read-only mode rejects write SQL on the connections that set it | pkg/toolkits/trino/readonly.go (opt-in per connection 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_KEYprotects 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;syncdelivery trades latency for durability. The full loss model is documented in Delivery semantics; it is not restated here. - Downstream identity is per connection, not per user. This is the authorization
design rather than an unmitigated gap, and the distinction matters for how a
reader sizes it. Access is scoped at the connection: a connection is a named
binding to one downstream system under one operator-authored credential,
connection rules are deny-by-default and enforced on every tool call and every
discovery surface (
pkg/persona/filter.go,internal/platform/connscope), and several connections may front the same system under different credentials at different permission levels. The platform performs no per-user token exchange, no impersonation, and no session-user propagation to Trino, S3, DataHub, upstream MCP servers, or HTTP APIs, because the construct every one of those backends shares is a credential and an endpoint, not a caller identity the platform could pass through. Outbound OAuth is per connection rather than per user (pkg/connoauth/exchange.go, and the mitigations table above); the API-gateway case is recorded as #374. What that costs is stated without softening: all callers granted a connection are indistinguishable to the downstream system, and warehouse row policies or column masks that key off the end user do not follow a caller through the platform. Expressing per-person policy means one connection per distinct policy outcome, which is workable when those outcomes are few and unpleasant when they are many. Per-user attribution comes from the audit trail, not from distinct downstream identities: with audit enabled, each call recordsuser_id,user_email,persona, and the connection it targeted, subject to the best-effort delivery model stated above. Rationale and the add-a-connection-not-a-role guidance: Authorization model. - 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. IfENCRYPTION_KEYis 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, orpkg/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.