feat(secrets): opt-in injected secret delivery, scoped per server

Completes the path that stops mcpd writing secret VALUES into MCP server
pod specs. With `secretDelivery: injector`, the pod fetches its own
secrets from OpenBao through the agent injector, under a ServiceAccount
and role scoped to just that server's secrets — so the value never enters
etcd, and gitea-mcp cannot read the Grafana token.

Opt-in per server, defaulting to `env`. Every existing server is
bit-for-bit unchanged, and migrating is one reversible decision at a time
rather than a flag day.

The two invariants under most risk, both tested:

- **Opted-out servers produce an identical manifest.** No annotations, no
  serviceAccountName, automountServiceAccountToken still false.

- **Opted-in servers still fail LOUDLY on a bad ref.** Once mcpd stops
  reading a server's secrets, the check e6cd735 added no longer fires for
  it, and a typo'd secretRef would degrade into a vault-agent-init
  crashloop that mcpd reports as a generic pod failure — the same class of
  bug that had gitea-mcp running for weeks on an empty token while
  reporting healthy. `validateServerEnvRefs` resolves every ref and throws
  the value away, purely to keep that error. After the value cache it is a
  cache hit and costs nothing.

Shell quoting is the other silent-failure trap and is treated as part of
the contract: the agent renders `export NAME='value'` and the container
command sources it, so a value containing a space, `$`, a quote or a
newline would truncate and yield an empty token. `shellSingleQuote` is
tested by executing a real /bin/sh over nine adversarial values including
`'; export PWNED=1; '` — and those tests fail against naive quoting,
confirmed before keeping them.

`sh -c <script> arg0 arg1 …` preserves argv via $0/$@, and `exec` keeps
PID 1 as the real process, which matters because mcpd attaches to PID 1's
stdin/stdout for STDIO servers.

Docker/Podman declare `capabilities.secretRefs: false` and fall back to
inline resolution, so local development is untouched. Deleting a server
revokes its identity, after its pods are gone and best-effort — a role no
pod can authenticate as grants nothing, and failing the delete over it
would strand the row.

Per the CLI rules, `secretDelivery`/`entrypoint` are `create` flags,
round-trip through apply -f, and show in `describe server` — which now
also flags servers still inlining secrets into their pod spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
This commit is contained in:
Michal
2026-08-20 23:33:13 +01:00
parent f8427959e5
commit 370fd0a034
22 changed files with 740 additions and 14 deletions

View File

@@ -71,9 +71,23 @@ model McpServer {
env Json @default("[]")
healthCheck Json?
volumes Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// How secret-backed env reaches the container.
/// env — mcpd resolves values and writes them into the pod spec.
/// Cleartext in etcd, readable by anyone with `get pod`.
/// injector — the pod fetches its own secrets from OpenBao via the agent
/// injector, using a ServiceAccount + role scoped to just this
/// server's secrets. The value never touches the pod spec.
/// Defaults to `env` so existing servers are bit-for-bit unchanged.
secretDelivery String @default("env")
/// argv the injector wrapper must exec after sourcing the rendered secrets.
/// Only needed for dockerImage servers using `injector`, where the image's
/// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it.
entrypoint Json?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
templateName String?
templateVersion String?
@@ -109,8 +123,22 @@ model McpTemplate {
env Json @default("[]")
healthCheck Json?
volumes Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// How secret-backed env reaches the container.
/// env — mcpd resolves values and writes them into the pod spec.
/// Cleartext in etcd, readable by anyone with `get pod`.
/// injector — the pod fetches its own secrets from OpenBao via the agent
/// injector, using a ServiceAccount + role scoped to just this
/// server's secrets. The value never touches the pod spec.
/// Defaults to `env` so existing servers are bit-for-bit unchanged.
secretDelivery String @default("env")
/// argv the injector wrapper must exec after sourcing the rendered secrets.
/// Only needed for dockerImage servers using `injector`, where the image's
/// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it.
entrypoint Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}