Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 5 additions & 5 deletions packages/control-plane/src/routes/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,17 @@ export const catalog: RouteCatalogEntry[] = [
slackNotifyRoutes,

// Repository management
...reposRoutes,
reposRoutes,

// Secrets
...secretsRoutes,
secretsRoutes,

// Environments (Phase-2 session target; internal-HMAC only, web BFF proxied)
...environmentRoutes,
...environmentSecretsRoutes,
environmentRoutes,
environmentSecretsRoutes,

// Image builds (scope-generic)
...imageBuildRoutes,
imageBuildRoutes,

// Model preferences
modelPreferencesRoutes,
Expand Down
138 changes: 66 additions & 72 deletions packages/control-plane/src/routes/environment-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as AuthenticateModule from "../auth/authenticate";
import { generateEncryptionKey } from "../auth/crypto";
import type { SqlDatabase } from "../db/sql-database";
import { environmentSecretsRoutes } from "./environment-secrets";
import type { RequestContext, Route } from "./shared";
import { routePathPattern, TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support";
import {
createTestRequestHandler,
ownerAuthorizationDatabase,
TEST_BACKGROUND_TASK_CONTEXT,
TEST_SERVICE_SECRETS,
} from "../router.test-support";
import type { Env } from "../types";

function findRoute(method: string, path: string): { route: Route; match: RegExpMatchArray } {
const route = environmentSecretsRoutes.find(
(candidate) => candidate.method === method && path.match(routePathPattern(candidate.path))
);
if (!route) throw new Error(`Missing ${method} ${path} route`);
return { route, match: path.match(routePathPattern(route.path))! };
const mocks = vi.hoisted(() => ({ authenticate: vi.fn() }));

vi.mock("../auth/authenticate", async (importOriginal) => ({
...(await importOriginal<typeof AuthenticateModule>()),
authenticate: mocks.authenticate,
}));

const handleRequest = createTestRequestHandler([environmentSecretsRoutes]);

/** Admission's role lookup is answered for the owner; every other statement reaches the test's database. */
function withOwnerAuthorization(delegate: SqlDatabase): SqlDatabase {
const authorization = ownerAuthorizationDatabase();
return {
prepare: (sql) => (sql.includes("FROM users u") ? authorization : delegate).prepare(sql),
batch: (statements) => delegate.batch(statements),
};
}

function createContext() {
const batch = vi.fn(async () => undefined);
function createEnv(encryptionKey: string) {
const batch = vi.fn(async () => []);
const run = vi.fn(async () => ({ meta: { changes: 0 } }));
const all = vi.fn(async () => ({ results: [] }));
const first = vi.fn(async () => ({
Expand All @@ -26,35 +43,42 @@ function createContext() {
updated_at: 1,
}));
const bind = vi.fn(() => ({ first, all, run }));
const db = { batch, prepare: vi.fn(() => ({ bind })) } as unknown as SqlDatabase;
return {
ctx: {
request_id: "request-1",
trace_id: "trace-1",
executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
db: {
batch,
prepare: vi.fn(() => ({ bind })),
},
} as unknown as RequestContext,
env: {
...TEST_SERVICE_SECRETS,
SCM_PROVIDER: "github",
REPO_SECRETS_ENCRYPTION_KEY: encryptionKey,
DB: withOwnerAuthorization(db),
} as unknown as Env,
batch,
};
}

function putSecrets(env: Env, body: string): Promise<Response> {
return handleRequest(
new Request("https://test.local/environments/env-1/secrets", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
}),
env,
TEST_BACKGROUND_TASK_CONTEXT
);
}

describe("environment secrets routes", () => {
beforeEach(() => {
mocks.authenticate.mockImplementation(async (request: Request) => ({
principal: { kind: "user", userId: "user-1" },
request,
}));
});

it("rejects malformed secret values before persistence", async () => {
const { route, match } = findRoute("PUT", "/environments/env-1/secrets");
const { ctx, batch } = createContext();

const response = await route.handler(
new Request("https://test.local/environments/env-1/secrets", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secrets: { API_KEY: 123 } }),
}),
{ REPO_SECRETS_ENCRYPTION_KEY: "test-key" } as never,
match,
ctx
);
const { env, batch } = createEnv("test-key");

const response = await putSecrets(env, JSON.stringify({ secrets: { API_KEY: 123 } }));

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
Expand All @@ -64,19 +88,9 @@ describe("environment secrets routes", () => {
});

it("rejects array-shaped secrets before persistence", async () => {
const { route, match } = findRoute("PUT", "/environments/env-1/secrets");
const { ctx, batch } = createContext();

const response = await route.handler(
new Request("https://test.local/environments/env-1/secrets", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secrets: [] }),
}),
{ REPO_SECRETS_ENCRYPTION_KEY: "test-key" } as never,
match,
ctx
);
const { env, batch } = createEnv("test-key");

const response = await putSecrets(env, JSON.stringify({ secrets: [] }));

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
Expand All @@ -86,39 +100,19 @@ describe("environment secrets routes", () => {
});

it("preserves an own __proto__ secret key for canonical normalization", async () => {
const { route, match } = findRoute("PUT", "/environments/env-1/secrets");
const { ctx, batch } = createContext();

const response = await route.handler(
new Request("https://test.local/environments/env-1/secrets", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: '{"secrets":{"__proto__":"value"}}',
}),
{ REPO_SECRETS_ENCRYPTION_KEY: generateEncryptionKey() } as never,
match,
ctx
);
const { env, batch } = createEnv(generateEncryptionKey());

const response = await putSecrets(env, '{"secrets":{"__proto__":"value"}}');

expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ keys: ["__PROTO__"], created: 1 });
expect(batch).toHaveBeenCalledTimes(1);
});

it("accepts valid secret records", async () => {
const { route, match } = findRoute("PUT", "/environments/env-1/secrets");
const { ctx, batch } = createContext();

const response = await route.handler(
new Request("https://test.local/environments/env-1/secrets", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secrets: { API_KEY: "secret" } }),
}),
{ REPO_SECRETS_ENCRYPTION_KEY: generateEncryptionKey() } as never,
match,
ctx
);
const { env, batch } = createEnv(generateEncryptionKey());

const response = await putSecrets(env, JSON.stringify({ secrets: { API_KEY: "secret" } }));

expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
Expand Down
68 changes: 31 additions & 37 deletions packages/control-plane/src/routes/environment-secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
* Split from ./environments so each routes file stays focused.
*/

import { Hono } from "hono";
import { admit, dispatch } from "../routing/admit";
import type { ControlPlaneHonoEnv } from "../routing/hono-env";
import { EnvironmentStore, type EnvironmentRow } from "../db/environments";
import { EnvironmentSecretsStore } from "../db/environment-secrets";
import { GlobalSecretsStore } from "../db/global-secrets";
Expand All @@ -14,10 +17,8 @@ import {
} from "../image-builds/save-hooks";
import { createLogger } from "../logger";
import {
type Route,
type RequestContext,
GITHUB_USER_OR_SERVICE_ROUTE,
defineRoutes,
json,
error,
parseJsonBody,
Expand Down Expand Up @@ -78,13 +79,13 @@ function requireSecretsConfig(env: Env): { key: string } | Response {
async function handleListEnvironmentSecrets(
_request: Request,
env: Env,
match: RegExpMatchArray,
params: { id: string },
ctx: RequestContext
): Promise<Response> {
const config = requireSecretsConfig(env);
if (config instanceof Response) return config;

const id = match.groups?.id;
const id = params.id;
if (!id) return error("Environment ID required", 400);

const store = new EnvironmentStore(ctx.db);
Expand Down Expand Up @@ -118,13 +119,13 @@ async function handleListEnvironmentSecrets(
async function handleSetEnvironmentSecrets(
request: Request,
env: Env,
match: RegExpMatchArray,
params: { id: string },
ctx: RequestContext
): Promise<Response> {
const config = requireSecretsConfig(env);
if (config instanceof Response) return config;

const id = match.groups?.id;
const id = params.id;
if (!id) return error("Environment ID required", 400);

const store = new EnvironmentStore(ctx.db);
Expand Down Expand Up @@ -175,14 +176,14 @@ async function handleSetEnvironmentSecrets(
async function handleDeleteEnvironmentSecret(
_request: Request,
env: Env,
match: RegExpMatchArray,
params: { id: string; key: string },
ctx: RequestContext
): Promise<Response> {
const config = requireSecretsConfig(env);
if (config instanceof Response) return config;

const id = match.groups?.id;
const key = match.groups?.key;
const id = params.id;
const key = params.key;
if (!id || !key) return error("Environment ID and key are required", 400);

const secretsStore = new EnvironmentSecretsStore(ctx.db, config.key);
Expand Down Expand Up @@ -226,13 +227,13 @@ async function handleDeleteEnvironmentSecret(
async function handleImportEnvironmentSecrets(
request: Request,
env: Env,
match: RegExpMatchArray,
params: { id: string },
ctx: RequestContext
): Promise<Response> {
const config = requireSecretsConfig(env);
if (config instanceof Response) return config;

const id = match.groups?.id;
const id = params.id;
if (!id) return error("Environment ID required", 400);

const store = new EnvironmentStore(ctx.db);
Expand Down Expand Up @@ -300,29 +301,22 @@ async function handleImportEnvironmentSecrets(
}
}

export const environmentSecretsRoutes: Route[] = defineRoutes(GITHUB_USER_OR_SERVICE_ROUTE, [
{
method: "GET",
path: "/environments/:id/secrets",
authorization: requirePermission("environments.secrets.manage"),
handler: handleListEnvironmentSecrets,
},
{
method: "PUT",
path: "/environments/:id/secrets",
authorization: requirePermission("environments.secrets.manage"),
handler: handleSetEnvironmentSecrets,
},
{
method: "POST",
path: "/environments/:id/secrets/import",
authorization: requirePermission("environments.secrets.manage"),
handler: handleImportEnvironmentSecrets,
},
{
method: "DELETE",
path: "/environments/:id/secrets/:key",
authorization: requirePermission("environments.secrets.manage"),
handler: handleDeleteEnvironmentSecret,
},
]);
const ENVIRONMENT_SECRETS_MANAGE = admit({
...GITHUB_USER_OR_SERVICE_ROUTE,
authorization: requirePermission("environments.secrets.manage"),
});

export const environmentSecretsRoutes = new Hono<ControlPlaneHonoEnv>();

environmentSecretsRoutes.get("/environments/:id/secrets", ENVIRONMENT_SECRETS_MANAGE, (c) =>
dispatch(c, handleListEnvironmentSecrets)
);
environmentSecretsRoutes.put("/environments/:id/secrets", ENVIRONMENT_SECRETS_MANAGE, (c) =>
dispatch(c, handleSetEnvironmentSecrets)
);
environmentSecretsRoutes.post("/environments/:id/secrets/import", ENVIRONMENT_SECRETS_MANAGE, (c) =>
dispatch(c, handleImportEnvironmentSecrets)
);
environmentSecretsRoutes.delete("/environments/:id/secrets/:key", ENVIRONMENT_SECRETS_MANAGE, (c) =>
dispatch(c, handleDeleteEnvironmentSecret)
);
Loading
Loading