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