@rebasepro/server-postgres 0.17.3 → 0.18.0

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.
Files changed (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,473 +0,0 @@
1
- /**
2
- * Compare the RLS policies a database actually has against the ones the
3
- * collections describe.
4
- *
5
- * Policies live in Postgres; the collection config is only their *source*.
6
- * Nothing reconciled the two, which is how the demo database served empty
7
- * collections indefinitely: its policies granted `TO authenticated` (a Supabase
8
- * role name) while requests run as `rebase_user`, so RLS filtered every row.
9
- * The config was later corrected and the database never noticed — an empty
10
- * table is indistinguishable from a table with no data.
11
- *
12
- * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same
13
- * function `db push` uses to write `drizzle/policies.sql`, so this compares
14
- * against exactly what would be applied rather than a reimplementation.
15
- */
16
- import { RLS_UID_SQL, type CollectionConfig } from "@rebasepro/types";
17
-
18
- import { generatePostgresPoliciesDdl } from "../schema/generate-postgres-ddl-logic";
19
-
20
- export interface PolicyRef {
21
- schema: string;
22
- table: string;
23
- name: string;
24
- /** Roles in the TO clause. */
25
- roles: string[];
26
- /** SELECT / INSERT / UPDATE / DELETE / ALL. */
27
- command: string;
28
- /** Whether a USING clause is present at all (not what it says). */
29
- hasUsing: boolean;
30
- /** Whether a WITH CHECK clause is present at all (not what it says). */
31
- hasWithCheck: boolean;
32
- /**
33
- * PERMISSIVE or RESTRICTIVE — the `AS` clause.
34
- *
35
- * An exact catalogue value on both sides, so it belongs with roles and
36
- * command rather than with the expression text. It matters more than either:
37
- * permissive policies are ORed together and restrictive ones ANDed, so a rule
38
- * declared `mode: "restrictive"` whose live policy is PERMISSIVE has had its
39
- * gate turned from a requirement into an alternative — the maximally
40
- * permissive way for this to be wrong.
41
- *
42
- * The DDL regex captured this from the start and the destructuring threw it
43
- * away; `pg_policies.permissive` was never selected.
44
- */
45
- mode?: "PERMISSIVE" | "RESTRICTIVE";
46
- /**
47
- * The live clause text, when read from `pg_policies`. Present only for live
48
- * policies (the expected side is parsed from DDL and does not carry it).
49
- * Used solely for the insecure-tautology scan, not for divergence — Postgres
50
- * rewrites this text, so it is not safe to diff against expected.
51
- */
52
- qual?: string | null;
53
- withCheck?: string | null;
54
- }
55
-
56
- export interface PolicyDrift {
57
- /** Described by the collections, absent from the database. */
58
- missing: PolicyRef[];
59
- /** In the database, described by no collection — stale pushes live here. */
60
- orphaned: PolicyRef[];
61
- /** Same policy name, different roles or command. */
62
- diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];
63
- /**
64
- * A live policy whose expression is the known-permissive tautology
65
- * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the
66
- * user path coerces a blank id to the `'anonymous'` sentinel. This is what
67
- * `policy.authenticated()` used to compile to, so a database pushed before
68
- * that fix carries it, and neither the name, roles, command nor clause
69
- * *presence* differs from the corrected policy — the only thing that changed
70
- * is the expression text, which this checker otherwise (correctly) ignores.
71
- * So it is the one drift that hides from every other check here.
72
- *
73
- * @see reason a sentence naming the clause and what to do.
74
- */
75
- insecure: { policy: PolicyRef; reason: string }[];
76
- /**
77
- * A table the collections describe whose RLS switch is off.
78
- *
79
- * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in
80
- * `pg_policies` untouched, so before this category every expected policy
81
- * still matched on name, roles, command and clause presence and the checker
82
- * reported clean — on a table Postgres was applying no filter to at all.
83
- * Requests run as `rebase_user`, which holds full DML, so the table is wide
84
- * open while `doctor` certifies it.
85
- *
86
- * `forced` reports `relforcerowsecurity`, which is what also subjects the
87
- * table's *owner* to its policies. Its absence is not drift on its own —
88
- * Rebase does not connect as the owner in the request path — so it is
89
- * reported for context rather than raised as a failure.
90
- */
91
- rlsDisabled: { schema: string; table: string; forced: boolean }[];
92
- }
93
-
94
- export interface Queryable {
95
- query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;
96
- }
97
-
98
- // The trailing group captures whichever clause follows the TO list, which is
99
- // what tells us the clause is present.
100
- const CREATE_POLICY = /CREATE POLICY "([^"]+)" ON "([^"]+)"\."([^"]+)"\s+AS (\w+)\s+FOR (\w+)\s+TO ([^\n]+?)(\s+USING\s*\(|\s+WITH CHECK\s*\(|;)/gi;
101
-
102
- const WITH_CHECK_NEXT = /^\s+WITH CHECK\s*\(/i;
103
-
104
- /**
105
- * Index just past the `)` closing a clause whose `(` ends at `open`.
106
- *
107
- * Needed because a policy expression nests parens and can contain a quoted
108
- * literal holding either character, so "find the next `)`" would stop early and
109
- * miss the `WITH CHECK` that follows.
110
- */
111
- function clauseEnd(ddl: string, open: number): number {
112
- let depth = 1;
113
- let inQuote = false;
114
- for (let i = open; i < ddl.length; i++) {
115
- const c = ddl[i];
116
- if (inQuote) {
117
- // '' is an escaped quote inside a string, not a close.
118
- if (c === "'") {
119
- if (ddl[i + 1] === "'") i++;
120
- else inQuote = false;
121
- }
122
- continue;
123
- }
124
- if (c === "'") inQuote = true;
125
- else if (c === "(") depth++;
126
- else if (c === ")" && --depth === 0) return i + 1;
127
- }
128
- return ddl.length;
129
- }
130
-
131
- /**
132
- * `AS PERMISSIVE` / `AS RESTRICTIVE` from either side, or undefined.
133
- *
134
- * The DDL spells it as a bare word; `pg_policies.permissive` is the string
135
- * `"PERMISSIVE"`/`"RESTRICTIVE"` on modern Postgres but a boolean on some
136
- * drivers and older servers, so both are accepted. Anything unrecognised
137
- * becomes `undefined` and the comparison below skips it rather than inventing
138
- * drift from a shape we did not anticipate.
139
- */
140
- function normalizeMode(raw: unknown): "PERMISSIVE" | "RESTRICTIVE" | undefined {
141
- if (typeof raw === "boolean") return raw ? "PERMISSIVE" : "RESTRICTIVE";
142
- if (typeof raw !== "string") return undefined;
143
- const upper = raw.trim().toUpperCase();
144
- if (upper === "PERMISSIVE" || upper === "TRUE" || upper === "T") return "PERMISSIVE";
145
- if (upper === "RESTRICTIVE" || upper === "FALSE" || upper === "F") return "RESTRICTIVE";
146
- return undefined;
147
- }
148
-
149
- /** Parse the generated DDL rather than rebuilding the shape by hand. */
150
- export function parseExpectedPolicies(ddl: string): PolicyRef[] {
151
- const found: PolicyRef[] = [];
152
- for (const m of ddl.matchAll(CREATE_POLICY)) {
153
- const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;
154
- const roles = rolesRaw
155
- .split(",")
156
- .map((r) => r.trim().replace(/^"|"$/g, ""))
157
- .filter(Boolean);
158
-
159
- // The generator emits USING before WITH CHECK, so what follows the TO
160
- // list settles USING; WITH CHECK is then whatever follows that clause.
161
- const hasUsing = /USING/i.test(clause);
162
- const hasWithCheck = hasUsing
163
- ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))
164
- : /WITH CHECK/i.test(clause);
165
-
166
- found.push({
167
- schema, table, name, roles,
168
- command: command.toUpperCase(),
169
- hasUsing, hasWithCheck,
170
- mode: normalizeMode(modeRaw)
171
- });
172
- }
173
- return found;
174
- }
175
-
176
- async function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {
177
- const { rows } = await client.query<{
178
- schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;
179
- qual: string | null; with_check: string | null; permissive: string | boolean | null;
180
- }>(
181
- `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive
182
- FROM pg_policies
183
- WHERE schemaname = ANY($1)`,
184
- [schemas]
185
- );
186
-
187
- return rows.map((r) => ({
188
- schema: r.schemaname,
189
- table: r.tablename,
190
- name: r.policyname,
191
- // node-postgres yields text[] as an array; some drivers hand back "{a,b}".
192
- roles: Array.isArray(r.roles)
193
- ? r.roles
194
- : String(r.roles ?? "").replace(/^\{|\}$/g, "").split(",").filter(Boolean),
195
- command: (r.cmd ?? "ALL").toUpperCase(),
196
- // Presence only. Postgres rewrites the text, but it does not invent or
197
- // drop a clause: NULL here means the policy genuinely has none.
198
- hasUsing: r.qual != null,
199
- hasWithCheck: r.with_check != null,
200
- mode: normalizeMode(r.permissive),
201
- qual: r.qual,
202
- withCheck: r.with_check
203
- }));
204
- }
205
-
206
- /**
207
- * Tables the collections describe whose RLS switch is off.
208
- *
209
- * Read from `pg_class` because `pg_policies` cannot answer it: disabling RLS
210
- * leaves the policy rows in place, so every other comparison in this file keeps
211
- * matching while Postgres applies none of them.
212
- *
213
- * Only tables that expect at least one policy are considered. A table with no
214
- * declared rules is not asserting anything about RLS, and reporting it would
215
- * make the check noisy on exactly the projects least able to judge the noise.
216
- */
217
- async function readTablesWithRlsOff(
218
- client: Queryable,
219
- tables: { schema: string; table: string }[]
220
- ): Promise<{ schema: string; table: string; forced: boolean }[]> {
221
- if (tables.length === 0) return [];
222
- const { rows } = await client.query<{
223
- schemaname: string; tablename: string; relrowsecurity: boolean; relforcerowsecurity: boolean;
224
- }>(
225
- `SELECT n.nspname AS schemaname,
226
- c.relname AS tablename,
227
- c.relrowsecurity,
228
- c.relforcerowsecurity
229
- FROM pg_class c
230
- JOIN pg_namespace n ON n.oid = c.relnamespace
231
- WHERE c.relkind = 'r'
232
- AND (n.nspname || '.' || c.relname) = ANY($1)`,
233
- [tables.map((t) => `${t.schema}.${t.table}`)]
234
- );
235
-
236
- return rows
237
- .filter((r) => !r.relrowsecurity)
238
- .map((r) => ({
239
- schema: r.schemaname,
240
- table: r.tablename,
241
- forced: !!r.relforcerowsecurity
242
- }));
243
- }
244
-
245
- /**
246
- * The permissive tautology `rebase.uid() IS NOT NULL`, without the
247
- * `<> 'anonymous'` guard that makes it mean "signed in".
248
- *
249
- * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The
250
- * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the
251
- * corrected policy from the stale one, so its presence clears the text.
252
- *
253
- * Both schema spellings are matched. The helpers moved from `auth` to `rebase`
254
- * in 1.0, and this check reads policies *as the database stored them*: a
255
- * database pushed before the move still holds `auth.uid()` until it is
256
- * recompiled, while everything written since — including raw `securityRules`
257
- * SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.
258
- * Matching only the pre-1.0 name made the check blind to every policy a current
259
- * release could write, which is precisely the set still worth scanning.
260
- */
261
- function isPermissiveAuthTautology(clause: string | null | undefined): boolean {
262
- if (!clause) return false;
263
- const flat = clause.toLowerCase().replace(/\s+/g, " ");
264
- if (!/\b(?:rebase|auth)\.uid\s*\(\s*\)\s*is not null/.test(flat)) return false;
265
- // The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the
266
- // literal as `'anonymous'::text`. Either spelling means it is the corrected
267
- // policy, not the tautology.
268
- return !/<>\s*'anonymous'/.test(flat) && !/!=\s*'anonymous'/.test(flat);
269
- }
270
-
271
- const keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;
272
- const sameRoles = (a: string[], b: string[]) =>
273
- a.length === b.length && [...a].sort().join(",") === [...b].sort().join(",");
274
-
275
- /**
276
- * Diff expected against live.
277
- *
278
- * Compares names, roles, command, and whether each clause exists — all exact
279
- * values. Policy expression *text* is deliberately not compared: Postgres
280
- * rewrites `qual`/`with_check` when storing them (parenthesising, casting,
281
- * schema-qualifying), so text comparison reports drift that does not exist, and
282
- * a check that cries wolf gets ignored.
283
- *
284
- * Presence is not text, though. A NULL `qual` is not a rewrite of an
285
- * expression, it is the absence of one, and absence has no false-positive risk:
286
- * either the generator emitted a clause or it did not. That distinction is worth
287
- * the extra comparison — a production database was found with a SELECT policy
288
- * whose `qual` was NULL, matching on every field this checked and denying 100%
289
- * of reads. The same blindness would hide a policy that fails open.
290
- */
291
- export async function checkPolicyDrift(
292
- client: Queryable,
293
- collections: CollectionConfig[]
294
- ): Promise<PolicyDrift> {
295
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));
296
- const schemas = [...new Set(expected.map((p) => p.schema))];
297
- // Nothing expected means nothing to reconcile against; scanning every
298
- // schema would report the whole database as orphaned.
299
- if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };
300
-
301
- const live = await readLivePolicies(client, schemas);
302
- const liveByKey = new Map(live.map((p) => [keyOf(p), p]));
303
- const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));
304
-
305
- const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };
306
-
307
- // Before comparing policy against policy: is the switch even on? A table
308
- // with RLS disabled matches every expected policy on every field compared
309
- // below, so this has to be asked separately or it cannot be asked at all.
310
- const expectedTables = [...new Map(
311
- expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])
312
- ).values()];
313
- drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);
314
-
315
- // Scan every live policy for the permissive tautology. This is deliberately
316
- // independent of the name-keyed diff below: a database pushed before the
317
- // `authenticated()` fix matches its expected policy on name, roles, command
318
- // and clause presence, so nothing else here would flag it.
319
- for (const p of live) {
320
- const clause = isPermissiveAuthTautology(p.qual)
321
- ? "USING"
322
- : isPermissiveAuthTautology(p.withCheck) ? "WITH CHECK" : null;
323
- if (clause) {
324
- drift.insecure.push({
325
- policy: p,
326
- reason: `${clause} is \`${RLS_UID_SQL} IS NOT NULL\`, which is true for anonymous ` +
327
- `visitors too — this grants access to signed-out requests. It predates the ` +
328
- `\`policy.authenticated()\` fix; re-run \`rebase db push\` to tighten it.`
329
- });
330
- }
331
- }
332
-
333
- for (const [key, want] of expectedByKey) {
334
- const got = liveByKey.get(key);
335
- if (!got) {
336
- drift.missing.push(want);
337
- continue;
338
- }
339
- const differences: string[] = [];
340
- if (!sameRoles(want.roles, got.roles)) {
341
- differences.push(`roles: expected [${want.roles.join(", ")}], database has [${got.roles.join(", ")}]`);
342
- }
343
- if (want.command !== got.command) {
344
- differences.push(`command: expected ${want.command}, database has ${got.command}`);
345
- }
346
- // Skipped when either side is unknown: an unrecognised catalogue shape
347
- // should not manufacture drift. See `normalizeMode`.
348
- if (want.mode && got.mode && want.mode !== got.mode) {
349
- differences.push(
350
- `mode: expected ${want.mode}, database has ${got.mode}` +
351
- (got.mode === "PERMISSIVE"
352
- ? " — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it"
353
- : " — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it")
354
- );
355
- }
356
- for (const clause of ["USING", "WITH CHECK"] as const) {
357
- const key = clause === "USING" ? "hasUsing" : "hasWithCheck";
358
- if (want[key] === got[key]) continue;
359
- differences.push(want[key]
360
- ? `${clause}: expected an expression, database has none — this policy matches no rows`
361
- : `${clause}: expected none, database has an expression`);
362
- }
363
- if (differences.length > 0) drift.diverged.push({ expected: want, actual: got, differences });
364
- }
365
-
366
- for (const [key, got] of liveByKey) {
367
- if (!expectedByKey.has(key)) drift.orphaned.push(got);
368
- }
369
-
370
- return drift;
371
- }
372
-
373
- /**
374
- * Does this name look like one the generator produced for this table?
375
- *
376
- * Unnamed rules compile to `<table>_<op>_<sha1[0:7]>` (plus `_<idx>` when one
377
- * rule spans several operations), and the hash covers the rule's semantics — so
378
- * *editing* a rule renames its policy. The policy under the old name is left
379
- * behind by `db push`, which only DROPs the names it is about to CREATE, and
380
- * Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps
381
- * granting everything no matter how tight its replacement is.
382
- *
383
- * Matching the shape is what makes dropping them safe. A hand-written policy
384
- * would have to collide with a 7-hex digest to be mistaken for generated one;
385
- * a policy named anything else is left alone and merely reported, because a
386
- * custom name is indistinguishable from one someone wrote in SQL on purpose.
387
- */
388
- export function isGeneratedPolicyName(name: string, table: string): boolean {
389
- return new RegExp(`^${table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\d+)?$`)
390
- .test(name);
391
- }
392
-
393
- export interface OrphanCleanup {
394
- /** Superseded generated policies that were dropped. */
395
- dropped: PolicyRef[];
396
- /** Orphans left in place because their names are not generator-shaped. */
397
- kept: PolicyRef[];
398
- }
399
-
400
- /**
401
- * Drop the policies an earlier push superseded but never removed.
402
- *
403
- * Only touches tables the collections describe — a table with no expected
404
- * policy is not ours to reconcile, and scanning by schema alone would sweep up
405
- * policies belonging to something else sharing the database.
406
- */
407
- export async function dropOrphanedPolicies(
408
- client: Queryable,
409
- drift: PolicyDrift,
410
- collections: CollectionConfig[]
411
- ): Promise<OrphanCleanup> {
412
- const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));
413
- const managed = new Set(expected.map((p) => `${p.schema}.${p.table}`));
414
-
415
- const cleanup: OrphanCleanup = { dropped: [], kept: [] };
416
- for (const p of drift.orphaned) {
417
- if (!managed.has(`${p.schema}.${p.table}`) || !isGeneratedPolicyName(p.name, p.table)) {
418
- cleanup.kept.push(p);
419
- continue;
420
- }
421
- // Identifiers are quoted, and the name came from pg_policies rather than
422
- // from user input, so it is already a valid identifier.
423
- await client.query(`DROP POLICY IF EXISTS "${p.name}" ON "${p.schema}"."${p.table}"`);
424
- cleanup.dropped.push(p);
425
- }
426
- return cleanup;
427
- }
428
-
429
- export const hasDrift = (d: PolicyDrift): boolean =>
430
- d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0
431
- || d.rlsDisabled.length > 0;
432
-
433
- /** Human-readable report; empty string when the database matches the config. */
434
- export function formatPolicyDrift(drift: PolicyDrift): string {
435
- if (!hasDrift(drift)) return "";
436
- const lines: string[] = [];
437
-
438
- // First, because it subsumes every other finding on the same table: if RLS
439
- // is off, the policies listed below are not being applied at all.
440
- if (drift.rlsDisabled.length > 0) {
441
- lines.push(" RLS DISABLED — the table has policies, and Postgres is applying none of them:");
442
- for (const t of drift.rlsDisabled) {
443
- lines.push(` • ${t.schema}.${t.table}${t.forced ? "" : " (and FORCE is off)"}`);
444
- }
445
- lines.push(" Every row is readable and writable by any request that reaches the table.");
446
- lines.push(` Fix: ALTER TABLE "<schema>"."<table>" ENABLE ROW LEVEL SECURITY; or re-run \`rebase db push\`.`);
447
- }
448
- if (drift.missing.length > 0) {
449
- lines.push(" Missing — described by your collections, absent from the database:");
450
- for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`);
451
- lines.push(" Run `rebase db push` to apply them.");
452
- }
453
- if (drift.orphaned.length > 0) {
454
- lines.push(" Orphaned — in the database, described by no collection:");
455
- for (const p of drift.orphaned) lines.push(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`);
456
- lines.push(" Left behind by an earlier push. These still filter rows.");
457
- }
458
- if (drift.diverged.length > 0) {
459
- lines.push(" Diverged — same policy, different definition:");
460
- for (const d of drift.diverged) {
461
- lines.push(` • ${d.expected.schema}.${d.expected.table} → "${d.expected.name}"`);
462
- for (const diff of d.differences) lines.push(` ${diff}`);
463
- }
464
- }
465
- if (drift.insecure.length > 0) {
466
- lines.push(" Insecure — a live policy grants access it should not:");
467
- for (const i of drift.insecure) {
468
- lines.push(` • ${i.policy.schema}.${i.policy.table} → "${i.policy.name}"`);
469
- lines.push(` ${i.reason}`);
470
- }
471
- }
472
- return lines.join("\n");
473
- }