feat(openbao): kubernetes ServiceAccount auth — no static token in DB
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / test (push) Successful in 1m5s
CI/CD / typecheck (push) Successful in 2m8s
CI/CD / smoke (push) Failing after 3m38s
CI/CD / build (push) Successful in 4m15s
CI/CD / publish (push) Has been skipped

Why: requiring a static OpenBao root token to live (even once-bootstrap) on
the plaintext backend is the weakest link in the chain. With the bao-side
Kubernetes auth method enabled, mcpd's pod can authenticate using its own
projected SA token, exchange it for a short-lived Vault client token, and
keep the database free of any vault credentials at all.

Driver changes (src/mcpd/src/services/secret-backends/openbao.ts):
- New `OpenBaoConfig.auth = 'token' | 'kubernetes'`. Defaults to 'token' so
  existing rows keep working. Both shapes share url + mount + pathPrefix +
  namespace; auth-specific fields are mutually exclusive in the config schema.
- Kubernetes auth flow: read JWT from /var/run/secrets/.../token, POST to
  /v1/auth/<authMount>/login {role, jwt}, cache the returned client_token
  for `lease_duration - 60s` (grace window), then re-login.
- One-shot 403-retry: if a request comes back 403 (revoked / clock skew),
  purge cache and retry the original request once with a fresh login.
- Reads + writes go through the same getToken() path so token-auth is
  unchanged for existing deployments.

CLI (src/cli/src/commands/create.ts):
- `mcpctl create secretbackend bao --type openbao --auth kubernetes \
     --url https://bao.example:8200 --role mcpctl`
- Optional `--auth-mount` (default 'kubernetes') + `--sa-token-path` (default
  the standard projected-token path) for non-default deployments.
- Token-auth path unchanged: `--auth token --token-secret SECRET/KEY`
  (or omit `--auth` since 'token' is the default).

Validation (factory.ts) gates on the auth strategy: each path enforces its
own required fields and produces a clear error if misconfigured.

Tests: 6 new k8s-auth unit cases (login wire shape, lease-based caching,
custom authMount, 403-on-login, missing-role rejection, missing-tokenSecretRef
rejection). Full suite 1859/1859. Completions regenerated for the new flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-19 23:23:05 +01:00
parent a21220b6f6
commit 515206685b
6 changed files with 293 additions and 29 deletions

View File

@@ -25,10 +25,26 @@ export function createDriver(row: SecretBackend, deps: DriverFactoryDeps): Secre
case 'openbao': {
const cfg = row.config as unknown as OpenBaoConfig;
if (!cfg.url || !cfg.tokenSecretRef?.name || !cfg.tokenSecretRef?.key) {
throw new Error(
`SecretBackend '${row.name}' (openbao): config must provide url + tokenSecretRef {name, key}`,
);
if (!cfg.url) {
throw new Error(`SecretBackend '${row.name}' (openbao): config.url is required`);
}
const auth = cfg.auth ?? 'token';
if (auth === 'token') {
const t = cfg as Extract<OpenBaoConfig, { auth?: 'token' }>;
if (!t.tokenSecretRef?.name || !t.tokenSecretRef?.key) {
throw new Error(
`SecretBackend '${row.name}' (openbao token auth): config.tokenSecretRef {name, key} is required`,
);
}
} else if (auth === 'kubernetes') {
const k = cfg as Extract<OpenBaoConfig, { auth: 'kubernetes' }>;
if (!k.role) {
throw new Error(
`SecretBackend '${row.name}' (openbao kubernetes auth): config.role is required`,
);
}
} else {
throw new Error(`SecretBackend '${row.name}' (openbao): unknown auth '${String(auth)}'`);
}
const driverDeps: { fetch?: typeof globalThis.fetch; secretRefResolver: SecretRefResolver } = {
secretRefResolver: deps.secretRefResolver,

View File

@@ -8,33 +8,69 @@
* GET <url>/v1/<mount>/data/<path> -- read latest
* DELETE <url>/v1/<mount>/metadata/<path> -- full delete (all versions)
* LIST <url>/v1/<mount>/metadata/ -- for migration
* POST <url>/v1/auth/<mount>/login -- kubernetes auth
*
* Auth: static token for v1. The token is stored in a `Secret` on the
* plaintext backend (see `config.tokenSecretRef = { name, key }`); the driver
* resolves it on construction via the injected `SecretRefResolver`. Follow-up
* work (not here) adds Kubernetes ServiceAccount auth.
* Auth strategies (`config.auth`):
* - `token` (default): static token loaded once via the injected
* SecretRefResolver from a Secret on the plaintext backend
* (`tokenSecretRef = { name, key }`). Cached for the driver's lifetime —
* no expiry handling.
* - `kubernetes`: log in to OpenBao's Kubernetes auth method using the
* pod's projected ServiceAccount token. Vault returns a client token +
* lease TTL; we cache it and renew lazily on TTL expiry, with a 60s
* grace window. No static credentials in the database — the bao-side
* role binds to the mcpd ServiceAccount + namespace.
*
* Path layout inside OpenBao:
* <mount>/<pathPrefix>/<secretName>
* `mount` and `pathPrefix` come from the backend's `config` JSON; defaults are
* `secret` and `mcpctl/`.
*/
import { readFile } from 'node:fs/promises';
import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js';
export interface OpenBaoConfig {
export interface OpenBaoConfigBase {
url: string;
mount?: string;
pathPrefix?: string;
namespace?: string;
}
export interface OpenBaoConfigToken extends OpenBaoConfigBase {
auth?: 'token';
tokenSecretRef: { name: string; key: string };
}
export interface OpenBaoConfigKubernetes extends OpenBaoConfigBase {
auth: 'kubernetes';
/** Vault role to login as (configured server-side at `auth/<authMount>/role/<role>`). */
role: string;
/** Auth method mount path. Defaults to `kubernetes`. */
authMount?: string;
/**
* Filesystem path to the projected ServiceAccount token. Defaults to
* `/var/run/secrets/kubernetes.io/serviceaccount/token` (the standard
* mount). Override only for tests or non-default projections.
*/
serviceAccountTokenPath?: string;
}
export type OpenBaoConfig = OpenBaoConfigToken | OpenBaoConfigKubernetes;
export interface OpenBaoDriverDeps {
/** Injected HTTP fetcher — mockable in tests. */
fetch?: typeof globalThis.fetch;
secretRefResolver: SecretRefResolver;
/** Required only for `auth: 'token'`. */
secretRefResolver?: SecretRefResolver;
/** Override for the SA-token reader; tests use this to supply a fake JWT. */
readServiceAccountToken?: (path: string) => Promise<string>;
/** Clock for cache TTL — overridable in tests. */
now?: () => number;
}
const SA_TOKEN_DEFAULT_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token';
const TOKEN_RENEW_GRACE_MS = 60_000;
export class OpenBaoDriver implements SecretBackendDriver {
readonly kind = 'openbao';
@@ -42,19 +78,48 @@ export class OpenBaoDriver implements SecretBackendDriver {
private readonly mount: string;
private readonly pathPrefix: string;
private readonly namespace: string | undefined;
private readonly tokenSecretRef: { name: string; key: string };
private readonly authStrategy: 'token' | 'kubernetes';
private readonly tokenSecretRef: { name: string; key: string } | undefined;
private readonly k8sRole: string | undefined;
private readonly k8sAuthMount: string;
private readonly k8sTokenPath: string;
private readonly fetchImpl: typeof globalThis.fetch;
private readonly resolver: SecretRefResolver;
private readonly resolver: SecretRefResolver | undefined;
private readonly readSaToken: (path: string) => Promise<string>;
private readonly nowFn: () => number;
// Cached vault token + when (epoch ms) it should be considered expired and refetched.
private cachedToken: string | undefined;
private cachedTokenExpiresAt: number = Number.POSITIVE_INFINITY;
constructor(config: OpenBaoConfig, deps: OpenBaoDriverDeps) {
this.url = config.url.replace(/\/+$/, '');
this.mount = (config.mount ?? 'secret').replace(/^\/|\/$/g, '');
this.pathPrefix = (config.pathPrefix ?? 'mcpctl').replace(/^\/|\/$/g, '');
if (config.namespace !== undefined) this.namespace = config.namespace;
this.tokenSecretRef = config.tokenSecretRef;
this.authStrategy = config.auth ?? 'token';
if (this.authStrategy === 'kubernetes') {
const k = config as OpenBaoConfigKubernetes;
if (!k.role) throw new Error('openbao kubernetes auth: `role` is required');
this.k8sRole = k.role;
this.k8sAuthMount = (k.authMount ?? 'kubernetes').replace(/^\/|\/$/g, '');
this.k8sTokenPath = k.serviceAccountTokenPath ?? SA_TOKEN_DEFAULT_PATH;
} else {
const t = config as OpenBaoConfigToken;
if (!t.tokenSecretRef) throw new Error('openbao token auth: `tokenSecretRef` is required');
if (deps.secretRefResolver === undefined) {
throw new Error('openbao token auth: secretRefResolver dependency is required');
}
this.tokenSecretRef = t.tokenSecretRef;
this.k8sAuthMount = 'kubernetes';
this.k8sTokenPath = SA_TOKEN_DEFAULT_PATH;
}
this.fetchImpl = deps.fetch ?? globalThis.fetch;
this.resolver = deps.secretRefResolver;
if (deps.secretRefResolver !== undefined) this.resolver = deps.secretRefResolver;
this.readSaToken = deps.readServiceAccountToken ?? ((path) => readFile(path, 'utf-8').then((s) => s.trim()));
this.nowFn = deps.now ?? (() => Date.now());
}
async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise<SecretData> {
@@ -113,10 +178,44 @@ export class OpenBaoDriver implements SecretBackendDriver {
}
private async getToken(): Promise<string> {
if (this.cachedToken !== undefined) return this.cachedToken;
const token = await this.resolver.resolve(this.tokenSecretRef.name, this.tokenSecretRef.key);
this.cachedToken = token;
return token;
if (this.cachedToken !== undefined && this.nowFn() < this.cachedTokenExpiresAt - TOKEN_RENEW_GRACE_MS) {
return this.cachedToken;
}
if (this.authStrategy === 'token') {
// Static token from a plaintext Secret. No TTL — cache for the driver's lifetime.
const token = await this.resolver!.resolve(this.tokenSecretRef!.name, this.tokenSecretRef!.key);
this.cachedToken = token;
this.cachedTokenExpiresAt = Number.POSITIVE_INFINITY;
return token;
}
// Kubernetes auth: read the projected SA JWT, exchange it for a Vault token.
const jwt = await this.readSaToken(this.k8sTokenPath);
const loginUrl = `${this.url}/v1/auth/${this.k8sAuthMount}/login`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace;
const res = await this.fetchImpl(loginUrl, {
method: 'POST',
headers,
body: JSON.stringify({ role: this.k8sRole, jwt }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`OpenBao kubernetes login (role=${this.k8sRole!}): HTTP ${String(res.status)} ${text}`);
}
const body = await res.json() as { auth?: { client_token?: string; lease_duration?: number } };
const clientToken = body.auth?.client_token;
if (clientToken === undefined || clientToken === '') {
throw new Error(`OpenBao kubernetes login: response missing auth.client_token`);
}
// lease_duration is seconds; 0 means token doesn't expire (rare for k8s auth).
const leaseSec = body.auth?.lease_duration ?? 0;
this.cachedToken = clientToken;
this.cachedTokenExpiresAt = leaseSec > 0
? this.nowFn() + leaseSec * 1000
: Number.POSITIVE_INFINITY;
return clientToken;
}
private async request(method: string, path: string, body?: unknown): Promise<Response> {
@@ -128,6 +227,21 @@ export class OpenBaoDriver implements SecretBackendDriver {
const init: RequestInit = { method, headers };
if (body !== undefined) init.body = JSON.stringify(body);
return this.fetchImpl(`${this.url}${path}`, init);
const res = await this.fetchImpl(`${this.url}${path}`, init);
// If the cached token expired between cache-check and request (k8s clock
// skew, server-side revocation, etc.), purge cache and retry once.
if (res.status === 403 && this.cachedToken !== undefined) {
this.cachedToken = undefined;
this.cachedTokenExpiresAt = 0;
const fresh = await this.getToken();
const retryHeaders: Record<string, string> = { 'X-Vault-Token': fresh };
if (this.namespace !== undefined) retryHeaders['X-Vault-Namespace'] = this.namespace;
if (body !== undefined) retryHeaders['Content-Type'] = 'application/json';
const retryInit: RequestInit = { method, headers: retryHeaders };
if (body !== undefined) retryInit.body = JSON.stringify(body);
return this.fetchImpl(`${this.url}${path}`, retryInit);
}
return res;
}
}

View File

@@ -129,4 +129,116 @@ describe('OpenBaoDriver', () => {
const headers = init.headers as Record<string, string>;
expect(headers['X-Vault-Namespace']).toBe('myteam');
});
describe('kubernetes auth', () => {
it('exchanges the SA JWT for a vault client token via /v1/auth/kubernetes/login', async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fetchFn = vi.fn(async (url: string | URL, init?: RequestInit) => {
const u = String(url);
calls.push({ url: u, init: init ?? {} });
if (u.endsWith('/v1/auth/kubernetes/login')) {
return new Response(JSON.stringify({
auth: { client_token: 'vault.client.token.xyz', lease_duration: 3600 },
}), { status: 200 });
}
return new Response(JSON.stringify({}), { status: 200 });
});
const driver = new OpenBaoDriver(
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
{
fetch: fetchFn as unknown as typeof fetch,
readServiceAccountToken: async () => 'eyJ.fake.sa.jwt',
},
);
await driver.write({ name: 'x', data: { k: 'v' } });
// Two calls: login + write
expect(calls).toHaveLength(2);
expect(calls[0]!.url).toBe('http://bao.example:8200/v1/auth/kubernetes/login');
expect(JSON.parse(calls[0]!.init.body as string)).toEqual({ role: 'mcpctl', jwt: 'eyJ.fake.sa.jwt' });
// Write uses the returned client token
const writeHeaders = calls[1]!.init.headers as Record<string, string>;
expect(writeHeaders['X-Vault-Token']).toBe('vault.client.token.xyz');
});
it('caches the vault token across requests and renews after lease expiry', async () => {
let nowMs = 1_000_000_000_000;
let loginCount = 0;
const fetchFn = vi.fn(async (url: string | URL) => {
const u = String(url);
if (u.endsWith('/v1/auth/kubernetes/login')) {
loginCount++;
// 600s lease leaves 540s of cached window after the 60s grace.
return new Response(JSON.stringify({
auth: { client_token: `tok-${String(loginCount)}`, lease_duration: 600 },
}), { status: 200 });
}
return new Response(JSON.stringify({}), { status: 200 });
});
const driver = new OpenBaoDriver(
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
{
fetch: fetchFn as unknown as typeof fetch,
readServiceAccountToken: async () => 'jwt',
now: () => nowMs,
},
);
await driver.write({ name: 'a', data: { k: 'v' } });
await driver.write({ name: 'b', data: { k: 'v' } });
expect(loginCount).toBe(1); // both writes share the cached token
// Advance past lease - grace window → driver re-logs in
nowMs += 600_000;
await driver.write({ name: 'c', data: { k: 'v' } });
expect(loginCount).toBe(2);
});
it('honours custom authMount path', async () => {
const calls: string[] = [];
const fetchFn = vi.fn(async (url: string | URL) => {
calls.push(String(url));
if (String(url).includes('/login')) {
return new Response(JSON.stringify({ auth: { client_token: 't', lease_duration: 3600 } }), { status: 200 });
}
return new Response(JSON.stringify({}), { status: 200 });
});
const driver = new OpenBaoDriver(
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl', authMount: 'kubernetes/cluster-a' },
{ fetch: fetchFn as unknown as typeof fetch, readServiceAccountToken: async () => 'jwt' },
);
await driver.write({ name: 'x', data: {} });
expect(calls[0]).toBe('http://bao.example:8200/v1/auth/kubernetes/cluster-a/login');
});
it('throws on login failure with a clear error', async () => {
const fetchFn = vi.fn(async () => new Response('permission denied', { status: 403 }));
const driver = new OpenBaoDriver(
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
{ fetch: fetchFn as unknown as typeof fetch, readServiceAccountToken: async () => 'jwt' },
);
await expect(driver.read({ name: 'x', externalRef: '', data: {} }))
.rejects.toThrow(/kubernetes login.*role=mcpctl.*HTTP 403/);
});
it('rejects construction when role is missing', () => {
expect(() => new OpenBaoDriver(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ url: 'http://bao.example:8200', auth: 'kubernetes' } as any,
{ fetch: vi.fn() as unknown as typeof fetch, readServiceAccountToken: async () => 'jwt' },
)).toThrow(/role.*required/);
});
it('rejects token-auth construction when tokenSecretRef is missing', () => {
expect(() => new OpenBaoDriver(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ url: 'http://bao.example:8200' } as any,
{ fetch: vi.fn() as unknown as typeof fetch, secretRefResolver: resolver },
)).toThrow(/tokenSecretRef.*required/);
});
});
});