Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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}`);
Expand Down
65 changes: 63 additions & 2 deletions src/provider-security/chefvault-client.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string, string> {
const headers: Record<string, string> = {
accept: "application/json",
...resolveChefVaultIdentityHeaders(),
};
if (this.token) headers.authorization = `Bearer ${this.token}`;
return headers;
}

async health(): Promise<ChefVaultHealth> {
Expand All @@ -37,12 +67,43 @@ export class ChefVaultProviderSecurityClient {
}
}

/** Probe Bearer auth against a protected route without requiring a real ref to exist. */
async probeAuthentication(): Promise<ChefVaultAuthProbe> {
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" };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Resolve ref metadata without returning secret material. */
async inspectRef(ref: string): Promise<ChefVaultRefStatus> {
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})` };
}
Expand Down
17 changes: 17 additions & 0 deletions src/provider-security/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const headers: Record<string, string> = {};
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;
}
14 changes: 13 additions & 1 deletion src/provider-security/policy-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,10 @@ export async function doctorProviderSecurity(home: string): Promise<PolicyDoctor
}

let chefvaultReachable: boolean | undefined;
let chefvaultAuthenticated: boolean | undefined;
if (config.secretBackend === "chefvault") {
const health = await new ChefVaultProviderSecurityClient(config).health();
const client = new ChefVaultProviderSecurityClient(config);
const health = await client.health();
chefvaultReachable = health.ok;
if (!health.ok) {
issues.push({
Expand All @@ -270,6 +272,15 @@ export async function doctorProviderSecurity(home: string): Promise<PolicyDoctor
? "ChefVault provider-security endpoint unreachable; fleet mode forbids local fallback"
: `ChefVault provider-security endpoint unreachable at ${health.url}`,
});
} else {
const auth = await client.probeAuthentication();
chefvaultAuthenticated = auth.ok;
if (!auth.ok) {
issues.push({
code: auth.error?.includes("not set") ? "chefvault-token-missing" : "chefvault-unauthenticated",
message: auth.error ?? "ChefVault Bearer authentication failed on /v1/refs/* probe",
});
}
}
}

Expand All @@ -295,6 +306,7 @@ export async function doctorProviderSecurity(home: string): Promise<PolicyDoctor
activeRevision,
targetPath,
chefvaultReachable,
chefvaultAuthenticated,
issues,
};
}
Expand Down
3 changes: 3 additions & 0 deletions src/provider-security/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export interface PolicyDoctorResult {
config: ProviderSecurityConfig;
activeRevision?: string;
targetPath: string;
/** `/healthz` responded successfully (unauthenticated). */
chefvaultReachable?: boolean;
/** Bearer token accepted on a protected `/v1/refs/*` probe. */
chefvaultAuthenticated?: boolean;
issues: PolicyValidationIssue[];
}
130 changes: 130 additions & 0 deletions test/chefvault-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChefVaultProviderSecurityClient } from "../src/provider-security/chefvault-client.js";
import { defaultProviderSecurityConfig } from "../src/provider-security/config.js";

const TOKEN = "test-bearer-token";
const envKeys = [
"CHEF_PROVIDER_SECURITY_TOKEN",
"CHEF_WORKLOAD_ID",
"CHEF_HOST_ID",
"CHEF_ACTOR",
] as const;

afterEach(() => {
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\)/);
});
});
41 changes: 41 additions & 0 deletions test/provider-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "../src/provider-security/config.js";
import {
applyDesiredPolicy,
doctorProviderSecurity,
opencodexPolicyPath,
planDesiredPolicy,
rollbackDesiredPolicy,
Expand All @@ -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" } },
Expand Down Expand Up @@ -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);
});
});
Loading