diff --git a/src/cli.ts b/src/cli.ts index 5b00b83..c44e584 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1081,6 +1081,7 @@ policyCommand.command("config") else { console.log(`fleetMode=${config.fleetMode} secretBackend=${config.secretBackend} target=${config.targetRuntime}`); console.log(`chefvaultUrl=${config.chefvaultUrl ?? process.env.CHEF_PROVIDER_SECURITY_URL ?? "http://127.0.0.1:8323"}`); + console.log(`chefvaultToken=${process.env.CHEF_PROVIDER_SECURITY_TOKEN?.trim() ? "set (CHEF_PROVIDER_SECURITY_TOKEN)" : "unset"}`); } }); policyCommand.command("plan") @@ -1161,7 +1162,10 @@ policyCommand.command("doctor") console.log(pc.bold("Provider Security Plane doctor")); console.log(`fleetMode=${result.config.fleetMode} secretBackend=${result.config.secretBackend}`); if (result.chefvaultReachable !== undefined) { - console.log(`chefvault=${result.chefvaultReachable ? pc.green("reachable") : pc.red("unreachable")}`); + console.log(`chefvaultReachable=${result.chefvaultReachable ? pc.green("yes") : pc.red("no")}`); + } + if (result.chefvaultAuthenticated !== undefined) { + console.log(`chefvaultAuthenticated=${result.chefvaultAuthenticated ? pc.green("yes") : pc.red("no")}`); } console.log(`activeRevision=${result.activeRevision ?? "none"}`); console.log(`target=${result.targetPath}`); diff --git a/src/provider-security/chefvault-client.ts b/src/provider-security/chefvault-client.ts index d6bcc6f..f5b85ee 100644 --- a/src/provider-security/chefvault-client.ts +++ b/src/provider-security/chefvault-client.ts @@ -1,5 +1,9 @@ import type { ProviderSecurityConfig } from "./types.js"; -import { resolveChefVaultSecurityUrl } from "./config.js"; +import { + resolveChefVaultIdentityHeaders, + resolveChefVaultSecurityToken, + resolveChefVaultSecurityUrl, +} from "./config.js"; export interface ChefVaultRefStatus { ref: string; @@ -15,17 +19,43 @@ export interface ChefVaultHealth { error?: string; } +export interface ChefVaultAuthProbe { + ok: boolean; + error?: string; +} + function normalizeRef(ref: string): string { return ref.startsWith("chefvault://") ? ref : `chefvault://${ref.replace(/^\/+/, "")}`; } +function authErrorMessage(status: number, context: "ref probe" | "auth probe"): string { + if (status === 401) { + return `ChefVault ${context} unauthorized (401): missing or invalid Bearer token`; + } + if (status === 403) { + return `ChefVault ${context} forbidden (403): Bearer token rejected or identity headers mismatch`; + } + return `ChefVault ${context} failed (${status})`; +} + export class ChefVaultProviderSecurityClient { readonly baseUrl: string; readonly timeoutMs: number; + readonly token?: string; constructor(config: ProviderSecurityConfig, timeoutMs = 5_000) { this.baseUrl = resolveChefVaultSecurityUrl(config).replace(/\/+$/, ""); this.timeoutMs = timeoutMs; + this.token = resolveChefVaultSecurityToken(config); + } + + protectedHeaders(): Record { + const headers: Record = { + accept: "application/json", + ...resolveChefVaultIdentityHeaders(), + }; + if (this.token) headers.authorization = `Bearer ${this.token}`; + return headers; } async health(): Promise { @@ -37,12 +67,43 @@ export class ChefVaultProviderSecurityClient { } } + /** Probe Bearer auth against a protected route without requiring a real ref to exist. */ + async probeAuthentication(): Promise { + if (!this.token) { + return { ok: false, error: "CHEF_PROVIDER_SECURITY_TOKEN is not set (required for /v1/refs/*)" }; + } + const status = await this.inspectRef("chefvault://_probe/auth"); + // 200 means the probe ref resolved; 404 means ChefVault accepted the token + // and only the ref is missing. Anything else (transport error, 5xx, 401/403) + // is not proof that authentication succeeded. + if (status.ok || status.error?.includes("(404)")) return { ok: true }; + return { ok: false, error: status.error ?? "ChefVault authentication probe failed" }; + } + /** Resolve ref metadata without returning secret material. */ async inspectRef(ref: string): Promise { const normalized = normalizeRef(ref); + if (!this.token) { + return { + ref: normalized, + ok: false, + error: "ChefVault ref probe requires CHEF_PROVIDER_SECURITY_TOKEN (Bearer auth)", + }; + } try { const encoded = encodeURIComponent(normalized); - const response = await fetch(`${this.baseUrl}/v1/refs/${encoded}`, { method: "GET", signal: AbortSignal.timeout(this.timeoutMs) }); + const response = await fetch(`${this.baseUrl}/v1/refs/${encoded}`, { + method: "GET", + headers: this.protectedHeaders(), + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (response.status === 401 || response.status === 403) { + return { + ref: normalized, + ok: false, + error: authErrorMessage(response.status, "ref probe"), + }; + } if (!response.ok) { return { ref: normalized, ok: false, error: `ChefVault ref probe failed (${response.status})` }; } diff --git a/src/provider-security/config.ts b/src/provider-security/config.ts index 6f0ade1..d49cb13 100644 --- a/src/provider-security/config.ts +++ b/src/provider-security/config.ts @@ -73,3 +73,20 @@ export function resolveChefVaultSecurityUrl(config: ProviderSecurityConfig): str || "http://127.0.0.1:8323" ); } + +/** Bearer token for protected ChefVault provider-security routes (`/v1/refs/*`). Env is SSOT. */ +export function resolveChefVaultSecurityToken(_config?: ProviderSecurityConfig): string | undefined { + return process.env.CHEF_PROVIDER_SECURITY_TOKEN?.trim() || undefined; +} + +/** Optional workload identity headers; only sent when the corresponding env vars are set. */ +export function resolveChefVaultIdentityHeaders(): Record { + const headers: Record = {}; + const workloadId = process.env.CHEF_WORKLOAD_ID?.trim(); + const hostId = process.env.CHEF_HOST_ID?.trim(); + const actor = process.env.CHEF_ACTOR?.trim(); + if (workloadId) headers["x-chef-workload-id"] = workloadId; + if (hostId) headers["x-chef-host-id"] = hostId; + if (actor) headers["x-chef-actor"] = actor; + return headers; +} diff --git a/src/provider-security/policy-ops.ts b/src/provider-security/policy-ops.ts index 24a928e..4d978b1 100644 --- a/src/provider-security/policy-ops.ts +++ b/src/provider-security/policy-ops.ts @@ -260,8 +260,10 @@ export async function doctorProviderSecurity(home: string): Promise { + vi.unstubAllGlobals(); + for (const key of envKeys) delete process.env[key]; +}); + +describe("ChefVaultProviderSecurityClient", () => { + it("sends Authorization Bearer and X-Chef-* headers on inspectRef", async () => { + process.env.CHEF_PROVIDER_SECURITY_TOKEN = TOKEN; + process.env.CHEF_WORKLOAD_ID = "cpm"; + process.env.CHEF_HOST_ID = "joep"; + process.env.CHEF_ACTOR = "policy-doctor"; + + const calls: { url: string; init?: RequestInit }[] = []; + vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ fingerprint: "sha256:abc" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const client = new ChefVaultProviderSecurityClient(defaultProviderSecurityConfig()); + const result = await client.inspectRef("chefvault://pools/zai-coding/primary"); + + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + const headers = new Headers(calls[0]?.init?.headers as HeadersInit); + expect(headers.get("authorization")).toBe(`Bearer ${TOKEN}`); + expect(headers.get("x-chef-workload-id")).toBe("cpm"); + expect(headers.get("x-chef-host-id")).toBe("joep"); + expect(headers.get("x-chef-actor")).toBe("policy-doctor"); + }); + + it("does not require a token for health()", async () => { + delete process.env.CHEF_PROVIDER_SECURITY_TOKEN; + + vi.stubGlobal("fetch", async () => new Response("ok", { status: 200 })); + + const client = new ChefVaultProviderSecurityClient(defaultProviderSecurityConfig()); + const health = await client.health(); + expect(health.ok).toBe(true); + }); + + it("fails inspectRef when CHEF_PROVIDER_SECURITY_TOKEN is missing", async () => { + delete process.env.CHEF_PROVIDER_SECURITY_TOKEN; + + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const client = new ChefVaultProviderSecurityClient(defaultProviderSecurityConfig()); + const result = await client.inspectRef("chefvault://pools/zai-coding/primary"); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/CHEF_PROVIDER_SECURITY_TOKEN/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns clear errors on 401 and 403", async () => { + process.env.CHEF_PROVIDER_SECURITY_TOKEN = TOKEN; + + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "nope" }), { status: 401 })); + + const client = new ChefVaultProviderSecurityClient(defaultProviderSecurityConfig()); + const unauthorized = await client.inspectRef("chefvault://pools/test/primary"); + expect(unauthorized.ok).toBe(false); + expect(unauthorized.error).toMatch(/unauthorized \(401\)/); + + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "forbidden" }), { status: 403 })); + const forbidden = await client.inspectRef("chefvault://pools/test/primary"); + expect(forbidden.ok).toBe(false); + expect(forbidden.error).toMatch(/forbidden \(403\)/); + }); + + it("probeAuthentication treats 404 as authenticated when token is accepted", async () => { + process.env.CHEF_PROVIDER_SECURITY_TOKEN = TOKEN; + + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "not found" }), { status: 404 })); + + const client = new ChefVaultProviderSecurityClient(defaultProviderSecurityConfig()); + const probe = await client.probeAuthentication(); + expect(probe.ok).toBe(true); + }); + + it("probeAuthentication fails on a 500 response", async () => { + process.env.CHEF_PROVIDER_SECURITY_TOKEN = TOKEN; + + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "boom" }), { status: 500 })); + + const client = new ChefVaultProviderSecurityClient(defaultProviderSecurityConfig()); + const probe = await client.probeAuthentication(); + expect(probe.ok).toBe(false); + expect(probe.error).toMatch(/\(500\)/); + }); + + it("probeAuthentication fails when the request cannot be sent", async () => { + process.env.CHEF_PROVIDER_SECURITY_TOKEN = TOKEN; + + vi.stubGlobal("fetch", async () => { + throw new Error("connect ECONNREFUSED 127.0.0.1:8080"); + }); + + const client = new ChefVaultProviderSecurityClient(defaultProviderSecurityConfig()); + const probe = await client.probeAuthentication(); + expect(probe.ok).toBe(false); + expect(probe.error).toMatch(/ECONNREFUSED/); + }); + + it("probeAuthentication fails when token is rejected", async () => { + process.env.CHEF_PROVIDER_SECURITY_TOKEN = TOKEN; + + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "bad token" }), { status: 403 })); + + const client = new ChefVaultProviderSecurityClient(defaultProviderSecurityConfig()); + const probe = await client.probeAuthentication(); + expect(probe.ok).toBe(false); + expect(probe.error).toMatch(/forbidden \(403\)/); + }); +}); diff --git a/test/provider-security.test.ts b/test/provider-security.test.ts index ad3a01d..a4386b0 100644 --- a/test/provider-security.test.ts +++ b/test/provider-security.test.ts @@ -11,6 +11,7 @@ import { } from "../src/provider-security/config.js"; import { applyDesiredPolicy, + doctorProviderSecurity, opencodexPolicyPath, planDesiredPolicy, rollbackDesiredPolicy, @@ -33,11 +34,13 @@ import type { DesiredPolicyDocument } from "../src/provider-security/types.js"; const homes: string[] = []; afterEach(async () => { vi.unstubAllGlobals(); + delete process.env.CHEF_PROVIDER_SECURITY_TOKEN; await Promise.all(homes.splice(0).map((home) => fs.rm(home, { recursive: true, force: true }))); }); /** Pretend every chefvault:// ref resolves, so ref probing does not require a live service. */ function stubChefVaultRefs(resolvable = true): void { + process.env.CHEF_PROVIDER_SECURITY_TOKEN = process.env.CHEF_PROVIDER_SECURITY_TOKEN ?? "test-token"; vi.stubGlobal("fetch", async () => new Response( JSON.stringify({ fingerprint: "sha256:test" }), { status: resolvable ? 200 : 404, headers: { "content-type": "application/json" } }, @@ -227,4 +230,42 @@ describe("provider security plane", () => { const issues = scanRawCredentials('{"apiKey":"supersec1"}'); expect(issues.some((item) => item.code === "raw-api-key-field")).toBe(true); }); + + it("doctor distinguishes healthz reachability from Bearer authentication", async () => { + const home = await tempHome(); + const config = { + ...defaultProviderSecurityConfig(), + fleetMode: true, + secretBackend: "chefvault" as const, + }; + await saveProviderSecurityConfig(home, config); + + vi.stubGlobal("fetch", async (url: string) => { + if (url.endsWith("/healthz")) return new Response("ok", { status: 200 }); + return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }); + }); + + delete process.env.CHEF_PROVIDER_SECURITY_TOKEN; + const missingToken = await doctorProviderSecurity(home); + expect(missingToken.chefvaultReachable).toBe(true); + expect(missingToken.chefvaultAuthenticated).toBe(false); + expect(missingToken.issues.some((item) => item.code === "chefvault-token-missing")).toBe(true); + + process.env.CHEF_PROVIDER_SECURITY_TOKEN = "test-token"; + const badToken = await doctorProviderSecurity(home); + expect(badToken.chefvaultReachable).toBe(true); + expect(badToken.chefvaultAuthenticated).toBe(false); + expect(badToken.issues.some((item) => item.code === "chefvault-unauthenticated")).toBe(true); + + vi.stubGlobal("fetch", async (url: string) => { + if (url.endsWith("/healthz")) return new Response("ok", { status: 200 }); + return new Response(JSON.stringify({ fingerprint: "sha256:test" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + const ok = await doctorProviderSecurity(home); + expect(ok.chefvaultReachable).toBe(true); + expect(ok.chefvaultAuthenticated).toBe(true); + }); });