diff --git a/src/mcpd/src/services/secret-backends/caching.ts b/src/mcpd/src/services/secret-backends/caching.ts index 22b4276..bdc87c4 100644 --- a/src/mcpd/src/services/secret-backends/caching.ts +++ b/src/mcpd/src/services/secret-backends/caching.ts @@ -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 { + 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 { + await this.inner.removeServerIdentity?.(input); + } + async authCheck(): Promise<{ ok: boolean; detail?: string }> { return this.inner.authCheck?.() ?? { ok: true, detail: 'no probe' }; } diff --git a/src/mcpd/src/services/secret-backends/openbao.ts b/src/mcpd/src/services/secret-backends/openbao.ts index 34ddedc..9bee647 100644 --- a/src/mcpd/src/services/secret-backends/openbao.ts +++ b/src/mcpd/src/services/secret-backends/openbao.ts @@ -7,7 +7,7 @@ * POST /v1//data/ -- write * GET /v1//data/ -- read latest * DELETE /v1//metadata/ -- full delete (all versions) - * LIST /v1//metadata/ -- for migration + * GET /v1//metadata/?list=true -- for migration (see list()) * POST /v1/auth//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 { @@ -185,7 +192,13 @@ export class OpenBaoDriver implements SecretBackendDriver { async list(): Promise> { 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 { + 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 { + 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}`; diff --git a/src/mcpd/src/services/secret-backends/types.ts b/src/mcpd/src/services/secret-backends/types.ts index 8d91ce9..74a423b 100644 --- a/src/mcpd/src/services/secret-backends/types.ts +++ b/src/mcpd/src/services/secret-backends/types.ts @@ -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; + + /** Optional: tear down what `ensureServerIdentity` created. Idempotent. */ + removeServerIdentity?(input: { name: string }): Promise; + /** * Optional READINESS probe: can we actually read through this backend with * the credentials we hold? diff --git a/src/mcpd/src/services/server-identity.service.ts b/src/mcpd/src/services/server-identity.service.ts new file mode 100644 index 0000000..f6f8d30 --- /dev/null +++ b/src/mcpd/src/services/server-identity.service.ts @@ -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-` + * - its own OpenBao policy `mcpctl-server-` (names only + * the secrets that server declares — no wildcards) + * - its own OpenBao k8s auth role `mcpctl-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; + /** Delete it. Must tolerate "not found". */ + remove(name: string): Promise; +} + +export interface ServerIdentityLog { + info(obj: Record, msg: string): void; + warn(obj: Record, 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): string[] { + const entries = (server.env ?? []) as ServerEnvEntry[]; + const names = new Set(); + 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): Promise { + 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 { + 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}'`); + } + } +} diff --git a/src/mcpd/tests/secret-backends.test.ts b/src/mcpd/tests/secret-backends.test.ts index 43785a2..d43cb20 100644 --- a/src/mcpd/tests/secret-backends.test.ts +++ b/src/mcpd/tests/secret-backends.test.ts @@ -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' }, diff --git a/src/shared/src/vault/client.ts b/src/shared/src/vault/client.ts index ef79e9c..9e3c218 100644 --- a/src/shared/src/vault/client.ts +++ b/src/shared/src/vault/client.ts @@ -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//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 { + 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//role/. Idempotent — 404 is success. */ +export async function deleteKubernetesAuthRole( + url: string, + token: string, + authMount: string, + role: string, + deps: VaultDeps = {}, +): Promise { + 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/. Idempotent — 404 is success. */ +export async function deletePolicy( + url: string, + token: string, + name: string, + deps: VaultDeps = {}, +): Promise { + 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)}`); + } +} diff --git a/src/shared/src/vault/policy.ts b/src/shared/src/vault/policy.ts index 65b30cb..d28d535 100644 --- a/src/shared/src/vault/policy.ts +++ b/src/shared/src/vault/policy.ts @@ -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'); +} diff --git a/src/shared/tests/vault-server-scoping.test.ts b/src/shared/tests/vault-server-scoping.test.ts new file mode 100644 index 0000000..e870100 --- /dev/null +++ b/src/shared/tests/vault-server-scoping.test.ts @@ -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; + 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/); + }); +});