/** * Per-server identity provisioning. The property under test throughout is * CONTAINMENT: a server's identity must grant exactly its own secrets, because * the alternative (one shared role) would let third-party MCP images read every * secret under the prefix — worse than the pod-spec exposure it replaces. */ import { describe, it, expect, vi } from 'vitest'; import type { SecretBackend } from '@prisma/client'; import { ServerIdentityService, type ServiceAccountPort } from '../src/services/server-identity.service.js'; import type { SecretBackendService } from '../src/services/secret-backend.service.js'; import type { SecretBackendDriver } from '../src/services/secret-backends/types.js'; const NS = 'mcpctl-servers'; function envRef(name: string, secret: string, key: string): unknown { return { name, valueFrom: { secretRef: { name: secret, key } } }; } function harness(driverOverrides: Partial = {}, backendType = 'openbao') { const calls = { sa: [] as string[], saRemoved: [] as string[] }; const ensureServerIdentity = vi.fn(async () => undefined); const removeServerIdentity = vi.fn(async () => undefined); const driver = { kind: backendType, ensureServerIdentity, removeServerIdentity, ...driverOverrides } as unknown as SecretBackendDriver; const backends = { getDefault: async (): Promise => ({ id: 'b1', name: 'bao-k8s', type: backendType } as SecretBackend), driverFor: () => driver, } as unknown as SecretBackendService; const serviceAccounts: ServiceAccountPort = { namespace: NS, ensure: async (n) => { calls.sa.push(n); }, remove: async (n) => { calls.saRemoved.push(n); }, }; return { svc: new ServerIdentityService(backends, serviceAccounts), calls, ensureServerIdentity, removeServerIdentity }; } describe('ServerIdentityService', () => { it('grants a server exactly the secrets it declares', async () => { const h = harness(); await h.svc.ensureFor({ name: 'gitea', env: [envRef('GITEA_ACCESS_TOKEN', 'gitea-creds', 'GITEA_ACCESS_TOKEN'), envRef('GITEA_HOST', 'gitea-creds', 'GITEA_HOST')], } as never); expect(h.ensureServerIdentity).toHaveBeenCalledWith({ name: 'mcpctl-server-gitea', namespace: NS, // One secret, not two: the same secret referenced twice is one grant. secretNames: ['gitea-creds'], }); }); it('does not leak another server\'s secrets into the grant', async () => { const h = harness(); await h.svc.ensureFor({ name: 'gitea', env: [envRef('T', 'gitea-creds', 'K')] } as never); const granted = h.ensureServerIdentity.mock.calls[0]?.[0] as { secretNames: string[] }; expect(granted.secretNames).not.toContain('unifi-creds'); expect(granted.secretNames).not.toContain('anthropic-key'); }); it('ignores inline env values — they are not secrets', async () => { const h = harness(); await h.svc.ensureFor({ name: 's', env: [{ name: 'PLAIN', value: 'not-a-secret' }, envRef('T', 'real', 'K')], } as never); const granted = h.ensureServerIdentity.mock.calls[0]?.[0] as { secretNames: string[] }; expect(granted.secretNames).toEqual(['real']); }); it('creates the ServiceAccount BEFORE binding the role to it', async () => { // Reverse order lets the pod start, fail to log in, and crashloop while the // role is still being written. const order: string[] = []; const h = harness(); const svc = new ServerIdentityService( { getDefault: async () => ({ id: 'b1', name: 'bao', type: 'openbao' } as SecretBackend), driverFor: () => ({ kind: 'openbao', ensureServerIdentity: async () => { order.push('role'); } }) } as unknown as SecretBackendService, { namespace: NS, ensure: async () => { order.push('sa'); }, remove: async () => undefined }, ); await svc.ensureFor({ name: 'x', env: [envRef('T', 's', 'K')] } as never); expect(order).toEqual(['sa', 'role']); expect(h).toBeDefined(); }); it('produces an empty grant for a server with no secret refs', async () => { const h = harness(); await h.svc.ensureFor({ name: 'plain', env: [] } as never); const granted = h.ensureServerIdentity.mock.calls[0]?.[0] as { secretNames: string[] }; expect(granted.secretNames).toEqual([]); }); it('refuses on a backend that cannot scope identities, rather than silently succeeding', async () => { // A plaintext backend has no auth mount to bind a role on. Succeeding here // would leave a pod believing it had been granted access it never got. const h = harness({ ensureServerIdentity: undefined }, 'plaintext'); await expect(h.svc.ensureFor({ name: 's', env: [] } as never)) .rejects.toThrow(/cannot provision per-server identities/); }); it('revokes the OpenBao identity and the ServiceAccount on removal', async () => { const h = harness(); await h.svc.removeFor('gitea'); expect(h.removeServerIdentity).toHaveBeenCalledWith({ name: 'mcpctl-server-gitea' }); expect(h.calls.saRemoved).toEqual(['mcpctl-server-gitea']); }); it('still removes the ServiceAccount when OpenBao teardown fails', async () => { // A half-removed identity grants nothing useful; abandoning the rest would // strand the k8s side. const h = harness({ removeServerIdentity: vi.fn(async () => { throw new Error('bao down'); }) as never }); await expect(h.svc.removeFor('gitea')).resolves.toBeUndefined(); expect(h.calls.saRemoved).toEqual(['mcpctl-server-gitea']); }); });