Merge pull request 'fix(secrets): list via GET ?list=true — the LIST verb dies at the proxy' (#116) from fix/openbao-list-verb into main
Some checks failed
Some checks failed
This commit was merged in pull request #116.
This commit is contained in:
@@ -164,6 +164,23 @@ export class CachingSecretBackendDriver implements SecretBackendDriver {
|
||||
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 }> {
|
||||
return this.inner.authCheck?.() ?? { ok: true, detail: 'no probe' };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* POST <url>/v1/<mount>/data/<path> -- write
|
||||
* GET <url>/v1/<mount>/data/<path> -- read latest
|
||||
* 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
|
||||
*
|
||||
* Auth strategies (`config.auth`):
|
||||
@@ -29,6 +29,13 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } 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. */
|
||||
async function bodyText(res: Response): Promise<string> {
|
||||
@@ -185,7 +192,13 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
||||
|
||||
async list(): Promise<Array<{ name: string; externalRef: ExternalRef }>> {
|
||||
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.ok) throw new Error(`OpenBao list: HTTP ${res.status} ${await bodyText(res)}`);
|
||||
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 {
|
||||
const safe = encodeURIComponent(name);
|
||||
return this.pathPrefix === '' ? safe : `${this.pathPrefix}/${safe}`;
|
||||
|
||||
@@ -55,6 +55,31 @@ export interface SecretBackendDriver {
|
||||
*/
|
||||
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
|
||||
* 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();
|
||||
});
|
||||
|
||||
it('list returns names from the metadata LIST call', async () => {
|
||||
it('list returns names from the metadata listing', async () => {
|
||||
const fetchFn = makeFetch([{
|
||||
url: /\/v1\/secret\/metadata\/mcpctl\/$/,
|
||||
url: /\/v1\/secret\/metadata\/mcpctl\/\?list=true$/,
|
||||
status: 200,
|
||||
body: { data: { keys: ['token1', 'token2', 'sub-folder/'] } },
|
||||
}]);
|
||||
@@ -98,6 +98,10 @@ describe('OpenBaoDriver', () => {
|
||||
{ fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver },
|
||||
);
|
||||
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.
|
||||
expect(result).toEqual([
|
||||
{ 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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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