fix: honor runtime db ssl mode in postgres adapter
This commit is contained in:
@@ -48,9 +48,12 @@ non-secret env flags:
|
||||
|
||||
The Postgres adapter uses `HWLAB_CLOUD_DB_URL` from
|
||||
`hwlab-cloud-api-dev-db/database-url`; source records only the Secret reference
|
||||
and non-secret DNS contract. The adapter reports `runtime.durable: true` only
|
||||
after the configured adapter proves all durable gates through read-only runtime
|
||||
checks:
|
||||
and non-secret DNS contract. `HWLAB_CLOUD_DB_SSL_MODE` is the runtime authority
|
||||
for DB TLS mode; if it is `disable`, the adapter strips URL `sslmode` query
|
||||
parameters before opening the Postgres pool so stale Secret URL parameters
|
||||
cannot override the DEV SSL contract. The adapter reports `runtime.durable: true`
|
||||
only after the configured adapter proves all durable gates through read-only
|
||||
runtime checks:
|
||||
|
||||
- auth/query access to the configured DB path;
|
||||
- required runtime table and column schema;
|
||||
|
||||
@@ -176,6 +176,11 @@ Postgres adapter may report `runtime.durableRequested=true`, but it still stays
|
||||
non-durable until the schema, migration ledger, and read readiness gates are
|
||||
proven through the configured adapter.
|
||||
|
||||
`HWLAB_CLOUD_DB_SSL_MODE` is the non-secret runtime authority for Postgres TLS
|
||||
mode. When DEV sets it to `disable`, the runtime strips stale URL `sslmode`,
|
||||
`ssl`, and certificate query parameters before constructing the `pg` pool; a
|
||||
Secret URL query parameter must not silently override the manifest SSL contract.
|
||||
|
||||
`/health` and `/health/live` expose this split in `readiness.durability`.
|
||||
`dbLiveEvidenceObserved=true` with
|
||||
`dbLiveEvidenceIsDurabilityEvidence=false` means the DB TCP/live layer is
|
||||
|
||||
@@ -68,6 +68,15 @@ export function createConfiguredCloudRuntimeStore(options = {}) {
|
||||
return createCloudRuntimeStore(options);
|
||||
}
|
||||
|
||||
export function buildPostgresPoolConfig({ dbUrl, sslMode = "require", timeoutMs } = {}) {
|
||||
const normalizedSslMode = normalizePostgresSslMode(sslMode);
|
||||
return {
|
||||
connectionString: normalizePostgresConnectionStringForSslMode(dbUrl, normalizedSslMode),
|
||||
ssl: postgresSslOption(normalizedSslMode),
|
||||
connectionTimeoutMillis: normalizeRuntimeTimeoutMs(timeoutMs)
|
||||
};
|
||||
}
|
||||
|
||||
export class CloudRuntimeStore {
|
||||
constructor({ now = () => new Date().toISOString() } = {}) {
|
||||
this.now = now;
|
||||
@@ -1139,11 +1148,11 @@ export class PostgresCloudRuntimeStore {
|
||||
throw driverError;
|
||||
}
|
||||
|
||||
this.pool = new Pool({
|
||||
connectionString: this.dbUrl,
|
||||
ssl: this.sslMode === "require" ? { rejectUnauthorized: false } : false,
|
||||
connectionTimeoutMillis: normalizeRuntimeTimeoutMs(this.env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS)
|
||||
});
|
||||
this.pool = new Pool(buildPostgresPoolConfig({
|
||||
dbUrl: this.dbUrl,
|
||||
sslMode: this.sslMode,
|
||||
timeoutMs: this.env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS
|
||||
}));
|
||||
return this.pool;
|
||||
}
|
||||
|
||||
@@ -1310,6 +1319,43 @@ function normalizeRuntimeAdapter(value) {
|
||||
return RUNTIME_STORE_KIND;
|
||||
}
|
||||
|
||||
function normalizePostgresSslMode(value) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (["disable", "false", "0", "off", "no"].includes(normalized)) {
|
||||
return "disable";
|
||||
}
|
||||
return "require";
|
||||
}
|
||||
|
||||
function postgresSslOption(sslMode) {
|
||||
return sslMode === "disable" ? false : { rejectUnauthorized: false };
|
||||
}
|
||||
|
||||
function normalizePostgresConnectionStringForSslMode(dbUrl, sslMode) {
|
||||
if (typeof dbUrl !== "string" || dbUrl.trim() === "") {
|
||||
return dbUrl;
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(dbUrl);
|
||||
} catch {
|
||||
return dbUrl;
|
||||
}
|
||||
|
||||
if (!["postgres:", "postgresql:"].includes(url.protocol)) {
|
||||
return dbUrl;
|
||||
}
|
||||
|
||||
for (const key of ["ssl", "sslmode", "sslcert", "sslkey", "sslrootcert"]) {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
if (sslMode !== "disable") {
|
||||
url.searchParams.set("sslmode", "require");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function postgresAdapterContract() {
|
||||
return {
|
||||
required: "Postgres-backed durable runtime adapter using HWLAB_CLOUD_DB_URL",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
RUNTIME_STORE_KIND_POSTGRES,
|
||||
buildPostgresPoolConfig,
|
||||
createCloudRuntimeStore,
|
||||
createConfiguredCloudRuntimeStore
|
||||
} from "./runtime-store.mjs";
|
||||
@@ -132,6 +133,63 @@ test("configured postgres runtime classifies SSL negotiation blocker separately
|
||||
assert.equal(JSON.stringify(readiness).includes("The server does not support SSL connections"), false);
|
||||
});
|
||||
|
||||
test("postgres pool config makes HWLAB_CLOUD_DB_SSL_MODE authoritative over URL sslmode", () => {
|
||||
const disabled = buildPostgresPoolConfig({
|
||||
dbUrl: "postgres://hwlab_user:fixture-pass@db.example.test:5432/hwlab?sslmode=require&application_name=hwlab",
|
||||
sslMode: "disable",
|
||||
timeoutMs: "2500"
|
||||
});
|
||||
|
||||
assert.equal(disabled.ssl, false);
|
||||
assert.equal(disabled.connectionTimeoutMillis, 2500);
|
||||
const disabledUrl = new URL(disabled.connectionString);
|
||||
assert.equal(disabledUrl.searchParams.get("sslmode"), null);
|
||||
assert.equal(disabledUrl.searchParams.get("ssl"), null);
|
||||
assert.equal(disabledUrl.searchParams.get("application_name"), "hwlab");
|
||||
|
||||
const required = buildPostgresPoolConfig({
|
||||
dbUrl: "postgres://hwlab_user:fixture-pass@db.example.test:5432/hwlab?sslmode=disable",
|
||||
sslMode: "require"
|
||||
});
|
||||
|
||||
assert.deepEqual(required.ssl, { rejectUnauthorized: false });
|
||||
assert.equal(new URL(required.connectionString).searchParams.get("sslmode"), "require");
|
||||
});
|
||||
|
||||
test("configured postgres runtime passes normalized pool config to pg", async () => {
|
||||
const pools = [];
|
||||
const store = createConfiguredCloudRuntimeStore({
|
||||
env: {
|
||||
HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres",
|
||||
HWLAB_CLOUD_RUNTIME_DURABLE: "true",
|
||||
HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS: "1800"
|
||||
},
|
||||
dbUrl: "postgres://hwlab_user:fixture-pass@db.example.test:5432/hwlab?sslmode=require",
|
||||
sslMode: "disable",
|
||||
pgModuleLoader: async () => ({
|
||||
Pool: class FakePool {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
pools.push(this);
|
||||
}
|
||||
|
||||
async query() {
|
||||
return { rows: [] };
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
await store.readiness();
|
||||
|
||||
assert.equal(pools.length, 1);
|
||||
assert.equal(pools[0].config.ssl, false);
|
||||
assert.equal(pools[0].config.connectionTimeoutMillis, 1800);
|
||||
assert.equal(new URL(pools[0].config.connectionString).searchParams.get("sslmode"), null);
|
||||
assert.equal(store.summary().blocker, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED);
|
||||
assert.equal(store.summary().durabilityContract.blockedLayer, "schema");
|
||||
});
|
||||
|
||||
test("configured postgres runtime classifies pg_hba no-encryption rejection as SSL before auth", async () => {
|
||||
const store = createConfiguredCloudRuntimeStore({
|
||||
env: {
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
buildPostgresPoolConfig
|
||||
} from "../../internal/db/runtime-store.mjs";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs";
|
||||
import { ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
||||
@@ -345,11 +346,11 @@ async function createPgPool(env, options) {
|
||||
driverError.code = "HWLAB_PG_DRIVER_MISSING";
|
||||
throw driverError;
|
||||
}
|
||||
return new Pool({
|
||||
connectionString: env.HWLAB_CLOUD_DB_URL,
|
||||
ssl: env.HWLAB_CLOUD_DB_SSL_MODE === "disable" ? false : { rejectUnauthorized: false },
|
||||
connectionTimeoutMillis: normalizeTimeoutMs(env.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS)
|
||||
});
|
||||
return new Pool(buildPostgresPoolConfig({
|
||||
dbUrl: env.HWLAB_CLOUD_DB_URL,
|
||||
sslMode: env.HWLAB_CLOUD_DB_SSL_MODE,
|
||||
timeoutMs: normalizeTimeoutMs(env.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS)
|
||||
}));
|
||||
}
|
||||
|
||||
async function rollbackQuietly(client) {
|
||||
|
||||
@@ -191,6 +191,31 @@ test("live dry-run closes pg pool after readiness verification", async () => {
|
||||
assert.ok(pools[0].calls.some((call) => call.type === "end"));
|
||||
});
|
||||
|
||||
test("live dry-run normalizes pg pool ssl config before readiness verification", async () => {
|
||||
const backingClient = createFakeQueryClient({ migrationReady: true });
|
||||
const pools = [];
|
||||
const report = await buildDevRuntimeMigrationReport(
|
||||
parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]),
|
||||
{
|
||||
env: {
|
||||
HWLAB_CLOUD_DB_URL: "postgresql://hwlab:fixture-pass@db.example.test:5432/hwlab?sslmode=require",
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "disable",
|
||||
HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS: "1600"
|
||||
},
|
||||
pgModuleLoader: async () => createFakePgModule({ backingClient, pools }),
|
||||
now: () => "2026-05-22T00:00:00.000Z"
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(report.conclusion.status, "ready");
|
||||
assert.equal(pools.length, 1);
|
||||
assert.equal(pools[0].config.ssl, false);
|
||||
assert.equal(pools[0].config.connectionTimeoutMillis, 1600);
|
||||
assert.equal(new URL(pools[0].config.connectionString).searchParams.get("sslmode"), null);
|
||||
assert.equal(JSON.stringify(report).includes("fixture-pass"), false);
|
||||
assert.equal(JSON.stringify(report).includes("db.example.test"), false);
|
||||
});
|
||||
|
||||
test("apply mode uses a dedicated pg client transaction", async () => {
|
||||
const backingClient = createFakeQueryClient({ migrationReady: false });
|
||||
const pools = [];
|
||||
|
||||
Reference in New Issue
Block a user