@supabase/pg-delta 1.0.0-alpha.23 → 1.0.0-alpha.25
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/dist/core/catalog.model.d.ts +2 -2
- package/dist/core/catalog.model.js +26 -21
- package/dist/core/integrations/supabase.js +84 -0
- package/dist/core/objects/aggregate/changes/aggregate.privilege.js +21 -9
- package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.js +4 -1
- package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.js +6 -3
- package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.d.ts +11 -0
- package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.js +11 -0
- package/dist/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.js +4 -1
- package/dist/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.js +6 -3
- package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.d.ts +11 -0
- package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.js +11 -0
- package/dist/core/objects/foreign-data-wrapper/sensitive-options.d.ts +32 -0
- package/dist/core/objects/foreign-data-wrapper/sensitive-options.js +129 -0
- package/dist/core/objects/foreign-data-wrapper/server/changes/server.alter.js +4 -1
- package/dist/core/objects/foreign-data-wrapper/server/changes/server.create.js +6 -3
- package/dist/core/objects/foreign-data-wrapper/server/server.model.d.ts +10 -0
- package/dist/core/objects/foreign-data-wrapper/server/server.model.js +10 -0
- package/dist/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.js +4 -1
- package/dist/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.js +6 -3
- package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.d.ts +10 -0
- package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.js +10 -0
- package/dist/core/objects/rls-policy/rls-policy.model.d.ts +2 -2
- package/dist/core/objects/table/table.model.js +7 -2
- package/dist/core/post-diff-normalization.d.ts +7 -0
- package/dist/core/post-diff-normalization.js +33 -4
- package/dist/core/sort/cycle-breakers.js +139 -17
- package/package.json +1 -1
- package/src/core/catalog.model.ts +36 -20
- package/src/core/integrations/supabase.test.ts +198 -0
- package/src/core/integrations/supabase.ts +84 -0
- package/src/core/objects/aggregate/changes/aggregate.privilege.test.ts +79 -0
- package/src/core/objects/aggregate/changes/aggregate.privilege.ts +22 -9
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.test.ts +34 -4
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.ts +5 -1
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.test.ts +34 -0
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.ts +7 -5
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts +11 -0
- package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.test.ts +25 -4
- package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.ts +5 -1
- package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.test.ts +54 -0
- package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.ts +7 -5
- package/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts +11 -0
- package/src/core/objects/foreign-data-wrapper/sensitive-options.test.ts +98 -0
- package/src/core/objects/foreign-data-wrapper/sensitive-options.ts +133 -0
- package/src/core/objects/foreign-data-wrapper/server/changes/server.alter.test.ts +39 -4
- package/src/core/objects/foreign-data-wrapper/server/changes/server.alter.ts +5 -1
- package/src/core/objects/foreign-data-wrapper/server/changes/server.create.test.ts +36 -0
- package/src/core/objects/foreign-data-wrapper/server/changes/server.create.ts +7 -5
- package/src/core/objects/foreign-data-wrapper/server/server.model.ts +10 -0
- package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.test.ts +39 -6
- package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.ts +5 -1
- package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.test.ts +38 -2
- package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.ts +7 -5
- package/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts +10 -0
- package/src/core/objects/table/table.model.ts +7 -2
- package/src/core/plan/sql-format/format-off.test.ts +4 -4
- package/src/core/plan/sql-format/format-pretty-lower-leading.test.ts +4 -4
- package/src/core/plan/sql-format/format-pretty-narrow.test.ts +5 -4
- package/src/core/plan/sql-format/format-pretty-preserve.test.ts +4 -4
- package/src/core/plan/sql-format/format-pretty-upper.test.ts +4 -4
- package/src/core/post-diff-normalization.test.ts +123 -0
- package/src/core/post-diff-normalization.ts +40 -4
- package/src/core/sort/cycle-breakers.test.ts +236 -2
- package/src/core/sort/cycle-breakers.ts +184 -24
- package/src/core/sort/sort-changes.test.ts +317 -0
|
@@ -6,8 +6,8 @@ import { type Collation } from "./objects/collation/collation.model.ts";
|
|
|
6
6
|
import type { Domain } from "./objects/domain/domain.model.ts";
|
|
7
7
|
import { type EventTrigger } from "./objects/event-trigger/event-trigger.model.ts";
|
|
8
8
|
import { type Extension } from "./objects/extension/extension.model.ts";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { ForeignDataWrapper } from "./objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts";
|
|
10
|
+
import { ForeignTable } from "./objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts";
|
|
11
11
|
import { Server } from "./objects/foreign-data-wrapper/server/server.model.ts";
|
|
12
12
|
import { UserMapping } from "./objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts";
|
|
13
13
|
import { type Index } from "./objects/index/index.model.ts";
|
|
@@ -5,8 +5,9 @@ import { extractCollations, } from "./objects/collation/collation.model.js";
|
|
|
5
5
|
import { extractDomains } from "./objects/domain/domain.model.js";
|
|
6
6
|
import { extractEventTriggers, } from "./objects/event-trigger/event-trigger.model.js";
|
|
7
7
|
import { extractExtensions, } from "./objects/extension/extension.model.js";
|
|
8
|
-
import { extractForeignDataWrappers, } from "./objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.js";
|
|
9
|
-
import { extractForeignTables, } from "./objects/foreign-data-wrapper/foreign-table/foreign-table.model.js";
|
|
8
|
+
import { extractForeignDataWrappers, ForeignDataWrapper, } from "./objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.js";
|
|
9
|
+
import { extractForeignTables, ForeignTable, } from "./objects/foreign-data-wrapper/foreign-table/foreign-table.model.js";
|
|
10
|
+
import { redactSensitiveOptionPairs } from "./objects/foreign-data-wrapper/sensitive-options.js";
|
|
10
11
|
import { extractServers, Server, } from "./objects/foreign-data-wrapper/server/server.model.js";
|
|
11
12
|
import { extractUserMappings, UserMapping, } from "./objects/foreign-data-wrapper/user-mapping/user-mapping.model.js";
|
|
12
13
|
import { extractIndexes } from "./objects/index/index.model.js";
|
|
@@ -264,27 +265,44 @@ function listToRecord(list) {
|
|
|
264
265
|
return Object.fromEntries(list.map((item) => [item.stableId, item]));
|
|
265
266
|
}
|
|
266
267
|
function normalizeCatalog(catalog) {
|
|
268
|
+
const foreignDataWrappers = mapRecord(catalog.foreignDataWrappers, (fdw) => new ForeignDataWrapper({
|
|
269
|
+
name: fdw.name,
|
|
270
|
+
owner: fdw.owner,
|
|
271
|
+
handler: fdw.handler,
|
|
272
|
+
validator: fdw.validator,
|
|
273
|
+
options: redactSensitiveOptionPairs(fdw.options),
|
|
274
|
+
comment: fdw.comment,
|
|
275
|
+
privileges: fdw.privileges,
|
|
276
|
+
}));
|
|
267
277
|
const servers = mapRecord(catalog.servers, (server) => {
|
|
268
|
-
const maskedOptions = maskOptions(server.options);
|
|
269
278
|
return new Server({
|
|
270
279
|
name: server.name,
|
|
271
280
|
owner: server.owner,
|
|
272
281
|
foreign_data_wrapper: server.foreign_data_wrapper,
|
|
273
282
|
type: server.type,
|
|
274
283
|
version: server.version,
|
|
275
|
-
options:
|
|
284
|
+
options: redactSensitiveOptionPairs(server.options),
|
|
276
285
|
comment: server.comment,
|
|
277
286
|
privileges: server.privileges,
|
|
278
287
|
});
|
|
279
288
|
});
|
|
280
289
|
const userMappings = mapRecord(catalog.userMappings, (mapping) => {
|
|
281
|
-
const maskedOptions = maskOptions(mapping.options);
|
|
282
290
|
return new UserMapping({
|
|
283
291
|
user: mapping.user,
|
|
284
292
|
server: mapping.server,
|
|
285
|
-
options:
|
|
293
|
+
options: redactSensitiveOptionPairs(mapping.options),
|
|
286
294
|
});
|
|
287
295
|
});
|
|
296
|
+
const foreignTables = mapRecord(catalog.foreignTables, (foreignTable) => new ForeignTable({
|
|
297
|
+
schema: foreignTable.schema,
|
|
298
|
+
name: foreignTable.name,
|
|
299
|
+
owner: foreignTable.owner,
|
|
300
|
+
server: foreignTable.server,
|
|
301
|
+
columns: foreignTable.columns,
|
|
302
|
+
options: redactSensitiveOptionPairs(foreignTable.options),
|
|
303
|
+
comment: foreignTable.comment,
|
|
304
|
+
privileges: foreignTable.privileges,
|
|
305
|
+
}));
|
|
288
306
|
const subscriptions = mapRecord(catalog.subscriptions, (subscription) => {
|
|
289
307
|
return new Subscription({
|
|
290
308
|
name: subscription.name,
|
|
@@ -330,29 +348,16 @@ function normalizeCatalog(catalog) {
|
|
|
330
348
|
rules: catalog.rules,
|
|
331
349
|
ranges: catalog.ranges,
|
|
332
350
|
views: catalog.views,
|
|
333
|
-
foreignDataWrappers
|
|
351
|
+
foreignDataWrappers,
|
|
334
352
|
servers,
|
|
335
353
|
userMappings,
|
|
336
|
-
foreignTables
|
|
354
|
+
foreignTables,
|
|
337
355
|
depends: catalog.depends,
|
|
338
356
|
indexableObjects: catalog.indexableObjects,
|
|
339
357
|
version: catalog.version,
|
|
340
358
|
currentUser: catalog.currentUser,
|
|
341
359
|
});
|
|
342
360
|
}
|
|
343
|
-
function maskOptions(options) {
|
|
344
|
-
if (!options || options.length === 0)
|
|
345
|
-
return options;
|
|
346
|
-
const masked = [];
|
|
347
|
-
for (let i = 0; i < options.length; i += 2) {
|
|
348
|
-
const key = options[i];
|
|
349
|
-
const value = options[i + 1];
|
|
350
|
-
if (key === undefined || value === undefined)
|
|
351
|
-
continue;
|
|
352
|
-
masked.push(key, `__OPTION_${key.toUpperCase()}__`);
|
|
353
|
-
}
|
|
354
|
-
return masked.length > 0 ? masked : null;
|
|
355
|
-
}
|
|
356
361
|
function mapRecord(record, mapper) {
|
|
357
362
|
return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, mapper(value)]));
|
|
358
363
|
}
|
|
@@ -94,6 +94,34 @@ export const supabase = {
|
|
|
94
94
|
operation: "drop",
|
|
95
95
|
scope: "object",
|
|
96
96
|
},
|
|
97
|
+
// Include user-attached triggers on tables in Supabase-managed schemas.
|
|
98
|
+
//
|
|
99
|
+
// Triggers live in the schema of the table they fire on, so a user
|
|
100
|
+
// trigger on `auth.users` reports `trigger/schema = auth` and is
|
|
101
|
+
// otherwise indistinguishable from Supabase's own triggers via the
|
|
102
|
+
// schema-level deny list. Triggers also have no real owner — pg-delta
|
|
103
|
+
// surfaces the parent table's owner as `trigger/owner`, which for
|
|
104
|
+
// `auth.users` and `storage.objects` is always a Supabase system role,
|
|
105
|
+
// so the owner-level deny list catches them too.
|
|
106
|
+
//
|
|
107
|
+
// The trigger function, however, is genuinely user-owned: a customer
|
|
108
|
+
// who wants to run code on an auth event creates a function in
|
|
109
|
+
// `public` (or any non-managed schema) and points the trigger at it.
|
|
110
|
+
// Supabase's own auth/storage triggers either come from extensions
|
|
111
|
+
// (already filtered out at extract time via `pg_depend`) or call
|
|
112
|
+
// functions inside the same managed schema, so `function_schema`
|
|
113
|
+
// outside the managed list is a reliable user-defined marker.
|
|
114
|
+
{
|
|
115
|
+
and: [
|
|
116
|
+
{ objectType: "trigger" },
|
|
117
|
+
{ "trigger/schema": [...SUPABASE_SYSTEM_SCHEMAS] },
|
|
118
|
+
{
|
|
119
|
+
not: {
|
|
120
|
+
"trigger/function_schema": [...SUPABASE_SYSTEM_SCHEMAS],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
97
125
|
// Exclude system objects
|
|
98
126
|
{
|
|
99
127
|
not: {
|
|
@@ -126,6 +154,62 @@ export const supabase = {
|
|
|
126
154
|
},
|
|
127
155
|
],
|
|
128
156
|
},
|
|
157
|
+
// Platform-managed foreign data wrapper ACL.
|
|
158
|
+
// `GRANT`/`REVOKE ... ON FOREIGN DATA WRAPPER` requires
|
|
159
|
+
// superuser. On Supabase Cloud `postgres` has the elevated
|
|
160
|
+
// rights to make this work, but the local Docker image does
|
|
161
|
+
// not, so `supabase db reset` aborts with
|
|
162
|
+
// `permission denied for foreign-data wrapper`. The
|
|
163
|
+
// `*/owner` rule above already covers wrappers owned by
|
|
164
|
+
// `supabase_admin`, but `pg_dump` rewrites OWNER TO clauses
|
|
165
|
+
// to whoever the dump runs under, so after a restore the
|
|
166
|
+
// FDW typically ends up owned by `postgres` and slips past
|
|
167
|
+
// the owner gate. A non-superuser `postgres` still can't
|
|
168
|
+
// grant on a FDW (this is true regardless of who owns the
|
|
169
|
+
// wrapper locally), so the ACL diff is not user-replayable.
|
|
170
|
+
// We don't apply the same blanket rule to `FOREIGN SERVER`:
|
|
171
|
+
// server GRANT/REVOKE doesn't require superuser, and
|
|
172
|
+
// user-created servers (e.g. a `dblink` server pointing to
|
|
173
|
+
// a peer DB) carry legitimate user ACL that should
|
|
174
|
+
// roundtrip — the existing `*/owner` rule already drops
|
|
175
|
+
// platform-managed servers.
|
|
176
|
+
{
|
|
177
|
+
and: [
|
|
178
|
+
{ objectType: "foreign_data_wrapper" },
|
|
179
|
+
{ scope: "privilege" },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
// Platform-managed foreign data wrappers — Wasm-based FDWs
|
|
183
|
+
// (e.g. `clerk`, `clerk_oauth`) whose handler/validator live in
|
|
184
|
+
// the `extensions` schema. `CREATE FOREIGN DATA WRAPPER`
|
|
185
|
+
// requires superuser, and Supabase Cloud provisions these via
|
|
186
|
+
// `supabase_admin` at project creation; replaying the DDL
|
|
187
|
+
// against a local image fails because the local environment
|
|
188
|
+
// has no equivalent pre-step. We can't rely on the FDW owner
|
|
189
|
+
// alone — after a dump/restore the owner is often rewritten
|
|
190
|
+
// away from `supabase_admin` — so match on the function
|
|
191
|
+
// reference instead.
|
|
192
|
+
{
|
|
193
|
+
and: [
|
|
194
|
+
{ objectType: "foreign_data_wrapper" },
|
|
195
|
+
{
|
|
196
|
+
or: [
|
|
197
|
+
{
|
|
198
|
+
"foreign_data_wrapper/handler": {
|
|
199
|
+
op: "regex",
|
|
200
|
+
value: "^extensions\\.",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"foreign_data_wrapper/validator": {
|
|
205
|
+
op: "regex",
|
|
206
|
+
value: "^extensions\\.",
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
},
|
|
129
213
|
],
|
|
130
214
|
},
|
|
131
215
|
},
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { formatObjectPrivilegeList, getObjectKindPrefix, } from "../../base.privilege.js";
|
|
2
2
|
import { stableId } from "../../utils.js";
|
|
3
3
|
import { AlterAggregateChange } from "./aggregate.base.js";
|
|
4
|
+
/**
|
|
5
|
+
* Build the signature `<schema>.<name>(<argtypes>)` for use inside
|
|
6
|
+
* `GRANT`/`REVOKE ... ON FUNCTION (...)`.
|
|
7
|
+
*
|
|
8
|
+
* The aggregate's `identityArguments` (from
|
|
9
|
+
* `pg_get_function_identity_arguments`) embeds `ORDER BY` for ordered-set
|
|
10
|
+
* and hypothetical-set aggregates (`aggkind` of `o`/`h`) and `VARIADIC`
|
|
11
|
+
* for variadic aggregates — both of which the GRANT parser rejects with
|
|
12
|
+
* a syntax error. PostgreSQL resolves the aggregate from the positional
|
|
13
|
+
* argument types alone, so use `argument_types` here regardless of
|
|
14
|
+
* `aggkind`. Other aggregate DDL (`ALTER AGGREGATE`, `COMMENT ON
|
|
15
|
+
* AGGREGATE`, `SECURITY LABEL ON AGGREGATE`, `DROP AGGREGATE`) accepts
|
|
16
|
+
* the identity form and keeps using it.
|
|
17
|
+
*/
|
|
18
|
+
function aggregateGrantSignature(aggregate) {
|
|
19
|
+
const args = (aggregate.argument_types ?? []).join(", ");
|
|
20
|
+
return `${aggregate.schema}.${aggregate.name}(${args})`;
|
|
21
|
+
}
|
|
4
22
|
export class GrantAggregatePrivileges extends AlterAggregateChange {
|
|
5
23
|
aggregate;
|
|
6
24
|
grantee;
|
|
@@ -30,9 +48,7 @@ export class GrantAggregatePrivileges extends AlterAggregateChange {
|
|
|
30
48
|
const kindPrefix = getObjectKindPrefix("FUNCTION");
|
|
31
49
|
const list = this.privileges.map((p) => p.privilege);
|
|
32
50
|
const privSql = formatObjectPrivilegeList("FUNCTION", list, this.version);
|
|
33
|
-
const
|
|
34
|
-
const signature = this.aggregate.identityArguments;
|
|
35
|
-
const qualified = `${aggregateName}(${signature})`;
|
|
51
|
+
const qualified = aggregateGrantSignature(this.aggregate);
|
|
36
52
|
return `GRANT ${privSql} ${kindPrefix} ${qualified} TO ${this.grantee}${withGrant}`;
|
|
37
53
|
}
|
|
38
54
|
}
|
|
@@ -65,9 +81,7 @@ export class RevokeAggregatePrivileges extends AlterAggregateChange {
|
|
|
65
81
|
const kindPrefix = getObjectKindPrefix("FUNCTION");
|
|
66
82
|
const list = this.privileges.map((p) => p.privilege);
|
|
67
83
|
const privSql = formatObjectPrivilegeList("FUNCTION", list, this.version);
|
|
68
|
-
const
|
|
69
|
-
const signature = this.aggregate.identityArguments;
|
|
70
|
-
const qualified = `${aggregateName}(${signature})`;
|
|
84
|
+
const qualified = aggregateGrantSignature(this.aggregate);
|
|
71
85
|
return `REVOKE ${privSql} ${kindPrefix} ${qualified} FROM ${this.grantee}`;
|
|
72
86
|
}
|
|
73
87
|
}
|
|
@@ -94,9 +108,7 @@ export class RevokeGrantOptionAggregatePrivileges extends AlterAggregateChange {
|
|
|
94
108
|
serialize(_options) {
|
|
95
109
|
const kindPrefix = getObjectKindPrefix("FUNCTION");
|
|
96
110
|
const privSql = formatObjectPrivilegeList("FUNCTION", this.privilegeNames, this.version);
|
|
97
|
-
const
|
|
98
|
-
const signature = this.aggregate.identityArguments;
|
|
99
|
-
const qualified = `${aggregateName}(${signature})`;
|
|
111
|
+
const qualified = aggregateGrantSignature(this.aggregate);
|
|
100
112
|
return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${qualified} FROM ${this.grantee}`;
|
|
101
113
|
}
|
|
102
114
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { quoteLiteral } from "../../../base.change.js";
|
|
2
2
|
import { stableId } from "../../../utils.js";
|
|
3
|
+
import { redactOptionValue } from "../../sensitive-options.js";
|
|
3
4
|
import { AlterForeignDataWrapperChange } from "./foreign-data-wrapper.base.js";
|
|
4
5
|
/**
|
|
5
6
|
* ALTER FOREIGN DATA WRAPPER ... OWNER TO ...
|
|
@@ -47,7 +48,9 @@ export class AlterForeignDataWrapperSetOptions extends AlterForeignDataWrapperCh
|
|
|
47
48
|
optionParts.push(`DROP ${opt.option}`);
|
|
48
49
|
}
|
|
49
50
|
else {
|
|
50
|
-
const value = opt.value !== undefined
|
|
51
|
+
const value = opt.value !== undefined
|
|
52
|
+
? quoteLiteral(redactOptionValue(opt.option, opt.value))
|
|
53
|
+
: "''";
|
|
51
54
|
optionParts.push(`${opt.action} ${opt.option} ${value}`);
|
|
52
55
|
}
|
|
53
56
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { quoteLiteral } from "../../../base.change.js";
|
|
2
2
|
import { stableId } from "../../../utils.js";
|
|
3
|
+
import { redactOptionValue } from "../../sensitive-options.js";
|
|
3
4
|
import { CreateForeignDataWrapperChange } from "./foreign-data-wrapper.base.js";
|
|
4
5
|
/**
|
|
5
6
|
* Create a foreign data wrapper.
|
|
@@ -65,9 +66,11 @@ export class CreateForeignDataWrapper extends CreateForeignDataWrapperChange {
|
|
|
65
66
|
this.foreignDataWrapper.options.length > 0) {
|
|
66
67
|
const optionPairs = [];
|
|
67
68
|
for (let i = 0; i < this.foreignDataWrapper.options.length; i += 2) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
const key = this.foreignDataWrapper.options[i];
|
|
70
|
+
const value = this.foreignDataWrapper.options[i + 1];
|
|
71
|
+
if (key === undefined || value === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
optionPairs.push(`${key} ${quoteLiteral(redactOptionValue(key, value))}`);
|
|
71
74
|
}
|
|
72
75
|
if (optionPairs.length > 0) {
|
|
73
76
|
parts.push(`OPTIONS (${optionPairs.join(", ")})`);
|
package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.d.ts
CHANGED
|
@@ -55,5 +55,16 @@ export declare class ForeignDataWrapper extends BasePgModel {
|
|
|
55
55
|
}[];
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Extract `pg_foreign_data_wrapper` rows into `ForeignDataWrapper` models.
|
|
60
|
+
*
|
|
61
|
+
* The returned models carry option values **verbatim** from
|
|
62
|
+
* `pg_foreign_data_wrapper.fdwoptions`, which means a wrapper that ships
|
|
63
|
+
* shared credentials (`password`, `api_key`, …) would expose them
|
|
64
|
+
* cleartext in memory. Always route through `extractCatalog` (which
|
|
65
|
+
* calls `normalizeCatalog`) before emitting options to any output
|
|
66
|
+
* channel — see CLI-1467 and
|
|
67
|
+
* `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
|
|
68
|
+
*/
|
|
58
69
|
export declare function extractForeignDataWrappers(pool: Pool): Promise<ForeignDataWrapper[]>;
|
|
59
70
|
export {};
|
package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.js
CHANGED
|
@@ -60,6 +60,17 @@ export class ForeignDataWrapper extends BasePgModel {
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Extract `pg_foreign_data_wrapper` rows into `ForeignDataWrapper` models.
|
|
65
|
+
*
|
|
66
|
+
* The returned models carry option values **verbatim** from
|
|
67
|
+
* `pg_foreign_data_wrapper.fdwoptions`, which means a wrapper that ships
|
|
68
|
+
* shared credentials (`password`, `api_key`, …) would expose them
|
|
69
|
+
* cleartext in memory. Always route through `extractCatalog` (which
|
|
70
|
+
* calls `normalizeCatalog`) before emitting options to any output
|
|
71
|
+
* channel — see CLI-1467 and
|
|
72
|
+
* `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
|
|
73
|
+
*/
|
|
63
74
|
export async function extractForeignDataWrappers(pool) {
|
|
64
75
|
const { rows: fdwRows } = await pool.query(sql `
|
|
65
76
|
with extension_oids as (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { quoteLiteral } from "../../../base.change.js";
|
|
2
2
|
import { stableId } from "../../../utils.js";
|
|
3
|
+
import { redactOptionValue } from "../../sensitive-options.js";
|
|
3
4
|
import { AlterForeignTableChange } from "./foreign-table.base.js";
|
|
4
5
|
/**
|
|
5
6
|
* ALTER FOREIGN TABLE ... OWNER TO ...
|
|
@@ -234,7 +235,9 @@ export class AlterForeignTableSetOptions extends AlterForeignTableChange {
|
|
|
234
235
|
optionParts.push(`DROP ${opt.option}`);
|
|
235
236
|
}
|
|
236
237
|
else {
|
|
237
|
-
const value = opt.value !== undefined
|
|
238
|
+
const value = opt.value !== undefined
|
|
239
|
+
? quoteLiteral(redactOptionValue(opt.option, opt.value))
|
|
240
|
+
: "''";
|
|
238
241
|
optionParts.push(`${opt.action} ${opt.option} ${value}`);
|
|
239
242
|
}
|
|
240
243
|
}
|
package/dist/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { quoteLiteral } from "../../../base.change.js";
|
|
2
2
|
import { stableId } from "../../../utils.js";
|
|
3
|
+
import { redactOptionValue } from "../../sensitive-options.js";
|
|
3
4
|
import { CreateForeignTableChange } from "./foreign-table.base.js";
|
|
4
5
|
/**
|
|
5
6
|
* Create a foreign table.
|
|
@@ -51,9 +52,11 @@ export class CreateForeignTable extends CreateForeignTableChange {
|
|
|
51
52
|
if (this.foreignTable.options && this.foreignTable.options.length > 0) {
|
|
52
53
|
const optionPairs = [];
|
|
53
54
|
for (let i = 0; i < this.foreignTable.options.length; i += 2) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const key = this.foreignTable.options[i];
|
|
56
|
+
const value = this.foreignTable.options[i + 1];
|
|
57
|
+
if (key === undefined || value === undefined)
|
|
58
|
+
continue;
|
|
59
|
+
optionPairs.push(`${key} ${quoteLiteral(redactOptionValue(key, value))}`);
|
|
57
60
|
}
|
|
58
61
|
if (optionPairs.length > 0) {
|
|
59
62
|
parts.push(`OPTIONS (${optionPairs.join(", ")})`);
|
|
@@ -135,5 +135,16 @@ export declare class ForeignTable extends BasePgModel implements TableLikeObject
|
|
|
135
135
|
};
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Extract `pg_foreign_table` rows into `ForeignTable` models.
|
|
140
|
+
*
|
|
141
|
+
* The returned models carry option values **verbatim** from
|
|
142
|
+
* `pg_foreign_table.ftoptions`, which means a wrapper that puts
|
|
143
|
+
* credentials at the table level (uncommon but possible) would expose
|
|
144
|
+
* them cleartext in memory. Always route through `extractCatalog`
|
|
145
|
+
* (which calls `normalizeCatalog`) before emitting options to any
|
|
146
|
+
* output channel — see CLI-1467 and
|
|
147
|
+
* `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
|
|
148
|
+
*/
|
|
138
149
|
export declare function extractForeignTables(pool: Pool): Promise<ForeignTable[]>;
|
|
139
150
|
export {};
|
|
@@ -89,6 +89,17 @@ export class ForeignTable extends BasePgModel {
|
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Extract `pg_foreign_table` rows into `ForeignTable` models.
|
|
94
|
+
*
|
|
95
|
+
* The returned models carry option values **verbatim** from
|
|
96
|
+
* `pg_foreign_table.ftoptions`, which means a wrapper that puts
|
|
97
|
+
* credentials at the table level (uncommon but possible) would expose
|
|
98
|
+
* them cleartext in memory. Always route through `extractCatalog`
|
|
99
|
+
* (which calls `normalizeCatalog`) before emitting options to any
|
|
100
|
+
* output channel — see CLI-1467 and
|
|
101
|
+
* `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
|
|
102
|
+
*/
|
|
92
103
|
export async function extractForeignTables(pool) {
|
|
93
104
|
const { rows: tableRows } = await pool.query(sql `
|
|
94
105
|
with extension_oids as (
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sensitive-option redaction for foreign-data-wrapper objects.
|
|
3
|
+
*
|
|
4
|
+
* Foreign servers (`pg_foreign_server.srvoptions`) and user mappings
|
|
5
|
+
* (`pg_user_mapping.umoptions`) store libpq/FDW credentials in cleartext.
|
|
6
|
+
* Any code path that emits these option values verbatim — plan SQL, catalog
|
|
7
|
+
* snapshots, declarative export, fingerprints — leaks the credentials to
|
|
8
|
+
* disk, stdout, CI logs, and version control.
|
|
9
|
+
*
|
|
10
|
+
* The redaction policy is **allowlist-based**: replace every option value
|
|
11
|
+
* with `__OPTION_<KEY>__` unless the option key appears in
|
|
12
|
+
* {@link SAFE_OPTION_KEYS}. Failure mode of a missing entry is "the plan
|
|
13
|
+
* shows the placeholder instead of the real value" — annoying, but safe;
|
|
14
|
+
* a denylist's failure mode was secrets leaking, which is the bug we are
|
|
15
|
+
* fixing (CLI-1467).
|
|
16
|
+
*
|
|
17
|
+
* Match is case-insensitive but exact — substrings do not match, so an
|
|
18
|
+
* option key like `password_validator_extension` will be redacted unless
|
|
19
|
+
* explicitly allowlisted. When a new wrapper introduces a non-credential
|
|
20
|
+
* key we want to surface in plans, add it here.
|
|
21
|
+
*/
|
|
22
|
+
export declare function redactOptionValue(key: string, value: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Redact non-allowlisted values in a flat `[key, value, key, value, ...]`
|
|
25
|
+
* options array — the shape used by the {@link Server},
|
|
26
|
+
* {@link UserMapping}, {@link ForeignDataWrapper}, and {@link ForeignTable}
|
|
27
|
+
* models.
|
|
28
|
+
*
|
|
29
|
+
* Returns `null` for `null` input, and otherwise returns an array of the
|
|
30
|
+
* same length as the input with sensitive values replaced.
|
|
31
|
+
*/
|
|
32
|
+
export declare function redactSensitiveOptionPairs(options: readonly string[] | null): string[] | null;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sensitive-option redaction for foreign-data-wrapper objects.
|
|
3
|
+
*
|
|
4
|
+
* Foreign servers (`pg_foreign_server.srvoptions`) and user mappings
|
|
5
|
+
* (`pg_user_mapping.umoptions`) store libpq/FDW credentials in cleartext.
|
|
6
|
+
* Any code path that emits these option values verbatim — plan SQL, catalog
|
|
7
|
+
* snapshots, declarative export, fingerprints — leaks the credentials to
|
|
8
|
+
* disk, stdout, CI logs, and version control.
|
|
9
|
+
*
|
|
10
|
+
* The redaction policy is **allowlist-based**: replace every option value
|
|
11
|
+
* with `__OPTION_<KEY>__` unless the option key appears in
|
|
12
|
+
* {@link SAFE_OPTION_KEYS}. Failure mode of a missing entry is "the plan
|
|
13
|
+
* shows the placeholder instead of the real value" — annoying, but safe;
|
|
14
|
+
* a denylist's failure mode was secrets leaking, which is the bug we are
|
|
15
|
+
* fixing (CLI-1467).
|
|
16
|
+
*
|
|
17
|
+
* Match is case-insensitive but exact — substrings do not match, so an
|
|
18
|
+
* option key like `password_validator_extension` will be redacted unless
|
|
19
|
+
* explicitly allowlisted. When a new wrapper introduces a non-credential
|
|
20
|
+
* key we want to surface in plans, add it here.
|
|
21
|
+
*/
|
|
22
|
+
const SAFE_OPTION_KEYS = new Set([
|
|
23
|
+
// libpq connection params (non-credential subset).
|
|
24
|
+
// https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS
|
|
25
|
+
"host",
|
|
26
|
+
"hostaddr",
|
|
27
|
+
"port",
|
|
28
|
+
"dbname",
|
|
29
|
+
"user",
|
|
30
|
+
"sslmode",
|
|
31
|
+
"sslcompression",
|
|
32
|
+
"sslcert",
|
|
33
|
+
"sslkey",
|
|
34
|
+
"sslrootcert",
|
|
35
|
+
"sslcrl",
|
|
36
|
+
"sslcrldir",
|
|
37
|
+
"sslsni",
|
|
38
|
+
"requirepeer",
|
|
39
|
+
"krbsrvname",
|
|
40
|
+
"gsslib",
|
|
41
|
+
"sspi",
|
|
42
|
+
"gssencmode",
|
|
43
|
+
"gssdelegation",
|
|
44
|
+
"channel_binding",
|
|
45
|
+
"target_session_attrs",
|
|
46
|
+
"application_name",
|
|
47
|
+
"fallback_application_name",
|
|
48
|
+
"connect_timeout",
|
|
49
|
+
"client_encoding",
|
|
50
|
+
"options",
|
|
51
|
+
"keepalives",
|
|
52
|
+
"keepalives_idle",
|
|
53
|
+
"keepalives_interval",
|
|
54
|
+
"keepalives_count",
|
|
55
|
+
"tcp_user_timeout",
|
|
56
|
+
"replication",
|
|
57
|
+
"load_balance_hosts",
|
|
58
|
+
// postgres_fdw behavior tuning.
|
|
59
|
+
// https://www.postgresql.org/docs/current/postgres-fdw.html#POSTGRES-FDW-OPTIONS-CONNECTION
|
|
60
|
+
"use_remote_estimate",
|
|
61
|
+
"fdw_startup_cost",
|
|
62
|
+
"fdw_tuple_cost",
|
|
63
|
+
"fetch_size",
|
|
64
|
+
"batch_size",
|
|
65
|
+
"async_capable",
|
|
66
|
+
"analyze_sampling",
|
|
67
|
+
"parallel_commit",
|
|
68
|
+
"parallel_abort",
|
|
69
|
+
"extensions",
|
|
70
|
+
"updatable",
|
|
71
|
+
"truncatable",
|
|
72
|
+
"schema_name",
|
|
73
|
+
"table_name",
|
|
74
|
+
"column_name",
|
|
75
|
+
// Common shape for table-like FDWs (file_fdw, cloud-storage wrappers).
|
|
76
|
+
"schema",
|
|
77
|
+
"database",
|
|
78
|
+
"table",
|
|
79
|
+
"format",
|
|
80
|
+
"header",
|
|
81
|
+
"delimiter",
|
|
82
|
+
"quote",
|
|
83
|
+
"escape",
|
|
84
|
+
"encoding",
|
|
85
|
+
"compression",
|
|
86
|
+
// Cloud / Supabase Wrappers non-credential shape.
|
|
87
|
+
// https://github.com/supabase/wrappers
|
|
88
|
+
"region",
|
|
89
|
+
"endpoint",
|
|
90
|
+
"bucket",
|
|
91
|
+
"prefix",
|
|
92
|
+
"location",
|
|
93
|
+
"project_id",
|
|
94
|
+
"dataset_id",
|
|
95
|
+
"dataset",
|
|
96
|
+
"workspace",
|
|
97
|
+
"organization",
|
|
98
|
+
"api_version",
|
|
99
|
+
]);
|
|
100
|
+
function redactedOptionPlaceholder(key) {
|
|
101
|
+
return `__OPTION_${key.toUpperCase()}__`;
|
|
102
|
+
}
|
|
103
|
+
export function redactOptionValue(key, value) {
|
|
104
|
+
return SAFE_OPTION_KEYS.has(key.toLowerCase())
|
|
105
|
+
? value
|
|
106
|
+
: redactedOptionPlaceholder(key);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Redact non-allowlisted values in a flat `[key, value, key, value, ...]`
|
|
110
|
+
* options array — the shape used by the {@link Server},
|
|
111
|
+
* {@link UserMapping}, {@link ForeignDataWrapper}, and {@link ForeignTable}
|
|
112
|
+
* models.
|
|
113
|
+
*
|
|
114
|
+
* Returns `null` for `null` input, and otherwise returns an array of the
|
|
115
|
+
* same length as the input with sensitive values replaced.
|
|
116
|
+
*/
|
|
117
|
+
export function redactSensitiveOptionPairs(options) {
|
|
118
|
+
if (options === null)
|
|
119
|
+
return null;
|
|
120
|
+
const result = [];
|
|
121
|
+
for (let i = 0; i < options.length; i += 2) {
|
|
122
|
+
const key = options[i];
|
|
123
|
+
const value = options[i + 1];
|
|
124
|
+
if (key === undefined || value === undefined)
|
|
125
|
+
continue;
|
|
126
|
+
result.push(key, redactOptionValue(key, value));
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { quoteLiteral } from "../../../base.change.js";
|
|
2
2
|
import { stableId } from "../../../utils.js";
|
|
3
|
+
import { redactOptionValue } from "../../sensitive-options.js";
|
|
3
4
|
import { AlterServerChange } from "./server.base.js";
|
|
4
5
|
/**
|
|
5
6
|
* ALTER SERVER ... OWNER TO ...
|
|
@@ -70,7 +71,9 @@ export class AlterServerSetOptions extends AlterServerChange {
|
|
|
70
71
|
optionParts.push(`DROP ${opt.option}`);
|
|
71
72
|
}
|
|
72
73
|
else {
|
|
73
|
-
const value = opt.value !== undefined
|
|
74
|
+
const value = opt.value !== undefined
|
|
75
|
+
? quoteLiteral(redactOptionValue(opt.option, opt.value))
|
|
76
|
+
: "''";
|
|
74
77
|
optionParts.push(`${opt.action} ${opt.option} ${value}`);
|
|
75
78
|
}
|
|
76
79
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { quoteLiteral } from "../../../base.change.js";
|
|
2
2
|
import { stableId } from "../../../utils.js";
|
|
3
|
+
import { redactOptionValue } from "../../sensitive-options.js";
|
|
3
4
|
import { CreateServerChange } from "./server.base.js";
|
|
4
5
|
/**
|
|
5
6
|
* Create a server.
|
|
@@ -49,9 +50,11 @@ export class CreateServer extends CreateServerChange {
|
|
|
49
50
|
if (this.server.options && this.server.options.length > 0) {
|
|
50
51
|
const optionPairs = [];
|
|
51
52
|
for (let i = 0; i < this.server.options.length; i += 2) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
const key = this.server.options[i];
|
|
54
|
+
const value = this.server.options[i + 1];
|
|
55
|
+
if (key === undefined || value === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
optionPairs.push(`${key} ${quoteLiteral(redactOptionValue(key, value))}`);
|
|
55
58
|
}
|
|
56
59
|
if (optionPairs.length > 0) {
|
|
57
60
|
parts.push(`OPTIONS (${optionPairs.join(", ")})`);
|
|
@@ -58,5 +58,15 @@ export declare class Server extends BasePgModel {
|
|
|
58
58
|
}[];
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Extract `pg_foreign_server` rows into `Server` models.
|
|
63
|
+
*
|
|
64
|
+
* The returned models carry option values **verbatim** from
|
|
65
|
+
* `pg_foreign_server.srvoptions`, which means cleartext secrets like
|
|
66
|
+
* `password` are present in memory. Always route through
|
|
67
|
+
* `extractCatalog` (which calls `normalizeCatalog`) before emitting
|
|
68
|
+
* options to any output channel — see CLI-1467 and
|
|
69
|
+
* `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
|
|
70
|
+
*/
|
|
61
71
|
export declare function extractServers(pool: Pool): Promise<Server[]>;
|
|
62
72
|
export {};
|
|
@@ -64,6 +64,16 @@ export class Server extends BasePgModel {
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Extract `pg_foreign_server` rows into `Server` models.
|
|
69
|
+
*
|
|
70
|
+
* The returned models carry option values **verbatim** from
|
|
71
|
+
* `pg_foreign_server.srvoptions`, which means cleartext secrets like
|
|
72
|
+
* `password` are present in memory. Always route through
|
|
73
|
+
* `extractCatalog` (which calls `normalizeCatalog`) before emitting
|
|
74
|
+
* options to any output channel — see CLI-1467 and
|
|
75
|
+
* `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
|
|
76
|
+
*/
|
|
67
77
|
export async function extractServers(pool) {
|
|
68
78
|
const { rows: serverRows } = await pool.query(sql `
|
|
69
79
|
select
|