From 515206685b8d7993228a91faad092e96b609cb2f Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 19 Apr 2026 23:23:05 +0100 Subject: [PATCH] =?UTF-8?q?feat(openbao):=20kubernetes=20ServiceAccount=20?= =?UTF-8?q?auth=20=E2=80=94=20no=20static=20token=20in=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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) --- completions/mcpctl.bash | 2 +- completions/mcpctl.fish | 6 +- src/cli/src/commands/create.ts | 34 ++++- .../src/services/secret-backends/factory.ts | 24 ++- .../src/services/secret-backends/openbao.ts | 144 ++++++++++++++++-- src/mcpd/tests/secret-backends.test.ts | 112 ++++++++++++++ 6 files changed, 293 insertions(+), 29 deletions(-) diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 152d3ad..5072a29 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -188,7 +188,7 @@ _mcpctl() { COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --force -h --help" -- "$cur")) ;; secretbackend) - COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --token-secret --config --force -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --auth --token-secret --role --auth-mount --sa-token-path --config --force -h --help" -- "$cur")) ;; project) COMPREPLY=($(compgen -W "-d --description --proxy-model --prompt --llm --llm-model --gated --no-gated --server --force -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 6b6907b..742f31a 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -336,7 +336,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l url -d 'o complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l namespace -d 'openbao: X-Vault-Namespace header value' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l mount -d 'openbao: KV v2 mount point (default: secret)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l path-prefix -d 'openbao: path prefix under mount (default: mcpctl)' -x -complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l token-secret -d 'openbao: token secret reference in SECRET/KEY form (e.g. bao-creds/token)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l auth -d 'openbao: auth method — \'token\' (default) or \'kubernetes\'' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l token-secret -d 'openbao token auth: token secret reference in SECRET/KEY form (e.g. bao-creds/token)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l role -d 'openbao kubernetes auth: vault role to login as (e.g. \'mcpctl\')' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l auth-mount -d 'openbao kubernetes auth: vault auth method mount path (default: \'kubernetes\')' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l sa-token-path -d 'openbao kubernetes auth: filesystem path to projected SA token (default: \'/var/run/secrets/kubernetes.io/serviceaccount/token\')' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l config -d 'Extra config as key=value (repeat for multiple)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l force -d 'Update if already exists' diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 895ca9b..bc5d94b 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -319,7 +319,11 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--namespace ', 'openbao: X-Vault-Namespace header value') .option('--mount ', 'openbao: KV v2 mount point (default: secret)') .option('--path-prefix ', 'openbao: path prefix under mount (default: mcpctl)') - .option('--token-secret ', 'openbao: token secret reference in SECRET/KEY form (e.g. bao-creds/token)') + .option('--auth ', "openbao: auth method — 'token' (default) or 'kubernetes'") + .option('--token-secret ', 'openbao token auth: token secret reference in SECRET/KEY form (e.g. bao-creds/token)') + .option('--role ', "openbao kubernetes auth: vault role to login as (e.g. 'mcpctl')") + .option('--auth-mount ', "openbao kubernetes auth: vault auth method mount path (default: 'kubernetes')") + .option('--sa-token-path ', "openbao kubernetes auth: filesystem path to projected SA token (default: '/var/run/secrets/kubernetes.io/serviceaccount/token')") .option('--config ', 'Extra config as key=value (repeat for multiple)', collect, []) .option('--force', 'Update if already exists') .action(async (name: string, opts) => { @@ -328,14 +332,28 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (type === 'openbao') { if (!opts.url) throw new Error('--url is required for openbao backend'); - if (!opts.tokenSecret) throw new Error('--token-secret is required for openbao backend (format: SECRET/KEY)'); - const slashIdx = (opts.tokenSecret as string).indexOf('/'); - if (slashIdx < 1) throw new Error(`Invalid --token-secret '${opts.tokenSecret as string}'. Expected SECRET_NAME/KEY_NAME`); + const auth = (opts.auth as string | undefined) ?? 'token'; + if (auth !== 'token' && auth !== 'kubernetes') { + throw new Error(`--auth must be 'token' or 'kubernetes' (got '${auth}')`); + } config.url = opts.url; - config.tokenSecretRef = { - name: (opts.tokenSecret as string).slice(0, slashIdx), - key: (opts.tokenSecret as string).slice(slashIdx + 1), - }; + config.auth = auth; + + if (auth === 'token') { + if (!opts.tokenSecret) throw new Error('--token-secret is required for openbao token auth (format: SECRET/KEY)'); + const slashIdx = (opts.tokenSecret as string).indexOf('/'); + if (slashIdx < 1) throw new Error(`Invalid --token-secret '${opts.tokenSecret as string}'. Expected SECRET_NAME/KEY_NAME`); + config.tokenSecretRef = { + name: (opts.tokenSecret as string).slice(0, slashIdx), + key: (opts.tokenSecret as string).slice(slashIdx + 1), + }; + } else { + if (!opts.role) throw new Error("--role is required for openbao kubernetes auth (the vault role bound to this pod's ServiceAccount)"); + config.role = opts.role; + if (opts.authMount) config.authMount = opts.authMount; + if (opts.saTokenPath) config.serviceAccountTokenPath = opts.saTokenPath; + } + if (opts.namespace) config.namespace = opts.namespace; if (opts.mount) config.mount = opts.mount; if (opts.pathPrefix) config.pathPrefix = opts.pathPrefix; diff --git a/src/mcpd/src/services/secret-backends/factory.ts b/src/mcpd/src/services/secret-backends/factory.ts index e4c89b1..4341df5 100644 --- a/src/mcpd/src/services/secret-backends/factory.ts +++ b/src/mcpd/src/services/secret-backends/factory.ts @@ -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; + 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; + 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, diff --git a/src/mcpd/src/services/secret-backends/openbao.ts b/src/mcpd/src/services/secret-backends/openbao.ts index 895394b..5d086fc 100644 --- a/src/mcpd/src/services/secret-backends/openbao.ts +++ b/src/mcpd/src/services/secret-backends/openbao.ts @@ -8,33 +8,69 @@ * GET /v1//data/ -- read latest * DELETE /v1//metadata/ -- full delete (all versions) * LIST /v1//metadata/ -- for migration + * POST /v1/auth//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` 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//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; + /** 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; + 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 { @@ -113,10 +178,44 @@ export class OpenBaoDriver implements SecretBackendDriver { } private async getToken(): Promise { - 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 = { '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 { @@ -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 = { '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; } } diff --git a/src/mcpd/tests/secret-backends.test.ts b/src/mcpd/tests/secret-backends.test.ts index 6e0da9e..0f86075 100644 --- a/src/mcpd/tests/secret-backends.test.ts +++ b/src/mcpd/tests/secret-backends.test.ts @@ -129,4 +129,116 @@ describe('OpenBaoDriver', () => { const headers = init.headers as Record; 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; + 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/); + }); + }); });