139 lines
6.0 KiB
TypeScript
139 lines
6.0 KiB
TypeScript
|
|
/**
|
||
|
|
* 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/);
|
||
|
|
});
|
||
|
|
});
|