@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.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 (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -18,9 +18,50 @@ function collection(slug: string): CollectionConfig {
18
18
  } as unknown as CollectionConfig;
19
19
  }
20
20
 
21
- /** Stands in for pg_policies. */
22
- function dbWith(rows: Record<string, unknown>[]): Queryable {
23
- return { query: async () => ({ rows: rows as never[] }) };
21
+ /** A collection whose write rule is declared RESTRICTIVE, so the DDL says `AS RESTRICTIVE`. */
22
+ function restrictiveCollection(slug: string): CollectionConfig {
23
+ return {
24
+ name: slug,
25
+ slug,
26
+ table: slug,
27
+ schema: "public",
28
+ properties: { id: { name: "ID", type: "string", isId: "uuid" } },
29
+ securityRules: [
30
+ { operation: "select", access: "public" },
31
+ { operations: ["insert", "update", "delete"], roles: ["admin"], mode: "restrictive" }
32
+ ]
33
+ } as unknown as CollectionConfig;
34
+ }
35
+
36
+ /**
37
+ * Stands in for the two catalogue reads: `pg_policies`, then `pg_class`.
38
+ *
39
+ * The second one is dispatched on the query text rather than call order, so a
40
+ * test that stops caring about ordering does not silently start feeding policy
41
+ * rows to the RLS-status read — which would report every table as RLS-off,
42
+ * since a policy row has no `relrowsecurity` column and `undefined` is falsy.
43
+ *
44
+ * `rlsOff` names tables whose RLS switch is off. Anything not listed is on,
45
+ * which is what a healthy `db push` leaves behind.
46
+ */
47
+ function dbWith(rows: Record<string, unknown>[], rlsOff: string[] = []): Queryable {
48
+ return {
49
+ query: async (text: string, values?: unknown[]) => {
50
+ if (!text.includes("pg_class")) return { rows: rows as never[] };
51
+ const wanted = (values?.[0] as string[] | undefined) ?? [];
52
+ return {
53
+ rows: wanted.map((qualified) => {
54
+ const [schemaname, tablename] = qualified.split(".");
55
+ return {
56
+ schemaname,
57
+ tablename,
58
+ relrowsecurity: !rlsOff.includes(tablename),
59
+ relforcerowsecurity: !rlsOff.includes(tablename)
60
+ };
61
+ }) as never[]
62
+ };
63
+ }
64
+ };
24
65
  }
25
66
 
26
67
  /** A pg_policies row matching an expected policy, clauses and all. */
@@ -30,6 +71,7 @@ function liveRow(p: PolicyRef, overrides: Record<string, unknown> = {}) {
30
71
  // Postgres rewrites the text it stores; only presence is compared.
31
72
  qual: p.hasUsing ? "(rewritten by postgres)" : null,
32
73
  with_check: p.hasWithCheck ? "(rewritten by postgres)" : null,
74
+ permissive: p.mode ?? "PERMISSIVE",
33
75
  ...overrides
34
76
  };
35
77
  }
@@ -56,6 +98,65 @@ describe("checkPolicyDrift", () => {
56
98
  expect(formatPolicyDrift(drift)).toBe("");
57
99
  });
58
100
 
101
+ it("flags a table whose RLS switch is off, though every policy still matches", async () => {
102
+ // The whole point: DISABLE ROW LEVEL SECURITY leaves pg_policies
103
+ // untouched, so name, roles, command and clause presence all agree and
104
+ // every other check here passes. Before this category the report was
105
+ // empty on a table Postgres was filtering nothing on.
106
+ const cols = [collection("authors")];
107
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
108
+
109
+ const drift = await checkPolicyDrift(
110
+ dbWith(expected.map((p) => liveRow(p)), ["authors"]),
111
+ cols
112
+ );
113
+
114
+ expect(drift.missing).toHaveLength(0);
115
+ expect(drift.diverged).toHaveLength(0);
116
+ expect(drift.orphaned).toHaveLength(0);
117
+ expect(drift.rlsDisabled).toEqual([{ schema: "public", table: "authors", forced: false }]);
118
+ expect(hasDrift(drift)).toBe(true);
119
+ expect(formatPolicyDrift(drift)).toContain("RLS DISABLED");
120
+ });
121
+
122
+ it("flags a RESTRICTIVE rule the database stores as PERMISSIVE", async () => {
123
+ // A gate that should be ANDed in is now ORed in — it widens access
124
+ // instead of narrowing it, with identical name, roles, command and
125
+ // clauses. Only the `AS` clause differs, and it used to be discarded by
126
+ // the DDL parser and never selected from pg_policies.
127
+ const cols = [restrictiveCollection("ledger")];
128
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
129
+ const restrictive = expected.filter((p) => p.mode === "RESTRICTIVE");
130
+ // Guards the test itself: if the generator stops emitting AS RESTRICTIVE
131
+ // this must fail loudly rather than pass by having nothing to compare.
132
+ expect(restrictive.length).toBeGreaterThan(0);
133
+
134
+ const live = expected.map((p) =>
135
+ p.name === restrictive[0].name ? liveRow(p, { permissive: "PERMISSIVE" }) : liveRow(p)
136
+ );
137
+
138
+ const drift = await checkPolicyDrift(dbWith(live), cols);
139
+
140
+ expect(drift.diverged).toHaveLength(1);
141
+ expect(drift.diverged[0].differences.join(" ")).toContain("mode: expected RESTRICTIVE");
142
+ expect(formatPolicyDrift(drift)).toContain("widens access");
143
+ });
144
+
145
+ it("does not invent drift when either side's mode is unreadable", async () => {
146
+ // An older server or a driver that hands back a shape we do not know
147
+ // must not turn into a wall of false positives.
148
+ const cols = [collection("authors")];
149
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
150
+
151
+ const drift = await checkPolicyDrift(
152
+ dbWith(expected.map((p) => liveRow(p, { permissive: null }))),
153
+ cols
154
+ );
155
+
156
+ expect(drift.diverged).toHaveLength(0);
157
+ expect(hasDrift(drift)).toBe(false);
158
+ });
159
+
59
160
  it("flags a policy the database never received as missing", async () => {
60
161
  const drift = await checkPolicyDrift(dbWith([]), [collection("authors")]);
61
162
 
@@ -29,6 +29,20 @@ export interface PolicyRef {
29
29
  hasUsing: boolean;
30
30
  /** Whether a WITH CHECK clause is present at all (not what it says). */
31
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";
32
46
  /**
33
47
  * The live clause text, when read from `pg_policies`. Present only for live
34
48
  * policies (the expected side is parsed from DDL and does not carry it).
@@ -59,6 +73,22 @@ export interface PolicyDrift {
59
73
  * @see reason a sentence naming the clause and what to do.
60
74
  */
61
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 }[];
62
92
  }
63
93
 
64
94
  export interface Queryable {
@@ -98,11 +128,29 @@ function clauseEnd(ddl: string, open: number): number {
98
128
  return ddl.length;
99
129
  }
100
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
+
101
149
  /** Parse the generated DDL rather than rebuilding the shape by hand. */
102
150
  export function parseExpectedPolicies(ddl: string): PolicyRef[] {
103
151
  const found: PolicyRef[] = [];
104
152
  for (const m of ddl.matchAll(CREATE_POLICY)) {
105
- const [, name, schema, table, , command, rolesRaw, clause] = m;
153
+ const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;
106
154
  const roles = rolesRaw
107
155
  .split(",")
108
156
  .map((r) => r.trim().replace(/^"|"$/g, ""))
@@ -115,7 +163,12 @@ export function parseExpectedPolicies(ddl: string): PolicyRef[] {
115
163
  ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))
116
164
  : /WITH CHECK/i.test(clause);
117
165
 
118
- found.push({ schema, table, name, roles, command: command.toUpperCase(), hasUsing, hasWithCheck });
166
+ found.push({
167
+ schema, table, name, roles,
168
+ command: command.toUpperCase(),
169
+ hasUsing, hasWithCheck,
170
+ mode: normalizeMode(modeRaw)
171
+ });
119
172
  }
120
173
  return found;
121
174
  }
@@ -123,9 +176,9 @@ export function parseExpectedPolicies(ddl: string): PolicyRef[] {
123
176
  async function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {
124
177
  const { rows } = await client.query<{
125
178
  schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;
126
- qual: string | null; with_check: string | null;
179
+ qual: string | null; with_check: string | null; permissive: string | boolean | null;
127
180
  }>(
128
- `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check
181
+ `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive
129
182
  FROM pg_policies
130
183
  WHERE schemaname = ANY($1)`,
131
184
  [schemas]
@@ -144,11 +197,51 @@ async function readLivePolicies(client: Queryable, schemas: string[]): Promise<P
144
197
  // drop a clause: NULL here means the policy genuinely has none.
145
198
  hasUsing: r.qual != null,
146
199
  hasWithCheck: r.with_check != null,
200
+ mode: normalizeMode(r.permissive),
147
201
  qual: r.qual,
148
202
  withCheck: r.with_check
149
203
  }));
150
204
  }
151
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
+
152
245
  /**
153
246
  * The permissive tautology `auth.uid() IS NOT NULL`, without the
154
247
  * `<> 'anonymous'` guard that makes it mean "signed in".
@@ -195,13 +288,21 @@ export async function checkPolicyDrift(
195
288
  const schemas = [...new Set(expected.map((p) => p.schema))];
196
289
  // Nothing expected means nothing to reconcile against; scanning every
197
290
  // schema would report the whole database as orphaned.
198
- if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [] };
291
+ if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };
199
292
 
200
293
  const live = await readLivePolicies(client, schemas);
201
294
  const liveByKey = new Map(live.map((p) => [keyOf(p), p]));
202
295
  const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));
203
296
 
204
- const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [] };
297
+ const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };
298
+
299
+ // Before comparing policy against policy: is the switch even on? A table
300
+ // with RLS disabled matches every expected policy on every field compared
301
+ // below, so this has to be asked separately or it cannot be asked at all.
302
+ const expectedTables = [...new Map(
303
+ expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])
304
+ ).values()];
305
+ drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);
205
306
 
206
307
  // Scan every live policy for the permissive tautology. This is deliberately
207
308
  // independent of the name-keyed diff below: a database pushed before the
@@ -234,6 +335,16 @@ export async function checkPolicyDrift(
234
335
  if (want.command !== got.command) {
235
336
  differences.push(`command: expected ${want.command}, database has ${got.command}`);
236
337
  }
338
+ // Skipped when either side is unknown: an unrecognised catalogue shape
339
+ // should not manufacture drift. See `normalizeMode`.
340
+ if (want.mode && got.mode && want.mode !== got.mode) {
341
+ differences.push(
342
+ `mode: expected ${want.mode}, database has ${got.mode}` +
343
+ (got.mode === "PERMISSIVE"
344
+ ? " — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it"
345
+ : " — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it")
346
+ );
347
+ }
237
348
  for (const clause of ["USING", "WITH CHECK"] as const) {
238
349
  const key = clause === "USING" ? "hasUsing" : "hasWithCheck";
239
350
  if (want[key] === got[key]) continue;
@@ -308,13 +419,24 @@ export async function dropOrphanedPolicies(
308
419
  }
309
420
 
310
421
  export const hasDrift = (d: PolicyDrift): boolean =>
311
- d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0;
422
+ d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0
423
+ || d.rlsDisabled.length > 0;
312
424
 
313
425
  /** Human-readable report; empty string when the database matches the config. */
314
426
  export function formatPolicyDrift(drift: PolicyDrift): string {
315
427
  if (!hasDrift(drift)) return "";
316
428
  const lines: string[] = [];
317
429
 
430
+ // First, because it subsumes every other finding on the same table: if RLS
431
+ // is off, the policies listed below are not being applied at all.
432
+ if (drift.rlsDisabled.length > 0) {
433
+ lines.push(" RLS DISABLED — the table has policies, and Postgres is applying none of them:");
434
+ for (const t of drift.rlsDisabled) {
435
+ lines.push(` • ${t.schema}.${t.table}${t.forced ? "" : " (and FORCE is off)"}`);
436
+ }
437
+ lines.push(" Every row is readable and writable by any request that reaches the table.");
438
+ lines.push(` Fix: ALTER TABLE "<schema>"."<table>" ENABLE ROW LEVEL SECURITY; or re-run \`rebase db push\`.`);
439
+ }
318
440
  if (drift.missing.length > 0) {
319
441
  lines.push(" Missing — described by your collections, absent from the database:");
320
442
  for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`);
@@ -1,6 +1,13 @@
1
1
  import { sql as drizzleSql, SQL } from "drizzle-orm";
2
2
  import { ANONYMOUS_USER_ID, PolicyExpression, SecurityRule } from "@rebasepro/types";
3
- import { AnonymousGrantRisk, findAnonymousGrants, securityRuleToConditions } from "@rebasepro/common";
3
+ import {
4
+ AnonymousGrantRisk,
5
+ findAnonymousGrants,
6
+ REBASE_USER_ROLE,
7
+ revokeInternalTableAccess,
8
+ securityRuleToConditions
9
+ } from "@rebasepro/common";
10
+ import { REBASE_SCHEMA, usesLegacyRlsFunctions } from "@rebasepro/types";
4
11
  import { logger } from "@rebasepro/server";
5
12
 
6
13
  /**
@@ -16,9 +23,14 @@ import { logger } from "@rebasepro/server";
16
23
  * are validation/side-effects, not a security boundary.
17
24
  *
18
25
  * - **Server context** — the base (owner) connection: auth flows, migrations,
19
- * background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
20
- * owner it bypasses RLS. This is the trusted plane, equivalent to
21
- * Supabase's `service_role`.
26
+ * and raw `rebase.sql`. As table owner it bypasses RLS. This is the trusted
27
+ * plane, equivalent to Supabase's `service_role`.
28
+ *
29
+ * `rebase.dataAsAdmin` is **not** in it, despite the name. `init.ts` scopes
30
+ * that driver with `withAuth(SERVICE_IDENTITY)`, so it arrives as user
31
+ * context above — `rebase_user`, `app.uid = 'service'`, policies evaluated —
32
+ * and clears the default policies through their admin arm rather than the
33
+ * `auth.uid() IS NULL` one.
22
34
  *
23
35
  * This module provides the three pieces:
24
36
  *
@@ -36,8 +48,14 @@ import { logger } from "@rebasepro/server";
36
48
  * not an operator opt-in.
37
49
  */
38
50
 
39
- /** The restricted role every authenticated (user-context) request runs as. */
40
- export const REBASE_USER_ROLE = "rebase_user";
51
+ /**
52
+ * The restricted role every authenticated (user-context) request runs as.
53
+ *
54
+ * Re-exported, not re-declared: the same name is needed by
55
+ * `@rebasepro/common`'s internal-table revokes, and two spellings of a role name
56
+ * fail as a silent no-op rather than an error.
57
+ */
58
+ export { REBASE_USER_ROLE };
41
59
 
42
60
  /** Minimal SQL runner so callers can adapt drizzle or pg.Client. */
43
61
  export type RawSqlRunner = (sqlText: string) => Promise<Record<string, unknown>[]>;
@@ -69,6 +87,57 @@ const quoteIdent = (name: string): string => `"${name.replace(/"/g, "\"\"")}"`;
69
87
  /** DML the user role holds on managed tables (RLS still filters per row). */
70
88
  const USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
71
89
 
90
+ /**
91
+ * Warn when the connection role shares its name with an existing schema.
92
+ *
93
+ * Postgres resolves unqualified names through `search_path`, which defaults to
94
+ * `"$user", public` — and `$user` is the connection ROLE. When a schema of that
95
+ * name exists it sits ahead of `public`, so every unqualified statement
96
+ * silently operates on it instead:
97
+ *
98
+ * CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
99
+ *
100
+ * Nothing errors. You get a second table of the same name in the wrong schema,
101
+ * and reads that pin `public` cannot see it — which reads as "missing table" and
102
+ * sends people to re-run a push that creates a *third* copy. The bootstrapper
103
+ * has a whole branch dedicated to recognising the symptom after the fact.
104
+ *
105
+ * Rebase shipped straight into this: it creates a schema named `rebase` while
106
+ * every template named the database role `rebase` too. The scaffold uses
107
+ * `rebase_app` now, and every pool Rebase opens pins `search_path=public`
108
+ * (`pinSearchPath`), which covers the paths the framework controls. This covers
109
+ * the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
110
+ * a hand-written migration — because the hazard is a property of the two NAMES,
111
+ * not of any one connection.
112
+ *
113
+ * A warning rather than a boot failure: the database works, the framework's own
114
+ * traffic is pinned, and refusing to start over a naming choice a user may have
115
+ * inherited would be worse than the risk.
116
+ */
117
+ export async function warnOnRoleSchemaCollision(run: RawSqlRunner): Promise<void> {
118
+ try {
119
+ const rows = await run(`
120
+ SELECT current_user AS role,
121
+ EXISTS (
122
+ SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user
123
+ ) AS collides
124
+ `);
125
+ if (rows[0]?.collides !== true) return;
126
+ const role = String(rows[0]?.role ?? "the connection role");
127
+ logger.warn(
128
+ `⚠️ The database role "${role}" has the same name as a schema. Postgres resolves unqualified ` +
129
+ `names through \`search_path\`, which defaults to \`"$user", public\` — so "${role}" is searched ` +
130
+ `BEFORE public, and any unqualified \`CREATE TABLE\`/\`SELECT\` from a tool that does not pin the ` +
131
+ `path (psql, pg_dump, drizzle-kit, a hand-written migration) silently lands in "${role}" instead. ` +
132
+ `Rebase's own connections pin \`search_path=public\`, so the server is unaffected. To remove the ` +
133
+ `hazard entirely, connect as a role whose name is not also a schema — the scaffold uses ` +
134
+ `"rebase_app".`
135
+ );
136
+ } catch {
137
+ // A diagnostic must never be the reason a boot fails.
138
+ }
139
+ }
140
+
72
141
  export async function detectConnectionPosture(run: RawSqlRunner): Promise<ConnectionPosture> {
73
142
  const rows = await run(`
74
143
  SELECT current_user AS role,
@@ -180,9 +249,24 @@ export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promi
180
249
  // migrations), so a migrate can never strand the user role.
181
250
  await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
182
251
  await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
252
+
253
+ // The grants above are deliberately schema-wide — a project's own
254
+ // collections may live in `rebase`, and future tables must be reachable
255
+ // or a migration strands the role. Rebase's OWN tables are the exception:
256
+ // refresh tokens, MFA secrets, API keys and the rest carry no RLS and no
257
+ // row an end user should ever address. Taking the privilege back here
258
+ // covers every table that already exists; each creator revokes on the
259
+ // table it just made, for the boot that creates them for the first time.
260
+ await revokeInternalTableAccess(async (text) => { await run(text); }, schema, {
261
+ onError: (table, error) => logger.warn(
262
+ `🔐 [rls] Could not revoke "${REBASE_USER_ROLE}" access to "${schema}"."${table}" — ` +
263
+ "it stays reachable by authenticated requests: " +
264
+ (error instanceof Error ? error.message : String(error))
265
+ )
266
+ });
183
267
  }
184
268
 
185
- logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
269
+ logger.debug(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
186
270
  }
187
271
 
188
272
  /**
@@ -293,6 +377,65 @@ export function warnOnAnonymousGrants(
293
377
  );
294
378
  }
295
379
 
380
+ /**
381
+ * Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
382
+ *
383
+ * The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
384
+ * database, so nothing is broken and no policy is wrong — which is exactly why
385
+ * this has to be said out loud. A silent rewrite that works forever is not a
386
+ * migration, it is a second supported spelling nobody wrote down, and the next
387
+ * person to read those rules will copy the old one.
388
+ *
389
+ * Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
390
+ * `policy.rolesOverlap(...)`) compile from the model and were never affected.
391
+ */
392
+ export function warnOnLegacyRlsFunctions(
393
+ collections: { slug?: string; securityRules?: readonly SecurityRule[] }[]
394
+ ): void {
395
+ const sites: string[] = [];
396
+
397
+ for (const collection of collections) {
398
+ for (const rule of collection.securityRules ?? []) {
399
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
400
+ const carriesLegacy = [usingExpr, withCheckExpr]
401
+ .filter((e): e is PolicyExpression => e !== null)
402
+ .some(containsLegacyRlsCall);
403
+ if (!carriesLegacy) continue;
404
+
405
+ const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
406
+ if (!sites.includes(site)) sites.push(site);
407
+ }
408
+ }
409
+
410
+ if (sites.length === 0) return;
411
+
412
+ logger.warn(
413
+ `These security rules call the pre-1.0 RLS helpers (\`auth.uid()\`, \`auth.roles()\`, \`auth.jwt()\`). ` +
414
+ `They still work — the compiler rewrites them — but the functions now live in the \`rebase\` schema, ` +
415
+ `and the \`auth\` one is Supabase's. Update the raw SQL in these rules to \`${REBASE_SCHEMA}.uid()\` ` +
416
+ `and friends, or switch them to the structured helpers (\`policy.authUid()\`, \`policy.rolesOverlap()\`), ` +
417
+ `which never had to be spelled by hand:\n\n` +
418
+ sites.map(s => ` • ${s}`).join("\n") + "\n"
419
+ );
420
+ }
421
+
422
+ /** Whether any `raw` expression in the tree calls a pre-1.0 helper. */
423
+ function containsLegacyRlsCall(expr: PolicyExpression): boolean {
424
+ switch (expr.kind) {
425
+ case "raw":
426
+ return usesLegacyRlsFunctions(expr.sql);
427
+ case "and":
428
+ case "or":
429
+ return expr.operands.some(containsLegacyRlsCall);
430
+ case "not":
431
+ return containsLegacyRlsCall(expr.operand);
432
+ case "existsIn":
433
+ return containsLegacyRlsCall(expr.where);
434
+ default:
435
+ return false;
436
+ }
437
+ }
438
+
296
439
  /**
297
440
  * Reject `pgRoles` that this server can never satisfy.
298
441
  *
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { sql } from "drizzle-orm";
11
11
  import { BranchInfo } from "@rebasepro/types";
12
+ import { revokeInternalTableSql } from "@rebasepro/common";
12
13
  import { DrizzleClient } from "../interfaces";
13
14
  import { DatabasePoolManager } from "../databasePoolManager";
14
15
  import { extractPgError, extractCauseMessage } from "../utils/pg-error-utils";
@@ -124,6 +125,10 @@ export class BranchService {
124
125
  metadata JSONB DEFAULT '{}'
125
126
  );
126
127
  `));
128
+
129
+ // Not a collection, so no RLS — and it names every branch database on
130
+ // this server. The driver's schema-wide grant reaches it, so revoke.
131
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
127
132
  }
128
133
 
129
134
  /**