mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
fix(secrets): encrypt service configuration and frozen snapshots (#844)
Reuse one explicit-key AES-GCM implementation in the shared repository layer. Protect live env/build args, advanced configuration, drift baselines, and frozen service snapshots; decode legacy rows and convert them in bounded, guarded startup batches. Re-encrypt transfers for the receiving installation and redact protected configuration from transfers that omit secrets. Verify repository semantics, native/HTTP masking, cross-key HTTP transfers, PostgreSQL update races, and real Docker rollback with the original secret.
This commit is contained in:
@@ -66,6 +66,7 @@ import { repos } from "@repo/db";
|
||||
|
||||
/* ---------- Initialize platform (runtime + infra + system) ---------- */
|
||||
await initPlatform(resolvePlatformConfig());
|
||||
await repos.configurationSecrets.backfillLegacy();
|
||||
|
||||
export const app = new Hono();
|
||||
|
||||
|
||||
@@ -11,11 +11,28 @@
|
||||
*/
|
||||
|
||||
import { encrypt, decrypt, decryptEnvMap } from "@repo/platform/engine/lib/encryption";
|
||||
import { encryptSecretField, decryptSecretField } from "@repo/platform/engine/lib/credential-encryption";
|
||||
import {
|
||||
encryptSecretField,
|
||||
decryptSecretField,
|
||||
} from "@repo/platform/engine/lib/credential-encryption";
|
||||
import { createConfigurationSecrets, SERVICE_SECRET_FIELDS } from "@repo/db/configuration-secrets";
|
||||
|
||||
import type { SecretColumn } from "./secret-registry";
|
||||
import type { SecretEntry } from "./types";
|
||||
|
||||
const configuration = createConfigurationSecrets({ encrypt, decrypt });
|
||||
function configurationCell(spec: SecretColumn, cell: unknown, direction: "open" | "seal"): unknown {
|
||||
if (spec.sqlName === "deployment" && spec.column === "meta") {
|
||||
return direction === "open"
|
||||
? configuration.openDeploymentMeta(cell)
|
||||
: configuration.sealDeploymentMeta(cell);
|
||||
}
|
||||
if (spec.sqlName === "service" && SERVICE_SECRET_FIELDS.some((key) => key === spec.column)) {
|
||||
return direction === "open" ? configuration.openJson(cell) : configuration.sealJson(cell);
|
||||
}
|
||||
return structuredClone(cell);
|
||||
}
|
||||
|
||||
/** Decrypt one stored cell → plaintext entry, or null if empty/absent. */
|
||||
export function extractPlaintext(
|
||||
spec: SecretColumn,
|
||||
@@ -27,7 +44,7 @@ export function extractPlaintext(
|
||||
|
||||
switch (spec.scheme) {
|
||||
case "json":
|
||||
return { ...base, scheme: "json", json: structuredClone(cell) };
|
||||
return { ...base, scheme: "json", json: configurationCell(spec, cell, "open") };
|
||||
case "scalar": {
|
||||
if (typeof cell !== "string" || cell === "") return null;
|
||||
return { ...base, scheme: "scalar", value: decrypt(cell) };
|
||||
@@ -73,7 +90,7 @@ export function sealForInstance(
|
||||
): unknown {
|
||||
switch (spec.scheme) {
|
||||
case "json":
|
||||
return structuredClone(entry.json);
|
||||
return configurationCell(spec, entry.json, "seal");
|
||||
case "scalar":
|
||||
return entry.value != null ? encrypt(entry.value) : null;
|
||||
case "enc1":
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Maps every encrypted column (the single source of truth in @repo/db
|
||||
* `ENCRYPTED_COLUMNS`) to the crypto scheme used to seal it at rest. This
|
||||
* drives the export decrypt and the import re-encrypt. Kept in `apps/api`
|
||||
* because the crypto helpers live here and `packages/db` cannot import them.
|
||||
* drives export decryption and import re-encryption. The transfer coordinator
|
||||
* binds the shared codecs to the source or destination installation's key.
|
||||
*
|
||||
* A build-time assertion below fails fast if `ENCRYPTED_COLUMNS` gains an
|
||||
* entry this registry doesn't know how to (de)crypt.
|
||||
@@ -89,10 +89,9 @@ const SCHEME_BY_KEY: Record<string, { table: AnyTable; scheme: SecretScheme }> =
|
||||
},
|
||||
};
|
||||
|
||||
// Kept separate from ENCRYPTED_COLUMNS: tenant/cloud promotion still transports
|
||||
// ordinary Compose configuration directly. File/direct control-plane transfers
|
||||
// seal it because inline env values and mounted files may contain credentials.
|
||||
const PLAINTEXT_CONFIG_COLUMNS = [
|
||||
// File/direct transfers also remove non-encrypted sensitive configuration. The
|
||||
// service fields are included here to keep legacy export redaction identical.
|
||||
const TRANSFER_CONFIG_COLUMNS = [
|
||||
{ table: "deployment", column: "meta" },
|
||||
{ table: "service", column: "environment" },
|
||||
{ table: "service", column: "buildArgs" },
|
||||
@@ -109,7 +108,12 @@ const PLAINTEXT_CONFIG_COLUMNS = [
|
||||
|
||||
export const SECRET_COLUMNS: readonly SecretColumn[] = [
|
||||
...ENCRYPTED_COLUMNS,
|
||||
...PLAINTEXT_CONFIG_COLUMNS,
|
||||
...TRANSFER_CONFIG_COLUMNS.filter(
|
||||
(spec) =>
|
||||
!ENCRYPTED_COLUMNS.some(
|
||||
(encrypted) => encrypted.table === spec.table && encrypted.column === spec.column,
|
||||
),
|
||||
),
|
||||
].map((spec) => {
|
||||
const key = `${spec.table}.${spec.column}`;
|
||||
const meta = SCHEME_BY_KEY[key];
|
||||
@@ -129,7 +133,7 @@ export const SECRET_COLUMNS: readonly SecretColumn[] = [
|
||||
|
||||
export function stripTransferSecrets(tables: DatabaseDump["tables"]): void {
|
||||
stripEncryptedInPlace(tables);
|
||||
for (const spec of PLAINTEXT_CONFIG_COLUMNS) {
|
||||
for (const spec of TRANSFER_CONFIG_COLUMNS) {
|
||||
for (const row of tables[spec.table] ?? []) {
|
||||
if (spec.table === "deployment" && spec.column === "meta") {
|
||||
// Frozen Compose services can contain inline credentials. Keep only
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/** Real PostgreSQL lock races: startup conversion must not replace a newer edit. */
|
||||
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
createDatabase,
|
||||
createRepositories,
|
||||
schema,
|
||||
type DatabaseConnection,
|
||||
} from "@repo/db/factory";
|
||||
import { createEncryption } from "@repo/db/encryption";
|
||||
import { createConfigurationSecrets } from "@repo/db/configuration-secrets";
|
||||
import { describeDockerE2E, requireDocker } from "../helpers/docker-e2e";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const container = `openship-e2e-config-secrets-${process.pid}`;
|
||||
const encryption = createEncryption("configuration-e2e-key-844");
|
||||
const codec = createConfigurationSecrets(encryption);
|
||||
let connection: DatabaseConnection;
|
||||
let repos: ReturnType<typeof createRepositories>;
|
||||
|
||||
describeDockerE2E("encrypted configuration conversion (GH-844, real PostgreSQL)", () => {
|
||||
beforeAll(async () => {
|
||||
await requireDocker();
|
||||
await exec(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
container,
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=config-test-password",
|
||||
"-p",
|
||||
"127.0.0.1::5432",
|
||||
"postgres:16-alpine",
|
||||
],
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
const port = (await exec("docker", ["port", container, "5432/tcp"])).stdout
|
||||
.trim()
|
||||
.split(":")
|
||||
.at(-1);
|
||||
connection = await createDatabase({
|
||||
driver: "pg",
|
||||
url: `postgresql://postgres:config-test-password@127.0.0.1:${port}/postgres`,
|
||||
migrationsDir: resolve(import.meta.dirname, "../../../../packages/db/drizzle"),
|
||||
poolMax: 4,
|
||||
registerExitHook: false,
|
||||
});
|
||||
repos = createRepositories(connection.db, encryption);
|
||||
await connection.db.insert(schema.organization).values({ id: "org", name: "Config test" });
|
||||
await connection.db
|
||||
.insert(schema.projectGroup)
|
||||
.values({ id: "group", organizationId: "org", name: "Test", slug: "test" });
|
||||
await connection.db
|
||||
.insert(schema.project)
|
||||
.values({
|
||||
id: "project",
|
||||
groupId: "group",
|
||||
organizationId: "org",
|
||||
name: "Test",
|
||||
slug: "test",
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await connection?.close();
|
||||
} finally {
|
||||
encryption.close();
|
||||
await exec("docker", ["rm", "-fv", container]).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["service", "deployment"] as const)(
|
||||
"does not overwrite a concurrent %s edit",
|
||||
async (table) => {
|
||||
const id = `legacy-${table}`;
|
||||
if (table === "service") {
|
||||
await connection.db
|
||||
.insert(schema.service)
|
||||
.values({
|
||||
id,
|
||||
projectId: "project",
|
||||
name: "web",
|
||||
environment: { PASSWORD: "legacy-844" },
|
||||
});
|
||||
} else {
|
||||
await connection.db
|
||||
.insert(schema.deployment)
|
||||
.values({
|
||||
id,
|
||||
projectId: "project",
|
||||
organizationId: "org",
|
||||
branch: "main",
|
||||
status: "ready",
|
||||
meta: {
|
||||
serverId: "before",
|
||||
composeServices: [{ name: "web", environment: { PASSWORD: "legacy-844" } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
const locker = await connection.pool!.connect();
|
||||
let pending: ReturnType<typeof repos.configurationSecrets.backfillLegacy> | undefined;
|
||||
try {
|
||||
await locker.query("BEGIN");
|
||||
// Identifiers are a fixed test union; only the id/value is user-shaped.
|
||||
await locker.query(`SELECT id FROM "${table}" WHERE id = $1 FOR UPDATE`, [id]);
|
||||
pending = repos.configurationSecrets.backfillLegacy();
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const blocked = await connection.pool!.query(
|
||||
"SELECT 1 FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND query LIKE $1",
|
||||
[`update "${table}"%`],
|
||||
);
|
||||
expect(blocked.rowCount).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 15_000, interval: 50 },
|
||||
);
|
||||
if (table === "service") {
|
||||
await locker.query('UPDATE "service" SET environment = $1::jsonb WHERE id = $2', [
|
||||
JSON.stringify(codec.sealJson({ PASSWORD: "rotated-844" })),
|
||||
id,
|
||||
]);
|
||||
} else {
|
||||
const updated = codec.sealDeploymentMeta({
|
||||
serverId: "after",
|
||||
composeServices: [{ name: "web", environment: { PASSWORD: "rotated-844" } }],
|
||||
});
|
||||
await locker.query('UPDATE "deployment" SET meta = $1::jsonb WHERE id = $2', [
|
||||
JSON.stringify(updated),
|
||||
id,
|
||||
]);
|
||||
}
|
||||
await locker.query("COMMIT");
|
||||
expect(await pending).toEqual({ services: 0, deployments: 0 });
|
||||
const raw = (await connection.pool!.query(`SELECT * FROM "${table}" WHERE id = $1`, [id]))
|
||||
.rows[0];
|
||||
expect(JSON.stringify(raw)).not.toContain("rotated-844");
|
||||
if (table === "service") {
|
||||
expect((await repos.service.findById(id))?.environment).toEqual({
|
||||
PASSWORD: "rotated-844",
|
||||
});
|
||||
} else {
|
||||
expect((await repos.deployment.findById(id))?.meta).toEqual({
|
||||
serverId: "after",
|
||||
composeServices: [{ name: "web", environment: { PASSWORD: "rotated-844" } }],
|
||||
});
|
||||
}
|
||||
expect(await repos.configurationSecrets.backfillLegacy()).toEqual({
|
||||
services: 0,
|
||||
deployments: 0,
|
||||
});
|
||||
} finally {
|
||||
await locker.query("ROLLBACK").catch(() => {});
|
||||
locker.release();
|
||||
await pending?.catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -9,7 +9,8 @@
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import type { Readable } from "node:stream";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createServer, request as httpRequest, type Server } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -35,7 +36,7 @@ const FILE_SECRET_VALUE = "file-resume-secret-816-✓";
|
||||
const FILE_PASSPHRASE = "issue-816-http-e2e-passphrase";
|
||||
|
||||
interface RunningApi {
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
child: ChildProcessByStdio<null, Readable, Readable>;
|
||||
baseUrl: string;
|
||||
dbDir: string;
|
||||
port: number;
|
||||
@@ -367,6 +368,26 @@ it("transfers multiple HTTP chunks atomically and resumes a file import after re
|
||||
await mergeEnv(source.baseUrl, project.id, [
|
||||
{ key: "E2E_SECRET", value: SECRET_VALUE, isSecret: true },
|
||||
]);
|
||||
const inlineSecret = "inline-transfer-844-✓";
|
||||
const buildSecret = "build-transfer-844-✓";
|
||||
const fileSecret = "mounted-transfer-844-✓";
|
||||
const created = await jsonRequest<{ service: { id: string; buildArgs: Record<string, string> } }>(
|
||||
source.baseUrl,
|
||||
`/api/projects/${project.id}/services`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "web",
|
||||
kind: "compose",
|
||||
image: "busybox:1.37.0",
|
||||
exposed: false,
|
||||
environment: { PASSWORD: inlineSecret },
|
||||
buildArgs: { TOKEN: buildSecret },
|
||||
advanced: { files: [{ path: "/run/config", content: fileSecret }] },
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(created.service.buildArgs.TOKEN).toBe("••••••••");
|
||||
|
||||
const retryProxy = await startRetryProxy(destination.port);
|
||||
const receive = await jsonRequest<{ code: string }>(
|
||||
@@ -404,6 +425,21 @@ it("transfers multiple HTTP chunks atomically and resumes a file import after re
|
||||
expect(masked.data).toContainEqual(
|
||||
expect.objectContaining({ key: "E2E_SECRET", value: "••••••••", isSecret: true }),
|
||||
);
|
||||
const servicePath = `/api/projects/${project.id}/services/${created.service.id}`;
|
||||
const serviceRead = await jsonRequest<{
|
||||
service: { environment: Record<string, string>; buildArgs: Record<string, string> };
|
||||
}>(destination.baseUrl, servicePath);
|
||||
expect(serviceRead.service.environment.PASSWORD).toBe("••••••••");
|
||||
expect(serviceRead.service.buildArgs.TOKEN).toBe("••••••••");
|
||||
const revealed = await jsonRequest<{ environment: Record<string, string> }>(
|
||||
destination.baseUrl,
|
||||
`${servicePath}/env-reveal`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ keys: ["PASSWORD"] }),
|
||||
},
|
||||
);
|
||||
expect(revealed.environment.PASSWORD).toBe(inlineSecret);
|
||||
|
||||
const destinationExport = await jsonRequest<DataTransferFile>(
|
||||
destination.baseUrl,
|
||||
@@ -416,6 +452,19 @@ it("transfers multiple HTTP chunks atomically and resumes a file import after re
|
||||
expect(
|
||||
findSecret(openTransferSecrets(destinationExport.secrets, FILE_PASSPHRASE), SECRET_VALUE),
|
||||
).toBe(true);
|
||||
const transferredConfig = openTransferSecrets(destinationExport.secrets, FILE_PASSPHRASE)!;
|
||||
for (const secret of [inlineSecret, buildSecret, fileSecret]) {
|
||||
expect(JSON.stringify(destinationExport)).not.toContain(secret);
|
||||
expect(JSON.stringify(transferredConfig)).toContain(secret);
|
||||
}
|
||||
expect(transferredConfig.entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: "service",
|
||||
id: created.service.id,
|
||||
column: "buildArgs",
|
||||
json: { TOKEN: buildSecret },
|
||||
}),
|
||||
);
|
||||
|
||||
// File upload uses the same import boundary. Upload one chunk, restart the
|
||||
// destination process against the same DB, then resume and finalize.
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
createHostExecutor,
|
||||
type CommandExecutor,
|
||||
} from "@repo/adapters";
|
||||
import { repos } from "@repo/db";
|
||||
import { db, eq, repos, schema } from "@repo/db";
|
||||
import { LOCAL_HOST_PORT_TARGET } from "@repo/platform/engine/lib/host-port-target";
|
||||
import { describeDockerE2E, requireDocker } from "../helpers/docker-e2e";
|
||||
import {
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
seedServiceDeployment,
|
||||
} from "../helpers/seed";
|
||||
|
||||
const BASE_IMAGE = "busybox:latest";
|
||||
const BASE_IMAGE = "busybox:1.37.0";
|
||||
const WEB_V1 = "openship/e2e-compose-web:v1";
|
||||
const WEB_V2 = "openship/e2e-compose-web:v2";
|
||||
const API_V1 = "openship/e2e-compose-api:v1";
|
||||
@@ -88,7 +88,7 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
|
||||
let apiPort = 0;
|
||||
let contextDir = "";
|
||||
|
||||
let rollbackMod: typeof import("../../src/modules/deployments/rollback");
|
||||
let rollbackMod: typeof import("@repo/platform/engine/modules/deployments/rollback/index");
|
||||
|
||||
const buildImage = async (tag: string, body: string) => {
|
||||
await writeFile(
|
||||
@@ -185,6 +185,13 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
|
||||
}),
|
||||
};
|
||||
});
|
||||
// These factories close over this fixture's real runtime, after it exists.
|
||||
for (const module of ["@repo/platform/engine/lib/platform-config", "@repo/platform/engine/lib/resource-access"]) {
|
||||
vi.doMock(module, async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, platform: () => localPlatform.platform };
|
||||
});
|
||||
}
|
||||
vi.doMock("../../src/lib/controller-helpers", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, platform: () => localPlatform.platform };
|
||||
@@ -196,6 +203,7 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
getPlatform: () => localPlatform.platform,
|
||||
edgeProxyFor: () => ({
|
||||
listLoopbackUpstreamPortsStrict: async () => new Set<number>(),
|
||||
}),
|
||||
@@ -236,8 +244,8 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
|
||||
runtimeMode: "docker" as const,
|
||||
serviceDeploymentMode: "services" as const,
|
||||
composeServices: [
|
||||
{ id: web.id, name: "web", kind: "compose", image: webImage, enabled: true, ports: [`${webPort}:${SVC_PORT}`] },
|
||||
{ id: api.id, name: "api", kind: "compose", image: API_V1, enabled: true, ports: [`${apiPort}:${SVC_PORT}`] },
|
||||
{ id: web.id, name: "web", kind: "compose", image: webImage, environment: { ROLLBACK_SECRET: webImage === WEB_V1 ? "original-844" : "rotated-844" }, enabled: true, ports: [`${webPort}:${SVC_PORT}`] },
|
||||
{ id: api.id, name: "api", kind: "compose", image: API_V1, environment: { API_SECRET: "api-secret-844" }, enabled: true, ports: [`${apiPort}:${SVC_PORT}`] },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -339,6 +347,12 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
|
||||
// a null name here would silently rebuild the whole stack next time.
|
||||
expect(byName.get("web")).toBe(WEB_V1);
|
||||
expect(byName.get("api")).toBe(API_V1);
|
||||
const webRow = rows.find(row => row.serviceName === "web")!;
|
||||
const live = await runtime.docker.getContainer(webRow.containerId!).inspect();
|
||||
expect(live.Config.Env).toContain("ROLLBACK_SECRET=original-844");
|
||||
const stored = await db.query.deployment.findFirst({ where: eq(schema.deployment.id, restore.id) });
|
||||
expect((stored!.meta as Record<string, unknown>).composeServices).toEqual(expect.stringMatching(/^openship:config:v1:/));
|
||||
expect(JSON.stringify(stored!.meta)).not.toContain("original-844");
|
||||
}, 600_000);
|
||||
|
||||
it("cancels a lost pre-activation host callback, preserves v1, and lets the next real rollout finish", async () => {
|
||||
@@ -518,14 +532,3 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
|
||||
throw new Error(`deploy never finished (last status: ${last || "no new row"})`);
|
||||
}
|
||||
});
|
||||
|
||||
// The application seams moved with the shared engine.
|
||||
vi.doMock("@repo/platform/engine/lib/platform-config", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, platform: () => localPlatform.platform };
|
||||
});
|
||||
|
||||
vi.doMock("@repo/platform/engine/lib/resource-access", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, platform: () => localPlatform.platform };
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "@repo/db/encryption";
|
||||
import { afterAll, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
@@ -52,7 +53,7 @@ describeDockerE2E("update cache lock deadlines (GH-880, real PostgreSQL)", () =>
|
||||
poolMax: 3,
|
||||
registerExitHook: false,
|
||||
});
|
||||
repos = createRepositories(connection.db);
|
||||
repos = createRepositories(connection.db, createEncryption("repository-test-secret"));
|
||||
await connection.db.insert(schema.organization).values({ id: "org", name: "Cache test" });
|
||||
await connection.db
|
||||
.insert(schema.projectGroup)
|
||||
|
||||
@@ -11,8 +11,8 @@ import { db, eq, schema } from "@repo/db";
|
||||
|
||||
// Skip the full zod-validated env (which refuses to load outside desktop mode
|
||||
// without INTERNAL_TOKEN); the crypto helpers only need BETTER_AUTH_SECRET.
|
||||
vi.mock("@repo/platform/engine/config/env", () => ({
|
||||
env: { BETTER_AUTH_SECRET: "test-secret-for-data-transfer-unit-tests", CLOUD_MODE: false },
|
||||
vi.mock("@repo/platform/engine/config/env", async () => ({
|
||||
env: { BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? (await import("@repo/db/encryption")).DEFAULT_ENCRYPTION_SECRET, CLOUD_MODE: false },
|
||||
}));
|
||||
|
||||
import { decrypt, encrypt, encryptBytesWithKey, encryptWithKey } from "@repo/platform/engine/lib/encryption";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createConfigurationSecrets } from "@repo/db/configuration-secrets";
|
||||
import { createEncryption } from "@repo/db/encryption";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -208,6 +210,9 @@ const composeServices = [
|
||||
* seam. The rest of a deployment stays mocked (no clone/build/Docker), while
|
||||
* service writes are stateful and observable across the full trigger boundary.
|
||||
*/
|
||||
const testEncryption = createEncryption("repository-test-secret");
|
||||
const configuration = createConfigurationSecrets(testEncryption);
|
||||
|
||||
function installStatefulComposeRepo<T extends Record<string, unknown>>(initial: T) {
|
||||
let stored = structuredClone(initial);
|
||||
const writes: Array<Record<string, unknown>> = [];
|
||||
@@ -216,16 +221,16 @@ function installStatefulComposeRepo<T extends Record<string, unknown>>(initial:
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => ({
|
||||
where: async () => {
|
||||
writes.push(data);
|
||||
writes.push(configuration.openService(data));
|
||||
stored = { ...stored, ...data } as T;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
} as unknown as Database;
|
||||
const real = createServiceRepo(db);
|
||||
const real = createServiceRepo(db, testEncryption);
|
||||
repos.service.listByProject.mockImplementation(real.listByProject.bind(real));
|
||||
repos.service.reconcileFromCompose.mockImplementation(real.reconcileFromCompose.bind(real));
|
||||
return { stored: () => stored, writes };
|
||||
return { stored: () => configuration.openService(stored), writes };
|
||||
}
|
||||
|
||||
const criticalApiEnvironment = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "@repo/db/encryption";
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
|
||||
import { createDeploymentRepo } from "../../../../../packages/db/src/repos/deployment.repo";
|
||||
@@ -437,7 +438,7 @@ describe("repo: finishBuildSession sheds the payload, never the status", () => {
|
||||
}),
|
||||
}),
|
||||
};
|
||||
return { writes, repo: createDeploymentRepo(db as never) };
|
||||
return { writes, repo: createDeploymentRepo(db as never, createEncryption("repository-test-secret")) };
|
||||
}
|
||||
|
||||
it("retries with a marker payload and keeps status + duration", async () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { db, eq, schema, sql } from "@repo/db";
|
||||
import { db, eq, schema, sql, repos } from "@repo/db";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
vi.mock("@repo/platform/engine/config/env", () => ({
|
||||
env: { BETTER_AUTH_SECRET: "project-transfer-test-key", CLOUD_MODE: false },
|
||||
vi.mock("@repo/platform/engine/config/env", async () => ({
|
||||
env: { BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? (await import("@repo/db/encryption")).DEFAULT_ENCRYPTION_SECRET, CLOUD_MODE: false },
|
||||
}));
|
||||
vi.mock("../../src/lib/database-runtime-state", () => ({
|
||||
reconcileRuntimeStateAfterImport: vi.fn(),
|
||||
@@ -434,12 +434,15 @@ describe("project control-plane export and import", () => {
|
||||
.where(eq(schema.envVar.id, "env_service"));
|
||||
expect(environment!.serviceId).toBe("service_web");
|
||||
expect(decrypt(environment!.value)).toBe("service-secret");
|
||||
const [service] = await db.select().from(schema.service);
|
||||
const [storedService] = await db.select().from(schema.service);
|
||||
expect(typeof storedService!.environment).toBe("string");
|
||||
const service = await repos.service.findById(storedService!.id);
|
||||
expect(service!.environment).toEqual({ INLINE_PASSWORD: "inline-secret" });
|
||||
expect(service!.advanced).toEqual({
|
||||
files: [{ path: "/run/secrets/key", content: "private-file-contents" }],
|
||||
});
|
||||
const [deployment] = await db.select().from(schema.deployment);
|
||||
const [storedDeployment] = await db.select().from(schema.deployment);
|
||||
const deployment = await repos.deployment.findById(storedDeployment!.id);
|
||||
expect(deployment!.meta).toMatchObject({
|
||||
organizationId: context.organizationId,
|
||||
serverId: "target_server",
|
||||
@@ -590,7 +593,8 @@ describe("project control-plane export and import", () => {
|
||||
serviceId: "existing_service",
|
||||
serviceIds: ["existing_service"],
|
||||
});
|
||||
const [deployment] = await db.select().from(schema.deployment);
|
||||
const [storedDeployment] = await db.select().from(schema.deployment);
|
||||
const deployment = await repos.deployment.findById(storedDeployment!.id);
|
||||
expect(deployment!.meta).toMatchObject({
|
||||
targetServiceIds: ["existing_service"],
|
||||
composeServices: [
|
||||
@@ -735,7 +739,8 @@ describe("project control-plane export and import", () => {
|
||||
const [project] = await db.select().from(schema.project).where(eq(schema.project.id, "web"));
|
||||
expect(project!.activeDeploymentId).toBeNull();
|
||||
expect(project!.disabledAt).toBeInstanceOf(Date);
|
||||
const [deployment] = await db.select().from(schema.deployment);
|
||||
const [storedDeployment] = await db.select().from(schema.deployment);
|
||||
const deployment = await repos.deployment.findById(storedDeployment!.id);
|
||||
expect(deployment!.containerId).toBeNull();
|
||||
expect(deployment!.meta).toBeNull();
|
||||
const [domain] = await db.select().from(schema.domain);
|
||||
|
||||
@@ -52,12 +52,23 @@ Some values are sensitive — passwords, API keys, tokens. For each of those, pr
|
||||
row to **mark it as secret**. A secret's value is stored the same way, but from then on it shows as dots
|
||||
(`••••••••`) instead of plain text, so it stays hidden whenever you reopen the editor.
|
||||
|
||||
<Callout title="Everything is encrypted at rest" type="info">
|
||||
<Callout title="Environment values are encrypted at rest" type="info">
|
||||
Whether or not you mark a variable secret, Openship **encrypts every value before saving it**.
|
||||
Marking a value secret adds the on-screen masking on top — it controls what *you* can see later,
|
||||
not whether it's protected on disk.
|
||||
</Callout>
|
||||
|
||||
Inline service environment values and build arguments are also encrypted, including their copies in
|
||||
saved deployment snapshots and Compose drift baselines. Existing plaintext service configuration is
|
||||
converted when the API or native engine starts after upgrading. Keep your `BETTER_AUTH_SECRET` with
|
||||
your database backup: it is needed to read these values. To downgrade to a version from before this
|
||||
protection, restore its matching database backup as well.
|
||||
|
||||
When moving to an installation with a different key, use **Settings → Data Transfer** with secrets
|
||||
included. It re-encrypts the configuration for the destination. Transfers or recovery manifests that
|
||||
omit secrets also omit inline environment, build arguments and protected service configuration; set
|
||||
those values again before deploying.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
"./lock": {
|
||||
"types": "./src/pglite-lock.ts",
|
||||
"import": "./src/pglite-lock.ts"
|
||||
},
|
||||
"./encryption": {
|
||||
"types": "./src/encryption.ts",
|
||||
"import": "./src/encryption.ts"
|
||||
},
|
||||
"./configuration-secrets": {
|
||||
"types": "./src/configuration-secrets.ts",
|
||||
"import": "./src/configuration-secrets.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** HTTP/CLI process composition. Native embedders import @repo/db/factory. */
|
||||
import { createDatabase, PG_POOL_MAX, type DatabaseOptions } from "./connection";
|
||||
import { resolve } from "node:path";
|
||||
import { createEncryption, DEFAULT_ENCRYPTION_SECRET } from "./encryption";
|
||||
export { PG_POOL_MAX, type Database, type DatabaseTransaction, type Driver } from "./connection";
|
||||
export { awaitPgReady } from "./pg-ready";
|
||||
|
||||
@@ -84,9 +85,16 @@ const options: DatabaseOptions = {
|
||||
lockWaitMs: process.env.OPENSHIP_NATIVE === "true" ? 0 : undefined,
|
||||
takeover: process.env.OPENSHIP_NATIVE !== "true" && (process.execArgv.includes("--watch") || process.env.OPENSHIP_DEV_LOCK_TAKEOVER === "true"),
|
||||
};
|
||||
const connection = await createDatabase(options);
|
||||
export const storageEncryption = createEncryption(process.env.BETTER_AUTH_SECRET ?? DEFAULT_ENCRYPTION_SECRET);
|
||||
const connection = await createDatabase(options).catch(error => {
|
||||
storageEncryption.close();
|
||||
throw error;
|
||||
});
|
||||
export const db = connection.db;
|
||||
export const closeDb = connection.close;
|
||||
export async function closeDb(): Promise<void> {
|
||||
try { await connection.close(); }
|
||||
finally { storageEncryption.close(); }
|
||||
}
|
||||
export const getDriver = () => connection.driver;
|
||||
export function getPgPool() {
|
||||
if (!connection.pool) throw new Error("Postgres pool is unavailable (active driver is not 'pg')");
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Storage codec for JSON configuration containing inline secrets. Repositories
|
||||
* expose ordinary domain objects; only the persisted JSONB cell is a string.
|
||||
* Legacy objects remain readable until the bounded startup backfill seals them.
|
||||
* No environment access, connection, or process-global key lives in this module.
|
||||
*/
|
||||
import type { Encryption } from "./encryption";
|
||||
|
||||
export type ConfigurationEncryption = Pick<Encryption, "encrypt" | "decrypt">;
|
||||
export const CONFIGURATION_PREFIX = "openship:config:v1:";
|
||||
export const SERVICE_SECRET_FIELDS = [
|
||||
"environment",
|
||||
"buildArgs",
|
||||
"advanced",
|
||||
"importedSpec",
|
||||
"driftSpec",
|
||||
] as const;
|
||||
// Keep routing identity and composeDeployment.decision queryable in SQL. The
|
||||
// service snapshot includes env, build args, advanced inline files and baselines.
|
||||
export const DEPLOYMENT_SECRET_FIELDS = ["composeServices"] as const;
|
||||
|
||||
export function isPlainConfiguration(value: unknown): value is Record<string, unknown> | unknown[] {
|
||||
return value !== null && typeof value === "object" && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
export function createConfigurationSecrets(encryption: ConfigurationEncryption) {
|
||||
function sealJson(value: unknown): unknown {
|
||||
if (value === undefined || value === null) return value;
|
||||
if (typeof value !== "object") throw new Error("Expected JSON configuration before encryption");
|
||||
if (!isPlainConfiguration(value)) return value;
|
||||
return CONFIGURATION_PREFIX + encryption.encrypt(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function openJson(value: unknown): unknown {
|
||||
if (value === undefined || value === null || typeof value === "object") return value;
|
||||
try {
|
||||
if (typeof value !== "string" || !value.startsWith(CONFIGURATION_PREFIX)) throw new Error();
|
||||
const plain: unknown = JSON.parse(
|
||||
encryption.decrypt(value.slice(CONFIGURATION_PREFIX.length)),
|
||||
);
|
||||
if (plain === null || typeof plain !== "object") throw new Error();
|
||||
return plain;
|
||||
} catch {
|
||||
// Never pass ciphertext to a runtime, expose it in errors, or turn a wrong
|
||||
// key into an empty configuration that could overwrite the stored secret.
|
||||
throw new Error("Unable to decrypt stored configuration with this installation's key");
|
||||
}
|
||||
}
|
||||
|
||||
// This is the storage/domain boundary: schema types describe the plaintext
|
||||
// object callers use, while JSONB also accepts the sealed string on disk.
|
||||
function fields<T extends object>(
|
||||
row: T,
|
||||
keys: readonly string[],
|
||||
map: (value: unknown) => unknown,
|
||||
): T {
|
||||
const copy = { ...row } as Record<string, unknown>;
|
||||
for (const key of keys) if (copy[key] !== undefined) copy[key] = map(copy[key]);
|
||||
return copy as T;
|
||||
}
|
||||
function sealService<T extends object>(row: T): T {
|
||||
return fields(row, SERVICE_SECRET_FIELDS, sealJson);
|
||||
}
|
||||
function openService<T extends object | undefined>(row: T): T {
|
||||
return row === undefined ? row : (fields(row, SERVICE_SECRET_FIELDS, openJson) as T);
|
||||
}
|
||||
function meta(value: unknown, map: (value: unknown) => unknown): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value !== "object" || Array.isArray(value))
|
||||
throw new Error("Invalid deployment configuration");
|
||||
return fields(value, DEPLOYMENT_SECRET_FIELDS, map);
|
||||
}
|
||||
function sealDeployment<T extends object>(row: T): T {
|
||||
return fields(row, ["meta"], (value) => meta(value, sealJson));
|
||||
}
|
||||
function openDeployment<T extends object | undefined>(row: T): T {
|
||||
return row === undefined ? row : (fields(row, ["meta"], (value) => meta(value, openJson)) as T);
|
||||
}
|
||||
return {
|
||||
sealJson,
|
||||
openJson,
|
||||
sealService,
|
||||
openService,
|
||||
sealDeployment,
|
||||
openDeployment,
|
||||
sealDeploymentMeta: (value: unknown) => meta(value, sealJson),
|
||||
openDeploymentMeta: (value: unknown) => meta(value, openJson),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "./encryption";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createDatabase, createRepositories, schema, type DatabaseConnection } from "./factory";
|
||||
import { createPgliteLock } from "./pglite-lock";
|
||||
@@ -21,8 +22,8 @@ describe("instance-owned database composition", () => {
|
||||
it("keeps repositories with the same identifiers isolated and closes only owned resources", async () => {
|
||||
await first.db.insert(schema.user).values({ id: "same-user", name: "First", email: "first@example.test" });
|
||||
await second.db.insert(schema.user).values({ id: "same-user", name: "Second", email: "second@example.test" });
|
||||
const a = createRepositories(first.db);
|
||||
const b = createRepositories(second.db);
|
||||
const a = createRepositories(first.db, createEncryption("repository-test-secret"));
|
||||
const b = createRepositories(second.db, createEncryption("repository-test-secret"));
|
||||
expect((await a.user.findById("same-user"))?.name).toBe("First");
|
||||
expect((await b.user.findById("same-user"))?.name).toBe("Second");
|
||||
await Promise.all([first.close(), first.close()]);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { sql, eq, inArray, count, getTableColumns } from "drizzle-orm";
|
||||
import { getTableConfig, type PgTable } from "drizzle-orm/pg-core";
|
||||
import { db, getDriver, type DatabaseTransaction } from "./client";
|
||||
import * as schema from "./schema";
|
||||
import { SERVICE_SECRET_FIELDS, DEPLOYMENT_SECRET_FIELDS } from "./configuration-secrets";
|
||||
|
||||
export const DUMP_FORMAT_VERSION = 1;
|
||||
|
||||
@@ -948,6 +949,8 @@ export const ENCRYPTED_COLUMNS: ReadonlyArray<EncryptedColumnSpec> = [
|
||||
{ table: "instance_settings", column: "tunnelToken" },
|
||||
{ table: "instance_settings", column: "ghDeviceTokenEncrypted" },
|
||||
{ table: "deployment", column: "envVars" },
|
||||
...SERVICE_SECRET_FIELDS.map(column => ({ table: "service", column })),
|
||||
{ table: "deployment", column: "meta", secretPaths: [...DEPLOYMENT_SECRET_FIELDS] },
|
||||
{ table: "notification_channel", column: "config", secretPaths: ["hmacSecret", "webhookUrl", "botToken"] },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Application-level encryption for sensitive data (env vars, secrets).
|
||||
*
|
||||
* Uses AES-256-GCM (authenticated encryption) via Node.js built-in crypto.
|
||||
* The encryption key is derived from BETTER_AUTH_SECRET (which every install
|
||||
* already has) - no extra env var needed.
|
||||
*
|
||||
* Format: base64( iv:16 || authTag:16 || ciphertext )
|
||||
*
|
||||
* Usage:
|
||||
* import { encrypt, decrypt } from "../lib/encryption";
|
||||
* const sealed = encrypt("my secret");
|
||||
* const plain = decrypt(sealed); // "my secret"
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export const DEFAULT_ENCRYPTION_SECRET = "change-me-in-production";
|
||||
|
||||
// ─── Key derivation ──────────────────────────────────────────────────────────
|
||||
|
||||
const ALGORITHM = "aes-256-gcm" as const;
|
||||
const IV_LENGTH = 16;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seal a plaintext under an EXPLICIT 32-byte key. The single AES-256-GCM
|
||||
* implementation for the whole app — `encrypt()` uses the instance key,
|
||||
* while callers with a different key source (e.g. a passphrase-derived key
|
||||
* for data export) reuse this so the cipher/format never diverges.
|
||||
* Returns base64( iv:16 || authTag:16 || ciphertext ).
|
||||
*/
|
||||
export function encryptWithKey(key: Buffer, plaintext: string): string {
|
||||
return encryptBytesWithKey(key, Buffer.from(plaintext, "utf8")).toString("base64");
|
||||
}
|
||||
|
||||
/** Binary twin used by bounded transfer chunks; keeps bytes binary on the wire. */
|
||||
export function encryptBytesWithKey(key: Buffer, plaintext: Uint8Array): Buffer {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, authTag, encrypted]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a value produced by `encryptWithKey()` (or `encrypt()`), using an
|
||||
* explicit key. Throws if the data is tampered with or the key is wrong.
|
||||
*/
|
||||
export function decryptWithKey(key: Buffer, sealed: string): string {
|
||||
return decryptBytesWithKey(key, Buffer.from(sealed, "base64")).toString("utf8");
|
||||
}
|
||||
|
||||
/** Open one binary AES-GCM chunk without converting its payload through UTF-8. */
|
||||
export function decryptBytesWithKey(key: Buffer, sealed: Uint8Array): Buffer {
|
||||
const packed = Buffer.from(sealed);
|
||||
if (packed.length < IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||
throw new Error("Invalid encrypted data: too short");
|
||||
}
|
||||
const iv = packed.subarray(0, IV_LENGTH);
|
||||
const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const ciphertext = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
/** One installation's secrets. The existing ciphertext format and key derivation are unchanged. */
|
||||
export function createEncryption(secret: string) {
|
||||
if (typeof secret !== "string" || !secret)
|
||||
throw new TypeError("An explicit encryption secret is required");
|
||||
const key = createHash("sha256").update(secret).digest();
|
||||
let closed = false;
|
||||
function getKey(): Buffer {
|
||||
if (closed) throw new Error("Encryption context is closed");
|
||||
return key;
|
||||
}
|
||||
/**
|
||||
* Encrypt a plaintext string under the instance key.
|
||||
* Returns a base64-encoded string containing IV + auth tag + ciphertext.
|
||||
*/
|
||||
function encrypt(plaintext: string): string {
|
||||
return encryptWithKey(getKey(), plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value produced by `encrypt()`.
|
||||
* Throws if the data is tampered with or the key is wrong.
|
||||
*/
|
||||
function decrypt(sealed: string): string {
|
||||
return decryptWithKey(getKey(), sealed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a Record<string, encryptedValue> → Record<string, plaintext>.
|
||||
* On decryption failure for a key, that key is omitted (not silently passed through).
|
||||
*/
|
||||
function decryptEnvMap(
|
||||
encrypted: Record<string, string>,
|
||||
onError?: (key: string, err: unknown) => void,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(encrypted)) {
|
||||
try {
|
||||
result[k] = decrypt(v);
|
||||
} catch (err) {
|
||||
onError?.(k, err);
|
||||
// Omit keys that fail decryption - never leak ciphertext into containers
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
encrypt,
|
||||
decrypt,
|
||||
decryptEnvMap,
|
||||
close() {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
key.fill(0);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
export type Encryption = ReturnType<typeof createEncryption>;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createConfigurationSecrets } from "../configuration-secrets";
|
||||
import { createEncryption } from "../encryption";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Database } from "../client";
|
||||
import {
|
||||
@@ -7,6 +9,9 @@ import {
|
||||
type ParsedComposeService,
|
||||
} from "./service.repo";
|
||||
|
||||
const testEncryption = createEncryption("repository-test-secret");
|
||||
const configuration = createConfigurationSecrets(testEncryption);
|
||||
|
||||
const fullEnvironment = {
|
||||
NODE_ENV: "production",
|
||||
PORT: "4000",
|
||||
@@ -56,16 +61,16 @@ function harness(initial = existingService()) {
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => ({
|
||||
where: async () => {
|
||||
writes.push(data);
|
||||
writes.push(configuration.openService(data));
|
||||
stored = { ...stored, ...data };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
} as unknown as Database;
|
||||
return {
|
||||
repo: createServiceRepo(db),
|
||||
repo: createServiceRepo(db, testEncryption),
|
||||
writes,
|
||||
stored: () => stored,
|
||||
stored: () => configuration.openService(stored),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createDatabase, createRepositories, schema, type DatabaseConnection } from "../factory";
|
||||
import { createEncryption } from "../encryption";
|
||||
import {
|
||||
CONFIGURATION_PREFIX,
|
||||
createConfigurationSecrets,
|
||||
SERVICE_SECRET_FIELDS,
|
||||
} from "../configuration-secrets";
|
||||
import { toComposeSpec } from "./service.repo";
|
||||
|
||||
describe("service configuration at rest (GH-844)", () => {
|
||||
const encryption = createEncryption("configuration-test-installation-a");
|
||||
const otherEncryption = createEncryption("configuration-test-installation-b");
|
||||
const codec = createConfigurationSecrets(encryption);
|
||||
let connection: DatabaseConnection;
|
||||
let repos: ReturnType<typeof createRepositories>;
|
||||
let sequence = 0;
|
||||
beforeAll(async () => {
|
||||
connection = await createDatabase({ driver: "pglite", dataDir: "memory://" });
|
||||
repos = createRepositories(connection.db, encryption);
|
||||
await connection.db.insert(schema.organization).values({ id: "org", name: "Test" });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await connection?.close();
|
||||
encryption.close();
|
||||
otherEncryption.close();
|
||||
});
|
||||
async function project() {
|
||||
const slug = `app-${++sequence}`;
|
||||
const group = await repos.projectGroup.create({ organizationId: "org", name: slug, slug });
|
||||
return repos.project.create({ groupId: group.id, organizationId: "org", name: slug, slug });
|
||||
}
|
||||
const config = {
|
||||
environment: {
|
||||
PASSWORD: "inline-secret-844",
|
||||
LITERAL: `${CONFIGURATION_PREFIX}literal-user-value`,
|
||||
},
|
||||
buildArgs: { TOKEN: "build-secret-844", INHERIT: null, EMPTY: "" },
|
||||
advanced: { files: [{ path: "/run/config", content: "mounted-secret-844" }] },
|
||||
};
|
||||
async function storedService(id: string) {
|
||||
return connection.db.query.service.findFirst({ where: eq(schema.service.id, id) });
|
||||
}
|
||||
|
||||
it("seals live config and both drift baselines while every repository read returns usable values", async () => {
|
||||
const app = await project();
|
||||
const baseline = toComposeSpec(config);
|
||||
const created = await repos.service.create({
|
||||
projectId: app.id,
|
||||
name: "web",
|
||||
...config,
|
||||
importedSpec: baseline,
|
||||
driftSpec: baseline,
|
||||
});
|
||||
expect(created).toMatchObject(config);
|
||||
const stored = await storedService(created.id);
|
||||
for (const field of SERVICE_SECRET_FIELDS)
|
||||
expect(stored![field]).toEqual(expect.stringMatching(/^openship:config:v1:/));
|
||||
for (const secret of ["inline-secret-844", "build-secret-844", "mounted-secret-844"])
|
||||
expect(JSON.stringify(stored)).not.toContain(secret);
|
||||
const reads = [
|
||||
await repos.service.findById(created.id),
|
||||
await repos.service.findByName(app.id, "web"),
|
||||
...(await repos.service.listByProject(app.id)),
|
||||
...(await repos.service.listByProjectKind(app.id, "compose")),
|
||||
...(await repos.service.listByProjects([app.id])).get(app.id)!,
|
||||
];
|
||||
for (const read of reads)
|
||||
expect(read).toMatchObject({ ...config, importedSpec: baseline, driftSpec: baseline });
|
||||
await repos.service.update(created.id, { environment: { PASSWORD: "rotated-844" } });
|
||||
expect((await repos.service.findById(created.id))?.environment).toEqual({
|
||||
PASSWORD: "rotated-844",
|
||||
});
|
||||
expect((await repos.service.findById(created.id))?.buildArgs).toEqual(config.buildArgs);
|
||||
expect(JSON.stringify(await storedService(created.id))).not.toContain("rotated-844");
|
||||
});
|
||||
|
||||
it("compares decrypted baselines when Compose changes and preserves the original frozen release", async () => {
|
||||
const app = await project();
|
||||
await repos.service.syncFromCompose(app.id, [{ name: "web", image: "app:1", ...config }], {
|
||||
composeAuthoritative: true,
|
||||
});
|
||||
const before = (await repos.service.listByProject(app.id))[0]!;
|
||||
const release = await repos.deployment.create({
|
||||
projectId: app.id,
|
||||
organizationId: "org",
|
||||
branch: "main",
|
||||
status: "ready",
|
||||
meta: {
|
||||
serverId: "server-844",
|
||||
composeServices: [before],
|
||||
composeDeployment: { decision: "pending" },
|
||||
},
|
||||
});
|
||||
expect(release).toBeDefined();
|
||||
const updated = await repos.service.reconcileFromCompose(app.id, [
|
||||
{
|
||||
name: "web",
|
||||
image: "app:2",
|
||||
...config,
|
||||
environment: { ...config.environment, PASSWORD: "new-repo-secret-844" },
|
||||
},
|
||||
]);
|
||||
expect(updated.driftedNames).toEqual([]);
|
||||
expect((await repos.service.findById(before.id))?.environment?.PASSWORD).toBe(
|
||||
"new-repo-secret-844",
|
||||
);
|
||||
const stored = await connection.db.query.deployment.findFirst({
|
||||
where: eq(schema.deployment.id, release!.id),
|
||||
});
|
||||
expect(stored!.meta).toMatchObject({
|
||||
serverId: "server-844",
|
||||
composeServices: expect.stringMatching(/^openship:config:v1:/),
|
||||
});
|
||||
expect(JSON.stringify(stored!.meta)).not.toContain("inline-secret-844");
|
||||
const historical = { meta: { composeServices: [expect.objectContaining(config)] } };
|
||||
const reads = [
|
||||
await repos.deployment.findById(release!.id),
|
||||
await repos.deployment.findLatestReady(app.id, "production"),
|
||||
await repos.deployment.findLatestByProject(app.id),
|
||||
await repos.deployment.getLatestSuccessfulForBranch(app.id, "main"),
|
||||
...(await repos.deployment.listByProject(app.id)).rows,
|
||||
...(await repos.deployment.listReadyOrderedDesc(app.id)),
|
||||
(await repos.deployment.findManyById([release!.id])).get(release!.id),
|
||||
(await repos.deployment.findLatestByProjects([app.id])).get(app.id),
|
||||
];
|
||||
for (const read of reads) expect(read).toMatchObject(historical);
|
||||
await repos.deployment.supersedePendingDecisions(app.id, "a-new-release");
|
||||
expect((await repos.deployment.findById(release!.id))?.meta).toMatchObject({
|
||||
composeDeployment: { decision: "superseded" },
|
||||
composeServices: [expect.objectContaining(config)],
|
||||
});
|
||||
});
|
||||
|
||||
it("seals replacement deployment metadata without changing target identity or status rules", async () => {
|
||||
const app = await project();
|
||||
const dep = await repos.deployment.create({
|
||||
projectId: app.id,
|
||||
organizationId: "org",
|
||||
branch: "main",
|
||||
meta: { composeServices: [{ name: "web", ...config }] },
|
||||
});
|
||||
const meta = {
|
||||
serverId: "remote",
|
||||
composeServices: [{ name: "web", environment: { PASSWORD: "replacement-secret-844" } }],
|
||||
};
|
||||
expect(await repos.deployment.updateStatus(dep!.id, "building", { meta })).toBe(true);
|
||||
expect((await repos.deployment.listInFlightByProject(app.id))[0]?.meta).toEqual(meta);
|
||||
expect(
|
||||
await repos.deployment.findInProgressByReleaseVersion(app.id, undefined),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(await repos.deployment.listByStatus("building")).find((row) => row.id === dep!.id)?.meta,
|
||||
).toEqual(meta);
|
||||
expect(
|
||||
(await repos.deployment.listByOrganization("org")).rows.find((row) => row.id === dep!.id)
|
||||
?.meta,
|
||||
).toEqual(meta);
|
||||
expect(await repos.deployment.cancelInFlight(dep!.id, { meta })).toBe(true);
|
||||
expect(await repos.deployment.updateStatus(dep!.id, "ready", { meta })).toBe(false);
|
||||
const raw = await connection.db.query.deployment.findFirst({
|
||||
where: eq(schema.deployment.id, dep!.id),
|
||||
});
|
||||
expect(JSON.stringify(raw!.meta)).not.toContain("replacement-secret-844");
|
||||
expect((await repos.deployment.findById(dep!.id))?.meta).toEqual(meta);
|
||||
});
|
||||
|
||||
it("encrypts services created by atomic project cloning", async () => {
|
||||
const cloned = await repos.project.createProjectWithRecords({
|
||||
group: { organizationId: "org", name: "Cloned", slug: "cloned" },
|
||||
project: { organizationId: "org", name: "Cloned", slug: "cloned" },
|
||||
services: [{ sourceId: "source", row: { name: "web", ...config } }],
|
||||
envVars: [],
|
||||
});
|
||||
const id = cloned.serviceIdBySourceId.source!;
|
||||
expect(await repos.service.findById(id)).toMatchObject(config);
|
||||
expect(JSON.stringify(await storedService(id))).not.toContain("inline-secret-844");
|
||||
});
|
||||
|
||||
it("backfills multiple pages of legacy rows, keeps timestamps and is idempotent", async () => {
|
||||
const app = await project();
|
||||
const baseline = toComposeSpec(config);
|
||||
await connection.db.insert(schema.service).values(
|
||||
Array.from({ length: 103 }, (_, i) => ({
|
||||
id: `legacy-${i}`,
|
||||
name: `legacy-${i}`,
|
||||
projectId: app.id,
|
||||
...config,
|
||||
importedSpec: baseline,
|
||||
driftSpec: baseline,
|
||||
})),
|
||||
);
|
||||
await connection.db
|
||||
.insert(schema.deployment)
|
||||
.values({
|
||||
id: "legacy-release",
|
||||
projectId: app.id,
|
||||
organizationId: "org",
|
||||
branch: "main",
|
||||
status: "ready",
|
||||
meta: { serverId: "old-server", composeServices: [{ name: "web", ...config }] },
|
||||
});
|
||||
const original = await storedService("legacy-0");
|
||||
expect(await repos.service.findById("legacy-0")).toMatchObject(config);
|
||||
expect(await repos.configurationSecrets.backfillLegacy()).toEqual({
|
||||
services: 103,
|
||||
deployments: 1,
|
||||
});
|
||||
const sealed = await storedService("legacy-0");
|
||||
expect(sealed!.updatedAt).toEqual(original!.updatedAt);
|
||||
expect(JSON.stringify(sealed)).not.toContain("inline-secret-844");
|
||||
expect(await repos.service.findById("legacy-102")).toMatchObject(config);
|
||||
expect((await repos.deployment.findById("legacy-release"))?.meta).toMatchObject({
|
||||
serverId: "old-server",
|
||||
composeServices: [expect.objectContaining(config)],
|
||||
});
|
||||
expect(await repos.configurationSecrets.backfillLegacy()).toEqual({
|
||||
services: 0,
|
||||
deployments: 0,
|
||||
});
|
||||
expect((await storedService("legacy-0"))!.environment).toBe(sealed!.environment);
|
||||
});
|
||||
|
||||
it("fails closed on a wrong key or damaged ciphertext without altering saved values", async () => {
|
||||
const app = await project();
|
||||
const service = await repos.service.create({ projectId: app.id, name: "web", ...config });
|
||||
const wrongRepos = createRepositories(connection.db, otherEncryption);
|
||||
await expect(wrongRepos.service.findById(service.id)).rejects.toThrow(
|
||||
"Unable to decrypt stored configuration",
|
||||
);
|
||||
const raw = await storedService(service.id);
|
||||
const broken = String(raw!.environment).slice(0, -8) + "tampered";
|
||||
await connection.db
|
||||
.update(schema.service)
|
||||
.set({ environment: broken as never })
|
||||
.where(eq(schema.service.id, service.id));
|
||||
await expect(repos.service.findById(service.id)).rejects.toThrow(
|
||||
"Unable to decrypt stored configuration",
|
||||
);
|
||||
expect((await storedService(service.id))!.environment).toBe(broken);
|
||||
});
|
||||
|
||||
it("never accepts a public string as trusted ciphertext and preserves empty defaults", () => {
|
||||
const sealed = codec.sealJson(config.environment);
|
||||
expect(() => codec.sealJson(sealed)).toThrow("Expected JSON configuration");
|
||||
expect(codec.openJson(sealed)).toEqual(config.environment);
|
||||
for (const value of [undefined, null, {}, []])
|
||||
expect(codec.openJson(codec.sealJson(value))).toEqual(value);
|
||||
expect(() => codec.openJson("openship:config:v99:unknown")).toThrow("Unable to decrypt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, asc, eq, gt, or, sql, type SQL } from "drizzle-orm";
|
||||
import type { Database } from "../connection";
|
||||
import { deployment, service } from "../schema";
|
||||
import {
|
||||
createConfigurationSecrets,
|
||||
isPlainConfiguration,
|
||||
SERVICE_SECRET_FIELDS,
|
||||
DEPLOYMENT_SECRET_FIELDS,
|
||||
type ConfigurationEncryption,
|
||||
} from "../configuration-secrets";
|
||||
|
||||
/** No schema rewrite or unbounded transaction. New writers always seal; the
|
||||
* compare-and-swap guards keep a boot-time backfill from losing concurrent edits. */
|
||||
export function createConfigurationSecretsRepo(db: Database, encryption: ConfigurationEncryption) {
|
||||
const codec = createConfigurationSecrets(encryption);
|
||||
const nonemptyJson = (cell: SQL) =>
|
||||
sql`jsonb_typeof(${cell}) in ('object', 'array') and ${cell} <> '{}'::jsonb and ${cell} <> '[]'::jsonb`;
|
||||
return {
|
||||
async backfillLegacy(): Promise<{ services: number; deployments: number }> {
|
||||
const counts = { services: 0, deployments: 0 };
|
||||
let after: string | undefined;
|
||||
for (;;) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(service)
|
||||
.where(
|
||||
and(
|
||||
after ? gt(service.id, after) : undefined,
|
||||
or(...SERVICE_SECRET_FIELDS.map((key) => nonemptyJson(sql`${service[key]}`))),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(service.id))
|
||||
.limit(100);
|
||||
if (!rows.length) break;
|
||||
for (const row of rows) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const unchanged: SQL[] = [eq(service.id, row.id)];
|
||||
for (const key of SERVICE_SECRET_FIELDS) {
|
||||
if (!isPlainConfiguration(row[key])) continue;
|
||||
patch[key] = codec.sealJson(row[key]);
|
||||
unchanged.push(
|
||||
sql`${service[key]} is not distinct from ${JSON.stringify(row[key])}::jsonb`,
|
||||
);
|
||||
}
|
||||
if (Object.keys(patch).length) {
|
||||
const changed = await db
|
||||
.update(service)
|
||||
.set(patch)
|
||||
.where(and(...unchanged))
|
||||
.returning();
|
||||
counts.services += changed.length;
|
||||
}
|
||||
}
|
||||
after = rows.at(-1)!.id;
|
||||
}
|
||||
after = undefined;
|
||||
for (;;) {
|
||||
const rows = await db
|
||||
.select({ id: deployment.id, meta: deployment.meta })
|
||||
.from(deployment)
|
||||
.where(
|
||||
and(
|
||||
after ? gt(deployment.id, after) : undefined,
|
||||
or(
|
||||
...DEPLOYMENT_SECRET_FIELDS.map((key) =>
|
||||
nonemptyJson(sql`${deployment.meta}->${key}`),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(deployment.id))
|
||||
.limit(100);
|
||||
if (!rows.length) break;
|
||||
for (const row of rows) {
|
||||
// Leave fields already sealed by another code path untouched.
|
||||
const next = { ...(row.meta as Record<string, unknown>) };
|
||||
for (const key of DEPLOYMENT_SECRET_FIELDS) {
|
||||
if (isPlainConfiguration(next[key])) next[key] = codec.sealJson(next[key]);
|
||||
}
|
||||
const changed = await db
|
||||
.update(deployment)
|
||||
.set({ meta: next })
|
||||
.where(
|
||||
and(
|
||||
eq(deployment.id, row.id),
|
||||
sql`${deployment.meta} is not distinct from ${JSON.stringify(row.meta)}::jsonb`,
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
counts.deployments += changed.length;
|
||||
}
|
||||
after = rows.at(-1)!.id;
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "../encryption";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
@@ -25,8 +26,8 @@ async function freshDb() {
|
||||
await client.exec("SET session_replication_role = replica;"); // skip FK seeding
|
||||
return {
|
||||
db,
|
||||
projectRepo: createProjectRepo(db),
|
||||
deploymentRepo: createDeploymentRepo(db),
|
||||
projectRepo: createProjectRepo(db, createEncryption("repository-test-secret")),
|
||||
deploymentRepo: createDeploymentRepo(db, createEncryption("repository-test-secret")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "../encryption";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
@@ -25,7 +26,7 @@ async function freshRepo() {
|
||||
const db = drizzle(client, { schema });
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
await client.exec("SET session_replication_role = replica;"); // skip FK seeding
|
||||
const repo = createDeploymentRepo(db);
|
||||
const repo = createDeploymentRepo(db, createEncryption("repository-test-secret"));
|
||||
await db.insert(deployment).values({
|
||||
id: "d1",
|
||||
projectId: "p1",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "../encryption";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
@@ -23,7 +24,7 @@ async function freshRepo(deletionInProgress: boolean) {
|
||||
slug: "app",
|
||||
deletionInProgress,
|
||||
});
|
||||
return { db, repo: createDeploymentRepo(db) };
|
||||
return { db, repo: createDeploymentRepo(db, createEncryption("repository-test-secret")) };
|
||||
}
|
||||
|
||||
describe("deployment creation vs. project deletion", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq, and, desc, gte, lte, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { generateId } from "@repo/core";
|
||||
import type { Database } from "../client";
|
||||
import type { Database } from "../connection";
|
||||
import { createConfigurationSecrets, type ConfigurationEncryption } from "../configuration-secrets";
|
||||
import { deployment, buildSession, project } from "../schema";
|
||||
import { detailOf } from "./storable-detail";
|
||||
import { withProjectWorkAdmission } from "./project-work-admission";
|
||||
@@ -14,25 +15,27 @@ export type NewBuildSession = typeof buildSession.$inferInsert;
|
||||
|
||||
// ─── Repository ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function createDeploymentRepo(db: Database) {
|
||||
export function createDeploymentRepo(db: Database, encryption: ConfigurationEncryption) {
|
||||
const codec = createConfigurationSecrets(encryption);
|
||||
return {
|
||||
// ── Deployments ────────────────────────────────────────────────────
|
||||
|
||||
async findById(id: string) {
|
||||
return db.query.deployment.findFirst({
|
||||
return codec.openDeployment(await db.query.deployment.findFirst({
|
||||
where: eq(deployment.id, id),
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
/** All deployments in a given status (e.g. "reconciling") — drives the
|
||||
* reconcile sweep. Bounded to avoid pulling an unbounded history. */
|
||||
async listByStatus(status: string, limit = 200) {
|
||||
return db
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(deployment)
|
||||
.where(eq(deployment.status, status))
|
||||
.orderBy(desc(deployment.createdAt))
|
||||
.limit(limit);
|
||||
return rows.map(codec.openDeployment);
|
||||
},
|
||||
|
||||
async listByProject(
|
||||
@@ -48,12 +51,12 @@ export function createDeploymentRepo(db: Database) {
|
||||
conditions.push(eq(deployment.environment, opts.environment));
|
||||
}
|
||||
|
||||
const rows = await db.query.deployment.findMany({
|
||||
const rows = (await db.query.deployment.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: [desc(deployment.createdAt)],
|
||||
limit: perPage,
|
||||
offset,
|
||||
});
|
||||
})).map(codec.openDeployment);
|
||||
|
||||
const [{ value: total }] = await db
|
||||
.select({ value: sql<number>`count(*)` })
|
||||
@@ -76,7 +79,7 @@ export function createDeploymentRepo(db: Database) {
|
||||
* status side at one; the EXISTS side covers its still-running cancelled
|
||||
* worker without trusting that terminal-looking status. */
|
||||
async listInFlightByProject(projectId: string): Promise<Deployment[]> {
|
||||
return db.query.deployment.findMany({
|
||||
return (await db.query.deployment.findMany({
|
||||
where: and(
|
||||
eq(deployment.projectId, projectId),
|
||||
or(
|
||||
@@ -91,7 +94,7 @@ export function createDeploymentRepo(db: Database) {
|
||||
)`,
|
||||
),
|
||||
),
|
||||
}) as Promise<Deployment[]>;
|
||||
})).map(codec.openDeployment);
|
||||
},
|
||||
|
||||
async hasLiveBuildExecution(deploymentId: string, projectId: string): Promise<boolean> {
|
||||
@@ -119,12 +122,12 @@ export function createDeploymentRepo(db: Database) {
|
||||
const perPage = opts?.perPage ?? 50;
|
||||
const offset = (page - 1) * perPage;
|
||||
|
||||
const rows = await db.query.deployment.findMany({
|
||||
const rows = (await db.query.deployment.findMany({
|
||||
where: eq(deployment.organizationId, organizationId),
|
||||
orderBy: [desc(deployment.createdAt)],
|
||||
limit: perPage,
|
||||
offset,
|
||||
});
|
||||
})).map(codec.openDeployment);
|
||||
|
||||
const [{ value: total }] = await db
|
||||
.select({ value: sql<number>`count(*)` })
|
||||
@@ -200,10 +203,10 @@ export function createDeploymentRepo(db: Database) {
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(deployment)
|
||||
.values({ id, ...rest })
|
||||
.values(codec.sealDeployment({ id, ...rest }))
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
return inserted as Deployment | undefined;
|
||||
return codec.openDeployment(inserted as Deployment | undefined);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -371,14 +374,14 @@ export function createDeploymentRepo(db: Database) {
|
||||
*/
|
||||
async findInProgressByCommit(projectId: string, commitSha: string | null | undefined) {
|
||||
if (!commitSha) return undefined;
|
||||
return db.query.deployment.findFirst({
|
||||
return codec.openDeployment(await db.query.deployment.findFirst({
|
||||
where: and(
|
||||
eq(deployment.projectId, projectId),
|
||||
eq(deployment.commitSha, commitSha),
|
||||
inArray(deployment.status, ["queued", "building", "deploying"]),
|
||||
),
|
||||
orderBy: [desc(deployment.createdAt)],
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -393,14 +396,14 @@ export function createDeploymentRepo(db: Database) {
|
||||
releaseVersion: string | null | undefined,
|
||||
) {
|
||||
if (!releaseVersion) return undefined;
|
||||
return db.query.deployment.findFirst({
|
||||
return codec.openDeployment(await db.query.deployment.findFirst({
|
||||
where: and(
|
||||
eq(deployment.projectId, projectId),
|
||||
eq(deployment.releaseVersion, releaseVersion),
|
||||
inArray(deployment.status, ["queued", "building", "deploying"]),
|
||||
),
|
||||
orderBy: [desc(deployment.createdAt)],
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -426,7 +429,7 @@ export function createDeploymentRepo(db: Database) {
|
||||
): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(deployment)
|
||||
.set({ status, ...extra, updatedAt: new Date() })
|
||||
.set(codec.sealDeployment({ status, ...extra, updatedAt: new Date() }))
|
||||
.where(and(eq(deployment.id, id), ne(deployment.status, "cancelled")))
|
||||
.returning();
|
||||
return rows.length > 0;
|
||||
@@ -443,7 +446,7 @@ export function createDeploymentRepo(db: Database) {
|
||||
async cancelInFlight(id: string, extra?: Partial<NewDeployment>): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(deployment)
|
||||
.set({ ...extra, status: "cancelled", updatedAt: new Date() })
|
||||
.set(codec.sealDeployment({ ...extra, status: "cancelled", updatedAt: new Date() }))
|
||||
.where(
|
||||
and(
|
||||
eq(deployment.id, id),
|
||||
@@ -580,10 +583,10 @@ export function createDeploymentRepo(db: Database) {
|
||||
|
||||
/** Find the most recent deployment for a project (any status) */
|
||||
async findLatestByProject(projectId: string) {
|
||||
return db.query.deployment.findFirst({
|
||||
return codec.openDeployment(await db.query.deployment.findFirst({
|
||||
where: eq(deployment.projectId, projectId),
|
||||
orderBy: [desc(deployment.createdAt)],
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -597,10 +600,10 @@ export function createDeploymentRepo(db: Database) {
|
||||
*/
|
||||
async findLatestByProjects(projectIds: string[]): Promise<Map<string, Deployment>> {
|
||||
if (projectIds.length === 0) return new Map();
|
||||
const rows = await db.query.deployment.findMany({
|
||||
const rows = (await db.query.deployment.findMany({
|
||||
where: inArray(deployment.projectId, projectIds),
|
||||
orderBy: [desc(deployment.createdAt)],
|
||||
});
|
||||
})).map(codec.openDeployment);
|
||||
const out = new Map<string, Deployment>();
|
||||
for (const row of rows) {
|
||||
if (!out.has(row.projectId)) out.set(row.projectId, row);
|
||||
@@ -630,20 +633,20 @@ export function createDeploymentRepo(db: Database) {
|
||||
if (ids.length === 0) return new Map();
|
||||
const rows = await db.select().from(deployment).where(inArray(deployment.id, ids));
|
||||
const out = new Map<string, Deployment>();
|
||||
for (const row of rows) out.set(row.id, row);
|
||||
for (const row of rows) out.set(row.id, codec.openDeployment(row));
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Find the most recent successful deployment for rollback */
|
||||
async findLatestReady(projectId: string, environment: string) {
|
||||
return db.query.deployment.findFirst({
|
||||
return codec.openDeployment(await db.query.deployment.findFirst({
|
||||
where: and(
|
||||
eq(deployment.projectId, projectId),
|
||||
eq(deployment.environment, environment),
|
||||
eq(deployment.status, "ready"),
|
||||
),
|
||||
orderBy: [desc(deployment.createdAt)],
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -656,14 +659,14 @@ export function createDeploymentRepo(db: Database) {
|
||||
* that did come up.
|
||||
*/
|
||||
async getLatestSuccessfulForBranch(projectId: string, branch: string) {
|
||||
return db.query.deployment.findFirst({
|
||||
return codec.openDeployment(await db.query.deployment.findFirst({
|
||||
where: and(
|
||||
eq(deployment.projectId, projectId),
|
||||
eq(deployment.branch, branch),
|
||||
inArray(deployment.status, ["ready", "partial_failure"]),
|
||||
),
|
||||
orderBy: [desc(deployment.createdAt)],
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
// ── Rollback / retention ───────────────────────────────────────────
|
||||
@@ -708,10 +711,10 @@ export function createDeploymentRepo(db: Database) {
|
||||
if (environment) {
|
||||
conditions.push(eq(deployment.environment, environment));
|
||||
}
|
||||
return db.query.deployment.findMany({
|
||||
return (await db.query.deployment.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: [desc(deployment.createdAt)],
|
||||
});
|
||||
})).map(codec.openDeployment);
|
||||
},
|
||||
|
||||
// ── Build sessions ─────────────────────────────────────────────────
|
||||
|
||||
@@ -337,9 +337,14 @@ import { createStripeTopupGrantRepo } from "./stripe-topup-grant.repo";
|
||||
import { createBillingAnniversaryGrantRepo } from "./billing-anniversary-grant.repo";
|
||||
import { createBillingUsageSnapshotRepo } from "./billing-usage-snapshot.repo";
|
||||
|
||||
export function createRepositories(db: Database) {
|
||||
import type { ConfigurationEncryption } from "../configuration-secrets";
|
||||
import { createConfigurationSecretsRepo } from "./configuration-secrets.repo";
|
||||
|
||||
/** Passive composition: the caller owns both the connection and encryption key. */
|
||||
export function createRepositories(db: Database, encryption: ConfigurationEncryption) {
|
||||
const auditSettingsRepo = createAuditSettingsRepo(db);
|
||||
return {
|
||||
configurationSecrets: createConfigurationSecretsRepo(db, encryption),
|
||||
user: createUserRepo(db),
|
||||
session: createSessionRepo(db),
|
||||
account: createAccountRepo(db),
|
||||
@@ -347,8 +352,8 @@ export function createRepositories(db: Database) {
|
||||
githubInstallState: createGithubInstallStateRepo(db),
|
||||
gitSource: createGitSourceRepo(db),
|
||||
projectGroup: createProjectGroupRepo(db),
|
||||
project: createProjectRepo(db),
|
||||
deployment: createDeploymentRepo(db),
|
||||
project: createProjectRepo(db, encryption),
|
||||
deployment: createDeploymentRepo(db, encryption),
|
||||
domain: createDomainRepo(db),
|
||||
dnsCredential: createDnsCredentialRepo(db),
|
||||
credential: createCredentialRepo(db),
|
||||
@@ -365,7 +370,7 @@ export function createRepositories(db: Database) {
|
||||
projectConnection: createProjectConnectionRepo(db),
|
||||
customAppTemplate: createCustomAppTemplateRepo(db),
|
||||
webhookDelivery: createWebhookDeliveryRepo(db),
|
||||
service: createServiceRepo(db),
|
||||
service: createServiceRepo(db, encryption),
|
||||
serviceDeployment: createServiceDeploymentRepo(db),
|
||||
settings: createSettingsRepo(db),
|
||||
instanceSettings: createInstanceSettingsRepo(db),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from "./factory";
|
||||
import { createRepositories } from "./factory";
|
||||
import { db } from "../client";
|
||||
import { db, storageEncryption } from "../client";
|
||||
|
||||
export const repos = createRepositories(db);
|
||||
export const repos = createRepositories(db, storageEncryption);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "../encryption";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
@@ -30,7 +31,7 @@ async function freshDb() {
|
||||
const db = drizzle(client, { schema });
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
await client.exec("SET session_replication_role = replica;");
|
||||
return { db, projectRepo: createProjectRepo(db) };
|
||||
return { db, projectRepo: createProjectRepo(db, createEncryption("repository-test-secret")) };
|
||||
}
|
||||
|
||||
type Db = Awaited<ReturnType<typeof freshDb>>["db"];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "../encryption";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createDatabase, createRepositories, schema, type DatabaseConnection } from "../factory";
|
||||
|
||||
@@ -6,7 +7,7 @@ describe("atomic project creation and credential access", () => {
|
||||
let repos: ReturnType<typeof createRepositories>;
|
||||
beforeAll(async () => {
|
||||
connection = await createDatabase({ driver: "pglite", dataDir: "memory://" });
|
||||
repos = createRepositories(connection.db);
|
||||
repos = createRepositories(connection.db, createEncryption("repository-test-secret"));
|
||||
await connection.db.insert(schema.organization).values({ id: "org", name: "Test" });
|
||||
}, 30_000);
|
||||
afterAll(async () => { await connection?.close(); });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "../encryption";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
@@ -22,7 +23,7 @@ async function freshRepo() {
|
||||
const db = drizzle(client, { schema });
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
await client.exec("SET session_replication_role = replica;"); // skip FK seeding
|
||||
return { db, repo: createProjectRepo(db) };
|
||||
return { db, repo: createProjectRepo(db, createEncryption("repository-test-secret")) };
|
||||
}
|
||||
|
||||
async function seed(db: Awaited<ReturnType<typeof freshRepo>>["db"]) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq, and, isNull, isNotNull, inArray, desc, sql, type SQL } from "drizzle-orm";
|
||||
import { generateId, ForbiddenError, UnauthorizedError } from "@repo/core";
|
||||
import type { Database } from "../client";
|
||||
import type { Database } from "../connection";
|
||||
import { createConfigurationSecrets, type ConfigurationEncryption } from "../configuration-secrets";
|
||||
import { project, projectGroup, envVar, deployment, service } from "../schema";
|
||||
import { member } from "../schema/organization";
|
||||
// Cloning a project writes its group and service rows in the same transaction, so this repo
|
||||
@@ -93,7 +94,8 @@ export async function rebindGitHubInstallationRows(
|
||||
|
||||
// ─── Repository ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function createProjectRepo(db: Database) {
|
||||
export function createProjectRepo(db: Database, encryption: ConfigurationEncryption) {
|
||||
const codec = createConfigurationSecrets(encryption);
|
||||
return {
|
||||
// ── Projects ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -437,7 +439,7 @@ export function createProjectRepo(db: Database) {
|
||||
input.services.map((svc) => ({
|
||||
id: serviceIdBySourceId[svc.sourceId]!,
|
||||
projectId,
|
||||
...svc.row,
|
||||
...codec.sealService(svc.row),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "../encryption";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { drizzle } from "drizzle-orm/pglite";
|
||||
@@ -33,7 +34,7 @@ async function fresh() {
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
// Seed rows without the full org→project→service FK chain.
|
||||
await client.exec("SET session_replication_role = replica;");
|
||||
return { db, repo: createServiceRepo(db) };
|
||||
return { db, repo: createServiceRepo(db, createEncryption("repository-test-secret")) };
|
||||
}
|
||||
|
||||
const DEP = "dep_1";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createEncryption } from "../encryption";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { drizzle } from "drizzle-orm/pglite";
|
||||
@@ -30,7 +31,7 @@ async function fresh() {
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
// Seed rows without the full org→project→service FK chain.
|
||||
await client.exec("SET session_replication_role = replica;");
|
||||
return { db, repo: createServiceRepo(db) };
|
||||
return { db, repo: createServiceRepo(db, createEncryption("repository-test-secret")) };
|
||||
}
|
||||
|
||||
const DEP = "dep_1";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createConfigurationSecrets } from "../configuration-secrets";
|
||||
import { createEncryption } from "../encryption";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
composeWritePatch,
|
||||
@@ -8,6 +10,9 @@ import {
|
||||
} from "./service.repo";
|
||||
import type { Database } from "../client";
|
||||
|
||||
const testEncryption = createEncryption("repository-test-secret");
|
||||
const configuration = createConfigurationSecrets(testEncryption);
|
||||
|
||||
const multiRoute = [
|
||||
{ port: 3210, domainType: "free" as const, domain: "acme-backend" },
|
||||
{ port: 3211, domainType: "free" as const, domain: "acme-backend-http" },
|
||||
@@ -81,13 +86,13 @@ describe("reconcileFromCompose keeps the route set", () => {
|
||||
query: { service: { findMany: async () => [{ ...existing, importedSpec }] } },
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => {
|
||||
writes.push(data);
|
||||
writes.push(configuration.openService(data));
|
||||
return { where: async () => undefined };
|
||||
},
|
||||
}),
|
||||
} as unknown as Database;
|
||||
|
||||
await createServiceRepo(db).reconcileFromCompose("proj_1", [
|
||||
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [
|
||||
{ name: "backend", image: "convex:2" },
|
||||
]);
|
||||
|
||||
@@ -123,13 +128,13 @@ describe("reconcileFromCompose bootstraps dynamic env provenance (#673)", () =>
|
||||
},
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => {
|
||||
writes.push(data);
|
||||
writes.push(configuration.openService(data));
|
||||
return { where: async () => undefined };
|
||||
},
|
||||
}),
|
||||
} as unknown as Database;
|
||||
|
||||
await createServiceRepo(db).reconcileFromCompose("proj_1", [
|
||||
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [
|
||||
{
|
||||
name: "api",
|
||||
environment: {
|
||||
@@ -207,13 +212,13 @@ describe("legacy compose provenance baselines", () => {
|
||||
},
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => {
|
||||
writes.push(data);
|
||||
writes.push(configuration.openService(data));
|
||||
return { where: async () => undefined };
|
||||
},
|
||||
}),
|
||||
} as unknown as Database;
|
||||
|
||||
const result = await createServiceRepo(db).reconcileFromCompose("proj_1", [parsedNow]);
|
||||
const result = await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [parsedNow]);
|
||||
|
||||
expect(result.driftedNames).toEqual([]);
|
||||
expect(writes).toHaveLength(1);
|
||||
@@ -251,13 +256,13 @@ describe("legacy compose provenance baselines", () => {
|
||||
},
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => {
|
||||
writes.push(data);
|
||||
writes.push(configuration.openService(data));
|
||||
return { where: async () => undefined };
|
||||
},
|
||||
}),
|
||||
} as unknown as Database;
|
||||
|
||||
await createServiceRepo(db).reconcileFromCompose("proj_1", [parsedWithImageTemplate]);
|
||||
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [parsedWithImageTemplate]);
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0].advanced).toBeUndefined();
|
||||
@@ -335,7 +340,7 @@ describe("Compose image provenance (#809)", () => {
|
||||
query: { service: { findMany: async () => [row] } },
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => ({
|
||||
where: async () => writes.push(data),
|
||||
where: async () => writes.push(configuration.openService(data)),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Database;
|
||||
@@ -345,7 +350,7 @@ describe("Compose image provenance (#809)", () => {
|
||||
advanced: stored.advanced,
|
||||
};
|
||||
|
||||
await createServiceRepo(db).syncFromCompose("proj_1", [parsed], {
|
||||
await createServiceRepo(db, testEncryption).syncFromCompose("proj_1", [parsed], {
|
||||
removeMissing: false,
|
||||
composeAuthoritative: true,
|
||||
});
|
||||
@@ -376,7 +381,7 @@ describe("Compose image provenance (#809)", () => {
|
||||
query: { service: { findMany: async () => [row] } },
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => ({
|
||||
where: async () => writes.push(data),
|
||||
where: async () => writes.push(configuration.openService(data)),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Database;
|
||||
@@ -392,7 +397,7 @@ describe("Compose image provenance (#809)", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await createServiceRepo(db).reconcileFromCompose("proj_1", [parsed]);
|
||||
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [parsed]);
|
||||
return writes[0];
|
||||
};
|
||||
|
||||
@@ -430,13 +435,13 @@ describe("reconcileFromCompose bootstraps legacy build args (#689)", () => {
|
||||
query: { service: { findMany: async () => [{ ...row, importedSpec: oldBaseline }] } },
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => {
|
||||
writes.push(data);
|
||||
writes.push(configuration.openService(data));
|
||||
return { where: async () => undefined };
|
||||
},
|
||||
}),
|
||||
} as unknown as Database;
|
||||
|
||||
await createServiceRepo(db).reconcileFromCompose("proj_1", [
|
||||
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [
|
||||
{ name: "api", image: "example/api:1" },
|
||||
]);
|
||||
|
||||
@@ -484,13 +489,13 @@ describe("reconcileFromCompose bootstraps legacy build args (#689)", () => {
|
||||
},
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => {
|
||||
writes.push(data);
|
||||
writes.push(configuration.openService(data));
|
||||
return { where: async () => undefined };
|
||||
},
|
||||
}),
|
||||
} as unknown as Database;
|
||||
|
||||
await createServiceRepo(db).reconcileFromCompose("proj_1", [
|
||||
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [
|
||||
{
|
||||
name: "api",
|
||||
build: ".",
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
resolveCommandArgv,
|
||||
type ComposeAdvanced,
|
||||
} from "@repo/core";
|
||||
import type { Database } from "../client";
|
||||
import type { Database } from "../connection";
|
||||
import { createConfigurationSecrets, type ConfigurationEncryption } from "../configuration-secrets";
|
||||
import { project, service, serviceDeployment } from "../schema";
|
||||
import type { ComposeServiceSpec, ServicePublicEndpoint } from "../schema/service";
|
||||
|
||||
@@ -468,14 +469,15 @@ export function normalizeRoutingFields(input: {
|
||||
|
||||
// ─── Repository ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function createServiceRepo(db: Database) {
|
||||
export function createServiceRepo(db: Database, encryption: ConfigurationEncryption) {
|
||||
const codec = createConfigurationSecrets(encryption);
|
||||
return {
|
||||
// ── Services ───────────────────────────────────────────────────────
|
||||
|
||||
async findById(id: string) {
|
||||
return db.query.service.findFirst({
|
||||
return codec.openService(await db.query.service.findFirst({
|
||||
where: eq(service.id, id),
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
/** Batch id → display name, for naming services in list responses. */
|
||||
@@ -488,16 +490,16 @@ export function createServiceRepo(db: Database) {
|
||||
},
|
||||
|
||||
async findByName(projectId: string, name: string) {
|
||||
return db.query.service.findFirst({
|
||||
return codec.openService(await db.query.service.findFirst({
|
||||
where: and(eq(service.projectId, projectId), eq(service.name, name)),
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
return db.query.service.findMany({
|
||||
return (await db.query.service.findMany({
|
||||
where: eq(service.projectId, projectId),
|
||||
orderBy: [asc(service.sortOrder), asc(service.name)],
|
||||
});
|
||||
})).map(codec.openService);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -535,10 +537,10 @@ export function createServiceRepo(db: Database) {
|
||||
*/
|
||||
async listByProjects(projectIds: string[]): Promise<Map<string, Service[]>> {
|
||||
if (projectIds.length === 0) return new Map();
|
||||
const rows = await db.query.service.findMany({
|
||||
const rows = (await db.query.service.findMany({
|
||||
where: inArray(service.projectId, projectIds),
|
||||
orderBy: [asc(service.sortOrder), asc(service.name)],
|
||||
});
|
||||
})).map(codec.openService);
|
||||
const out = new Map<string, Service[]>();
|
||||
for (const id of projectIds) out.set(id, []);
|
||||
for (const row of rows) {
|
||||
@@ -553,14 +555,14 @@ export function createServiceRepo(db: Database) {
|
||||
// Return the persisted defaults and timestamps. Synthesizing a Service
|
||||
// from the input omitted fields such as namespaceVolumes and made create
|
||||
// disagree with the next read of the same row.
|
||||
const [row] = await db.insert(service).values({ id, ...data }).returning();
|
||||
return row!;
|
||||
const [row] = await db.insert(service).values(codec.sealService({ id, ...data })).returning();
|
||||
return codec.openService(row!);
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<NewService>) {
|
||||
await db
|
||||
.update(service)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.set(codec.sealService({ ...data, updatedAt: new Date() }))
|
||||
.where(eq(service.id, id));
|
||||
},
|
||||
|
||||
@@ -582,10 +584,10 @@ export function createServiceRepo(db: Database) {
|
||||
|
||||
/** List only the rows of one kind under a project. */
|
||||
async listByProjectKind(projectId: string, kind: "compose" | "monorepo") {
|
||||
return db.query.service.findMany({
|
||||
return (await db.query.service.findMany({
|
||||
where: and(eq(service.projectId, projectId), eq(service.kind, kind)),
|
||||
orderBy: [asc(service.sortOrder), asc(service.name)],
|
||||
});
|
||||
})).map(codec.openService);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { createPrivateKey } from "crypto";
|
||||
import { DEFAULT_ENCRYPTION_SECRET as DEFAULT_BETTER_AUTH_SECRET } from "@repo/db/encryption";
|
||||
import {
|
||||
runtimeTarget,
|
||||
runtimeTargetId,
|
||||
@@ -11,8 +12,6 @@ import {
|
||||
|
||||
export { runtimeTarget, runtimeTargetId, cloudRuntimeTarget, cloudRuntimeTargetId };
|
||||
|
||||
const DEFAULT_BETTER_AUTH_SECRET = "change-me-in-production";
|
||||
|
||||
/**
|
||||
* Parse a string env var as boolean. Accepts "true"/"1" → true,
|
||||
* "false"/"0"/"" → false. Defaults match the surrounding semantics.
|
||||
|
||||
@@ -334,7 +334,7 @@ export function maskScanService<T extends Parameters<typeof publicScanService>[0
|
||||
* (`meta.composeServices[].environment` and `buildArgs`). Returns a copy — the stored row/meta
|
||||
* is untouched (rollback/redeploy read the real values back). Apply at the
|
||||
* shared presentation boundary: `getDeployment` is also used internally and must
|
||||
* keep plaintext. No-op when there's no `meta.composeServices`.
|
||||
* use the decrypted repository values. No-op when there's no `meta.composeServices`.
|
||||
*/
|
||||
export function maskDeploymentEnv<T extends { meta?: unknown } | null | undefined>(dep: T): T {
|
||||
if (
|
||||
|
||||
@@ -83,9 +83,9 @@ export async function parseRepoCompose(
|
||||
// NB: we deliberately do NOT read the repo's `.env` for `${VAR}` interpolation.
|
||||
// Secrets live in Openship's ENCRYPTED env store — captured from the running
|
||||
// container for adopted services, or set via the wizard/env UI for new ones —
|
||||
// never a committed repo file. Pulling a `.env` here would drop those values
|
||||
// into the PLAINTEXT service.environment column. So a bare `${VAR}` with no
|
||||
// inline default resolves to "" and the real value comes from the env store.
|
||||
// never a committed repo file. Pulling a `.env` here would pin those values
|
||||
// as service overrides, hiding later rotations in the project store. A bare
|
||||
// `${VAR}` without an inline default resolves to "" and defers to that store.
|
||||
for (const file of REPO_COMPOSE_FILES) {
|
||||
let content: string | null = null;
|
||||
try {
|
||||
|
||||
@@ -60,6 +60,7 @@ try {
|
||||
const { configureNativeSourceRoots } = await import("./engine/native/source-policy");
|
||||
configureNativeSourceRoots(options.policy?.sourceRoots ?? []);
|
||||
await identities.bindInstallation(options.instanceId, options.encryptionKey);
|
||||
await database.repos.configurationSecrets.backfillLegacy();
|
||||
await adapters.initPlatform(resolvePlatformConfig());
|
||||
const kernel = getPlatformKernel();
|
||||
let started = false, draining = false, finalizing = false;
|
||||
|
||||
@@ -1,127 +1,9 @@
|
||||
/**
|
||||
* Application-level encryption for sensitive data (env vars, secrets).
|
||||
*
|
||||
* Uses AES-256-GCM (authenticated encryption) via Node.js built-in crypto.
|
||||
* The encryption key is derived from BETTER_AUTH_SECRET (which every install
|
||||
* already has) - no extra env var needed.
|
||||
*
|
||||
* Format: base64( iv:16 || authTag:16 || ciphertext )
|
||||
*
|
||||
* Usage:
|
||||
* import { encrypt, decrypt } from "../lib/encryption";
|
||||
* const sealed = encrypt("my secret");
|
||||
* const plain = decrypt(sealed); // "my secret"
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
|
||||
// ─── Key derivation ──────────────────────────────────────────────────────────
|
||||
|
||||
const ALGORITHM = "aes-256-gcm" as const;
|
||||
const IV_LENGTH = 16;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seal a plaintext under an EXPLICIT 32-byte key. The single AES-256-GCM
|
||||
* implementation for the whole app — `encrypt()` uses the instance key,
|
||||
* while callers with a different key source (e.g. a passphrase-derived key
|
||||
* for data export) reuse this so the cipher/format never diverges.
|
||||
* Returns base64( iv:16 || authTag:16 || ciphertext ).
|
||||
*/
|
||||
export function encryptWithKey(key: Buffer, plaintext: string): string {
|
||||
return encryptBytesWithKey(key, Buffer.from(plaintext, "utf8")).toString("base64");
|
||||
}
|
||||
|
||||
/** Binary twin used by bounded transfer chunks; keeps bytes binary on the wire. */
|
||||
export function encryptBytesWithKey(key: Buffer, plaintext: Uint8Array): Buffer {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, authTag, encrypted]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a value produced by `encryptWithKey()` (or `encrypt()`), using an
|
||||
* explicit key. Throws if the data is tampered with or the key is wrong.
|
||||
*/
|
||||
export function decryptWithKey(key: Buffer, sealed: string): string {
|
||||
return decryptBytesWithKey(key, Buffer.from(sealed, "base64")).toString("utf8");
|
||||
}
|
||||
|
||||
/** Open one binary AES-GCM chunk without converting its payload through UTF-8. */
|
||||
export function decryptBytesWithKey(key: Buffer, sealed: Uint8Array): Buffer {
|
||||
const packed = Buffer.from(sealed);
|
||||
if (packed.length < IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||
throw new Error("Invalid encrypted data: too short");
|
||||
}
|
||||
const iv = packed.subarray(0, IV_LENGTH);
|
||||
const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const ciphertext = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
/** One installation's secrets. The existing ciphertext format and key derivation are unchanged. */
|
||||
export function createEncryption(secret: string) {
|
||||
if (typeof secret !== "string" || !secret)
|
||||
throw new TypeError("An explicit encryption secret is required");
|
||||
const key = createHash("sha256").update(secret).digest();
|
||||
let closed = false;
|
||||
function getKey(): Buffer {
|
||||
if (closed) throw new Error("Encryption context is closed");
|
||||
return key;
|
||||
}
|
||||
/**
|
||||
* Encrypt a plaintext string under the instance key.
|
||||
* Returns a base64-encoded string containing IV + auth tag + ciphertext.
|
||||
*/
|
||||
function encrypt(plaintext: string): string {
|
||||
return encryptWithKey(getKey(), plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value produced by `encrypt()`.
|
||||
* Throws if the data is tampered with or the key is wrong.
|
||||
*/
|
||||
function decrypt(sealed: string): string {
|
||||
return decryptWithKey(getKey(), sealed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a Record<string, encryptedValue> → Record<string, plaintext>.
|
||||
* On decryption failure for a key, that key is omitted (not silently passed through).
|
||||
*/
|
||||
function decryptEnvMap(
|
||||
encrypted: Record<string, string>,
|
||||
onError?: (key: string, err: unknown) => void,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(encrypted)) {
|
||||
try {
|
||||
result[k] = decrypt(v);
|
||||
} catch (err) {
|
||||
onError?.(k, err);
|
||||
// Omit keys that fail decryption - never leak ciphertext into containers
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
encrypt,
|
||||
decrypt,
|
||||
decryptEnvMap,
|
||||
close() {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
key.fill(0);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
export type Encryption = ReturnType<typeof createEncryption>;
|
||||
/** Shared passive cipher; importing it never opens a database or reads process env. */
|
||||
export {
|
||||
createEncryption,
|
||||
encryptWithKey,
|
||||
decryptWithKey,
|
||||
encryptBytesWithKey,
|
||||
decryptBytesWithKey,
|
||||
type Encryption,
|
||||
} from "@repo/db/encryption";
|
||||
|
||||
Reference in New Issue
Block a user