@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,505 +0,0 @@
1
- import { sql as drizzleSql, SQL } from "drizzle-orm";
2
- import { ANONYMOUS_USER_ID, PolicyExpression, SecurityRule } from "@rebasepro/types";
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";
11
- import { logger } from "@rebasepro/server";
12
-
13
- /**
14
- * Unified RLS enforcement — the "user context vs server context" model.
15
- *
16
- * Every operation runs in one of two contexts:
17
- *
18
- * - **User context** — a request authenticated (or anonymous) via
19
- * `driver.withAuth(user)`. Runs as the restricted `rebase_user` role: a
20
- * non-owner, NOSUPERUSER, NOBYPASSRLS role, so Postgres RLS binds *every*
21
- * statement (SELECT, INSERT, UPDATE, DELETE). The collection's
22
- * `securityRules` are the whole authorization model; app-layer callbacks
23
- * are validation/side-effects, not a security boundary.
24
- *
25
- * - **Server context** — the base (owner) connection: auth flows, migrations,
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
- * `rebase.uid() IS NULL` one.
34
- *
35
- * This module provides the three pieces:
36
- *
37
- * 1. {@link detectConnectionPosture} — is the connection subject to RLS at
38
- * all? (superuser / BYPASSRLS / table owner ⇒ no)
39
- * 2. {@link ensureAppRole} — idempotently provision `rebase_user` with
40
- * SELECT/INSERT/UPDATE/DELETE grants (+ default privileges so future
41
- * tables stay covered).
42
- * 3. {@link applyAuthContext} — per-transaction: set the `app.*` GUCs the
43
- * policies read (`rebase.uid()` etc.) and `SET LOCAL ROLE rebase_user` so
44
- * RLS binds. Transaction-scoped, so it composes with poolers.
45
- *
46
- * Provisioning runs from the framework's own bootstrap/migrate (which already
47
- * self-creates the `auth` schema and functions) — enforcement is default-on,
48
- * not an operator opt-in.
49
- */
50
-
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 };
59
-
60
- /** Minimal SQL runner so callers can adapt drizzle or pg.Client. */
61
- export type RawSqlRunner = (sqlText: string) => Promise<Record<string, unknown>[]>;
62
-
63
- /** Minimal transaction surface needed by {@link applyAuthContext}. */
64
- export interface SqlTx {
65
- execute(query: SQL): Promise<unknown>;
66
- }
67
-
68
- export interface ConnectionPosture {
69
- /** The connection's `current_user`. */
70
- role: string;
71
- superuser: boolean;
72
- bypassRLS: boolean;
73
- /** Owns at least one user table — owners bypass non-FORCE RLS. */
74
- ownsTables: boolean;
75
- /** True when RLS would NOT constrain this connection. */
76
- privileged: boolean;
77
- }
78
-
79
- export interface AuthContext {
80
- uid: string;
81
- /** Raw roles as carried on the user (strings or `{ id }` objects). */
82
- roles: unknown[];
83
- }
84
-
85
- const quoteIdent = (name: string): string => `"${name.replace(/"/g, "\"\"")}"`;
86
-
87
- /** DML the user role holds on managed tables (RLS still filters per row). */
88
- const USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
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
-
141
- export async function detectConnectionPosture(run: RawSqlRunner): Promise<ConnectionPosture> {
142
- const rows = await run(`
143
- SELECT current_user AS role,
144
- r.rolsuper AS superuser,
145
- r.rolbypassrls AS bypassrls,
146
- EXISTS (
147
- SELECT 1 FROM pg_tables t
148
- WHERE t.tableowner = current_user
149
- AND t.schemaname NOT IN ('pg_catalog', 'information_schema')
150
- ) AS owns_tables
151
- FROM pg_roles r
152
- WHERE r.rolname = current_user
153
- `);
154
- const row = rows[0] ?? {};
155
- const superuser = row.superuser === true;
156
- const bypassRLS = row.bypassrls === true;
157
- const ownsTables = row.owns_tables === true;
158
- return {
159
- role: String(row.role ?? "unknown"),
160
- superuser,
161
- bypassRLS,
162
- ownsTables,
163
- privileged: superuser || bypassRLS || ownsTables
164
- };
165
- }
166
-
167
- /**
168
- * Human-actionable instructions for when the connection cannot provision the
169
- * user role itself (no CREATEROLE and role not pre-created by the platform).
170
- */
171
- export function appRoleSetupInstructions(connectionRole: string, schemas: string[]): string {
172
- const grants = schemas.map((s) =>
173
- `GRANT USAGE ON SCHEMA ${quoteIdent(s)} TO ${REBASE_USER_ROLE};\n` +
174
- `GRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${quoteIdent(s)} TO ${REBASE_USER_ROLE};\n` +
175
- `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${quoteIdent(s)} TO ${REBASE_USER_ROLE};`
176
- ).join("\n");
177
- return (
178
- `Rebase enforces row-level security by running authenticated requests as ` +
179
- `the restricted role "${REBASE_USER_ROLE}", but the connection role ` +
180
- `"${connectionRole}" bypasses RLS and cannot create that role itself.\n` +
181
- `Run the following as a database administrator, then restart:\n\n` +
182
- `CREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT;\n` +
183
- `GRANT ${REBASE_USER_ROLE} TO ${quoteIdent(connectionRole)};\n` +
184
- grants
185
- );
186
- }
187
-
188
- /**
189
- * Idempotently provision the `rebase_user` role, membership for the current
190
- * connection role, and DML grants (+ default privileges for future tables)
191
- * on every existing schema in `schemas`.
192
- *
193
- * Split into privilege tiers so it works both when the connection is a
194
- * superuser (creates everything) and when the platform pre-created the role
195
- * and membership (e.g. CNPG `postInitApplicationSQL`) and the connection is
196
- * merely the table owner — owners can always run the grant tier themselves.
197
- *
198
- * RLS still filters every row: these grants only make the tables *reachable*
199
- * by the role; the policies decide which rows/commands actually pass.
200
- *
201
- * Throws with precise setup instructions when the role is missing and the
202
- * connection cannot create it.
203
- */
204
- export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promise<void> {
205
- const uniqueSchemas = Array.from(new Set(schemas.filter(Boolean)));
206
-
207
- // Tier 1 — role existence.
208
- const roleRows = await run(`SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}'`);
209
- if (roleRows.length === 0) {
210
- try {
211
- await run(`CREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT`);
212
- } catch (err) {
213
- throw new Error(
214
- `Failed to create the "${REBASE_USER_ROLE}" role: ${err instanceof Error ? err.message : String(err)}\n\n` +
215
- appRoleSetupInstructions("current connection role", uniqueSchemas)
216
- );
217
- }
218
- }
219
-
220
- // Tier 2 — membership, so a non-superuser connection may SET ROLE to it.
221
- const memberRows = await run(`
222
- SELECT (pg_has_role(current_user, '${REBASE_USER_ROLE}', 'MEMBER')
223
- OR (SELECT rolsuper FROM pg_roles WHERE rolname = current_user)) AS can_set,
224
- current_user AS role
225
- `);
226
- if (memberRows[0]?.can_set !== true) {
227
- try {
228
- await run(`GRANT ${REBASE_USER_ROLE} TO CURRENT_USER`);
229
- } catch (err) {
230
- throw new Error(
231
- `The connection role is not a member of "${REBASE_USER_ROLE}" and cannot grant itself membership: ` +
232
- `${err instanceof Error ? err.message : String(err)}\n\n` +
233
- appRoleSetupInstructions(String(memberRows[0]?.role ?? "current connection role"), uniqueSchemas)
234
- );
235
- }
236
- }
237
-
238
- // Tier 3 — grants. Table owners (the expected non-superuser posture) can
239
- // always grant on their own objects, so this tier needs no extra privilege.
240
- const nspRows = await run("SELECT nspname FROM pg_namespace");
241
- const existing = new Set(nspRows.map((r) => String(r.nspname)));
242
- for (const schema of uniqueSchemas) {
243
- if (!existing.has(schema)) continue;
244
- const s = quoteIdent(schema);
245
- await run(`GRANT USAGE ON SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
246
- await run(`GRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
247
- await run(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
248
- // Cover objects created later by the CURRENT role (the role that runs
249
- // migrations), so a migrate can never strand the user role.
250
- await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
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
- });
267
- }
268
-
269
- logger.debug(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
270
- }
271
-
272
- /**
273
- * Apply the authenticated context to a transaction: the `app.*` GUCs that RLS
274
- * policies read via `rebase.uid()` / `rebase.roles()` / `rebase.jwt()`, and — when
275
- * `userRole` is set — `SET LOCAL ROLE` so RLS binds every statement in this
276
- * transaction (reads *and* writes).
277
- *
278
- * GUCs are set with `is_local = true` and the role switch is `LOCAL`: both
279
- * reset at commit/rollback, so pooled connections are never polluted.
280
- *
281
- * Fails closed by construction: if the role switch errors, the transaction
282
- * aborts instead of proceeding privileged.
283
- *
284
- * SECURITY: this function is only ever called on the **user** path (the server
285
- * context uses the base/owner driver and never calls it). The default policies
286
- * treat `rebase.uid() IS NULL` as the trusted server context, and `rebase.uid()`
287
- * is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
288
- * be read as NULL and silently escalate a user request to server privileges.
289
- * Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
290
- * rather than trusting every caller (e.g. realtime subscription auth) to do it.
291
- * That sentinel is exported from `@rebasepro/types` because it leaks into rule
292
- * semantics: it is why `rebase.uid() IS NOT NULL` is true for anonymous requests.
293
- */
294
- export async function applyAuthContext(tx: SqlTx, auth: AuthContext, userRole?: string): Promise<void> {
295
- const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
296
- const normalizedRoles = auth.roles.map((r: unknown) =>
297
- typeof r === "string" ? r : (r as Record<string, unknown>)?.id ?? String(r)
298
- );
299
- // `app.user_id` is the pre-rename spelling, still written because policies
300
- // are data: a database provisioned before the rename holds rules compiled
301
- // to `current_setting('app.user_id')`, and those predicates would evaluate
302
- // to NULL — failing open or locking out — if we stopped setting it. Drop
303
- // the alias only once no live database carries a legacy policy.
304
- await tx.execute(drizzleSql`
305
- SELECT
306
- set_config('app.uid', ${uid}, true),
307
- set_config('app.user_id', ${uid}, true),
308
- set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
309
- set_config('app.jwt', ${JSON.stringify({ sub: uid, roles: auth.roles })}, true)
310
- `);
311
- if (userRole) {
312
- await tx.execute(drizzleSql.raw(`SET LOCAL ROLE ${quoteIdent(userRole)}`));
313
- }
314
- }
315
-
316
- /** Role names from other BaaS platforms that people reach for out of habit. */
317
- const FOREIGN_CONVENTION_ROLES: Record<string, string> = {
318
- authenticated: "Supabase",
319
- anon: "Supabase",
320
- service_role: "Supabase"
321
- };
322
-
323
- /**
324
- * Warn about rules that read as "signed-in users only" but admit anonymous
325
- * callers — `rebase.uid() IS NOT NULL`, or a comparison against another
326
- * platform's magic user id such as `'anon'`.
327
- *
328
- * The sibling of {@link validatePolicyPgRoles}, for the more dangerous spelling
329
- * of the same habit. A foreign `pgRoles` value makes a policy unreachable and
330
- * the table reads empty — loud, and that guard throws. These do the opposite:
331
- * the rule compiles to a grant, and nothing looks wrong until the data is
332
- * already public.
333
- *
334
- * Warns rather than throws. Unlike an unreachable `pgRoles`, these rules are
335
- * serving traffic today: refusing to boot would take an app offline to report a
336
- * problem it already has, and on the read path it would take it offline
337
- * *because* its data was exposed. Rewriting the author's SQL is not an option
338
- * either — this is the escape hatch whose whole promise is that it means what it
339
- * says. So: say so, loudly, and leave the rule alone.
340
- */
341
- export function warnOnAnonymousGrants(
342
- collections: { slug?: string; securityRules?: readonly SecurityRule[] }[]
343
- ): void {
344
- // Grouped by the mistake, not by the rule: one habit typically repeats
345
- // across every collection an author wrote, and a per-rule list would repeat
346
- // the same paragraph dozens of times and get skimmed.
347
- const byRisk = new Map<string, { risk: AnonymousGrantRisk; sites: string[] }>();
348
-
349
- for (const collection of collections) {
350
- for (const rule of collection.securityRules ?? []) {
351
- const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
352
- const risks = [usingExpr, withCheckExpr]
353
- .filter((e): e is PolicyExpression => e !== null)
354
- .flatMap(findAnonymousGrants);
355
-
356
- for (const risk of risks) {
357
- const key = `${risk.pattern}:${risk.detail}`;
358
- const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
359
- const entry = byRisk.get(key) ?? { risk, sites: [] };
360
- if (!entry.sites.includes(site)) entry.sites.push(site);
361
- byRisk.set(key, entry);
362
- }
363
- }
364
- }
365
-
366
- if (byRisk.size === 0) return;
367
-
368
- const problems = [...byRisk.values()].map(({ risk, sites }) =>
369
- ` • ${risk.explanation}\n ${sites.length} rule(s): ${sites.join(", ")}`
370
- );
371
-
372
- logger.warn(
373
- `Security rules that read as a lockdown but grant access to anonymous requests. Every caller from a ` +
374
- `client carries a user id ('${ANONYMOUS_USER_ID}' when nobody is signed in), so these clauses are ` +
375
- `true for everyone:\n\n` +
376
- problems.join("\n\n") + "\n"
377
- );
378
- }
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
-
439
- /**
440
- * Reject `pgRoles` that this server can never satisfy.
441
- *
442
- * `pgRoles` sets the `TO` clause of a generated policy, so a policy naming a
443
- * role the request never runs as simply never applies — and RLS then filters
444
- * every row. The table reads as empty, which is indistinguishable from having
445
- * no data, so the mistake survives review and ships.
446
- *
447
- * Requests run as `rebase_user`, so a policy is only reachable if it targets
448
- * `public` or a role `rebase_user` holds. Anything else is a configuration
449
- * error worth failing the boot for.
450
- */
451
- export async function validatePolicyPgRoles(
452
- run: RawSqlRunner,
453
- collections: { slug?: string; securityRules?: readonly { name?: string; pgRoles?: readonly string[] }[] }[],
454
- /** The role requests actually run as: `rebase_user` when the connection is
455
- * privileged enough to switch, otherwise the connection role itself. */
456
- requestRole: string = REBASE_USER_ROLE
457
- ): Promise<void> {
458
- const wanted = new Map<string, string[]>();
459
- for (const collection of collections) {
460
- for (const rule of collection.securityRules ?? []) {
461
- for (const role of rule.pgRoles ?? []) {
462
- if (role === "public") continue;
463
- wanted.set(role, [...(wanted.get(role) ?? []), collection.slug ?? "(unnamed)"]);
464
- }
465
- }
466
- }
467
- if (wanted.size === 0) return;
468
-
469
- const names = [...wanted.keys()].map((r) => `'${r.replace(/'/g, "''")}'`).join(",");
470
- const escapedRequestRole = requestRole.replace(/'/g, "''");
471
- const rows = await run(`
472
- SELECT r.rolname AS role,
473
- COALESCE(pg_has_role(to_regrole('${escapedRequestRole}'), r.oid, 'MEMBER'), false) AS reachable
474
- FROM pg_roles r
475
- WHERE r.rolname IN (${names})
476
- `);
477
-
478
- const reachable = new Map(rows.map((row) => [String(row.role), row.reachable === true]));
479
- const problems: string[] = [];
480
-
481
- for (const [role, slugs] of wanted) {
482
- if (reachable.get(role) === true) continue;
483
-
484
- const why = reachable.has(role)
485
- ? `"${requestRole}" is not a member of it`
486
- : "no such role exists in this database";
487
- const platform = FOREIGN_CONVENTION_ROLES[role];
488
- const hint = platform
489
- ? `"${role}" is a ${platform} convention, not a PostgreSQL role. Application roles belong in \`roles: ["${role === "service_role" ? "admin" : role}"]\`, which is checked inside the policy via rebase.roles().`
490
- : `Either grant it (GRANT ${role} TO ${requestRole}) or drop \`pgRoles\` so the policy targets \`public\`.`;
491
-
492
- problems.push(
493
- ` • pgRoles: ["${role}"] on ${slugs.join(", ")} — ${why}.\n ${hint}`
494
- );
495
- }
496
-
497
- if (problems.length > 0) {
498
- throw new Error(
499
- `Security rules target PostgreSQL roles this server cannot use. Requests run as ` +
500
- `"${requestRole}", so these policies would never apply and every row would be ` +
501
- `filtered out — the collections would look empty rather than error.\n\n` +
502
- problems.join("\n\n") + "\n"
503
- );
504
- }
505
- }