@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 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.
- package/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- 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
|
-
}
|