Skip to content

feat(bb-data): configurable TLS for external database connections#107

Draft
vishaalmehrishi wants to merge 9 commits into
mainfrom
feat/external-db-connection-options
Draft

feat(bb-data): configurable TLS for external database connections#107
vishaalmehrishi wants to merge 9 commits into
mainfrom
feat/external-db-connection-options

Conversation

@vishaalmehrishi

@vishaalmehrishi vishaalmehrishi commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What

Adds an ssl option to the connection-string form of fromExisting(...) and makes server-certificate verification the default for external PostgreSQL connections (Supabase, Neon, RDS). db pull captures the provider's CA so generated apps verify by default — including in the deployed Lambda — with no runtime configuration.

Previously the external-database paths hardcoded ssl: { rejectUnauthorized: false }, so there was no supported way to configure TLS verification for these connections.

Approach

The connection's TLS policy is now configurable and verifies by default, with the CA delivered so verification actually works at every layer:

  1. TypeExternalDatabaseRef's connection-string variant gains ssl?: { rejectUnauthorized?: boolean; ca?: string }.
  2. Runtime (index.aws.ts) — passes the caller's ssl through; PgClientEngine already defaults to { rejectUnauthorized: true }, so omitting ssl verifies.
  3. Local dev (index.mock.ts) — honors connection.ssl; keeps an unverified default only for self-signed local databases.
  4. db pull — prompts for the provider CA and writes it to a generated, committed database.ca.ts (a public, non-secret certificate — public key + issuer metadata, no private key). The generated supabase.ts pins it via a visible resolveDbSsl(). Because it's a bundled JS import (not a runtime file/env read), verification works in the deployed Lambda with no env/SSM/CDK plumbing. DATABASE_CA_CERT (inline PEM or path) overrides it; with no CA the wiring falls back to a visible, editable rejectUnauthorized: false.
  5. Operational paths (introspect, migrate CLI, external migrations) — resolve ssl via a shared externalDbSsl() helper and strip sslmode from the URL so a pinned CA takes effect (node pg ignores a programmatic ssl.ca when sslmode is present).

Why a committed cert (not an env var): .env.* is only loaded on the deploy host, never injected into the Lambda — so an env-only CA would leave the deployed runtime unverified. The CA isn't secret, so committing + bundling it is the simplest mechanism that verifies in production. It's regenerated on each db pull (rotation) and preserved when a re-pull doesn't re-supply it (no silent downgrade).

What a customer sees

npx bb-data pull now asks for the CA and explains what it does:

TLS verification (recommended): download your CA certificate from the Supabase
dashboard → Database Settings → SSL Configuration (prod-ca-2021.crt).
  Path to CA certificate [Enter to skip]: ./prod-ca-2021.crt
  ✓ CA captured. It will be written to aws-blocks/database.ca.ts (a public cert, committed with
    your app). The generated connection (resolveDbSsl in supabase.ts) pins it, so
    your database's TLS certificate is verified — both with `npm run dev` and in the
    deployed function (the file is bundled). Re-run `bb-data pull` to refresh a
    rotated CA. Details: MIGRATION_GUIDE.md → "Securing the connection (TLS)".

Generated aws-blocks/supabase.ts (the wiring — TLS policy is visible and editable):

import { DATABASE_CA_CERT } from './database.ca.js';

function resolveDbSsl(): { ca?: string; rejectUnauthorized: boolean } {
  // DATABASE_CA_CERT (inline PEM or a file path) overrides the committed cert.
  const envCa = process.env.DATABASE_CA_CERT;
  const ca = envCa
    ? (envCa.includes('-----BEGIN') ? envCa : readFileSync(envCa, 'utf8'))
    : (DATABASE_CA_CERT || undefined);
  if (ca) {
    console.log('[bb-data] DB TLS: verifying the server certificate against the pinned CA (verify-full equivalent).');
    return { ca, rejectUnauthorized: true };
  }
  console.warn('[bb-data] DB TLS: server certificate NOT verified — encrypted only (no CA). ...');
  return { rejectUnauthorized: false };
}

export const db = new Database(scope, 'db', {
  connection: fromExisting({ connectionString: { get: resolveConnString }, ssl: resolveDbSsl() }),
  rlsPolicy: 'enforce',
  schema: tableMeta,
});

Generated aws-blocks/database.ca.ts (regenerated each pull; the customer's actual project CA):

// Generated by `db pull` — safe to regenerate.
export const DATABASE_CA_CERT = `-----BEGIN CERTIFICATE-----
MII... (your project's CA)
-----END CERTIFICATE-----`;

How the customer uses it is unchanged — spread supabaseCrud into an ApiNamespace:

import { supabaseCrud } from './supabase.js';
export const api = new ApiNamespace(scope, 'api', (context) => ({
  ...supabaseCrud(context, auth),
}));

On first connect the runtime logs the resolved mode, e.g. [bb-data] DB TLS: verifying the server certificate against the pinned CA (verify-full equivalent).

Behavior change

For hand-written fromExisting({ connectionString }) with no ssl, the connection now verifies the server certificate (it previously did not). Private-CA providers (e.g. Supabase) require pinning the CA via ssl: { ca } (the certificate contents). Pass ssl: { rejectUnauthorized: false } to keep the old behavior explicitly. db pull-generated apps are unaffected in default connectivity. Released as a minor bump (see the changeset's upgrade note).

Testing

  • bb-data unit suite: 336/336 passing (engine default + explicit ssl, externalDbSsl, generateCaFile, resolveCaFileWrite incl. the preserve-on-re-pull regression, generated-wiring assertions, updated toSessionPortUrl).
  • db-pull-typecheck app tsc --noEmit passes against the regenerated snapshot (generated wiring imports the committed CA constant).
  • Not run in this environment: Biome lint and the live Supabase end-to-end job (no DB credentials) — both run in CI.

Notes

  • database.ca.ts holds a certificate only (no private key); the connection string and other secrets continue to flow through SSM, not the repo. The MIGRATION_GUIDE notes keeping it committed and that a separate production project may present its own CA.
  • Operational db pull introspection resolves ssl from the captured CA / environment (it runs on the operator host, not the Lambda).

Add an `ssl` option to the connection-string form of `fromExisting` and
make server-certificate verification the default for external Postgres
connections (Supabase, Neon, RDS, etc.).

Previously the external-DB paths hardcoded `ssl: { rejectUnauthorized: false }`,
so callers had no way to configure TLS verification. `PgClientEngine` already
defaults to `{ rejectUnauthorized: true }`; the runtime now passes the caller's
`ssl` through and defaults to that.

- types: add `ssl?: { rejectUnauthorized?; ca? }` to `ExternalDatabaseRef`
- runtime (index.aws.ts): pass `conn.ssl` through (verify by default)
- local dev (index.mock.ts): honor `connection.ssl`, keep a documented
  unverified default for self-signed local databases
- db pull: prompt for the provider CA and write it to a generated, committed
  `database.ca.ts` (a public, non-secret cert), refreshed on re-pull and
  preserved when not re-supplied; generated `supabase.ts` pins it via a visible
  `resolveDbSsl()` (verify-full equivalent), with a `DATABASE_CA_CERT` override
  (inline PEM or path) and a visible `rejectUnauthorized: false` fallback
- operational paths (introspect, migrate CLI, migrations) resolve ssl via a
  shared `externalDbSsl()` helper; strip `sslmode` so a pinned CA takes effect
- docs: README + generated MIGRATION_GUIDE document CA provisioning
- tests: engine default + explicit ssl, `externalDbSsl`, `generateCaFile`,
  generated-wiring assertions; regenerated db-pull-typecheck snapshot
…re-pull test

- introspect: strip sslmode via URL.searchParams (not regex); accept an
  optional CA so `db pull` introspection verifies when one is provided
- db pull: extract the database.ca.ts write decision into a tested
  `resolveCaFileWrite` helper that preserves an existing CA on re-pull
- docs: DESIGN.md ssl-default parity row; DECISIONS.md TLS note updated to
  reflect this change; MIGRATION_GUIDE notes (keep database.ca.ts committed;
  separate prod project may need its own CA); README example reads the cert
  contents instead of passing a possibly-undefined env var
- changeset: add upgrade/remediation note for direct fromExisting callers
- tests: resolveCaFileWrite (incl. preserve-on-re-pull regression)
- regenerate API report (adds ssl to ExternalDatabaseRef)
- db pull: the CA-captured confirmation now explains where the cert is
  written (database.ca.ts), that it's bundled and pins the connection, and
  that it applies in dev and when deployed
- generated wiring: resolveDbSsl() logs the resolved TLS mode on first
  connect (verifying with pinned CA vs not verified) so success isn't silent
- test + regenerated db-pull-typecheck snapshot
Comment thread packages/bb-data/src/db-pull/templates.ts
Comment thread packages/bb-data/src/external-ssl.ts
Comment thread packages/bb-data/src/db-pull/templates.ts Outdated
Comment thread packages/bb-data/src/external-ssl.ts Outdated
Comment thread packages/bb-data/src/index.mock.ts Outdated
Comment thread packages/bb-data/src/external-ssl.ts Outdated
Comment thread packages/bb-data/src/types.ts Outdated
Comment thread packages/bb-data/src/db-pull/pull.ts Outdated

@soberm soberm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens with an app that used a prior version? Will it keep ssl off?

@vishaalmehrishi

Copy link
Copy Markdown
Contributor Author

What happens with an app that used a prior version? Will it keep ssl off?

Two cases:

  • db pull-generated apps: the verified wiring (resolveDbSsl() + the committed database.ca.ts) is only emitted on a re-pull. An app generated before this change keeps its existing supabase.ts (no ssl) until the next npx bb-data pull — so yes, it stays ssl-off until re-pulled. A re-pull regenerates the wiring and prompts for the CA.
  • Hand-written fromExisting({ connectionString }) with no ssl: behavior changes to verify by default. A private-CA provider (e.g. Supabase) will now fail until the CA is pinned via ssl: { ca } — or pass ssl: { rejectUnauthorized: false } to keep the old behavior explicitly. This is captured as an upgrade note in the changeset (minor bump).

Happy to add the "re-pull to pick up verification" note to the generated migration guide so existing-app users aren't surprised.

Comment thread packages/bb-data/src/index.aws.ts
Comment thread packages/bb-data/src/db-pull/introspect.ts Outdated
Comment thread packages/bb-data/src/engines/pg-client-engine.ts Outdated
Resolve the three review threads on PR #107:

- PgClientEngineConfig.ssl now reuses the public ExternalSslOptions
  discriminated union (intersected with minVersion), so the misleading
  { ca, rejectUnauthorized: false } combination is a compile error for
  direct engine callers too, not only fromExisting.
- externalDbSsl() returns ExternalSslOptions; extract a shared
  resolveCaPem() helper (inline PEM or file path) so the PEM-vs-path
  handling can't drift between the operational paths and db pull.
- Route db-pull schema introspection through PgClientEngine instead of
  a raw pg.Pool, so it inherits the TLS 1.2 floor and shared CA handling.
- Add an aws-layer test asserting Database._initBase forwards conn.ssl:
  verify-by-default when ssl is omitted, explicit ssl forwarded.

Regenerate API.md for the PgClientEngineConfig.ssl shape change.
The TLS handshake is lazy (it happens on the first query), so a
config-time log only states intent, not what actually happened. Add a
pure tlsConnectionMessage() helper plus a pool 'connect' listener that
logs exactly once, after the handshake succeeds, whether the server
certificate was verified (against a pinned CA or Node's trust store) or
the connection is encrypted-only. This covers every path that builds a
PgClientEngine, including db-pull introspection.

Reword the generated resolveDbSsl() log to match ("will verify ... on
connect") and regenerate the db-pull-typecheck snapshot.
@vishaalmehrishi

Copy link
Copy Markdown
Contributor Author

What happens with an app that used a prior version? Will it keep ssl off?

Good question — it's a deliberate behavior change, and it splits by how the app connects:

  • db pull-generated apps: unaffected in default connectivity. The generated supabase.ts wiring (resolveDbSsl()) is regenerated on each pull and supplies an explicit ssl config (pins the captured CA, or a visible commented opt-out), so upgrading the library doesn't silently change their behavior.
  • Hand-written fromExisting({ connectionString }) with no ssl: this now verifies the server certificate (it previously hardcoded rejectUnauthorized: false). For a private-CA provider like Supabase that means the connection will fail until they pin the CA via ssl: { ca } — which is the intent: the old default silently accepted any certificate (MITM-exposed). Passing ssl: { rejectUnauthorized: false } keeps the old behavior explicitly.

Shipped as a minor with an upgrade/remediation note in the changeset so the break is discoverable. So no — it does not keep ssl off by default anymore; that default was the vulnerability.

`db pull` threads the captured CA into introspection but not into baseline
generation, so the schema-baseline step connected unverified even when the
operator supplied a CA. Thread `caCert` through to `generateBaseline()`:

- the `server_version_num` check now connects with the pinned CA
  (`resolveCaPem` → `{ ca, rejectUnauthorized: true }`) instead of the
  env-only `externalDbSsl()` fallback;
- `pg_dump` runs with `PGSSLMODE=verify-full` + `PGSSLROOTCERT` pointing at
  the CA written to a short-lived 0600 temp file (removed in a finally).

With no CA, both fall back to the prior encrypted-but-unverified behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants