Zum Inhalt springen

Pod uplink and connector credentials

Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.

Pia Mesh adds one inbound route to the server and one class of outbound credential. Both are new trust boundaries, and they run in opposite directions: the uplink is a credential the server checks, and outbound connector auth is a credential the server replays. That asymmetry drives every design choice on this page.

Section titled “The uplink is the tool plane’s only inbound route”

Pods reach Pia at exactly one place: the SignalR hub /hubs/pod-uplink. There are no REST endpoints for pods, and no route on the tool plane is addressable by a client — a test enumerates the server’s whole route table and asserts that the only routes whose path contains connector are the two authenticated admin pages.

The hub authorizes against the PodUplink scheme and nothing else. A valid user JWT or a valid admin cookie is rejected with a bare 401.

This is the single most important constraint in the design. If the hub accepted a user token, any authenticated user could dial the uplink and impersonate a pod — advertising tools that Core would then offer to other users’ chat sessions.

A successful pod principal carries one claim, the connector id. It has no sub and no roles, because a pod is not a user and must never be mistaken for one on any downstream check.

Either Authorization: Bearer <token> or ?access_token=<token> in the query string. The second form exists because SignalR’s WebSocket handshake cannot carry an Authorization header — it is the standard SignalR convention, not a Pia-specific weakening. A real pod host should prefer the header.

A pod token looks like pia_pod_<connector-id>.<protected-payload>. The first segment identifies the row so the server can load exactly one record before doing any cryptography — no table scan by fingerprint. The second is a Data-Protection-sealed payload binding the token to that connector.

The catalog stores only a fingerprintSHA-256 of the token, base64url-encoded. The plaintext token is displayed once at generation and is never persisted, never logged, and never retrievable. If it is lost, generate a new one; there is no recovery path by design.

There is no expiry. The sealed payload binds a connector id and 32 random bytes and carries no time component at all, so a token stays valid until something revokes it. Revocation is one of three acts: generating a replacement (the previous token fails the comparison immediately), turning the row’s Active switch off, or deleting the row. Treat a pod token as a credential with no clock behind it, and rotate it on your own schedule. Adding expiry is a change to the token format, not a setting.

A token can also be minted headlessly, before the web host starts:

Terminal window
docker compose -f docker-compose.prod.yml run --rm pia-server \
--provision-pod issue-token --connector <connector-id>

The create verb of the same switch provisions a whole pod — row, token, operator row, group grants — in one command. Both print the token to stdout and every log line to stderr; see --provision-pod for the full contract and Writing a pod for what the pod author does with the result.

This grants no authority the caller did not already hold. Pod tokens are sealed with a Data Protection keyring that lives in the database under a pinned application name, so any process holding Database:ConnectionString could already mint one and decrypt every connector secret. An HTTP endpoint for this would have had to create a new way in; a pre-host command-line verb does not. If the keyring ever moves to a filesystem or a KMS, that argument stops holding and has to be re-derived.

Failed attempts are audited, bare ones are not

Section titled “Failed attempts are audited, bare ones are not”

An attempt that presents some credential — malformed, unknown, mismatched — writes a Connector.UplinkRejected audit row. An attempt that presents no credential at all does not. The route is anonymously reachable, so auditing every bare probe would turn it into an unbounded remote database-write amplifier that drowns real security events.

Because bare probes are deliberately not audited, a rate limit is what bounds them. The route carries the pod-uplink policy: 20 attempts per minute per IP, a sliding window, applied to both requests SignalR makes (the negotiate and the upgrade). Tune it on Limits & Policies; it is restart-only, like the other rate policies.

Budget one legitimate pod connect as at least two permits. If several pods share one egress address and reconnect together, raise the limit rather than removing it.

The hub raises its maximum receive message size to 256 KB, scoped to this hub only. SignalR’s 32 KB global default would silently reject a legitimate advertised tool list from a pod offering many tools. The Blazor admin circuit keeps the default.

When Pia is the client — dialing an MCP server over HTTP — it may have to present a credential. That credential has to be recoverable, which makes it fundamentally different from the pod token.

Pod token (inbound) Outbound secret
Direction The pod presents it to Pia Pia presents it to the target
Stored as SHA-256 fingerprint Reversible Data-Protection ciphertext
Recoverable No — a hash cannot be un-hashed Yes, by design
Shown in the UI Once, at generation Never

A fingerprint is kept for the outbound secret too, but only for display, audit and rotation identity. It never authenticates anything. It is also what the session-recycle check compares — Data Protection output is non-deterministic, so comparing the ciphertext would recycle the session on every activation.

The two are cryptographically separated: they use different Data Protection purposes (Pia.PodUplink.Token.v1 and Pia.Connector.OutboundAuth.v1), so an inbound token and an outbound secret can never be interchangeable ciphertexts.

Neither value ever reaches a log line. The configuration types override their own ToString() for exactly this reason — a record’s generated ToString() prints every property, so a single “log the config” call would otherwise dump the ciphertext.

Bounds that keep a bad row from becoming an attack

Section titled “Bounds that keep a bad row from becoming an attack”
  • The stored secret is capped at 4096 characters, and the plaintext a caller may submit at 2048, so a malformed row cannot carry an unbounded blob into an outbound header.
  • An api_key header name must match RFC 9110 token characters, anchored so that a trailing newline is rejected — X-Api-Key\n is a header-injection vector wearing a valid name.
  • Six header names are reserved: Authorization, Host, Content-Type, Content-Length, Accept, and Mcp-Session-Id. The last is the sharp one — letting a row overwrite the MCP session header breaks the transport in a way that reads as an SDK bug rather than a configuration error.
  • Connector URLs and OAuth token endpoints must be https, or http on loopback only.

Both pod tokens and outbound secrets are sealed with ASP.NET Core Data Protection. Keys are persisted to the database, so they survive container restarts, and ordinary 90-day key rolling is safe — superseded keys are retained for decryption.

The application discriminator is pinned to "Pia", so it no longer depends on the content root — a rebuilt output directory, a moved checkout, or a different container mount cannot change what a payload decrypts under. The one remaining hazard is losing the keys themselves (for example, the DataProtectionKeys table being dropped).

What breaks if that happens, in ascending order of annoyance:

Affected Recovery
MFA challenges, WebAuthn state Re-issued on the next attempt
Pod uplink tokens Regenerate on the connector’s page
Connector outbound secrets Must be re-entered — the plaintext exists nowhere else

An undecryptable outbound secret fails its row closed and loudly: the server logs an error naming the fingerprint prefix, and the admin page shows “this secret cannot be decrypted — re-enter it”. It does not silently fall back to calling the target unauthenticated. Without that distinguishable signal an operator cannot tell a lost keyring from a dead target.

A pod receives, per tool call:

  • the arguments the model supplied, after Core strips reserved _meta and _pia_* keys — a model cannot smuggle scope in through an argument;
  • a server-resolved scope and the user id, attached by Core from the authenticated request and carried in the MCP request’s _meta.

It does not receive credentials, other users’ data, or anything from the end-to-end encrypted interactive plane. The scope is decided by Core; a pod cannot widen it by asking.

Malformed annotations coming back from a pod — citations, sensitive-content flags — are ignored with a warning rather than treated as fatal. A third-party MCP server will not send them and must still work.

Nothing re-classifies what a pod sends back

Section titled “Nothing re-classifies what a pod sends back”

Guardrails classify the inbound request. That decision is made once, before connectors are resolved; the tool loop then appends each tool result to the conversation as a role: "tool" message and calls the answering model again. Those messages are not classified.

This is a property of the whole connector plane rather than something pods introduce: the knowledge base is the first connector, and search_knowledge_base results travel the identical path. The honest statement of the widening is that the mechanism is unchanged and the origin is not — the text now comes from a process a customer wrote rather than from content an administrator ingested. Weigh that when granting a connector row to a group whose chats are guardrailed, and treat a pod’s tool results as model-visible text the pod’s author is responsible for.

The operator plane is unreachable from chat

Section titled “The operator plane is unreachable from chat”

One uplink can serve two planes: ordinary chat tools, and assignment steps for a remote skill. MCP has a single tool list, so a pod’s step capability is advertised next to its chat tools — and if the model could call it directly there would be no assignment row, no per-user cap, no token ceiling and no spend attribution. It would look like a working feature.

Two rules close that, and only the first is the control:

  • The reserved pia_op_ name prefix is filtered out of the chat tool list, server-side. The pod author picks the names, so this can never be a convention a pod is trusted to follow. It is matched on the pod’s raw advertised name, before a connector row’s toolNamePrefix is applied: filtering the prefixed name would let a row with toolNamePrefix = "myprefix" advertise myprefix_pia_op_…, which no longer matches — the exact bypass the filter exists to stop. The other direction is closed at configuration time, where a row whose toolNamePrefix starts with pia_op is rejected, so an administrator cannot hide a row’s ordinary tools by prefixing them into the reserved space.
  • podType on the pod card declares which plane the uplink serves. This is a declaration the server compares, never a grant: a card that omits it means connector, and an operator-plane dispatch to a pod that did not declare operator fails closed. A pod that lies about it gains nothing — the authorisation is the operator row’s group grant, and that row is granted independently of the pod’s connector row.

The assignment’s own input is user-supplied — it arrives verbatim in the request body and is only checked for being valid JSON. It is therefore nested under one server-owned key rather than spliced into the tool arguments, and reserved _meta / _pia_* keys are stripped from it, so a user cannot forge scope at the pod through their own assignment input. The scope and user id in the request’s _meta are the server’s, exactly as for a chat tool call.

What a pod still cannot do on this plane: report its own token spend (a pod-supplied count is ignored, so a remote step contributes zero tokens), reach the server’s chat surface (that needs a user credential), reach the database, or reach Temporal.

A pod-served skill is also prompt-only: it receives the assignment’s prompt and no decrypted user records of any kind, where a skill built into the server can declare record kinds and receive them. That is fail-closed by design rather than unfinished. There is no server-side class for a pod skill to declare on, the card is an advertisement rather than a grant so it cannot declare either, and the operator row must not — an admin-editable scope declaration is an admin-editable widening of every user’s plaintext exposure. The declaration stays empty until a pod’s version of it has an enforcement point the server owns.

The task plane makes a different trade deliberately. An assignment’s input and its artifact are stored unencrypted in Core’s database, because whatever runs the work has to be able to read it.

The plaintext window is Operators:PlaintextRetentionHours — 72 hours by default, counted from the run finishing, and usually much shorter, because a client that acknowledges an assignment has the plaintext dropped there and then. Operators:RetentionDays (30) is a different and longer number: it is when the row and its events are deleted outright, and what survives into it is metadata with no plaintext in it. See Retention for both tiers.

Nothing in that stops an operator with database access from reading the plaintext while it is there. That residual is the trade, and it is the fact a pod author should weigh before routing anything sensitive through a custom skill — see Writing a pod.

The guarantee there is isolation, not encryption:

  • Every assignment row carries a non-null user id.
  • Every assignment query goes through one service seam, and the controller cannot reach the database directly.
  • Another user’s assignment returns 404 — not 403 — on read, list and cancel, because the store genuinely cannot distinguish “does not exist” from “belongs to someone else”.
  • The cross-user admin roll-up carries no artifact and no error message, only status, counters and a server-minted error code.
  • Where the deployment runs the app on its own non-owner database role, PostgreSQL row-level security on both tables limits that role to the calling user’s rows.

The Temporal service that backs the operator runtime must never be publicly routed. The shipped production compose file publishes no ports for it and gives it no reverse-proxy route: the default single-namespace dev-server setup has no authentication of its own.

A pod never holds a Temporal credential and cannot tell that Temporal exists. A remote skill’s step is a call the server makes to the pod over the uplink; the pod does not poll a queue. That is not a convenience — Temporal’s authorisation is namespace-and-API-level, with no discrimination by task queue, so anything able to poll the server’s own queue could receive the server’s own activity tasks, carrying other users’ assignment input, and complete them with results the projection would accept. Task-queue naming is routing on top of a boundary, never the boundary; the only unit the server enforces is the namespace.

Losing the Temporal store costs in-flight workflows only. Core’s assignments and assignment_events tables are the record of truth.

  • Connectors — the administrative workflow for all of the above.
  • Operators — pointing an operator row at a pod, and the failure codes that come with it.
  • Writing a pod — the author’s side of this boundary: the two transports, the wire contract, and what a pod may be built to do.
  • Encryption at rest — the master key, which is a separate mechanism from the Data Protection keyring.
  • Pia Mesh architecture — what the two planes are and how they fit together.