fix(secrets): list via GET ?list=true — the LIST verb dies at the proxy
Some checks failed
CI/CD / lint (pull_request) Successful in 1m19s
CI/CD / typecheck (pull_request) Successful in 2m44s
CI/CD / test (pull_request) Successful in 1m30s
CI/CD / build (pull_request) Successful in 2m23s
CI/CD / smoke (pull_request) Failing after 3m4s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m19s
CI/CD / typecheck (pull_request) Successful in 2m44s
CI/CD / test (pull_request) Successful in 1m30s
CI/CD / build (pull_request) Successful in 2m23s
CI/CD / smoke (pull_request) Failing after 3m4s
CI/CD / publish (pull_request) Has been skipped
Found by the readiness probe added in the previous commit, on its first run against production: `mcpctl status` reported Secrets: bao-k8s* ✗ auth failed: OpenBao list: HTTP 400 Bad Request That is a real bug, not a probe artifact. `bao-k8s` is configured with the public ingress URL, and Cilium's ingress Envoy rejects the non-standard LIST HTTP method outright. Verified live from the mcpd pod: LIST https://bao.ad.itaz.eu/v1/secret/metadata/mcpctl/ -> 400 Bad Request GET https://bao.ad.itaz.eu/v1/secret/metadata/mcpctl/?list=true -> 403 permission denied (bogus token, i.e. reached bao) LIST http://openbao.openbao.svc:8200/... (ClusterIP, no Envoy) -> 403 permission denied So the verb was fine against bao and fatal through the ingress. OpenBao accepts both forms and documents the GET form for exactly this reason. This was never noticed because nothing called list() in production — it backs `mcpctl migrate secrets`, which would have failed with an opaque HTTP 400 against any ingress-fronted backend. Also adds the per-server scoping primitives Phase 2 needs, with tests: buildServerSecretPolicyHcl (no wildcards, read-only, stable under reordering), buildServerProvisioningPolicyHcl (prefix-confined so mcpd cannot grant itself more than it holds), ensureKubernetesAuthRole / delete helpers, the driver-level ensureServerIdentity/removeServerIdentity capability, and ServerIdentityService. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
This commit is contained in:
@@ -164,6 +164,23 @@ export class CachingSecretBackendDriver implements SecretBackendDriver {
|
|||||||
return this.inner.healthCheck?.() ?? { ok: true, detail: 'no probe' };
|
return this.inner.healthCheck?.() ?? { ok: true, detail: 'no probe' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity provisioning is a control-plane operation on the backend itself,
|
||||||
|
* not a value read — nothing to cache, so pass straight through. Throwing
|
||||||
|
* when unsupported (rather than silently succeeding) keeps a misconfigured
|
||||||
|
* backend from looking like it granted access it never did.
|
||||||
|
*/
|
||||||
|
async ensureServerIdentity(input: { name: string; namespace: string; secretNames: string[] }): Promise<void> {
|
||||||
|
if (this.inner.ensureServerIdentity === undefined) {
|
||||||
|
throw new Error(`backend '${this.backendName}' (${this.inner.kind}) does not support per-server identities`);
|
||||||
|
}
|
||||||
|
await this.inner.ensureServerIdentity(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeServerIdentity(input: { name: string }): Promise<void> {
|
||||||
|
await this.inner.removeServerIdentity?.(input);
|
||||||
|
}
|
||||||
|
|
||||||
async authCheck(): Promise<{ ok: boolean; detail?: string }> {
|
async authCheck(): Promise<{ ok: boolean; detail?: string }> {
|
||||||
return this.inner.authCheck?.() ?? { ok: true, detail: 'no probe' };
|
return this.inner.authCheck?.() ?? { ok: true, detail: 'no probe' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* POST <url>/v1/<mount>/data/<path> -- write
|
* POST <url>/v1/<mount>/data/<path> -- write
|
||||||
* GET <url>/v1/<mount>/data/<path> -- read latest
|
* GET <url>/v1/<mount>/data/<path> -- read latest
|
||||||
* DELETE <url>/v1/<mount>/metadata/<path> -- full delete (all versions)
|
* DELETE <url>/v1/<mount>/metadata/<path> -- full delete (all versions)
|
||||||
* LIST <url>/v1/<mount>/metadata/ -- for migration
|
* GET <url>/v1/<mount>/metadata/?list=true -- for migration (see list())
|
||||||
* POST <url>/v1/auth/<mount>/login -- kubernetes auth
|
* POST <url>/v1/auth/<mount>/login -- kubernetes auth
|
||||||
*
|
*
|
||||||
* Auth strategies (`config.auth`):
|
* Auth strategies (`config.auth`):
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js';
|
import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js';
|
||||||
import { SecretNotFoundError, SecretBackendUnavailableError } from './types.js';
|
import { SecretNotFoundError, SecretBackendUnavailableError } from './types.js';
|
||||||
|
import {
|
||||||
|
buildServerSecretPolicyHcl,
|
||||||
|
writePolicy,
|
||||||
|
deletePolicy,
|
||||||
|
ensureKubernetesAuthRole,
|
||||||
|
deleteKubernetesAuthRole,
|
||||||
|
} from '@mcpctl/shared';
|
||||||
|
|
||||||
/** Best-effort read of a response body for error messages. Empty on parse failure. */
|
/** Best-effort read of a response body for error messages. Empty on parse failure. */
|
||||||
async function bodyText(res: Response): Promise<string> {
|
async function bodyText(res: Response): Promise<string> {
|
||||||
@@ -185,7 +192,13 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
|||||||
|
|
||||||
async list(): Promise<Array<{ name: string; externalRef: ExternalRef }>> {
|
async list(): Promise<Array<{ name: string; externalRef: ExternalRef }>> {
|
||||||
const listPath = this.pathPrefix === '' ? '' : `${this.pathPrefix}/`;
|
const listPath = this.pathPrefix === '' ? '' : `${this.pathPrefix}/`;
|
||||||
const res = await this.request('LIST', `/v1/${this.mount}/metadata/${listPath}`);
|
// `GET ?list=true`, not the LIST verb. OpenBao accepts both, but LIST is a
|
||||||
|
// non-standard HTTP method and proxies drop it: through Cilium's ingress
|
||||||
|
// Envoy (which is how `bao-k8s` is reached) LIST returns a bare
|
||||||
|
// `400 Bad Request` while the GET form returns normally. Verified live
|
||||||
|
// 2026-08-20 — this silently broke `mcpctl migrate secrets` against any
|
||||||
|
// ingress-fronted backend.
|
||||||
|
const res = await this.request('GET', `/v1/${this.mount}/metadata/${listPath}?list=true`);
|
||||||
if (res.status === 404) return [];
|
if (res.status === 404) return [];
|
||||||
if (!res.ok) throw new Error(`OpenBao list: HTTP ${res.status} ${await bodyText(res)}`);
|
if (!res.ok) throw new Error(`OpenBao list: HTTP ${res.status} ${await bodyText(res)}`);
|
||||||
const body = await res.json() as { data?: { keys?: string[] } };
|
const body = await res.json() as { data?: { keys?: string[] } };
|
||||||
@@ -247,6 +260,55 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create/refresh a per-server OpenBao identity: a policy naming exactly this
|
||||||
|
* server's secrets, and a Kubernetes auth role bound to exactly its
|
||||||
|
* ServiceAccount.
|
||||||
|
*
|
||||||
|
* Requires mcpd's own token to hold the provisioning grant (see
|
||||||
|
* `buildServerProvisioningPolicyHcl`) — deliberately confined to the
|
||||||
|
* `mcpctl-server-` name prefix, so this cannot mint an identity that reads
|
||||||
|
* anything mcpd itself could not already read.
|
||||||
|
*
|
||||||
|
* Only meaningful with `auth: 'kubernetes'`: the role has to live on the same
|
||||||
|
* auth mount that validates this cluster's ServiceAccount tokens.
|
||||||
|
*/
|
||||||
|
async ensureServerIdentity(input: { name: string; namespace: string; secretNames: string[] }): Promise<void> {
|
||||||
|
if (this.authStrategy !== 'kubernetes') {
|
||||||
|
throw new Error(
|
||||||
|
`OpenBao: per-server identities require auth: 'kubernetes' (this backend uses '${this.authStrategy}') — ` +
|
||||||
|
'a token-auth backend has no ServiceAccount auth mount to bind a role on',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const token = await this.getToken();
|
||||||
|
const deps = { fetch: this.fetchImpl, ...(this.namespace !== undefined ? { namespace: this.namespace } : {}) };
|
||||||
|
|
||||||
|
// Policy first, then the role that references it. The reverse order would
|
||||||
|
// briefly leave a role pointing at a non-existent policy, which OpenBao
|
||||||
|
// resolves as "no capabilities" — a confusing transient 403 for the pod.
|
||||||
|
await writePolicy(
|
||||||
|
this.url,
|
||||||
|
token,
|
||||||
|
input.name,
|
||||||
|
buildServerSecretPolicyHcl({ mount: this.mount, pathPrefix: this.pathPrefix, secretNames: input.secretNames }),
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
await ensureKubernetesAuthRole(this.url, token, this.k8sAuthMount, input.name, {
|
||||||
|
boundServiceAccountNames: [input.name],
|
||||||
|
boundServiceAccountNamespaces: [input.namespace],
|
||||||
|
tokenPolicies: [input.name],
|
||||||
|
}, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Role before policy: revoke the ability to log in before the grants vanish. */
|
||||||
|
async removeServerIdentity(input: { name: string }): Promise<void> {
|
||||||
|
if (this.authStrategy !== 'kubernetes') return;
|
||||||
|
const token = await this.getToken();
|
||||||
|
const deps = { fetch: this.fetchImpl, ...(this.namespace !== undefined ? { namespace: this.namespace } : {}) };
|
||||||
|
await deleteKubernetesAuthRole(this.url, token, this.k8sAuthMount, input.name, deps);
|
||||||
|
await deletePolicy(this.url, token, input.name, deps);
|
||||||
|
}
|
||||||
|
|
||||||
private pathFor(name: string): string {
|
private pathFor(name: string): string {
|
||||||
const safe = encodeURIComponent(name);
|
const safe = encodeURIComponent(name);
|
||||||
return this.pathPrefix === '' ? safe : `${this.pathPrefix}/${safe}`;
|
return this.pathPrefix === '' ? safe : `${this.pathPrefix}/${safe}`;
|
||||||
|
|||||||
@@ -55,6 +55,31 @@ export interface SecretBackendDriver {
|
|||||||
*/
|
*/
|
||||||
healthCheck?(): Promise<{ ok: boolean; detail?: string }>;
|
healthCheck?(): Promise<{ ok: boolean; detail?: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional: provision a per-server identity in the backend, so an MCP server
|
||||||
|
* pod can fetch its OWN secrets directly (Agent Injector) without mcpd ever
|
||||||
|
* materialising the value into the pod spec.
|
||||||
|
*
|
||||||
|
* Scoping is the entire point. Each server gets a policy naming only the
|
||||||
|
* secrets it declares, bound to its own ServiceAccount. A single shared role
|
||||||
|
* would let any opted-in pod — including third-party images we do not
|
||||||
|
* control — read every secret under the prefix, which is a worse position
|
||||||
|
* than leaving values in the pod spec.
|
||||||
|
*
|
||||||
|
* Idempotent: called on every reconcile, must converge rather than conflict.
|
||||||
|
*/
|
||||||
|
ensureServerIdentity?(input: {
|
||||||
|
/** Identity name — also the policy name and the k8s ServiceAccount name. */
|
||||||
|
name: string;
|
||||||
|
/** Namespace the bound ServiceAccount lives in. */
|
||||||
|
namespace: string;
|
||||||
|
/** Secrets this server may read. Empty means "revoke everything". */
|
||||||
|
secretNames: string[];
|
||||||
|
}): Promise<void>;
|
||||||
|
|
||||||
|
/** Optional: tear down what `ensureServerIdentity` created. Idempotent. */
|
||||||
|
removeServerIdentity?(input: { name: string }): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional READINESS probe: can we actually read through this backend with
|
* Optional READINESS probe: can we actually read through this backend with
|
||||||
* the credentials we hold?
|
* the credentials we hold?
|
||||||
|
|||||||
142
src/mcpd/src/services/server-identity.service.ts
Normal file
142
src/mcpd/src/services/server-identity.service.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Per-server OpenBao identities, so an MCP server pod can fetch its own secrets
|
||||||
|
* without mcpd ever writing the value into the pod spec.
|
||||||
|
*
|
||||||
|
* ## Why per-server, and not one shared role
|
||||||
|
*
|
||||||
|
* The obvious implementation gives every injected pod one shared ServiceAccount
|
||||||
|
* and one role granting `secret/data/mcpctl/*`. That would be a downgrade, not
|
||||||
|
* an improvement: today's exposure is "4 credentials readable by anyone with
|
||||||
|
* `get pod` in this namespace", and the shared-role version is "10 credentials
|
||||||
|
* readable by any process inside a third-party MCP server image". We do not
|
||||||
|
* control `gitea-mcp-server` or `ha-mcp`; they should not be able to read the
|
||||||
|
* Grafana token.
|
||||||
|
*
|
||||||
|
* So each server gets:
|
||||||
|
* - its own Kubernetes ServiceAccount `mcpctl-server-<server>`
|
||||||
|
* - its own OpenBao policy `mcpctl-server-<server>` (names only
|
||||||
|
* the secrets that server declares — no wildcards)
|
||||||
|
* - its own OpenBao k8s auth role `mcpctl-server-<server>` (bound to
|
||||||
|
* that one ServiceAccount in that one namespace)
|
||||||
|
*
|
||||||
|
* ## Convergence
|
||||||
|
*
|
||||||
|
* `ensureFor` is idempotent and is safe to call on every start. The generated
|
||||||
|
* policy is sorted and deduped, so re-writing an unchanged server produces a
|
||||||
|
* byte-identical body — an unstable policy would churn the OpenBao audit log
|
||||||
|
* and make real changes invisible.
|
||||||
|
*
|
||||||
|
* Removing a secret from a server's env and restarting it narrows the policy on
|
||||||
|
* the next reconcile. Removing the server entirely revokes the identity.
|
||||||
|
*/
|
||||||
|
import type { McpServer } from '@prisma/client';
|
||||||
|
import type { SecretBackendService } from './secret-backend.service.js';
|
||||||
|
import type { ServerEnvEntry } from '../validation/mcp-server.schema.js';
|
||||||
|
|
||||||
|
/** Minimal Kubernetes surface this needs — keeps the service testable. */
|
||||||
|
export interface ServiceAccountPort {
|
||||||
|
/** Namespace MCP server pods run in. */
|
||||||
|
readonly namespace: string;
|
||||||
|
/** Create the ServiceAccount if absent. Must tolerate "already exists". */
|
||||||
|
ensure(name: string): Promise<void>;
|
||||||
|
/** Delete it. Must tolerate "not found". */
|
||||||
|
remove(name: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerIdentityLog {
|
||||||
|
info(obj: Record<string, unknown>, msg: string): void;
|
||||||
|
warn(obj: Record<string, unknown>, msg: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOOP_LOG: ServerIdentityLog = { info: () => undefined, warn: () => undefined };
|
||||||
|
|
||||||
|
/** Shared prefix. mcpd's OpenBao grant is confined to exactly this prefix. */
|
||||||
|
export const IDENTITY_PREFIX = 'mcpctl-server-';
|
||||||
|
|
||||||
|
export class ServerIdentityService {
|
||||||
|
private readonly log: ServerIdentityLog;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly backends: SecretBackendService,
|
||||||
|
private readonly serviceAccounts: ServiceAccountPort,
|
||||||
|
log?: ServerIdentityLog,
|
||||||
|
) {
|
||||||
|
this.log = log ?? NOOP_LOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity name for a server — also the SA, policy and role name. */
|
||||||
|
identityNameFor(serverName: string): string {
|
||||||
|
return `${IDENTITY_PREFIX}${serverName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinct secrets a server declares. Inline `value` entries are not
|
||||||
|
* secrets and must not widen the policy.
|
||||||
|
*/
|
||||||
|
secretNamesFor(server: Pick<McpServer, 'env'>): string[] {
|
||||||
|
const entries = (server.env ?? []) as ServerEnvEntry[];
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const ref = entry.valueFrom?.secretRef;
|
||||||
|
if (ref !== undefined) names.add(ref.name);
|
||||||
|
}
|
||||||
|
return [...names].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converge the identity for one server. Returns the identity name so the
|
||||||
|
* caller can stamp it onto the pod spec.
|
||||||
|
*
|
||||||
|
* Order matters: the ServiceAccount must exist before the role that binds it,
|
||||||
|
* or the pod can start, fail to log in, and crashloop while the role is still
|
||||||
|
* being written.
|
||||||
|
*/
|
||||||
|
async ensureFor(server: Pick<McpServer, 'name' | 'env'>): Promise<string> {
|
||||||
|
const name = this.identityNameFor(server.name);
|
||||||
|
const secretNames = this.secretNamesFor(server);
|
||||||
|
|
||||||
|
const backend = await this.backends.getDefault();
|
||||||
|
const driver = this.backends.driverFor(backend);
|
||||||
|
if (driver.ensureServerIdentity === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`secret backend '${backend.name}' (${backend.type}) cannot provision per-server identities — ` +
|
||||||
|
'per-server secret delivery requires an openbao backend using kubernetes auth',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.serviceAccounts.ensure(name);
|
||||||
|
await driver.ensureServerIdentity({
|
||||||
|
name,
|
||||||
|
namespace: this.serviceAccounts.namespace,
|
||||||
|
secretNames,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.log.info(
|
||||||
|
{ identity: name, namespace: this.serviceAccounts.namespace, secrets: secretNames },
|
||||||
|
`provisioned scoped OpenBao identity for server '${server.name}' (${String(secretNames.length)} secret(s))`,
|
||||||
|
);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a server's identity. Best-effort per step: a half-removed identity
|
||||||
|
* grants nothing useful, and failing the whole teardown because a
|
||||||
|
* ServiceAccount was already gone would leave the OpenBao side orphaned.
|
||||||
|
*/
|
||||||
|
async removeFor(serverName: string): Promise<void> {
|
||||||
|
const name = this.identityNameFor(serverName);
|
||||||
|
const backend = await this.backends.getDefault();
|
||||||
|
const driver = this.backends.driverFor(backend);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await driver.removeServerIdentity?.({ name });
|
||||||
|
} catch (err) {
|
||||||
|
this.log.warn({ identity: name, err: String(err) }, `could not remove OpenBao identity '${name}'`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.serviceAccounts.remove(name);
|
||||||
|
} catch (err) {
|
||||||
|
this.log.warn({ identity: name, err: String(err) }, `could not remove ServiceAccount '${name}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,9 +87,9 @@ describe('OpenBaoDriver', () => {
|
|||||||
await expect(driver.delete({ name: 'gone', externalRef: '' })).resolves.toBeUndefined();
|
await expect(driver.delete({ name: 'gone', externalRef: '' })).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('list returns names from the metadata LIST call', async () => {
|
it('list returns names from the metadata listing', async () => {
|
||||||
const fetchFn = makeFetch([{
|
const fetchFn = makeFetch([{
|
||||||
url: /\/v1\/secret\/metadata\/mcpctl\/$/,
|
url: /\/v1\/secret\/metadata\/mcpctl\/\?list=true$/,
|
||||||
status: 200,
|
status: 200,
|
||||||
body: { data: { keys: ['token1', 'token2', 'sub-folder/'] } },
|
body: { data: { keys: ['token1', 'token2', 'sub-folder/'] } },
|
||||||
}]);
|
}]);
|
||||||
@@ -98,6 +98,10 @@ describe('OpenBaoDriver', () => {
|
|||||||
{ fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver },
|
{ fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver },
|
||||||
);
|
);
|
||||||
const result = await driver.list();
|
const result = await driver.list();
|
||||||
|
// GET ?list=true rather than the LIST verb: proxies (Cilium's ingress
|
||||||
|
// Envoy among them) answer a bare 400 to the non-standard method.
|
||||||
|
const [, listInit] = fetchFn.mock.calls[0] as [unknown, RequestInit];
|
||||||
|
expect(listInit.method).toBe('GET');
|
||||||
// Sub-folders (trailing slash) are excluded; only leaf keys are returned.
|
// Sub-folders (trailing slash) are excluded; only leaf keys are returned.
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{ name: 'token1', externalRef: 'secret/mcpctl/token1' },
|
{ name: 'token1', externalRef: 'secret/mcpctl/token1' },
|
||||||
|
|||||||
@@ -306,3 +306,83 @@ export async function testWriteReadDelete(
|
|||||||
throw new Error(`vault smoke delete ${relPath}: HTTP ${String(delRes.status)} ${await readError(delRes)}`);
|
throw new Error(`vault smoke delete ${relPath}: HTTP ${String(delRes.status)} ${await readError(delRes)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KubernetesAuthRoleConfig {
|
||||||
|
/** ServiceAccount names permitted to log in as this role. */
|
||||||
|
boundServiceAccountNames: string[];
|
||||||
|
/** Namespaces those ServiceAccounts must live in. */
|
||||||
|
boundServiceAccountNamespaces: string[];
|
||||||
|
/** Policies attached to tokens issued for this role. */
|
||||||
|
tokenPolicies: string[];
|
||||||
|
/** Token TTL in seconds. Default 3600 — pods only need it at startup. */
|
||||||
|
tokenTtlSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /v1/auth/<authMount>/role/<role>. Idempotent: upserts the role.
|
||||||
|
*
|
||||||
|
* Used to give each MCP server pod its own OpenBao identity, bound to its own
|
||||||
|
* ServiceAccount, so the injector can fetch only that server's secrets. Note
|
||||||
|
* `bound_service_account_names` is a list but we pass exactly one — a role
|
||||||
|
* shared between ServiceAccounts would defeat the point.
|
||||||
|
*/
|
||||||
|
export async function ensureKubernetesAuthRole(
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
authMount: string,
|
||||||
|
role: string,
|
||||||
|
cfg: KubernetesAuthRoleConfig,
|
||||||
|
deps: VaultDeps = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||||
|
const mount = authMount.replace(/^\/|\/$/g, '');
|
||||||
|
const res = await fetchImpl(`${baseUrl(url)}/v1/auth/${mount}/role/${encodeURIComponent(role)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(token, deps.namespace, true),
|
||||||
|
body: JSON.stringify({
|
||||||
|
bound_service_account_names: cfg.boundServiceAccountNames,
|
||||||
|
bound_service_account_namespaces: cfg.boundServiceAccountNamespaces,
|
||||||
|
token_policies: cfg.tokenPolicies,
|
||||||
|
token_ttl: cfg.tokenTtlSeconds ?? 3600,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`vault ensure k8s auth role ${role}: HTTP ${String(res.status)} ${await readError(res)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /v1/auth/<authMount>/role/<role>. Idempotent — 404 is success. */
|
||||||
|
export async function deleteKubernetesAuthRole(
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
authMount: string,
|
||||||
|
role: string,
|
||||||
|
deps: VaultDeps = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||||
|
const mount = authMount.replace(/^\/|\/$/g, '');
|
||||||
|
const res = await fetchImpl(`${baseUrl(url)}/v1/auth/${mount}/role/${encodeURIComponent(role)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(token, deps.namespace, false),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
throw new Error(`vault delete k8s auth role ${role}: HTTP ${String(res.status)} ${await readError(res)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /v1/sys/policies/acl/<name>. Idempotent — 404 is success. */
|
||||||
|
export async function deletePolicy(
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
name: string,
|
||||||
|
deps: VaultDeps = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||||
|
const res = await fetchImpl(`${baseUrl(url)}/v1/sys/policies/acl/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(token, deps.namespace, false),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
throw new Error(`vault delete policy ${name}: HTTP ${String(res.status)} ${await readError(res)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,3 +33,70 @@ export function buildAppMcpdPolicyHcl(cfg: AppMcpdPolicyConfig): string {
|
|||||||
'',
|
'',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-server read policy for the OpenBao Agent Injector.
|
||||||
|
*
|
||||||
|
* Each MCP server pod authenticates to OpenBao as its OWN ServiceAccount and
|
||||||
|
* gets a policy naming only the secrets that server actually declares. That is
|
||||||
|
* the difference between "the injector moved the credential out of the pod
|
||||||
|
* spec" and "the injector made things worse": with one shared role, every
|
||||||
|
* opted-in pod — including third-party images we do not control — could read
|
||||||
|
* every secret under the prefix. Here, `gitea` can read `gitea-creds` and
|
||||||
|
* nothing else.
|
||||||
|
*
|
||||||
|
* No wildcards. Each secret is named explicitly; adding a secret to a server
|
||||||
|
* means regenerating and re-writing this policy, which is exactly the audit
|
||||||
|
* trail we want.
|
||||||
|
*/
|
||||||
|
export interface ServerSecretPolicyConfig {
|
||||||
|
/** KV v2 mount name, e.g. 'secret'. */
|
||||||
|
mount: string;
|
||||||
|
/** Path prefix under the mount, e.g. 'mcpctl'. */
|
||||||
|
pathPrefix: string;
|
||||||
|
/** Secret names this server may read. Order-insensitive; deduped + sorted. */
|
||||||
|
secretNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildServerSecretPolicyHcl(cfg: ServerSecretPolicyConfig): string {
|
||||||
|
const { mount } = cfg;
|
||||||
|
const prefix = cfg.pathPrefix.replace(/^\/|\/$/g, '');
|
||||||
|
// Sort + dedupe so the generated HCL is stable: an unstable policy body would
|
||||||
|
// rewrite on every reconcile and make real changes invisible in the audit log.
|
||||||
|
const names = [...new Set(cfg.secretNames)].sort((a, b) => a.localeCompare(b));
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const path = prefix === '' ? name : `${prefix}/${name}`;
|
||||||
|
lines.push(`path "${mount}/data/${path}" { capabilities = ["read"] }`);
|
||||||
|
lines.push(`path "${mount}/metadata/${path}" { capabilities = ["read"] }`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grants mcpd needs in order to provision the per-server identities above:
|
||||||
|
* write its own scoped policies and the matching Kubernetes auth roles.
|
||||||
|
*
|
||||||
|
* Deliberately confined by name prefix. This is a real privilege increase for
|
||||||
|
* mcpd, but a much narrower one than the obvious alternative of granting it
|
||||||
|
* Kubernetes `secrets` verbs — that would let it read every Secret in the
|
||||||
|
* cluster, not just the ones it already owns.
|
||||||
|
*/
|
||||||
|
export interface ServerProvisioningPolicyConfig {
|
||||||
|
/** Kubernetes auth mount, e.g. 'kubernetes-worker0'. */
|
||||||
|
authMount: string;
|
||||||
|
/** Shared name prefix for generated policies + roles, e.g. 'mcpctl-server-'. */
|
||||||
|
namePrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildServerProvisioningPolicyHcl(cfg: ServerProvisioningPolicyConfig): string {
|
||||||
|
const authMount = cfg.authMount.replace(/^\/|\/$/g, '');
|
||||||
|
const prefix = cfg.namePrefix;
|
||||||
|
return [
|
||||||
|
`path "sys/policies/acl/${prefix}*" { capabilities = ["create", "read", "update", "delete"] }`,
|
||||||
|
`path "auth/${authMount}/role/${prefix}*" { capabilities = ["create", "read", "update", "delete"] }`,
|
||||||
|
`path "auth/${authMount}/role" { capabilities = ["list"] }`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|||||||
138
src/shared/tests/vault-server-scoping.test.ts
Normal file
138
src/shared/tests/vault-server-scoping.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Per-server OpenBao scoping: each MCP server pod gets its own identity and a
|
||||||
|
* policy naming only its own secrets.
|
||||||
|
*
|
||||||
|
* The property under test is containment. A shared role would let any opted-in
|
||||||
|
* pod — including third-party images we don't control — read every secret under
|
||||||
|
* the prefix, which is a worse position than leaving values in the pod spec.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildServerSecretPolicyHcl,
|
||||||
|
buildServerProvisioningPolicyHcl,
|
||||||
|
ensureKubernetesAuthRole,
|
||||||
|
deleteKubernetesAuthRole,
|
||||||
|
deletePolicy,
|
||||||
|
} from '../src/vault/index.js';
|
||||||
|
|
||||||
|
describe('buildServerSecretPolicyHcl', () => {
|
||||||
|
const cfg = { mount: 'secret', pathPrefix: 'mcpctl' };
|
||||||
|
|
||||||
|
it('grants read on exactly the named secrets and nothing else', () => {
|
||||||
|
const hcl = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['gitea-creds'] });
|
||||||
|
expect(hcl).toContain('path "secret/data/mcpctl/gitea-creds"');
|
||||||
|
expect(hcl).toContain('path "secret/metadata/mcpctl/gitea-creds"');
|
||||||
|
expect(hcl).toContain('capabilities = ["read"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never emits a wildcard — that is the whole point', () => {
|
||||||
|
const hcl = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['gitea-creds', 'unifi-creds'] });
|
||||||
|
expect(hcl).not.toContain('*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants no write capability anywhere', () => {
|
||||||
|
const hcl = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['a', 'b'] });
|
||||||
|
for (const verb of ['create', 'update', 'delete', 'list', 'sudo']) {
|
||||||
|
expect(hcl, `must not grant ${verb}`).not.toContain(verb);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reach other servers\' secrets', () => {
|
||||||
|
const gitea = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['gitea-creds'] });
|
||||||
|
expect(gitea).not.toContain('unifi-creds');
|
||||||
|
expect(gitea).not.toContain('anthropic-key');
|
||||||
|
expect(gitea).not.toContain('litellm-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable under reordering and duplication', () => {
|
||||||
|
// An unstable body would rewrite on every reconcile, drowning real changes
|
||||||
|
// in the OpenBao audit log.
|
||||||
|
const a = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['b', 'a', 'b'] });
|
||||||
|
const b = buildServerSecretPolicyHcl({ ...cfg, secretNames: ['a', 'b'] });
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an empty prefix without producing a double slash', () => {
|
||||||
|
const hcl = buildServerSecretPolicyHcl({ mount: 'secret', pathPrefix: '', secretNames: ['x'] });
|
||||||
|
expect(hcl).toContain('path "secret/data/x"');
|
||||||
|
expect(hcl).not.toContain('//');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits nothing but a trailing newline for a server with no secrets', () => {
|
||||||
|
expect(buildServerSecretPolicyHcl({ ...cfg, secretNames: [] })).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildServerProvisioningPolicyHcl', () => {
|
||||||
|
const cfg = { authMount: 'kubernetes-worker0', namePrefix: 'mcpctl-server-' };
|
||||||
|
|
||||||
|
it('confines mcpd to the generated name prefix', () => {
|
||||||
|
const hcl = buildServerProvisioningPolicyHcl(cfg);
|
||||||
|
expect(hcl).toContain('path "sys/policies/acl/mcpctl-server-*"');
|
||||||
|
expect(hcl).toContain('path "auth/kubernetes-worker0/role/mcpctl-server-*"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not grant blanket policy or auth administration', () => {
|
||||||
|
const hcl = buildServerProvisioningPolicyHcl(cfg);
|
||||||
|
expect(hcl).not.toContain('path "sys/policies/acl/*"');
|
||||||
|
expect(hcl).not.toContain('path "auth/*"');
|
||||||
|
expect(hcl).not.toContain('path "sys/*"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants no access to secret data at all', () => {
|
||||||
|
// mcpd reads secrets through its OWN policy; the provisioning grant must
|
||||||
|
// not widen that surface.
|
||||||
|
expect(buildServerProvisioningPolicyHcl(cfg)).not.toContain('secret/data');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ensureKubernetesAuthRole', () => {
|
||||||
|
it('binds exactly one ServiceAccount in one namespace', async () => {
|
||||||
|
const fetchFn = vi.fn(async () => new Response(null, { status: 204 }));
|
||||||
|
await ensureKubernetesAuthRole(
|
||||||
|
'http://bao.example:8200', 'tok', 'kubernetes-worker0', 'mcpctl-server-gitea',
|
||||||
|
{
|
||||||
|
boundServiceAccountNames: ['mcpctl-server-gitea'],
|
||||||
|
boundServiceAccountNamespaces: ['mcpctl-servers'],
|
||||||
|
tokenPolicies: ['mcpctl-server-gitea'],
|
||||||
|
},
|
||||||
|
{ fetch: fetchFn as unknown as typeof fetch },
|
||||||
|
);
|
||||||
|
const [url, init] = fetchFn.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe('http://bao.example:8200/v1/auth/kubernetes-worker0/role/mcpctl-server-gitea');
|
||||||
|
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||||
|
expect(body.bound_service_account_names).toEqual(['mcpctl-server-gitea']);
|
||||||
|
expect(body.bound_service_account_namespaces).toEqual(['mcpctl-servers']);
|
||||||
|
expect(body.token_policies).toEqual(['mcpctl-server-gitea']);
|
||||||
|
expect(body.token_ttl).toBe(3600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the OpenBao error body on failure', async () => {
|
||||||
|
const fetchFn = vi.fn(async () => new Response(JSON.stringify({ errors: ['permission denied'] }), { status: 403 }));
|
||||||
|
await expect(ensureKubernetesAuthRole(
|
||||||
|
'http://bao.example:8200', 'tok', 'kubernetes-worker0', 'r',
|
||||||
|
{ boundServiceAccountNames: ['a'], boundServiceAccountNamespaces: ['n'], tokenPolicies: ['p'] },
|
||||||
|
{ fetch: fetchFn as unknown as typeof fetch },
|
||||||
|
)).rejects.toThrow(/permission denied/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanup helpers are idempotent', () => {
|
||||||
|
it('treats a missing role as already deleted', async () => {
|
||||||
|
const fetchFn = vi.fn(async () => new Response('', { status: 404 }));
|
||||||
|
await expect(deleteKubernetesAuthRole('http://b', 't', 'kubernetes-worker0', 'gone', { fetch: fetchFn as unknown as typeof fetch }))
|
||||||
|
.resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a missing policy as already deleted', async () => {
|
||||||
|
const fetchFn = vi.fn(async () => new Response('', { status: 404 }));
|
||||||
|
await expect(deletePolicy('http://b', 't', 'gone', { fetch: fetchFn as unknown as typeof fetch }))
|
||||||
|
.resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still raises a real failure', async () => {
|
||||||
|
const fetchFn = vi.fn(async () => new Response(JSON.stringify({ errors: ['denied'] }), { status: 403 }));
|
||||||
|
await expect(deletePolicy('http://b', 't', 'p', { fetch: fetchFn as unknown as typeof fetch }))
|
||||||
|
.rejects.toThrow(/denied/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user