@malloydata/db-postgres 0.0.422 → 0.0.424
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/postgres_connection.d.ts +47 -1
- package/dist/postgres_connection.js +46 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,3 +5,32 @@ Malloy is a modern open source language for describing data relationships and tr
|
|
|
5
5
|
## This package
|
|
6
6
|
|
|
7
7
|
This package facilitates using the `malloydata/malloy` library with Postgres - see [here](https://github.com/malloydata/malloy/blob/main/packages/malloy/README.md) for additional information.
|
|
8
|
+
|
|
9
|
+
## Verified TLS through a tunnel
|
|
10
|
+
|
|
11
|
+
`PostgresConnectionConfiguration.ssl` is forwarded verbatim to `pg`'s TLS options. To get servername-based certificate verification through a local tunnel (e.g. an SSH bastion forwarding to a remote Postgres), connect via the loopback IP rather than `localhost` — `pg` overwrites `servername` with `host` whenever `host` is a DNS name. If the certificate doesn't match the host `pg` connects to, `pg` throws a certificate error and the connector annotates it with this servername/host guidance.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {PostgresConnection} from '@malloydata/db-postgres';
|
|
15
|
+
|
|
16
|
+
const connection = new PostgresConnection({
|
|
17
|
+
name: 'tunneled_postgres',
|
|
18
|
+
host: '127.0.0.1', // must be an IP, not 'localhost', for servername to take effect
|
|
19
|
+
port: 5432,
|
|
20
|
+
ssl: {
|
|
21
|
+
servername: 'db.example.com',
|
|
22
|
+
ca: '<PEM-encoded CA certificate>',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Semantics (pg/Node `tls`, not libpq)
|
|
28
|
+
|
|
29
|
+
- `ssl: true` — full verification against the system trust store, hostname included (verify-full). A self-signed or custom-CA server fails unless you supply `ca` (and/or set `servername` for a tunnel).
|
|
30
|
+
- `ssl: { rejectUnauthorized: false }` — encrypt but do **not** verify (libpq's `sslmode=require`/`no-verify`). Use only when verification is impossible; it does not stop MITM.
|
|
31
|
+
- `ssl: { ca }` — trust a specific CA (a PEM string, or an array of PEMs for rotation). This is the path for AWS RDS (or use `NODE_EXTRA_CA_CERTS`) and Cloud SQL.
|
|
32
|
+
- **verify-ca** against a cert whose SAN/CN doesn't match any reachable name (e.g. Cloud SQL *legacy* per-instance certs) needs a `checkServerIdentity` override. That is a function, so it is **not** expressible in a saved `json` config — construct the connection programmatically and pass the full `pg` `ssl` (`tls.ConnectionOptions`) directly.
|
|
33
|
+
|
|
34
|
+
Do **not** put TLS parameters in both `ssl` and the `connectionString`: `pg` merges URL ssl params (`sslmode`, `sslrootcert`, …) *over* the `ssl` object, silently dropping it. Set TLS in one place.
|
|
35
|
+
|
|
36
|
+
`ssl` is passed through literally — `json` config is never reference-resolved (a malloy security invariant), so `key`/`passphrase` cannot be pulled from an `{env:...}`/overlay reference. Inject secret material programmatically at construction time; never persist it in a shared connection config.
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -46,6 +46,14 @@ const postgres_connection_2 = require("./postgres_connection");
|
|
|
46
46
|
advanced: true,
|
|
47
47
|
description: 'SQL statements to run when the connection is established',
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
name: 'ssl',
|
|
51
|
+
displayName: 'SSL',
|
|
52
|
+
type: 'json',
|
|
53
|
+
optional: true,
|
|
54
|
+
advanced: true,
|
|
55
|
+
description: 'TLS/SSL options forwarded to pg, e.g. {"servername":"db.example.com","ca":"<PEM>"}. Passed through literally (json config is never reference-resolved), so do not place secret key/passphrase material in shared config.',
|
|
56
|
+
},
|
|
49
57
|
],
|
|
50
58
|
});
|
|
51
59
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
import type { Connection, ConnectionConfig, MalloyQueryData, PersistSQLResults, PooledConnection, QueryRecord, QueryOptionsReader, QueryRunStats, RunSQLOptions, SQLSourceDef, TableSourceDef, StreamingConnection, SQLSourceRequest } from '@malloydata/malloy';
|
|
2
2
|
import { BaseConnection } from '@malloydata/malloy/connection';
|
|
3
3
|
import { Client, Pool } from 'pg';
|
|
4
|
+
import type { ClientConfig } from 'pg';
|
|
5
|
+
/**
|
|
6
|
+
* Serializable TLS options for a Postgres connection, forwarded verbatim into
|
|
7
|
+
* pg's native `Object.assign(clientOptions, ssl)`. This is the subset that can
|
|
8
|
+
* live in a saved/registry connection config (all PEM strings + booleans).
|
|
9
|
+
*
|
|
10
|
+
* Semantics (these mirror pg / Node `tls`, NOT libpq):
|
|
11
|
+
* - `ssl: true` (or `rejectUnauthorized: true`, the default) → full
|
|
12
|
+
* verification against the trust store, including hostname (verify-full).
|
|
13
|
+
* - `rejectUnauthorized: false` → encrypt but do NOT verify (libpq's
|
|
14
|
+
* `sslmode=require` / `no-verify`); use only when you can't verify.
|
|
15
|
+
* - `ca` pins a trusted CA (PEM, or an array of PEMs).
|
|
16
|
+
*
|
|
17
|
+
* `servername` only takes effect when the connection `host` is an IP literal
|
|
18
|
+
* (e.g. `127.0.0.1`) — pg overwrites `servername` with `host` whenever `host`
|
|
19
|
+
* is a DNS name like `localhost`, so servername-based verification through a
|
|
20
|
+
* tunnel requires connecting via IP. When the certificate doesn't match the
|
|
21
|
+
* host pg connected to, pg throws; the connector annotates that error with
|
|
22
|
+
* this servername/host guidance.
|
|
23
|
+
*
|
|
24
|
+
* verify-ca against a cert with no matching SAN/CN (e.g. Cloud SQL legacy
|
|
25
|
+
* per-instance certs) needs `checkServerIdentity`, a function — not
|
|
26
|
+
* expressible here or in `type: 'json'` config. Pass the full pg `ssl`
|
|
27
|
+
* (`tls.ConnectionOptions`) via the config-reader constructor form
|
|
28
|
+
* (`new PostgresConnection(name, queryOptions, () => ({..., ssl}))`) for that
|
|
29
|
+
* case; the serializable options object is limited to this subset.
|
|
30
|
+
*
|
|
31
|
+
* `ssl` is passed through literally — `type: 'json'` config is never
|
|
32
|
+
* reference-resolved (a malloy security invariant), so secret `key`/
|
|
33
|
+
* `passphrase` material cannot be pulled from an `{env:...}`/overlay
|
|
34
|
+
* reference. Inject secrets programmatically at construction; never persist
|
|
35
|
+
* them in a shared connection config. Secrets are never logged.
|
|
36
|
+
*/
|
|
37
|
+
export type PostgresSSLConfig = {
|
|
38
|
+
ca?: string | string[];
|
|
39
|
+
cert?: string;
|
|
40
|
+
key?: string;
|
|
41
|
+
passphrase?: string;
|
|
42
|
+
crl?: string | string[];
|
|
43
|
+
servername?: string;
|
|
44
|
+
rejectUnauthorized?: boolean;
|
|
45
|
+
};
|
|
4
46
|
interface PostgresConnectionConfiguration {
|
|
5
47
|
host?: string;
|
|
6
48
|
port?: number;
|
|
@@ -9,9 +51,11 @@ interface PostgresConnectionConfiguration {
|
|
|
9
51
|
databaseName?: string;
|
|
10
52
|
connectionString?: string;
|
|
11
53
|
setupSQL?: string;
|
|
54
|
+
ssl?: ClientConfig['ssl'];
|
|
12
55
|
}
|
|
13
56
|
type PostgresConnectionConfigurationReader = PostgresConnectionConfiguration | (() => Promise<PostgresConnectionConfiguration>);
|
|
14
|
-
export interface PostgresConnectionOptions extends ConnectionConfig, PostgresConnectionConfiguration {
|
|
57
|
+
export interface PostgresConnectionOptions extends ConnectionConfig, Omit<PostgresConnectionConfiguration, 'ssl'> {
|
|
58
|
+
ssl?: boolean | PostgresSSLConfig;
|
|
15
59
|
}
|
|
16
60
|
export declare class PostgresConnection extends BaseConnection implements Connection, StreamingConnection, PersistSQLResults {
|
|
17
61
|
readonly name: string;
|
|
@@ -29,6 +73,8 @@ export declare class PostgresConnection extends BaseConnection implements Connec
|
|
|
29
73
|
canStream(): this is StreamingConnection;
|
|
30
74
|
getDigest(): string;
|
|
31
75
|
get supportsNesting(): boolean;
|
|
76
|
+
protected buildClientConfig(cfg: PostgresConnectionConfiguration): ClientConfig;
|
|
77
|
+
protected withTlsHint<T>(op: () => Promise<T>): Promise<T>;
|
|
32
78
|
protected getClient(): Promise<Client>;
|
|
33
79
|
protected runPostgresQuery(sqlCommand: string, _pageSize: number, _rowIndex: number, deJSON: boolean, values?: unknown[]): Promise<MalloyQueryData>;
|
|
34
80
|
fetchSelectSchema(sqlRef: SQLSourceRequest): Promise<SQLSourceDef | string>;
|
|
@@ -14,6 +14,24 @@ const pg_1 = require("pg");
|
|
|
14
14
|
const pg_query_stream_1 = __importDefault(require("pg-query-stream"));
|
|
15
15
|
const DEFAULT_PAGE_SIZE = 1000;
|
|
16
16
|
const SCHEMA_PAGE_SIZE = 1000;
|
|
17
|
+
// pg verifies the server certificate against the host it actually connects to,
|
|
18
|
+
// not `ssl.servername` (which pg drops when the host is a DNS name). When that
|
|
19
|
+
// check fails, pg throws a terse altname error; augment it with how to fix a
|
|
20
|
+
// tunneled connection rather than trying to predict pg's host resolution up
|
|
21
|
+
// front. Mutates and returns the original error so its type and stack survive.
|
|
22
|
+
function addTlsHint(err) {
|
|
23
|
+
if (!(err instanceof Error))
|
|
24
|
+
return err;
|
|
25
|
+
const code = err.code;
|
|
26
|
+
const isCertHostMismatch = code === 'ERR_TLS_CERT_ALTNAME_INVALID' ||
|
|
27
|
+
(/certificate/i.test(err.message) &&
|
|
28
|
+
/altname|does not match/i.test(err.message));
|
|
29
|
+
if (isCertHostMismatch) {
|
|
30
|
+
err.message +=
|
|
31
|
+
"\n[malloy-db-postgres] The server certificate does not match the host pg connected to. pg verifies against the connection host, not ssl.servername, unless the host is an IP. For a tunnel, connect via the DB's IP (e.g. host '127.0.0.1') and set ssl.servername to the real hostname; or omit ssl.servername to verify against the host directly.";
|
|
32
|
+
}
|
|
33
|
+
return err;
|
|
34
|
+
}
|
|
17
35
|
/**
|
|
18
36
|
* Decode a canonical Postgres dotted-table path into its underlying
|
|
19
37
|
* identifier strings as they appear in `information_schema`. The schema
|
|
@@ -94,20 +112,33 @@ class PostgresConnection extends connection_1.BaseConnection {
|
|
|
94
112
|
get supportsNesting() {
|
|
95
113
|
return true;
|
|
96
114
|
}
|
|
115
|
+
buildClientConfig(cfg) {
|
|
116
|
+
return {
|
|
117
|
+
user: cfg.username,
|
|
118
|
+
password: cfg.password,
|
|
119
|
+
database: cfg.databaseName,
|
|
120
|
+
port: cfg.port,
|
|
121
|
+
host: cfg.host,
|
|
122
|
+
connectionString: cfg.connectionString,
|
|
123
|
+
ssl: cfg.ssl,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Runs a connect/query op, annotating pg's terse certificate-mismatch error
|
|
127
|
+
// with actionable TLS guidance (see addTlsHint).
|
|
128
|
+
async withTlsHint(op) {
|
|
129
|
+
try {
|
|
130
|
+
return await op();
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
throw addTlsHint(e);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
97
136
|
async getClient() {
|
|
98
|
-
|
|
99
|
-
return new pg_1.Client({
|
|
100
|
-
user,
|
|
101
|
-
password,
|
|
102
|
-
database,
|
|
103
|
-
port,
|
|
104
|
-
host,
|
|
105
|
-
connectionString,
|
|
106
|
-
});
|
|
137
|
+
return new pg_1.Client(this.buildClientConfig(await this.readConfig()));
|
|
107
138
|
}
|
|
108
139
|
async runPostgresQuery(sqlCommand, _pageSize, _rowIndex, deJSON, values) {
|
|
109
140
|
const client = await this.getClient();
|
|
110
|
-
await client.connect();
|
|
141
|
+
await this.withTlsHint(() => client.connect());
|
|
111
142
|
await this.connectionSetup(client);
|
|
112
143
|
let result = await client.query(sqlCommand, values);
|
|
113
144
|
if (Array.isArray(result)) {
|
|
@@ -133,7 +164,7 @@ class PostgresConnection extends connection_1.BaseConnection {
|
|
|
133
164
|
name: (0, malloy_1.sqlKey)(sqlRef.connection, sqlRef.selectStr),
|
|
134
165
|
};
|
|
135
166
|
const client = await this.getClient();
|
|
136
|
-
await client.connect();
|
|
167
|
+
await this.withTlsHint(() => client.connect());
|
|
137
168
|
await this.connectionSetup(client);
|
|
138
169
|
// 1) Get row-descriptor without fetching data
|
|
139
170
|
const res = await client.query({
|
|
@@ -296,7 +327,7 @@ class PostgresConnection extends connection_1.BaseConnection {
|
|
|
296
327
|
async *runSQLStream(sqlCommand, { rowLimit, abortSignal } = {}) {
|
|
297
328
|
const query = new pg_query_stream_1.default(sqlCommand);
|
|
298
329
|
const client = await this.getClient();
|
|
299
|
-
await client.connect();
|
|
330
|
+
await this.withTlsHint(() => client.connect());
|
|
300
331
|
await this.connectionSetup(client);
|
|
301
332
|
const rowStream = client.query(query);
|
|
302
333
|
let index = 0;
|
|
@@ -345,15 +376,7 @@ class PooledPostgresConnection extends PostgresConnection {
|
|
|
345
376
|
}
|
|
346
377
|
async getPool() {
|
|
347
378
|
if (!this._pool) {
|
|
348
|
-
|
|
349
|
-
this._pool = new pg_1.Pool({
|
|
350
|
-
user,
|
|
351
|
-
password,
|
|
352
|
-
database,
|
|
353
|
-
port,
|
|
354
|
-
host,
|
|
355
|
-
connectionString,
|
|
356
|
-
});
|
|
379
|
+
this._pool = new pg_1.Pool(this.buildClientConfig(await this.readConfig()));
|
|
357
380
|
this._pool.on('acquire', client => {
|
|
358
381
|
client.query("SET TIME ZONE 'UTC'");
|
|
359
382
|
if (this.setupSQL) {
|
|
@@ -370,7 +393,7 @@ class PooledPostgresConnection extends PostgresConnection {
|
|
|
370
393
|
}
|
|
371
394
|
async runPostgresQuery(sqlCommand, _pageSize, _rowIndex, deJSON, values) {
|
|
372
395
|
const pool = await this.getPool();
|
|
373
|
-
let result = await pool.query(sqlCommand, values);
|
|
396
|
+
let result = await this.withTlsHint(() => pool.query(sqlCommand, values));
|
|
374
397
|
if (Array.isArray(result)) {
|
|
375
398
|
result = result.pop();
|
|
376
399
|
}
|
|
@@ -392,7 +415,7 @@ class PooledPostgresConnection extends PostgresConnection {
|
|
|
392
415
|
// `QueryStream` as well, but it's not. So instead, we get a client and call
|
|
393
416
|
// `client.query(query)`, which does what it's supposed to.
|
|
394
417
|
const pool = await this.getPool();
|
|
395
|
-
const client = await pool.connect();
|
|
418
|
+
const client = await this.withTlsHint(() => pool.connect());
|
|
396
419
|
const resultStream = client.query(query);
|
|
397
420
|
for await (const row of resultStream) {
|
|
398
421
|
yield row.row;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloydata/db-postgres",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.424",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"prepublishOnly": "npm run build"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@malloydata/malloy": "0.0.
|
|
26
|
+
"@malloydata/malloy": "0.0.424",
|
|
27
27
|
"@types/pg": "^8.6.1",
|
|
28
28
|
"pg": "8.7.3",
|
|
29
29
|
"pg-query-stream": "4.2.3"
|