diff --git a/packages/node/postgres/package.json b/packages/node/postgres/package.json index 2b901cc..4c34230 100644 --- a/packages/node/postgres/package.json +++ b/packages/node/postgres/package.json @@ -1,7 +1,7 @@ { "name": "@saga-ed/soa-postgres", "type": "module", - "version": "0.1.1", + "version": "0.1.2", "scripts": { "dev": "tsup --watch", "build": "tsup", diff --git a/packages/node/postgres/src/__tests__/postgres-provider.unit.test.ts b/packages/node/postgres/src/__tests__/postgres-provider.unit.test.ts index 7cc6907..d4e488e 100644 --- a/packages/node/postgres/src/__tests__/postgres-provider.unit.test.ts +++ b/packages/node/postgres/src/__tests__/postgres-provider.unit.test.ts @@ -206,4 +206,26 @@ describe('PostgresProvider config sources', () => { expect(state.options?.ssl).toBe(true); expect(state.options?.max).toBe(7); }); + + it('passes a CA-bundle ssl object straight through to pg (PostgresPoolConfig path)', async () => { + const ssl = { ca: '-----BEGIN CERTIFICATE-----\nMIIB...', rejectUnauthorized: true }; + await new PostgresProvider({ + instanceName: 'RdsDB', + host: 'rds.example', + port: 5432, + database: 'iam_db', + user: 'iam_api_app', + password: async () => 'iam-token', + ssl, + }).connect(); + + // pg forwards the object verbatim to tls.connect — the CA pin survives. + expect(state.options?.ssl).toEqual(ssl); + }); + + it('accepts and passes through an ssl object on the static schema path', async () => { + const ssl = { ca: 'PEM', rejectUnauthorized: true }; + await new PostgresProvider(baseConfig({ ssl })).connect(); + expect(state.options?.ssl).toEqual(ssl); + }); }); diff --git a/packages/node/postgres/src/aws-postgres-loader.ts b/packages/node/postgres/src/aws-postgres-loader.ts index d6415c1..982a134 100644 --- a/packages/node/postgres/src/aws-postgres-loader.ts +++ b/packages/node/postgres/src/aws-postgres-loader.ts @@ -90,11 +90,45 @@ export interface LoadPostgresConfigParams { region?: string; } +/** + * TLS options for a Postgres connection — a structural subset of Node's + * ``tls.ConnectionOptions`` that ``pg`` forwards verbatim to ``tls.connect``. + * + * Use this object form (instead of a bare ``true``) when the server cert + * chains to a root that is **not** in Node's default trust store — most + * notably to pin the Amazon RDS CA bundle for managed RDS: + * + * { + * ca: readFileSync(process.env.PG_RDS_CA_BUNDLE_PATH, 'utf8'), + * rejectUnauthorized: true, + * } + * + * ``ssl: true`` keeps full verification against Node's default CA bundle; + * ``ssl: false`` disables TLS (local dev). + */ +export interface PostgresSslConfig { + /** CA cert(s) to trust (PEM). Pin the RDS CA bundle here. */ + ca?: string | Buffer | Array; + /** Verify the server cert against `ca` / the default store. Defaults true. */ + rejectUnauthorized?: boolean; + /** Client cert (PEM) for mutual TLS. */ + cert?: string | Buffer | Array; + /** Client private key (PEM) for mutual TLS. */ + key?: string | Buffer | Array; + /** SNI / cert-identity hostname override. */ + servername?: string; +} + /** * `pg.Pool`-compatible config returned by the loader. The `password` * field is a union: a static string for dev (parity secret) or an * async callback for mirror/prod (mints IAM token per new pool * connection). `pg.Pool` natively accepts both shapes. + * + * `ssl` is `boolean | PostgresSslConfig`: the loader returns a bare + * `true`/`false`, but a consumer may override it with a {@link + * PostgresSslConfig} (e.g. pinning the RDS CA bundle) before handing the + * config to {@link PostgresProvider}. */ export interface PostgresPoolConfig { instanceName: string; @@ -103,7 +137,7 @@ export interface PostgresPoolConfig { database: string; user: string; password: string | (() => Promise); - ssl: boolean; + ssl: boolean | PostgresSslConfig; // Optional pool tuning. `PostgresProvider` applies conservative defaults // (matching `PostgresProviderSchema`) when these are omitted, so the diff --git a/packages/node/postgres/src/index.ts b/packages/node/postgres/src/index.ts index 34139ab..ac7ad55 100644 --- a/packages/node/postgres/src/index.ts +++ b/packages/node/postgres/src/index.ts @@ -10,6 +10,7 @@ export { export type { LoadPostgresConfigParams, PostgresPoolConfig, + PostgresSslConfig, } from './aws-postgres-loader.js'; export const POSTGRES_PROVIDER = Symbol.for('PostgresProvider'); diff --git a/packages/node/postgres/src/postgres-provider-config.ts b/packages/node/postgres/src/postgres-provider-config.ts index 6cc559d..3f189d2 100644 --- a/packages/node/postgres/src/postgres-provider-config.ts +++ b/packages/node/postgres/src/postgres-provider-config.ts @@ -1,5 +1,19 @@ import { z } from 'zod'; +/** + * TLS options for a static {@link PostgresProviderConfig}. JSON-friendly + * (PEM strings, not Buffers) since this path is parsed from a Secrets + * Manager payload. Mirrors {@link PostgresSslConfig} from the loader so + * both config shapes can pin a CA bundle (e.g. the Amazon RDS roots). + */ +export const PostgresSslSchema = z.object({ + ca: z.string().optional(), + rejectUnauthorized: z.boolean().optional(), + cert: z.string().optional(), + key: z.string().optional(), + servername: z.string().optional(), +}); + /** * Config schema for {@link PostgresProvider}. * @@ -24,9 +38,11 @@ export const PostgresProviderSchema = z.object({ username: z.string().min(1), password: z.string().min(1), - // TLS toggle. Required for managed RDS / RDS Proxy connections. - // Dev db-host containers can leave this off. - ssl: z.boolean().default(false), + // TLS: `true`/`false` toggle, or a CA-pinning object (see + // PostgresSslSchema) when the server cert chains to a root outside + // Node's default trust store. Required for managed RDS / RDS Proxy; + // dev db-host containers can leave this off. + ssl: z.union([z.boolean(), PostgresSslSchema]).default(false), // Optional pool tuning. Sensible defaults for an API service. poolSize: z.number().int().positive().default(10), diff --git a/packages/node/postgres/src/postgres-provider.ts b/packages/node/postgres/src/postgres-provider.ts index f7bbac2..f1add92 100644 --- a/packages/node/postgres/src/postgres-provider.ts +++ b/packages/node/postgres/src/postgres-provider.ts @@ -2,7 +2,10 @@ import 'reflect-metadata'; import { injectable } from 'inversify'; import { Pool, type PoolClient } from 'pg'; import type { PostgresProviderConfig } from './postgres-provider-config.js'; -import type { PostgresPoolConfig } from './aws-postgres-loader.js'; +import type { + PostgresPoolConfig, + PostgresSslConfig, +} from './aws-postgres-loader.js'; /** * Manages a single ``pg.Pool`` for one logical database. Provider is @@ -63,7 +66,7 @@ interface NormalizedConfig { database: string; user: string; password: string | (() => Promise); - ssl: boolean; + ssl: boolean | PostgresSslConfig; poolSize: number; idleTimeoutMs: number; connectionTimeoutMs: number; @@ -105,9 +108,12 @@ export class PostgresProvider { // specifically because pg ignores a discrete password when a // connectionString is also present — the callback would be lost. password: this.cfg.password, - // ssl:true => full cert verification, equivalent to the previous - // `sslmode=require` (which pg maps to {}). RDS certs chain to the - // Amazon roots in Node's CA bundle. ssl:false for local dev. + // ssl:true => full cert verification against Node's default CA + // bundle (equivalent to the previous `sslmode=require`, which pg + // maps to {}). Pass a PostgresSslConfig object instead to pin a CA + // bundle (e.g. the Amazon RDS roots, which aren't always in Node's + // default trust store) — pg forwards it verbatim to tls.connect. + // ssl:false for local dev. ssl: this.cfg.ssl, max: this.cfg.poolSize, idleTimeoutMillis: this.cfg.idleTimeoutMs,