The OAuth broker mints a JWT that names who authenticated. It does not pick a warehouse credential. Per-user token exchange, Entra app roles, agentgateway CEL keyed off jwt.sub, and warehouse session users all work for the backends that speak them. This platform fronts Trino, DataHub, S3, vendor MCP servers, and REST APIs. Trino can take a session user. A vendor MCP often cannot. An identity model that only works for one of five backends is a special case. I put the credential on the connection instead. This post is that boundary, as it sits in pkg/persona and pkg/audit in mcp-data-platform, also hosted as Plexara.
This is the eleventh post in MCP by Design. The earlier notes covered Go, composition, steering, knowledge, testing, Starlark, Hive, session handles, four tools, and the OAuth broker. This one is the boundary. It is grounded in the open-source
txn2/mcp-data-platform, also available hosted as Plexara.
§Role Versus Connection
A role divides people. A connection divides reach. Those are two axes, and collapsing them is how a grant that looks precise on paper does nothing downstream.
Keycloak (or Auth0, Okta, Azure AD) puts analyst or engineer on the token. The mapper in pkg/persona/mapper.go turns those claims into a persona. Roles that match no persona land on the built-in DefaultPersona: name default, display name Default User (No Access), tools deny *, connections empty. An identity the operator never granted a role reaches nothing. The persona is the grant: which tools, which connections.
A connection is a named, operator-authored binding to one downstream system under one credential: a Trino cluster as one service account, a DataHub instance with one token, an S3 account, an upstream MCP server, an HTTP API. Nothing stops several connections from pointing at the same system under different credentials. That is the intended shape rather than a workaround. Two connections to one Trino cluster, trino-read and trino-write, differ in the Trino account they authenticate as, and therefore in what that account is permitted to do. The persona decides which of the two a caller reaches.
The four-tools post already split catalog from credential: one OpenAPI document, many connections, each a base_url plus a secret pointed at one deployment. This post is the other half of that split. The catalog describes the API. The connection is who the platform authenticates as when it calls.
This piece does not install Keycloak. It does not walk the audit schema. It does not claim row-level security is unnecessary. Outbound OAuth to a vendor MCP is a different table and a later note. It covers a working slice: persona globs, connection credentials, and the audit row that names the person the warehouse cannot see.
§Why the Connection
The alternatives are real. Microsoft’s mcp-gateway authenticates with Entra ID and authorizes with app roles: mcp.admin, mcp.engineer, requiredRoles on each adapter. agentgateway evaluates CEL against the JWT, jwt.sub == "test-user" && mcp.tool.name == "add". Microsoft Foundry’s MCP authentication recommends OAuth identity passthrough when the goal is to preserve each user’s identity and permissions. A warehouse-native MCP server inherits that warehouse’s session user, so row policies and column masks apply to the calling person because the calling person is the session user. One shared superuser connection is the other extreme, and it is how a lot of early MCP servers shipped.
Those fit when the backends share an identity model. Entra app roles assume every backend is an Entra resource. CEL keyed off sub assumes every tool call can be decided from claims. A warehouse session user assumes the only system behind the server is that warehouse. The remaining case is one MCP process in front of five backends that do not share an identity model. I do not want an authorization story that only applies to Trino.
Three reasons the connection is the unit.
The downstream systems are heterogeneous. Trino has session users and can be fronted by a system that enforces row policies. DataHub has its own actor model. A third-party MCP server and a vendor REST API typically have neither, and offer no token-exchange endpoint to impersonate a caller through. The connection is the one construct every backend has: a credential and an endpoint.
A credential the operator wrote is auditable ahead of time. Read the Trino account bound to trino-read, see the catalogs it can SELECT, and the blast radius is that account’s grants, written down before anyone calls a tool. Per-user passthrough moves that reasoning into whatever the token said this morning, spread across every user.
Permission levels are usually coarse in practice. Read the warehouse, read and write the scratch schema, read the customer API, write the customer API. Those distinctions fit a handful of connections. Modeling them as connections keeps the grant explicit and greppable.
A server that lives inside one warehouse should keep that warehouse’s per-user authorization. That is a genuine advantage, and it is the right choice for a single-warehouse deployment that needs per-person data policy. It does not extend past the warehouse. The premise here is a caller reaching a warehouse, a catalog, object storage, third-party MCP servers, and REST APIs through one authenticated, audited, persona-governed endpoint. The connection is the boundary that spans all of them uniformly.
§What It Enforces
Deny-by-default on both axes, checked on the same call. Authorizer.IsAuthorized in pkg/persona/filter.go refuses unless the tool pattern and the connection both pass:
if !a.filter.IsAllowed(persona, toolName) {
return false, personaName, "tool not allowed for persona: " + personaName
}
if !a.filter.IsConnectionAllowed(persona, connectionName) {
return false, personaName, "connection not allowed for persona: " + personaName
}
IsConnectionAllowed is deny-by-default. A persona reaches a connection only when a connections.allow glob matches its name. An omitted connections block or an empty allow grants no connections. Deny patterns win over allow. A nil persona is refused at both checks. An empty connection name is a platform-level tool (platform_info, search, the others that belong to no backend), so the connection check admits it and the tool patterns are the gate.
The steering post put the persona filter in the middleware every call passes through. This is that filter’s other axis. Guidance in a tool description does not decide which warehouse account the call authenticates as. The glob does.
Discovery is bound by the same predicate. search, fetch, list_connections, and the portal search consult internal/platform/connscope, which delegates to IsConnectionAllowed rather than reimplementing the glob rules. Argument completion applies the same check directly. search and list_connections report a withheld count and a notice naming the persona, rather than silently shortening their results. An agent that sees a shorter list with no explanation concludes the data does not exist and re-derives it. The count names how many hits the persona cannot see.
One direction is deliberately permissive: a catalog dataset whose URN maps to no configured connection is unattributable and stays visible. A deployment with no persona registry has no scope to apply, so discovery is unfiltered there.
For kind=api connections, api_routes narrows further by (connection, method, path). When no rule names the connection, the route check is a no-op and the connection-level grant is the sole gate. A path glob is matched against the path a call reaches and the catalog path the operation declares, so a rule naming /v1/orders/{id} governs GET /v1/orders/42. Two connections to the same API, or one connection plus api_routes, both work; two connections are clearer when the credentials also differ.
personas:
analyst:
display_name: "Data Analyst"
roles: ["analyst"]
tools:
allow: ["*"]
connections:
allow: ["trino-read", "crm-*"]
api_routes:
- connection: "crm-*"
methods: ["GET", "HEAD"]
- connection: "crm-*"
methods: ["DELETE"]
paths: ["/v1/orders/{id}"]
action: deny
The toolkit adds what it can on top of the credential. The S3 toolkit’s read_only flag is per connection and withholds s3_put_object, s3_delete_object, and s3_copy_object outright (pkg/toolkits/s3/toolkit.go). Trino’s read_only is also per connection: several Trino instances fold into one multi-connection toolkit, and ReadOnlyInterceptor in pkg/toolkits/trino/readonly.go rejects write SQL on the connection each call names. The interceptor delegates detection to mcp-trino’s IsWriteSQL, which is a statement-prefix denylist:
var writeKeywords = []string{
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER",
"TRUNCATE", "GRANT", "REVOKE", "MERGE", "CALL", "EXECUTE",
}
What now exists, on a configured host: a persona that can reach trino-read and cannot reach trino-write; mutating S3 tools missing from tools/list on a read-only connection; a tool error not authorized: connection not allowed for persona: analyst when the glob does not match; a withheld count on search instead of a silently trimmed list.
§The Far Side
Every caller granted a connection acts as that connection’s credential downstream. The platform performs no per-user token exchange, no impersonation, and no session-user propagation. Nothing in the tree swaps a caller’s identity for a downstream one. It does run outbound OAuth per connection, obtaining and refreshing that connection’s own credential against upstream MCP servers and APIs; the identity that flow yields belongs to the connection, not to the caller. Two analysts granted trino-read are indistinguishable to Trino.
Row-level policies and column masking that key off the end user do not follow a caller through the platform. If a warehouse masks a column for one person and not another, and both reach it through one connection, both see whatever that connection’s service account sees. Getting per-person masking means giving those people different connections, which means a downstream account per distinct policy outcome. That is workable when the distinctions are few. It becomes one connection per person when they are many, which is per-user impersonation rebuilt out of service accounts.
The Hive post already ran this in miniature. What keeps registration DDL off the warehouse is the Trino identity the scratch connection authenticates as, not the platform’s read_only flag. catalog and schema on a connection are session defaults, not bounds. A scratch connection that authenticates as the same Trino user as the warehouse connection can write INSERT INTO warehouse.sales.orders .... Give the write-capable connection its own Trino identity. Adding a catalog allow-list to the toolkit is deliberately not done: parsing SQL to decide what a statement touches is the wrong layer for a boundary the query engine already enforces on its own identities.
Caution: Trino read_only is a statement-prefix denylist. Prove a denial. trino_query on the scratch connection against a warehouse catalog has to come back Access Denied. If it does not, the identity boundary is theater.
Per-user attribution comes from the audit trail, not from distinct downstream identities. With audit enabled, each tool call writes a row carrying user_id, user_email, persona, tool_name, timing, the connection when the call targets one, and the call arguments subject to redact_keys (pkg/audit/logger.go for the schema, pkg/middleware/mcp_audit.go for the redaction and the write). The downstream system, looking only at its own logs, sees the connection’s service account. This row is where “who ran this” lives.
That makes the audit trail load-bearing, and it is not unconditional. Audit requires a database. A deployment with no database.dsn, or one that sets audit.enabled: false, gets a no-op logger (pkg/platform/platform.go) and no rows at all. log_tool_calls: false keeps audit on but drops per-call rows. log_parameters: false keeps the row without the arguments. The default writer is async: events enqueue and the tool call is never blocked by store latency, which also means a sustained store outage drops queued events rather than retaining them. audit.delivery: sync writes on the request goroutine for backpressure and zero queue drops. Either way a failed store write never fails the tool call. A deployment that leans on connection-scoping for authorization should not also be running without audit.
The authorization post on the wire is who may reach this server. The trust-boundary post is what a server may say once reached. This post is who the server authenticates as when it turns around and talks to Trino.
There is one exception on the platform, and it is not this post. The embedded admin API, platform-admin on loopback, sets identity_passthrough: true so a mutation is attributed to the acting admin rather than a shared connection identity. That is the loopback case where the downstream is this process. Every other connection carries a service account.
§Add a Connection
The lever for tightening access is usually a new connection, not a new role. Adding a role to split two groups that both end up on the same connection changes nothing about what either group can do downstream.
Reach for another connection when a group needs a different permission level in the same system (read versus write, one schema versus all of them), when a group needs a different blast radius (an agent-facing surface and an operator-facing one are better as two credentials than one), or when an API needs a subset of its endpoints exposed and the credentials differ.
Reach for another role when the same reach should carry different tools, different agent instructions, or different portal visibility.
The gallery recipe is two Trino connections to the same cluster, the difference between them the Trino identity:
toolkits:
trino:
instances:
warehouse:
user: "${TRINO_READONLY_USER}"
catalog: warehouse
read_only: true
scratch:
user: "${TRINO_SCRATCH_USER}"
catalog: scratch
read_only: false
scratch:
catalog: scratch
schema: uploads
personas:
analyst:
roles: ["analyst"]
tools:
allow: ["*"]
connections:
allow: ["warehouse", "scratch"]
After that YAML exists, the analyst persona can query warehouse and register a CSV on scratch. The platform’s read_only flag refuses DDL on warehouse. The Trino identity on scratch is what keeps writes off the warehouse catalogs. The platform flag is the extra denylist, not the catalog boundary.
§Summary
The unit of access is the connection, not the end user, because the backends behind this process do not share an identity model and the connection is the construct every one of them has. A role divides people; a connection divides reach. The choice is enforced as deny-by-default on tools and connections, discovery bound by the same predicate, api_routes inside an API connection, and a toolkit denylist where the toolkit can add one. The far side is also the design: no impersonation, no session-user propagation, no per-person column masking through one connection. Who ran it lives in the audit row. The warehouse sees the service account.
In mcp-data-platform that is pkg/persona (IsConnectionAllowed, IsAPIRouteAllowed, the deny-all DefaultPersona), the interceptor in pkg/toolkits/trino/readonly.go, the withheld S3 tools, internal/platform/connscope, and the audit row in pkg/audit. Some of those connections are other people’s MCP servers. That is a later note.