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
CI/CD / typecheck (push) Successful in 1m21s
CI/CD / lint (push) Successful in 2m31s
CI/CD / test (push) Successful in 1m31s
CI/CD / smoke (push) Failing after 2m3s
CI/CD / build (push) Successful in 4m29s
CI/CD / publish (push) Has been skipped

This commit was merged in pull request #116.
This commit is contained in:
2026-08-20 22:03:40 +00:00
8 changed files with 539 additions and 4 deletions

View File

@@ -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' };
} }

View File

@@ -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}`;

View File

@@ -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?

View 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}'`);
}
}
}

View File

@@ -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' },

View File

@@ -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)}`);
}
}

View File

@@ -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');
}

View 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/);
});
});