@rebasepro/server-postgres 0.12.1-canary.gdfba2a1 → 0.12.1-canary.ge1bb178
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/PostgresBackendDriver.d.ts +1 -1
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +6 -1
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/{backup-service-DH9kPg-E.js → backup-service-CD8o_1Sl.js} +144 -11
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/cli-helpers.d.ts +39 -0
- package/dist/{connection-B5Wndbr1.js → connection-BuZ97wsr.js} +57 -3
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -0
- package/dist/ensure-collection-policies-BrUVgjz3.js +57 -0
- package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
- package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
- package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
- package/dist/index.es.js +529 -142
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CeA1JcxP.js +105 -0
- package/dist/policy-CeA1JcxP.js.map +1 -0
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +44 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/services/FetchService.d.ts +4 -1
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +24 -1
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-DihrDFuP.js → src-CzbghKwf.js} +189 -101
- package/dist/src-CzbghKwf.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +5 -4
- package/dist/utils/pg-error-utils.d.ts +19 -0
- package/dist/{websocket-BKcGvILX.js → websocket-B2LsrINK.js} +8 -6
- package/dist/websocket-B2LsrINK.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +4 -0
- package/src/PostgresBootstrapper.ts +192 -33
- package/src/auth/services.ts +6 -3
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli-helpers.ts +70 -0
- package/src/cli.ts +44 -26
- package/src/collections/validate-relations.ts +15 -0
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +9 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +220 -32
- package/src/schema/generate-drizzle-schema-logic.ts +16 -3
- package/src/schema/generate-postgres-ddl-logic.ts +375 -16
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +385 -71
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/security/policy-drift.test.ts +11 -3
- package/src/services/FetchService.ts +76 -14
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +40 -1
- package/src/services/dataService.ts +3 -1
- package/src/services/realtimeService.ts +3 -3
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +46 -0
- package/src/websocket.ts +18 -9
- package/dist/backup-service-DH9kPg-E.js.map +0 -1
- package/dist/connection-B5Wndbr1.js.map +0 -1
- package/dist/ensure-collection-tables-CIHSH962.js +0 -304
- package/dist/ensure-collection-tables-CIHSH962.js.map +0 -1
- package/dist/src-DihrDFuP.js.map +0 -1
- package/dist/websocket-BKcGvILX.js.map +0 -1
|
@@ -102,6 +102,40 @@ export interface PostgresDriverInternals {
|
|
|
102
102
|
|
|
103
103
|
// Re-export from shared CLI error utilities
|
|
104
104
|
import { isEconnrefused } from "./cli-errors";
|
|
105
|
+
import { classifyConnectFailure } from "./utils/pg-error-utils";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Which table name the boot-time drift check should look for, for one collection.
|
|
109
|
+
*
|
|
110
|
+
* A declared `table` IS the table name, not a hint to be second-guessed. This
|
|
111
|
+
* used to ask the registry whether it had indexed the declared name and fall
|
|
112
|
+
* back to the SLUG when it had not — but "the registry does not know this table"
|
|
113
|
+
* is exactly the condition the drift check exists to report, so the fallback
|
|
114
|
+
* fired precisely when it was most harmful.
|
|
115
|
+
*
|
|
116
|
+
* A collection with `slug: "usage-daily", table: "usage_daily"` was reported as
|
|
117
|
+
* missing table `usage-daily`: a name that does not exist, should never exist,
|
|
118
|
+
* and that nobody can find by looking. Worse, the remediation the caller prints
|
|
119
|
+
* says to run `rebase db push` — which would then CREATE that invented table
|
|
120
|
+
* beside the correct one, the same "second copy" hazard the misplaced-schema
|
|
121
|
+
* branch further down exists to prevent. Seen in production, where a correctly
|
|
122
|
+
* migrated database reported drift on every boot.
|
|
123
|
+
*
|
|
124
|
+
* The slug is used only when nothing was declared, which is the config shape
|
|
125
|
+
* where the slug genuinely is the table name.
|
|
126
|
+
*
|
|
127
|
+
* Exported for its own test: the caller needs a live pool and a real database,
|
|
128
|
+
* and this is the part that was wrong.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveDriftCheckName(
|
|
131
|
+
col: CollectionConfig,
|
|
132
|
+
registeredTableNames: string[]
|
|
133
|
+
): string {
|
|
134
|
+
const declaredTable = isRelationalCollectionConfig(col) ? col.table : undefined;
|
|
135
|
+
return declaredTable
|
|
136
|
+
?? registeredTableNames.find((k) => k === col.slug)
|
|
137
|
+
?? col.slug;
|
|
138
|
+
}
|
|
105
139
|
|
|
106
140
|
/**
|
|
107
141
|
* Default PostgreSQL bootstrapper.
|
|
@@ -260,9 +294,45 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
260
294
|
throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
|
|
261
295
|
}
|
|
262
296
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
297
|
+
/*
|
|
298
|
+
* Everything else.
|
|
299
|
+
*
|
|
300
|
+
* Two problems with what used to happen here. First, the logged
|
|
301
|
+
* error was Drizzle's wrapper — `Failed query: SELECT 1` and a
|
|
302
|
+
* stack through drizzle internals — while the sentence that
|
|
303
|
+
* actually says what is wrong ("password authentication failed
|
|
304
|
+
* for user …", "database … does not exist") sits in `.cause`
|
|
305
|
+
* and was never printed. A developer with a typo in their
|
|
306
|
+
* DATABASE_URL got two walls of stack trace and no cause.
|
|
307
|
+
*
|
|
308
|
+
* Second, "continuing… the pool may recover" is only true for
|
|
309
|
+
* a transient fault. A wrong password or a missing database is
|
|
310
|
+
* settled: the next query fails the same way, so the process
|
|
311
|
+
* died seconds later anyway — after printing a reassurance.
|
|
312
|
+
* Those now fail here, where the message can be about the
|
|
313
|
+
* cause rather than about whichever query ran next.
|
|
314
|
+
*/
|
|
315
|
+
const { fatal, reason, code } = classifyConnectFailure(err);
|
|
316
|
+
const detail = code ? ` [${code}]` : "";
|
|
317
|
+
|
|
318
|
+
if (fatal) {
|
|
319
|
+
logger.error(
|
|
320
|
+
`\n` +
|
|
321
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
322
|
+
` ❌ PostgreSQL refused the connection\n` +
|
|
323
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
324
|
+
`\n` +
|
|
325
|
+
` ${reason}${detail}\n` +
|
|
326
|
+
`\n` +
|
|
327
|
+
` The server is reachable, so this is the credentials or the\n` +
|
|
328
|
+
` database name in DATABASE_URL — check them in your .env.\n` +
|
|
329
|
+
`\n` +
|
|
330
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
|
|
331
|
+
);
|
|
332
|
+
throw new Error(`PostgreSQL refused the connection: ${reason}${detail}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
logger.error(`❌ Failed to connect to PostgreSQL: ${reason}${detail}`, { error: err });
|
|
266
336
|
logger.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
|
|
267
337
|
}
|
|
268
338
|
|
|
@@ -487,22 +557,28 @@ table: link.table });
|
|
|
487
557
|
try {
|
|
488
558
|
const registeredCollections = registry.getCollections();
|
|
489
559
|
if (registeredCollections.length > 0) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
560
|
+
// Deliberately unfiltered by schema: a table that is missing
|
|
561
|
+
// from where the collection says it lives is very often
|
|
562
|
+
// sitting in another schema entirely (see the misplaced-table
|
|
563
|
+
// report below), and knowing *which* is the whole answer.
|
|
494
564
|
const result = await schemaAwareDb.execute(sql.raw(`
|
|
495
565
|
SELECT table_name, table_schema
|
|
496
566
|
FROM information_schema.tables
|
|
497
|
-
WHERE table_schema IN (
|
|
567
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
498
568
|
AND table_type = 'BASE TABLE'
|
|
499
569
|
`));
|
|
570
|
+
const tablesByName = new Map<string, string[]>();
|
|
571
|
+
for (const row of result.rows as Array<{ table_name: string; table_schema: string }>) {
|
|
572
|
+
const schemas = tablesByName.get(row.table_name) ?? [];
|
|
573
|
+
schemas.push(row.table_schema);
|
|
574
|
+
tablesByName.set(row.table_name, schemas);
|
|
575
|
+
}
|
|
500
576
|
const dbTables = new Set(
|
|
501
577
|
(result.rows as Array<{ table_name: string; table_schema: string }>).map(r =>
|
|
502
578
|
r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`
|
|
503
579
|
)
|
|
504
580
|
);
|
|
505
|
-
const missing: Array<{ slug: string; table: string }> = [];
|
|
581
|
+
const missing: Array<{ slug: string; table: string; foundIn: string[] }> = [];
|
|
506
582
|
for (const col of registeredCollections) {
|
|
507
583
|
// Auth owns its table and creates it later in this same
|
|
508
584
|
// boot (initializeAuth → ensureAuthTablesExist), so it is
|
|
@@ -513,41 +589,72 @@ table: link.table });
|
|
|
513
589
|
if ((col as { auth?: { enabled?: boolean } }).auth?.enabled) continue;
|
|
514
590
|
|
|
515
591
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
516
|
-
const
|
|
517
|
-
const tableName = registry.hasTableForCollection(
|
|
518
|
-
declaredTable ?? col.slug
|
|
519
|
-
)
|
|
520
|
-
? (declaredTable ?? col.slug)
|
|
521
|
-
: col.slug;
|
|
522
|
-
// Resolve the actual table name the registry stored
|
|
523
|
-
const resolvedTable = registry.getTableNames().find((k) =>
|
|
524
|
-
k === tableName ||
|
|
525
|
-
k === col.slug
|
|
526
|
-
);
|
|
527
|
-
const checkName = resolvedTable ?? tableName;
|
|
592
|
+
const checkName = resolveDriftCheckName(col, registry.getTableNames());
|
|
528
593
|
const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
|
|
529
594
|
if (!dbTables.has(fullCheckName)) {
|
|
530
595
|
// Report what was actually looked up: an unqualified
|
|
531
596
|
// "users" sends people hunting for public.users.
|
|
532
597
|
missing.push({ slug: col.slug,
|
|
533
|
-
table: fullCheckName
|
|
598
|
+
table: fullCheckName,
|
|
599
|
+
foundIn: (tablesByName.get(checkName) ?? []).filter(s => s !== schemaName) });
|
|
534
600
|
}
|
|
535
601
|
}
|
|
536
602
|
if (missing.length > 0) {
|
|
537
603
|
const lines = missing.map(
|
|
538
|
-
m => ` • collection "${m.slug}" → table "${m.table}"`
|
|
604
|
+
m => ` • collection "${m.slug}" → table "${m.table}"` +
|
|
605
|
+
(m.foundIn.length > 0
|
|
606
|
+
? ` — but a table of that name exists in ${m.foundIn.map(s => `"${s}"`).join(", ")}`
|
|
607
|
+
: "")
|
|
539
608
|
);
|
|
609
|
+
// A table that exists under the same name in another
|
|
610
|
+
// schema is not ordinary drift: it is almost always this
|
|
611
|
+
// framework's own `search_path` hazard. Postgres resolves
|
|
612
|
+
// unqualified SQL through `"$user", public`, and this
|
|
613
|
+
// project creates a schema named `rebase` while every
|
|
614
|
+
// template names the database role `rebase` too — so an
|
|
615
|
+
// unqualified CREATE TABLE (a hand-written migration, the
|
|
616
|
+
// SQL editor, drizzle-kit) lands in `rebase`, and the
|
|
617
|
+
// runtime, which now pins `search_path=public`, cannot see
|
|
618
|
+
// it. Say so, because "missing table" sends people to
|
|
619
|
+
// re-run a push that will create a *second* copy.
|
|
620
|
+
const misplaced = missing.filter(m => m.foundIn.length > 0);
|
|
621
|
+
const misplacedHelp = misplaced.length === 0 ? [] : [
|
|
622
|
+
" Those tables exist — in the wrong schema. Unqualified SQL run by a",
|
|
623
|
+
" role whose name matches a schema (`rebase` is both, by default)",
|
|
624
|
+
" resolves through search_path's \"$user\" and creates there. Move them:",
|
|
625
|
+
...misplaced.map(m =>
|
|
626
|
+
` ALTER TABLE "${m.foundIn[0]}"."${m.table.split(".").pop()}" SET SCHEMA "${m.table.includes(".") ? m.table.split(".")[0] : "public"}";`
|
|
627
|
+
),
|
|
628
|
+
" and qualify the SQL that created them, or pin search_path in DATABASE_URL",
|
|
629
|
+
" (`?options=-c%20search_path%3Dpublic`).",
|
|
630
|
+
""
|
|
631
|
+
];
|
|
632
|
+
// This runtime creates collection tables (and their RLS)
|
|
633
|
+
// at boot unless REBASE_MIGRATE_ON_BOOT=none, so a drift
|
|
634
|
+
// this late means that step was disabled, could not run,
|
|
635
|
+
// or failed — the guidance names a path for both a managed
|
|
636
|
+
// tenant (whose in-cluster database `pnpm db:push` cannot
|
|
637
|
+
// reach) and a self-host. Naming only the pnpm scripts, as
|
|
638
|
+
// this once did, told a managed operator to run a command
|
|
639
|
+
// that structurally cannot touch their database.
|
|
540
640
|
logger.warn([
|
|
541
641
|
"",
|
|
542
|
-
"
|
|
543
|
-
|
|
544
|
-
"
|
|
545
|
-
...
|
|
546
|
-
"
|
|
547
|
-
"
|
|
548
|
-
"
|
|
549
|
-
"
|
|
550
|
-
"
|
|
642
|
+
"⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
|
|
643
|
+
...lines,
|
|
644
|
+
"",
|
|
645
|
+
...misplacedHelp,
|
|
646
|
+
" This runtime applies the collection schema at boot unless",
|
|
647
|
+
" REBASE_MIGRATE_ON_BOOT=none. Check the \"Collection schema\" / \"policies\"",
|
|
648
|
+
" log lines above — this drift means that step was off, skipped, or failed.",
|
|
649
|
+
" • Managed cloud: redeploy with REBASE_MIGRATE_ON_BOOT unset or",
|
|
650
|
+
" \"ensure\"; the runtime applies the schema to the tenant DB.",
|
|
651
|
+
" If the log above says the driver does not implement collection-table",
|
|
652
|
+
" creation, THIS driver is too old to do it. A driver is installed from",
|
|
653
|
+
" your bundle's dependencies, not supplied by the platform image, so a",
|
|
654
|
+
" newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
|
|
655
|
+
" in your project's package.json and redeploy.",
|
|
656
|
+
" • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
|
|
657
|
+
" against DATABASE_URL.",
|
|
551
658
|
""
|
|
552
659
|
].join("\n"));
|
|
553
660
|
}
|
|
@@ -711,7 +818,59 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
|
|
|
711
818
|
collections as Parameters<typeof ensureCollectionTables>[1],
|
|
712
819
|
log
|
|
713
820
|
);
|
|
714
|
-
|
|
821
|
+
for (const failure of plan.failures) {
|
|
822
|
+
logger.warn(
|
|
823
|
+
`🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the ` +
|
|
824
|
+
`collection still serves, but rows are not policed by this constraint: ${failure.error}`
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return { applied: plan.actions.length - plan.failures.length };
|
|
828
|
+
},
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Apply the collections' RLS policies — ENABLE ROW LEVEL SECURITY and the
|
|
832
|
+
* `securityRules` compiled to `CREATE POLICY` — so a freshly provisioned
|
|
833
|
+
* database serves data instead of denying every user-context read.
|
|
834
|
+
*
|
|
835
|
+
* The companion to {@link ensureCollectionSchema}: that creates the
|
|
836
|
+
* tables, this makes them servable. Boot runs it *after* auth
|
|
837
|
+
* initialization, because the generated policies call `auth.uid()` /
|
|
838
|
+
* `auth.roles()`, and `CREATE POLICY` validates those functions exist.
|
|
839
|
+
*
|
|
840
|
+
* Runs through the same drizzle handle, one statement at a time (that
|
|
841
|
+
* handle speaks the extended query protocol, which rejects multi-command
|
|
842
|
+
* strings). Failures are per-table and non-fatal: a table left un-policed
|
|
843
|
+
* stays RLS-enabled, so it denies rather than leaks.
|
|
844
|
+
*/
|
|
845
|
+
async ensureCollectionPolicies(
|
|
846
|
+
collections: unknown[],
|
|
847
|
+
driverResult: InitializedDriver,
|
|
848
|
+
log?: (message: string) => void
|
|
849
|
+
): Promise<{ applied: number }> {
|
|
850
|
+
const internals = driverResult.internals as PostgresDriverInternals;
|
|
851
|
+
const { ensureCollectionPolicies } = await import("./schema/ensure-collection-policies");
|
|
852
|
+
const queryable = {
|
|
853
|
+
async query<T>(text: string): Promise<{ rows: T[] }> {
|
|
854
|
+
const result = await internals.db.execute(sql.raw(text));
|
|
855
|
+
const rows = (result as unknown as { rows?: T[] }).rows;
|
|
856
|
+
return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
const outcome = await ensureCollectionPolicies(
|
|
860
|
+
queryable,
|
|
861
|
+
collections as CollectionConfig[],
|
|
862
|
+
log
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
for (const skip of outcome.skipped) {
|
|
866
|
+
logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
867
|
+
}
|
|
868
|
+
for (const failure of outcome.failures) {
|
|
869
|
+
logger.warn(
|
|
870
|
+
`🔐 [rls] Could not fully apply policies to "${failure.table}" — it stays locked (denies) until this is resolved: ${failure.error}`
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
return { applied: outcome.policiesApplied };
|
|
715
874
|
},
|
|
716
875
|
|
|
717
876
|
getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
|
package/src/auth/services.ts
CHANGED
|
@@ -68,10 +68,13 @@ function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebaseP
|
|
|
68
68
|
*
|
|
69
69
|
* Whitespace goes too: a trailing space survives the fold and reproduces the
|
|
70
70
|
* problem exactly.
|
|
71
|
+
*
|
|
72
|
+
* Re-exported rather than defined here: `@rebasepro/server` and
|
|
73
|
+
* `@rebasepro/server-mongo` write this column too, and a second copy of this
|
|
74
|
+
* rule is the defect it exists to prevent.
|
|
71
75
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
76
|
+
import { normalizeEmail } from "@rebasepro/common";
|
|
77
|
+
export { normalizeEmail };
|
|
75
78
|
|
|
76
79
|
/**
|
|
77
80
|
* PostgreSQL implementation of UserRepository.
|
package/src/backup/backup-cli.ts
CHANGED
|
@@ -100,6 +100,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
|
|
|
100
100
|
"--out": String,
|
|
101
101
|
"--exclude-schema": [String],
|
|
102
102
|
"--no-owner": Boolean,
|
|
103
|
+
"--enable-row-security": Boolean,
|
|
104
|
+
"--row-security-role": String,
|
|
103
105
|
"-o": "--out"
|
|
104
106
|
},
|
|
105
107
|
{ argv: rawArgs.slice(2), permissive: true }
|
|
@@ -129,6 +131,23 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
|
|
|
129
131
|
}
|
|
130
132
|
logger.info(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
|
|
131
133
|
|
|
134
|
+
// Opt-in, and loud. With row security on, pg_dump stops refusing to read
|
|
135
|
+
// rows it cannot see and starts leaving them out — an exit-0 backup that
|
|
136
|
+
// is quietly short. Anyone choosing that should know they chose it.
|
|
137
|
+
const rowSecurity = args["--enable-row-security"]
|
|
138
|
+
? { uid: "rebase-db-backup", roles: [args["--row-security-role"] || "admin"] }
|
|
139
|
+
: undefined;
|
|
140
|
+
|
|
141
|
+
if (rowSecurity) {
|
|
142
|
+
logger.warn("");
|
|
143
|
+
logger.warn(chalk.yellow(" ⚠ Dumping with row-level security ON."));
|
|
144
|
+
logger.warn(chalk.gray(` Reading as roles [${rowSecurity.roles.join(", ")}], which satisfies the generated`));
|
|
145
|
+
logger.warn(chalk.gray(" `admin_full_access` policy. This backup contains exactly the rows those"));
|
|
146
|
+
logger.warn(chalk.gray(" policies admit — a table whose policies have no admin rule comes out short,"));
|
|
147
|
+
logger.warn(chalk.gray(" and pg_dump will not say so. Prefer granting the dumping role BYPASSRLS."));
|
|
148
|
+
logger.warn("");
|
|
149
|
+
}
|
|
150
|
+
|
|
132
151
|
try {
|
|
133
152
|
if (dest.kind === "local") {
|
|
134
153
|
// Honour an explicit `…/name.dump` path; otherwise treat it as a
|
|
@@ -141,7 +160,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
|
|
|
141
160
|
fileName: explicitFile ? path.basename(dest.path) : undefined,
|
|
142
161
|
excludeSchemas: args["--exclude-schema"],
|
|
143
162
|
noOwner: args["--no-owner"],
|
|
144
|
-
inheritStdio: true
|
|
163
|
+
inheritStdio: true,
|
|
164
|
+
rowSecurity
|
|
145
165
|
});
|
|
146
166
|
await assertDumpValid(dump.localFile);
|
|
147
167
|
logger.info("");
|
|
@@ -156,7 +176,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
|
|
|
156
176
|
dbName,
|
|
157
177
|
excludeSchemas: args["--exclude-schema"],
|
|
158
178
|
noOwner: args["--no-owner"],
|
|
159
|
-
inheritStdio: true
|
|
179
|
+
inheritStdio: true,
|
|
180
|
+
rowSecurity
|
|
160
181
|
});
|
|
161
182
|
try {
|
|
162
183
|
await assertDumpValid(dump.localFile);
|
|
@@ -395,6 +416,24 @@ ${chalk.green.bold("Options")}
|
|
|
395
416
|
${chalk.blue("--out, -o")} <dest> Local path or s3://…/gs://… URL (default: ./backups)
|
|
396
417
|
${chalk.blue("--exclude-schema")} <s> Exclude a schema (repeatable)
|
|
397
418
|
${chalk.blue("--no-owner")} Omit ownership commands from the dump
|
|
419
|
+
${chalk.blue("--enable-row-security")} Dump as an admin subject instead of failing on RLS
|
|
420
|
+
${chalk.red("(may produce a partial dump — see below)")}
|
|
421
|
+
${chalk.blue("--row-security-role")} <r> Role to read as with the flag above (default: admin)
|
|
422
|
+
|
|
423
|
+
${chalk.green.bold("Row-level security")}
|
|
424
|
+
On a managed Postgres the dumping role usually owns nothing and has no
|
|
425
|
+
BYPASSRLS, so pg_dump refuses:
|
|
426
|
+
|
|
427
|
+
ERROR: query would be affected by row-level security policy for table "..."
|
|
428
|
+
|
|
429
|
+
That refusal is the safe behaviour. --enable-row-security replaces it by
|
|
430
|
+
reading as an admin subject: Rebase sets app.uid/app.user_roles so the
|
|
431
|
+
generated admin_full_access policy admits the dump. The dump then contains
|
|
432
|
+
exactly what those policies admit ${chalk.red("and no error is raised for what they do not")} —
|
|
433
|
+
a table whose policies lack an admin rule comes out short, silently.
|
|
434
|
+
|
|
435
|
+
Granting the dumping role BYPASSRLS is the option that keeps a backup
|
|
436
|
+
meaning "every row".
|
|
398
437
|
|
|
399
438
|
${chalk.green.bold("Notes")}
|
|
400
439
|
Backups may contain secrets and PII. Use private storage destinations and
|
|
@@ -18,6 +18,9 @@ import {
|
|
|
18
18
|
buildBackupFilename,
|
|
19
19
|
buildPgDumpArgs,
|
|
20
20
|
buildPgDumpallGlobalsArgs,
|
|
21
|
+
buildRowSecurityPgOptions,
|
|
22
|
+
diagnoseRowSecurityDumpFailure,
|
|
23
|
+
type RowSecurityIdentity,
|
|
21
24
|
buildPgRestoreArgs,
|
|
22
25
|
buildPgRestoreListArgs,
|
|
23
26
|
checkToolServerCompatibility,
|
|
@@ -140,6 +143,15 @@ export async function createDump(opts: {
|
|
|
140
143
|
inheritStdio?: boolean;
|
|
141
144
|
includeGlobals?: boolean;
|
|
142
145
|
env?: Record<string, string | undefined>;
|
|
146
|
+
/**
|
|
147
|
+
* Dump with row security left on, reading as this identity.
|
|
148
|
+
*
|
|
149
|
+
* The escape hatch for a managed Postgres, where the dumping role owns
|
|
150
|
+
* nothing and has no `BYPASSRLS`. Off by default, and deliberately so: with
|
|
151
|
+
* row security on, `pg_dump` stops erroring on rows it cannot see and
|
|
152
|
+
* simply omits them. See {@link RowSecurityIdentity}.
|
|
153
|
+
*/
|
|
154
|
+
rowSecurity?: RowSecurityIdentity;
|
|
143
155
|
}): Promise<BackupResult> {
|
|
144
156
|
const env = opts.env ?? process.env;
|
|
145
157
|
const bin = resolvePgBinary("pg_dump", env);
|
|
@@ -159,13 +171,34 @@ export async function createDump(opts: {
|
|
|
159
171
|
connectionString: opts.connectionString,
|
|
160
172
|
outFile: localFile,
|
|
161
173
|
excludeSchemas: opts.excludeSchemas,
|
|
162
|
-
noOwner: opts.noOwner
|
|
174
|
+
noOwner: opts.noOwner,
|
|
175
|
+
rowSecurity: opts.rowSecurity
|
|
163
176
|
});
|
|
164
177
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
178
|
+
// `PGOPTIONS` is the only way to set a GUC on a tool that takes no SQL.
|
|
179
|
+
// Built from the same object that added `--enable-row-security`, so the
|
|
180
|
+
// flag cannot travel without the identity that makes it safe.
|
|
181
|
+
const dumpEnv: Record<string, string> = { ...(env as Record<string, string>) };
|
|
182
|
+
if (opts.rowSecurity) {
|
|
183
|
+
dumpEnv.PGOPTIONS = [env.PGOPTIONS, buildRowSecurityPgOptions(opts.rowSecurity)]
|
|
184
|
+
.filter(Boolean).join(" ");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await execa(bin, args, {
|
|
189
|
+
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
190
|
+
env: dumpEnv
|
|
191
|
+
});
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// The RLS failure names a table and no cause. Replace it with the
|
|
194
|
+
// cause and the two ways out; anything else is re-thrown untouched.
|
|
195
|
+
const diagnosis = diagnoseRowSecurityDumpFailure(error);
|
|
196
|
+
if (!diagnosis) throw error;
|
|
197
|
+
throw new BackupToolError(
|
|
198
|
+
diagnosis,
|
|
199
|
+
"Run `rebase db backup --help` for the flag, and read what it says about partial dumps."
|
|
200
|
+
);
|
|
201
|
+
}
|
|
169
202
|
|
|
170
203
|
const sizeBytes = fs.existsSync(localFile) ? fs.statSync(localFile).size : 0;
|
|
171
204
|
|
package/src/backup/pg-tools.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* not require a database.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { forLibpq } from "../utils/connection-string";
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* A parsed backup destination. `--out` (and the scheduled-backup config)
|
|
11
13
|
* accepts either a local filesystem path or an object-storage URL.
|
|
@@ -171,6 +173,45 @@ export function joinStorageKey(prefix: string, fileName: string): string {
|
|
|
171
173
|
return clean.length > 0 ? `${clean}/${fileName}` : fileName;
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The identity `pg_dump` reads rows as, when row security is left on.
|
|
178
|
+
*
|
|
179
|
+
* Not optional, and that is the whole design. `pg_dump --enable-row-security`
|
|
180
|
+
* on its own is the dangerous command in this file: it turns the "query would
|
|
181
|
+
* be affected by row-level security policy" *error* into a dump that exits 0
|
|
182
|
+
* and is silently missing every row the dumping role's policies exclude. A
|
|
183
|
+
* backup that looks fine and restores most of your data is worse than one that
|
|
184
|
+
* refused to run.
|
|
185
|
+
*
|
|
186
|
+
* So the flag is unreachable without a subject to evaluate the policies
|
|
187
|
+
* against. Rebase's generated policies read `app.uid` and `app.user_roles`;
|
|
188
|
+
* supplying an admin role satisfies the `admin_full_access` rule and the dump
|
|
189
|
+
* sees everything that rule sees.
|
|
190
|
+
*/
|
|
191
|
+
export interface RowSecurityIdentity {
|
|
192
|
+
/** Written to `app.uid`. Any non-empty value — it is only an audit trail. */
|
|
193
|
+
uid: string;
|
|
194
|
+
/** Written to `app.user_roles`. Must include a role the policies admit. */
|
|
195
|
+
roles: string[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `PGOPTIONS` carrying an identity, for a libpq tool that has no other way to
|
|
200
|
+
* set a GUC.
|
|
201
|
+
*
|
|
202
|
+
* A backslash escape rather than quoting, which is what libpq's `-c` parsing
|
|
203
|
+
* takes: a space inside a value ends the option otherwise, so a role list is
|
|
204
|
+
* comma-joined and never spaced.
|
|
205
|
+
*/
|
|
206
|
+
export function buildRowSecurityPgOptions(identity: RowSecurityIdentity): string {
|
|
207
|
+
const escape = (value: string) => value.replace(/([\\ ])/g, "\\$1");
|
|
208
|
+
return [
|
|
209
|
+
`-c app.uid=${escape(identity.uid)}`,
|
|
210
|
+
`-c app.user_id=${escape(identity.uid)}`,
|
|
211
|
+
`-c app.user_roles=${escape(identity.roles.join(","))}`
|
|
212
|
+
].join(" ");
|
|
213
|
+
}
|
|
214
|
+
|
|
174
215
|
/**
|
|
175
216
|
* Assemble the `pg_dump` argument vector. Uses the custom format (`-Fc`),
|
|
176
217
|
* which is compressed and restorable selectively via `pg_restore`.
|
|
@@ -182,18 +223,70 @@ export function buildPgDumpArgs(opts: {
|
|
|
182
223
|
excludeSchemas?: string[];
|
|
183
224
|
/** Number of parallel jobs (directory format only; ignored for -Fc). */
|
|
184
225
|
noOwner?: boolean;
|
|
226
|
+
/**
|
|
227
|
+
* Dump with row security on, as this identity. Omit — which is the default
|
|
228
|
+
* — and `pg_dump` errors rather than skipping rows it cannot see.
|
|
229
|
+
*/
|
|
230
|
+
rowSecurity?: RowSecurityIdentity;
|
|
185
231
|
}): string[] {
|
|
186
232
|
const args = ["--format=custom", "--no-password", `--file=${opts.outFile}`];
|
|
187
233
|
if (opts.noOwner) {
|
|
188
234
|
args.push("--no-owner");
|
|
189
235
|
}
|
|
236
|
+
if (opts.rowSecurity) {
|
|
237
|
+
args.push("--enable-row-security");
|
|
238
|
+
}
|
|
190
239
|
for (const schema of opts.excludeSchemas ?? []) {
|
|
191
240
|
args.push(`--exclude-schema=${schema}`);
|
|
192
241
|
}
|
|
193
|
-
args.push(opts.connectionString);
|
|
242
|
+
args.push(forLibpq(opts.connectionString));
|
|
194
243
|
return args;
|
|
195
244
|
}
|
|
196
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Whether a `pg_dump` failure is the row-security one, and what to do about it.
|
|
248
|
+
*
|
|
249
|
+
* The error text names the table and nothing else, so the first read of it is
|
|
250
|
+
* "why would a backup be affected by RLS at all?" — the answer being that the
|
|
251
|
+
* dumping role is not the tables' owner and has no `BYPASSRLS`, which is the
|
|
252
|
+
* normal state of the `postgres` user on Cloud SQL, RDS and every other managed
|
|
253
|
+
* Postgres. Nothing about that is visible from the message.
|
|
254
|
+
*
|
|
255
|
+
* Returns `null` for any other failure, so the caller reports it unchanged.
|
|
256
|
+
*/
|
|
257
|
+
export function diagnoseRowSecurityDumpFailure(error: unknown): string | null {
|
|
258
|
+
const text = [
|
|
259
|
+
(error as { stderr?: unknown })?.stderr,
|
|
260
|
+
(error as { message?: unknown })?.message
|
|
261
|
+
].map(part => (typeof part === "string" ? part : "")).join("\n");
|
|
262
|
+
|
|
263
|
+
if (!/row-level security policy/i.test(text)) return null;
|
|
264
|
+
|
|
265
|
+
const table = text.match(/for table "([^"]+)"/)?.[1];
|
|
266
|
+
|
|
267
|
+
return [
|
|
268
|
+
`pg_dump cannot read ${table ? `"${table}"` : "one of the tables"} because row-level security applies to it.`,
|
|
269
|
+
"",
|
|
270
|
+
" The dumping role is neither the table's owner nor `BYPASSRLS`, which is the normal",
|
|
271
|
+
" state of the `postgres` user on Cloud SQL, RDS and other managed Postgres — there is",
|
|
272
|
+
" no superuser to hand out.",
|
|
273
|
+
"",
|
|
274
|
+
" Two ways out:",
|
|
275
|
+
"",
|
|
276
|
+
" • Grant the dumping role BYPASSRLS, or make it the owner, and run this again. The",
|
|
277
|
+
" dump then contains every row, which is what a backup should mean.",
|
|
278
|
+
"",
|
|
279
|
+
" • Re-run with --enable-row-security to dump as an admin subject instead. Rebase",
|
|
280
|
+
" sets `app.uid`/`app.user_roles` so the generated `admin_full_access` policy",
|
|
281
|
+
" admits the dump. Read the warning it prints: the result contains exactly the",
|
|
282
|
+
" rows those policies admit, and any table whose policies do not include an",
|
|
283
|
+
" admin rule comes out short — with no error.",
|
|
284
|
+
"",
|
|
285
|
+
" Do not reach for a bare `pg_dump --enable-row-security` by hand. Without the",
|
|
286
|
+
" settings above it succeeds and silently omits rows."
|
|
287
|
+
].join("\n");
|
|
288
|
+
}
|
|
289
|
+
|
|
197
290
|
/**
|
|
198
291
|
* Assemble the `pg_restore` argument vector for a custom-format dump.
|
|
199
292
|
*/
|
|
@@ -211,7 +304,7 @@ export function buildPgRestoreArgs(opts: {
|
|
|
211
304
|
exitOnError?: boolean;
|
|
212
305
|
noOwner?: boolean;
|
|
213
306
|
}): string[] {
|
|
214
|
-
const args = ["--format=custom", "--no-password", `--dbname=${opts.connectionString}`];
|
|
307
|
+
const args = ["--format=custom", "--no-password", `--dbname=${forLibpq(opts.connectionString)}`];
|
|
215
308
|
if (opts.clean) {
|
|
216
309
|
args.push("--clean", "--if-exists");
|
|
217
310
|
}
|
|
@@ -255,7 +348,7 @@ export function buildPgDumpallGlobalsArgs(opts: {
|
|
|
255
348
|
"--no-role-passwords",
|
|
256
349
|
"--no-password",
|
|
257
350
|
`--file=${opts.outFile}`,
|
|
258
|
-
`--dbname=${opts.connectionString}`
|
|
351
|
+
`--dbname=${forLibpq(opts.connectionString)}`
|
|
259
352
|
];
|
|
260
353
|
}
|
|
261
354
|
|