@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.
- 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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "module";
|
|
2
|
+
import "process";
|
|
3
|
+
__createRequire(import.meta.url);
|
|
4
|
+
import { isPostgresCollectionConfig } from "@rebasepro/types";
|
|
5
|
+
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
6
|
+
import { sha1Hex, truncateToBytes } from "@rebasepro/utils";
|
|
7
|
+
//#region src/schema/collection-index.ts
|
|
8
|
+
/**
|
|
9
|
+
* A declaration that cannot become an index.
|
|
10
|
+
*
|
|
11
|
+
* Thrown at build time, naming the collection and the array position, because
|
|
12
|
+
* the alternative is a `CREATE INDEX` that fails during a push with a Postgres
|
|
13
|
+
* error mentioning a column the author never wrote.
|
|
14
|
+
*/
|
|
15
|
+
var CollectionIndexConfigError = class extends Error {
|
|
16
|
+
collectionSlug;
|
|
17
|
+
position;
|
|
18
|
+
constructor(collectionSlug, position, message) {
|
|
19
|
+
super(`${collectionSlug}.indexes[${position}]: ${message}`);
|
|
20
|
+
this.name = "CollectionIndexConfigError";
|
|
21
|
+
this.collectionSlug = collectionSlug;
|
|
22
|
+
this.position = position;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* `_ix`/`_ux` plus `_` plus 7 hex — the part of the name that must always
|
|
27
|
+
* survive truncation, and therefore is never inside the truncated portion.
|
|
28
|
+
*/
|
|
29
|
+
var NAME_SUFFIX_BYTES = 11;
|
|
30
|
+
var isOrderedMethod = (method) => method === "btree";
|
|
31
|
+
/**
|
|
32
|
+
* The parts of an index that decide what it *is*.
|
|
33
|
+
*
|
|
34
|
+
* A semantic projection, not the rendered statement — the same arrangement as
|
|
35
|
+
* `getPolicyNameHash`, and for the same reason. A change to how this file
|
|
36
|
+
* formats SQL (eliding a default `USING btree`, quoting differently, emitting
|
|
37
|
+
* `NULLS LAST` explicitly) must not silently rename every index in every
|
|
38
|
+
* deployed database. Hashing generator output would make every cosmetic edit a
|
|
39
|
+
* fleet-wide DROP + CREATE.
|
|
40
|
+
*
|
|
41
|
+
* `reason` is deliberately absent: rewording a comment must not rebuild an
|
|
42
|
+
* index. `nulls` is the *effective* placement, so writing Postgres's own
|
|
43
|
+
* default down is a no-op rather than a redefinition.
|
|
44
|
+
*
|
|
45
|
+
* `v` is the only escape hatch, and it is expensive on purpose: bumping it
|
|
46
|
+
* renames every index in the field.
|
|
47
|
+
*/
|
|
48
|
+
var indexFingerprint = (spec) => sha1Hex(JSON.stringify({
|
|
49
|
+
v: 1,
|
|
50
|
+
s: spec.schema,
|
|
51
|
+
t: spec.table,
|
|
52
|
+
m: spec.method,
|
|
53
|
+
u: spec.unique,
|
|
54
|
+
k: spec.keys.map((k) => [
|
|
55
|
+
k.column,
|
|
56
|
+
k.direction,
|
|
57
|
+
k.nulls
|
|
58
|
+
]),
|
|
59
|
+
i: spec.include,
|
|
60
|
+
w: spec.predicate
|
|
61
|
+
})).substring(0, 7);
|
|
62
|
+
/**
|
|
63
|
+
* `<table>_<columns>_ix_<hash>`, or `_ux_` when unique.
|
|
64
|
+
*
|
|
65
|
+
* Truncation eats the readable head and never the hash. `toPostgresIdentifier`
|
|
66
|
+
* truncates the whole string at 63 bytes, which on a hashed name would cut off
|
|
67
|
+
* the one part that makes it unique — the failure already frozen into
|
|
68
|
+
* `contracts/derived-names.txt`, where a foreign key is recorded with its
|
|
69
|
+
* `_fkey` suffix truncated away, so a second foreign key on that table would
|
|
70
|
+
* derive a byte-identical name.
|
|
71
|
+
*/
|
|
72
|
+
var deriveIndexName = (spec) => {
|
|
73
|
+
const suffix = `_${spec.unique ? "ux" : "ix"}_${indexFingerprint(spec)}`;
|
|
74
|
+
return `${truncateToBytes(`${spec.table}_${spec.keys.map((k) => k.column).join("_")}`, 63 - NAME_SUFFIX_BYTES)}${suffix}`;
|
|
75
|
+
};
|
|
76
|
+
var quoteLiteral = (value) => {
|
|
77
|
+
if (typeof value === "number") return String(value);
|
|
78
|
+
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
|
79
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
80
|
+
};
|
|
81
|
+
/** Render a resolved predicate as the body of a `WHERE` clause. */
|
|
82
|
+
var renderPredicate = (predicate) => {
|
|
83
|
+
if ("and" in predicate) return predicate.and.map(renderPredicate).join(" AND ");
|
|
84
|
+
switch (predicate.op) {
|
|
85
|
+
case "is null":
|
|
86
|
+
case "is not null": return `"${predicate.column}" ${predicate.op.toUpperCase()}`;
|
|
87
|
+
case "in": return `"${predicate.column}" IN (${predicate.value.map(quoteLiteral).join(", ")})`;
|
|
88
|
+
default: return `"${predicate.column}" ${predicate.op} ${quoteLiteral(predicate.value)}`;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The `CREATE INDEX` for one spec.
|
|
93
|
+
*
|
|
94
|
+
* `concurrently` is a parameter rather than a string replacement on the way
|
|
95
|
+
* out. `search-column.ts` and `vector-index.ts` both reach for
|
|
96
|
+
* `.replace("CREATE INDEX IF NOT EXISTS", …)` instead, which silently does
|
|
97
|
+
* nothing for a UNIQUE index — the rendered text is `CREATE UNIQUE INDEX …`
|
|
98
|
+
* and the pattern never matches.
|
|
99
|
+
*/
|
|
100
|
+
var collectionIndexStatement = (spec, options = {}) => {
|
|
101
|
+
const unique = spec.unique ? "UNIQUE " : "";
|
|
102
|
+
const concurrently = options.concurrently ? "CONCURRENTLY " : "";
|
|
103
|
+
const ifNotExists = options.ifNotExists ? "IF NOT EXISTS " : "";
|
|
104
|
+
const using = spec.method === "btree" ? "" : ` USING ${spec.method}`;
|
|
105
|
+
const keys = spec.keys.map((k) => {
|
|
106
|
+
if (!isOrderedMethod(spec.method)) return `"${k.column}"`;
|
|
107
|
+
const direction = k.direction === "desc" ? " DESC" : "";
|
|
108
|
+
const impliedNulls = k.direction === "desc" ? "first" : "last";
|
|
109
|
+
const nulls = k.nulls === impliedNulls ? "" : ` NULLS ${k.nulls.toUpperCase()}`;
|
|
110
|
+
return `"${k.column}"${direction}${nulls}`;
|
|
111
|
+
}).join(", ");
|
|
112
|
+
const include = spec.include.length > 0 ? ` INCLUDE (${spec.include.map((c) => `"${c}"`).join(", ")})` : "";
|
|
113
|
+
const where = spec.predicate ? ` WHERE ${renderPredicate(spec.predicate)}` : "";
|
|
114
|
+
return `CREATE ${unique}INDEX ${concurrently}${ifNotExists}"${spec.indexName}" ON "${spec.schema}"."${spec.table}"${using} (${keys})${include}${where};`;
|
|
115
|
+
};
|
|
116
|
+
var collectionIndexStatements = (specs, options = {}) => specs.map((spec) => collectionIndexStatement(spec, options));
|
|
117
|
+
var relationOf = (collection, propKey) => resolveCollectionRelations(collection)[propKey];
|
|
118
|
+
/**
|
|
119
|
+
* The column a property key indexes.
|
|
120
|
+
*
|
|
121
|
+
* A `belongsTo` resolves to its `localKey` — `primaryCategory` becomes
|
|
122
|
+
* `primary_category_id` — which is the case an index is most often wanted for
|
|
123
|
+
* and the case where the property key and the column differ. Everything else
|
|
124
|
+
* goes through `resolveColumnName`.
|
|
125
|
+
*
|
|
126
|
+
* The other relation kinds have no local column at all: the foreign key lives
|
|
127
|
+
* on the target's table, or in a junction. Indexing them here is refused
|
|
128
|
+
* rather than resolved to a column that does not exist.
|
|
129
|
+
*/
|
|
130
|
+
var resolveIndexableColumn = (collection, propKey, resolveColumnName, fail) => {
|
|
131
|
+
const relation = relationOf(collection, propKey);
|
|
132
|
+
if (relation) {
|
|
133
|
+
if (relation.kind === "belongsTo") return relation.localKey;
|
|
134
|
+
fail(`"${propKey}" is a ${relation.kind} relation, which has no column on this table — the foreign key lives on "${relation.targetSlug}". Declare the index there.`);
|
|
135
|
+
}
|
|
136
|
+
const property = collection.properties?.[propKey];
|
|
137
|
+
if (!property) fail(`"${propKey}" is not a property of this collection.`);
|
|
138
|
+
return resolveColumnName(propKey, property);
|
|
139
|
+
};
|
|
140
|
+
var resolvePredicate = (collection, predicate, resolveColumnName, fail) => {
|
|
141
|
+
if ("and" in predicate) return { and: predicate.and.map((p) => resolvePredicate(collection, p, resolveColumnName, fail)) };
|
|
142
|
+
const column = resolveIndexableColumn(collection, predicate.prop, resolveColumnName, fail);
|
|
143
|
+
switch (predicate.op) {
|
|
144
|
+
case "is null":
|
|
145
|
+
case "is not null": return {
|
|
146
|
+
column,
|
|
147
|
+
op: predicate.op
|
|
148
|
+
};
|
|
149
|
+
case "in":
|
|
150
|
+
if (new Set(predicate.value).size !== predicate.value.length) fail(`the \`in\` list for "${predicate.prop}" repeats a value, which changes nothing.`);
|
|
151
|
+
return {
|
|
152
|
+
column,
|
|
153
|
+
op: "in",
|
|
154
|
+
value: [...predicate.value]
|
|
155
|
+
};
|
|
156
|
+
default: return {
|
|
157
|
+
column,
|
|
158
|
+
op: predicate.op,
|
|
159
|
+
value: predicate.value
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
/** The primary key columns of a collection, for the "you already have this" refusal. */
|
|
164
|
+
var primaryKeyColumns = (collection, resolveColumnName) => Object.entries(collection.properties ?? {}).filter(([, prop]) => prop && typeof prop === "object" && "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => resolveColumnName(key, prop));
|
|
165
|
+
/**
|
|
166
|
+
* Every index one collection declares, resolved and named.
|
|
167
|
+
*
|
|
168
|
+
* Throws {@link CollectionIndexConfigError} rather than dropping a bad entry:
|
|
169
|
+
* an index that silently does not exist is the failure mode this whole feature
|
|
170
|
+
* is here to remove.
|
|
171
|
+
*/
|
|
172
|
+
var buildCollectionIndexSpecs = (collection, resolveColumnName) => {
|
|
173
|
+
if (!isPostgresCollectionConfig(collection)) return [];
|
|
174
|
+
const declared = collection.indexes;
|
|
175
|
+
if (!declared || declared.length === 0) return [];
|
|
176
|
+
const slug = collection.slug ?? getTableName(collection);
|
|
177
|
+
const schema = collection.schema ?? "public";
|
|
178
|
+
const table = getTableName(collection);
|
|
179
|
+
const pk = primaryKeyColumns(collection, resolveColumnName).sort().join(",");
|
|
180
|
+
const specs = [];
|
|
181
|
+
const byName = /* @__PURE__ */ new Map();
|
|
182
|
+
declared.forEach((index, position) => {
|
|
183
|
+
const fail = (message) => {
|
|
184
|
+
throw new CollectionIndexConfigError(slug, position, message);
|
|
185
|
+
};
|
|
186
|
+
if (typeof index.reason !== "string" || index.reason.trim() === "") fail("`reason` is required — see the doc comment. An index nobody can justify is one nobody can delete.");
|
|
187
|
+
if (!Array.isArray(index.on) || index.on.length === 0) fail("`on` must name at least one property.");
|
|
188
|
+
if (index.on.length > 5) fail(`\`on\` has ${index.on.length} keys; the limit is 5. Payload columns belong in \`include\`.`);
|
|
189
|
+
const method = index.using ?? "btree";
|
|
190
|
+
const unique = method === "btree" && Boolean(index.unique);
|
|
191
|
+
if (!isOrderedMethod(method)) {
|
|
192
|
+
for (const key of index.on) if (typeof key !== "string" && ("direction" in key || "nulls" in key)) fail(`access method "${method}" does not support ASC/DESC or NULLS options.`);
|
|
193
|
+
}
|
|
194
|
+
const keys = index.on.map((key) => {
|
|
195
|
+
const column = resolveIndexableColumn(collection, typeof key === "string" ? key : key.prop, resolveColumnName, fail);
|
|
196
|
+
const direction = (typeof key === "string" ? void 0 : key.direction) ?? "asc";
|
|
197
|
+
return {
|
|
198
|
+
column,
|
|
199
|
+
direction,
|
|
200
|
+
nulls: (typeof key === "string" ? void 0 : key.nulls) ?? (direction === "desc" ? "first" : "last")
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
const duplicateKey = keys.map((k) => k.column).find((c, i, all) => all.indexOf(c) !== i);
|
|
204
|
+
if (duplicateKey) fail(`"${duplicateKey}" appears twice in \`on\`.`);
|
|
205
|
+
if (keys.map((k) => k.column).sort().join(",") === pk && pk !== "") fail(`this is the primary key — "${table}_pkey" already indexes exactly these columns.`);
|
|
206
|
+
const include = (index.include ?? []).map((propKey) => resolveIndexableColumn(collection, propKey, resolveColumnName, fail));
|
|
207
|
+
const overlap = include.find((c) => keys.some((k) => k.column === c));
|
|
208
|
+
if (overlap) fail(`"${overlap}" is in both \`on\` and \`include\`; Postgres rejects the overlap.`);
|
|
209
|
+
if (unique && keys.length === 1) {
|
|
210
|
+
const propKey = typeof index.on[0] === "string" ? index.on[0] : index.on[0].prop;
|
|
211
|
+
if ((collection.properties?.[propKey])?.validation?.unique) fail(`"${propKey}" already declares \`validation.unique\`, which compiles to an inline UNIQUE. Two declarations of one guarantee — remove one.`);
|
|
212
|
+
}
|
|
213
|
+
const predicate = index.where ? resolvePredicate(collection, index.where, resolveColumnName, fail) : null;
|
|
214
|
+
const withoutName = {
|
|
215
|
+
schema,
|
|
216
|
+
table,
|
|
217
|
+
method,
|
|
218
|
+
unique,
|
|
219
|
+
keys,
|
|
220
|
+
include,
|
|
221
|
+
predicate,
|
|
222
|
+
reason: index.reason
|
|
223
|
+
};
|
|
224
|
+
const indexName = deriveIndexName(withoutName);
|
|
225
|
+
const clash = byName.get(indexName);
|
|
226
|
+
if (clash !== void 0) fail(`derives the same name as indexes[${clash}] — they are the same index declared twice.`);
|
|
227
|
+
byName.set(indexName, position);
|
|
228
|
+
specs.push({
|
|
229
|
+
...withoutName,
|
|
230
|
+
indexName
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
return specs;
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Every declared index across a set of collections, in a stable order.
|
|
237
|
+
*
|
|
238
|
+
* Sorted because the result reaches `schema.sql`, which `doctor` string-
|
|
239
|
+
* compares against a regenerated copy — `generatePostgresDdl` does not sort its
|
|
240
|
+
* collections, so leaving this in declaration order would make the artifact
|
|
241
|
+
* depend on the order files happened to load in.
|
|
242
|
+
*/
|
|
243
|
+
var buildCollectionIndexPlan = (collections, resolveColumnName) => collections.flatMap((collection) => buildCollectionIndexSpecs(collection, resolveColumnName)).sort((a, b) => a.schema.localeCompare(b.schema) || a.table.localeCompare(b.table) || a.indexName.localeCompare(b.indexName));
|
|
244
|
+
//#endregion
|
|
245
|
+
export { collectionIndexStatements as i, buildCollectionIndexSpecs as n, collectionIndexStatement as r, buildCollectionIndexPlan as t };
|
|
246
|
+
|
|
247
|
+
//# sourceMappingURL=collection-index-C7LopMyX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"collection-index-C7LopMyX.js","names":[],"sources":["../src/schema/collection-index.ts"],"sourcesContent":["/**\n * The one place a collection's `indexes:` block becomes `CREATE INDEX`.\n *\n * Like `search-column.ts` and `vector-index.ts`, this module exists so the DDL\n * generator and the boot-time ensure render the *same* specification rather\n * than describing the same index twice, differently.\n *\n * ## Why every form here is core Postgres\n *\n * `rebase db push` runs `atlas schema apply`, which materialises the desired\n * state in a bare scratch database to plan against. `--exclude` does not\n * suppress that replay, and `CREATE EXTENSION` cannot go in the file. So an\n * index wanting `gin_trgm_ops` or `vector_cosine_ops` would parse, plan, and\n * then fail against a database the author has never heard of. Those are\n * refused here instead, and redirected to the feature that owns them: trigram\n * search is `search:`, ANN is a `vector` property.\n *\n * Verified against atlas v1.2.3 and Postgres 18 before this was written: plain,\n * composite with `DESC NULLS LAST`, partial, unique, covering `INCLUDE`, `GIN`\n * and expression indexes all parse, apply, and re-plan clean. The Atlas\n * limitation that forced the search carve-out is that it will not parse a file\n * containing a function *definition* — a function *call* inside an index is\n * fine. That is why this module needs no carve-out and search did.\n *\n * ## Why the name carries a hash\n *\n * `CREATE INDEX IF NOT EXISTS` is a **name** check, not a definition check. A\n * readable name means a changed declaration keeps the old index and reports\n * success, forever. Hashing the index's *semantics* into its name makes a\n * redefinition a different object: the new one is built before the old one is\n * dropped, there is never a window with no index, and drift detection reduces\n * to a set difference over names.\n */\nimport type { CollectionConfig, CollectionIndex, IndexPredicate, Property, ResolvedRelation } from \"@rebasepro/types\";\nimport { isPostgresCollectionConfig } from \"@rebasepro/types\";\nimport { getTableName, resolveCollectionRelations } from \"@rebasepro/common\";\nimport { sha1Hex, truncateToBytes } from \"@rebasepro/utils\";\n\n/** Resolve a property key to its column name. Injected to avoid an import cycle. */\nexport type ResolveColumnName = (propName: string, prop?: Property | null) => string;\n\nexport type IndexMethod = \"btree\" | \"gin\" | \"brin\";\n\n/** A key column, with the ordering Postgres will actually apply. */\nexport interface ResolvedIndexKey {\n column: string;\n /** Always concrete. `btree` defaults ascending; unordered methods report `asc`. */\n direction: \"asc\" | \"desc\";\n /** Postgres's own default: `last` under `asc`, `first` under `desc`. */\n nulls: \"first\" | \"last\";\n}\n\n/** A predicate resolved onto column names, ready to render and to hash. */\nexport type ResolvedPredicate =\n | { column: string; op: \"=\" | \"!=\" | \"<\" | \"<=\" | \">\" | \">=\"; value: string | number | boolean }\n | { column: string; op: \"is null\" | \"is not null\" }\n | { column: string; op: \"in\"; value: readonly (string | number)[] }\n | { and: readonly ResolvedPredicate[] };\n\n/** One index, fully resolved. The only shape the renderers accept. */\nexport interface CollectionIndexSpec {\n schema: string;\n table: string;\n method: IndexMethod;\n unique: boolean;\n keys: ResolvedIndexKey[];\n include: string[];\n predicate: ResolvedPredicate | null;\n /** The author's one-line justification. Never enters the name. */\n reason: string;\n /** Derived by {@link deriveIndexName}. Frozen — see the module comment. */\n indexName: string;\n}\n\n/**\n * A declaration that cannot become an index.\n *\n * Thrown at build time, naming the collection and the array position, because\n * the alternative is a `CREATE INDEX` that fails during a push with a Postgres\n * error mentioning a column the author never wrote.\n */\nexport class CollectionIndexConfigError extends Error {\n readonly collectionSlug: string;\n readonly position: number;\n\n constructor(collectionSlug: string, position: number, message: string) {\n super(`${collectionSlug}.indexes[${position}]: ${message}`);\n this.name = \"CollectionIndexConfigError\";\n this.collectionSlug = collectionSlug;\n this.position = position;\n }\n}\n\n/** Postgres allows 32 key columns. See the doc comment on `on`. */\nexport const MAX_INDEX_KEYS = 5;\n\n/**\n * `_ix`/`_ux` plus `_` plus 7 hex — the part of the name that must always\n * survive truncation, and therefore is never inside the truncated portion.\n */\nconst NAME_SUFFIX_BYTES = 11;\n\n/**\n * Every Rebase-managed index name, and nothing else.\n *\n * The terminal `_ix_`/`_ux_` plus exactly seven lowercase hex characters is\n * what separates this scheme from every other producer in the codebase —\n * `_fkey`, `_gin`, `_trgm`, `_pkey`, `_key`, the vector distances, and the\n * `idx_` prefix auth uses. `_idx` was rejected as a tail because it is already\n * taken for real: `users_email_verification_token_idx` is byte-for-byte what a\n * naive `<table>_<column>_idx` derives on an auth-enabled `users` collection.\n *\n * Load-bearing for safety, not just tidiness. An index that does NOT match this\n * belongs to somebody else — a hand-written one, or one an introspected\n * database arrived with — and is excluded from the Atlas diff so the push\n * cannot drop it.\n */\nexport const isRebaseIndexName = (name: string): boolean => /_(?:ix|ux)_[0-9a-f]{7}$/.test(name);\n\nconst isOrderedMethod = (method: IndexMethod): boolean => method === \"btree\";\n\n/**\n * The parts of an index that decide what it *is*.\n *\n * A semantic projection, not the rendered statement — the same arrangement as\n * `getPolicyNameHash`, and for the same reason. A change to how this file\n * formats SQL (eliding a default `USING btree`, quoting differently, emitting\n * `NULLS LAST` explicitly) must not silently rename every index in every\n * deployed database. Hashing generator output would make every cosmetic edit a\n * fleet-wide DROP + CREATE.\n *\n * `reason` is deliberately absent: rewording a comment must not rebuild an\n * index. `nulls` is the *effective* placement, so writing Postgres's own\n * default down is a no-op rather than a redefinition.\n *\n * `v` is the only escape hatch, and it is expensive on purpose: bumping it\n * renames every index in the field.\n */\nexport const indexFingerprint = (spec: Omit<CollectionIndexSpec, \"indexName\">): string => sha1Hex(JSON.stringify({\n v: 1,\n s: spec.schema,\n t: spec.table,\n m: spec.method,\n u: spec.unique,\n k: spec.keys.map(k => [k.column, k.direction, k.nulls]),\n i: spec.include,\n w: spec.predicate\n})).substring(0, 7);\n\n/**\n * `<table>_<columns>_ix_<hash>`, or `_ux_` when unique.\n *\n * Truncation eats the readable head and never the hash. `toPostgresIdentifier`\n * truncates the whole string at 63 bytes, which on a hashed name would cut off\n * the one part that makes it unique — the failure already frozen into\n * `contracts/derived-names.txt`, where a foreign key is recorded with its\n * `_fkey` suffix truncated away, so a second foreign key on that table would\n * derive a byte-identical name.\n */\nexport const deriveIndexName = (spec: Omit<CollectionIndexSpec, \"indexName\">): string => {\n // Built suffix-first, so the two parts that carry meaning — the `_ix`/`_ux`\n // tag that {@link isRebaseIndexName} matches on, and the fingerprint — are\n // never in the string being truncated. Composing the whole name and then\n // trimming it to 63 loses both, silently: an 80-byte table name yields\n // `xxxx…xxx_610bb9e` with the tag gone, so the index stops being\n // recognisable as Rebase's and `db push` treats it as foreign forever.\n const tag = spec.unique ? \"ux\" : \"ix\";\n const suffix = `_${tag}_${indexFingerprint(spec)}`;\n const readable = `${spec.table}_${spec.keys.map(k => k.column).join(\"_\")}`;\n return `${truncateToBytes(readable, 63 - NAME_SUFFIX_BYTES)}${suffix}`;\n};\n\nconst quoteLiteral = (value: string | number | boolean): string => {\n if (typeof value === \"number\") return String(value);\n if (typeof value === \"boolean\") return value ? \"TRUE\" : \"FALSE\";\n return `'${value.replace(/'/g, \"''\")}'`;\n};\n\n/** Render a resolved predicate as the body of a `WHERE` clause. */\nexport const renderPredicate = (predicate: ResolvedPredicate): string => {\n if (\"and\" in predicate) {\n // Always parenthesised. Postgres would apply the same precedence\n // without it, but the rendered SQL is read by people diffing a plan.\n return predicate.and.map(renderPredicate).join(\" AND \");\n }\n switch (predicate.op) {\n case \"is null\":\n case \"is not null\":\n return `\"${predicate.column}\" ${predicate.op.toUpperCase()}`;\n case \"in\":\n return `\"${predicate.column}\" IN (${predicate.value.map(quoteLiteral).join(\", \")})`;\n default:\n return `\"${predicate.column}\" ${predicate.op} ${quoteLiteral(predicate.value)}`;\n }\n};\n\n/**\n * The `CREATE INDEX` for one spec.\n *\n * `concurrently` is a parameter rather than a string replacement on the way\n * out. `search-column.ts` and `vector-index.ts` both reach for\n * `.replace(\"CREATE INDEX IF NOT EXISTS\", …)` instead, which silently does\n * nothing for a UNIQUE index — the rendered text is `CREATE UNIQUE INDEX …`\n * and the pattern never matches.\n */\nexport const collectionIndexStatement = (\n spec: CollectionIndexSpec,\n options: { concurrently?: boolean; ifNotExists?: boolean } = {}\n): string => {\n const unique = spec.unique ? \"UNIQUE \" : \"\";\n const concurrently = options.concurrently ? \"CONCURRENTLY \" : \"\";\n const ifNotExists = options.ifNotExists ? \"IF NOT EXISTS \" : \"\";\n const using = spec.method === \"btree\" ? \"\" : ` USING ${spec.method}`;\n\n const keys = spec.keys.map(k => {\n if (!isOrderedMethod(spec.method)) return `\"${k.column}\"`;\n const direction = k.direction === \"desc\" ? \" DESC\" : \"\";\n // Emitted only when it is not what the direction already implies, so\n // the rendered SQL matches what `pg_get_indexdef` reads back and a\n // re-plan finds no difference.\n const impliedNulls = k.direction === \"desc\" ? \"first\" : \"last\";\n const nulls = k.nulls === impliedNulls ? \"\" : ` NULLS ${k.nulls.toUpperCase()}`;\n return `\"${k.column}\"${direction}${nulls}`;\n }).join(\", \");\n\n const include = spec.include.length > 0\n ? ` INCLUDE (${spec.include.map(c => `\"${c}\"`).join(\", \")})`\n : \"\";\n const where = spec.predicate ? ` WHERE ${renderPredicate(spec.predicate)}` : \"\";\n\n return `CREATE ${unique}INDEX ${concurrently}${ifNotExists}\"${spec.indexName}\" ` +\n `ON \"${spec.schema}\".\"${spec.table}\"${using} (${keys})${include}${where};`;\n};\n\nexport const collectionIndexStatements = (\n specs: readonly CollectionIndexSpec[],\n options: { concurrently?: boolean; ifNotExists?: boolean } = {}\n): string[] => specs.map(spec => collectionIndexStatement(spec, options));\n\nconst relationOf = (\n collection: CollectionConfig,\n propKey: string\n): ResolvedRelation | undefined => resolveCollectionRelations(collection)[propKey];\n\n/**\n * The column a property key indexes.\n *\n * A `belongsTo` resolves to its `localKey` — `primaryCategory` becomes\n * `primary_category_id` — which is the case an index is most often wanted for\n * and the case where the property key and the column differ. Everything else\n * goes through `resolveColumnName`.\n *\n * The other relation kinds have no local column at all: the foreign key lives\n * on the target's table, or in a junction. Indexing them here is refused\n * rather than resolved to a column that does not exist.\n */\nexport const resolveIndexableColumn = (\n collection: CollectionConfig,\n propKey: string,\n resolveColumnName: ResolveColumnName,\n fail: (message: string) => never\n): string => {\n const relation = relationOf(collection, propKey);\n if (relation) {\n if (relation.kind === \"belongsTo\") return relation.localKey;\n fail(\n `\"${propKey}\" is a ${relation.kind} relation, which has no column on this table — ` +\n `the foreign key lives on \"${relation.targetSlug}\". Declare the index there.`\n );\n }\n\n const property = collection.properties?.[propKey] as Property | undefined;\n if (!property) {\n fail(`\"${propKey}\" is not a property of this collection.`);\n }\n return resolveColumnName(propKey, property);\n};\n\nconst resolvePredicate = (\n collection: CollectionConfig,\n predicate: IndexPredicate,\n resolveColumnName: ResolveColumnName,\n fail: (message: string) => never\n): ResolvedPredicate => {\n if (\"and\" in predicate) {\n return { and: predicate.and.map(p => resolvePredicate(collection, p, resolveColumnName, fail)) };\n }\n const column = resolveIndexableColumn(collection, predicate.prop, resolveColumnName, fail);\n switch (predicate.op) {\n case \"is null\":\n case \"is not null\":\n return { column, op: predicate.op };\n case \"in\": {\n const seen = new Set(predicate.value);\n if (seen.size !== predicate.value.length) {\n fail(`the \\`in\\` list for \"${predicate.prop}\" repeats a value, which changes nothing.`);\n }\n return { column, op: \"in\", value: [...predicate.value] };\n }\n default:\n return { column, op: predicate.op, value: predicate.value };\n }\n};\n\n/** The primary key columns of a collection, for the \"you already have this\" refusal. */\nconst primaryKeyColumns = (collection: CollectionConfig, resolveColumnName: ResolveColumnName): string[] =>\n Object.entries(collection.properties ?? {})\n .filter(([, prop]) => prop && typeof prop === \"object\" && \"isId\" in prop && Boolean((prop as { isId?: unknown }).isId))\n .map(([key, prop]) => resolveColumnName(key, prop as Property));\n\n/**\n * Every index one collection declares, resolved and named.\n *\n * Throws {@link CollectionIndexConfigError} rather than dropping a bad entry:\n * an index that silently does not exist is the failure mode this whole feature\n * is here to remove.\n */\nexport const buildCollectionIndexSpecs = (\n collection: CollectionConfig,\n resolveColumnName: ResolveColumnName\n): CollectionIndexSpec[] => {\n if (!isPostgresCollectionConfig(collection)) return [];\n const declared = collection.indexes;\n if (!declared || declared.length === 0) return [];\n\n const slug = collection.slug ?? getTableName(collection);\n const schema = collection.schema ?? \"public\";\n const table = getTableName(collection);\n const pk = primaryKeyColumns(collection, resolveColumnName).sort().join(\",\");\n\n const specs: CollectionIndexSpec[] = [];\n const byName = new Map<string, number>();\n\n declared.forEach((index: CollectionIndex, position: number) => {\n const fail = (message: string): never => {\n throw new CollectionIndexConfigError(slug, position, message);\n };\n\n if (typeof index.reason !== \"string\" || index.reason.trim() === \"\") {\n fail(\"`reason` is required — see the doc comment. An index nobody can justify is one nobody can delete.\");\n }\n if (!Array.isArray(index.on) || index.on.length === 0) {\n fail(\"`on` must name at least one property.\");\n }\n if (index.on.length > MAX_INDEX_KEYS) {\n fail(`\\`on\\` has ${index.on.length} keys; the limit is ${MAX_INDEX_KEYS}. Payload columns belong in \\`include\\`.`);\n }\n\n const method: IndexMethod = index.using ?? \"btree\";\n const unique = method === \"btree\" && Boolean((index as { unique?: boolean }).unique);\n if (!isOrderedMethod(method)) {\n for (const key of index.on) {\n if (typeof key !== \"string\" && (\"direction\" in key || \"nulls\" in key)) {\n fail(`access method \"${method}\" does not support ASC/DESC or NULLS options.`);\n }\n }\n }\n\n const keys: ResolvedIndexKey[] = index.on.map(key => {\n const propKey = typeof key === \"string\" ? key : key.prop;\n const column = resolveIndexableColumn(collection, propKey, resolveColumnName, fail);\n const direction = (typeof key === \"string\" ? undefined : (key as { direction?: \"asc\" | \"desc\" }).direction) ?? \"asc\";\n const nulls = (typeof key === \"string\" ? undefined : (key as { nulls?: \"first\" | \"last\" }).nulls)\n ?? (direction === \"desc\" ? \"first\" : \"last\");\n return { column, direction, nulls };\n });\n\n const duplicateKey = keys.map(k => k.column).find((c, i, all) => all.indexOf(c) !== i);\n if (duplicateKey) fail(`\"${duplicateKey}\" appears twice in \\`on\\`.`);\n\n if (keys.map(k => k.column).sort().join(\",\") === pk && pk !== \"\") {\n fail(`this is the primary key — \"${table}_pkey\" already indexes exactly these columns.`);\n }\n\n const include = ((index as { include?: readonly string[] }).include ?? [])\n .map(propKey => resolveIndexableColumn(collection, propKey, resolveColumnName, fail));\n const overlap = include.find(c => keys.some(k => k.column === c));\n if (overlap) fail(`\"${overlap}\" is in both \\`on\\` and \\`include\\`; Postgres rejects the overlap.`);\n\n if (unique && keys.length === 1) {\n const propKey = typeof index.on[0] === \"string\" ? index.on[0] as string : (index.on[0] as { prop: string }).prop;\n const property = collection.properties?.[propKey] as { validation?: { unique?: boolean } } | undefined;\n if (property?.validation?.unique) {\n fail(\n `\"${propKey}\" already declares \\`validation.unique\\`, which compiles to an inline UNIQUE. ` +\n `Two declarations of one guarantee — remove one.`\n );\n }\n }\n\n const predicate = index.where\n ? resolvePredicate(collection, index.where, resolveColumnName, fail)\n : null;\n\n const withoutName = { schema, table, method, unique, keys, include, predicate, reason: index.reason };\n const indexName = deriveIndexName(withoutName);\n\n const clash = byName.get(indexName);\n if (clash !== undefined) {\n fail(`derives the same name as indexes[${clash}] — they are the same index declared twice.`);\n }\n byName.set(indexName, position);\n\n specs.push({ ...withoutName, indexName });\n });\n\n return specs;\n};\n\n/**\n * Every declared index across a set of collections, in a stable order.\n *\n * Sorted because the result reaches `schema.sql`, which `doctor` string-\n * compares against a regenerated copy — `generatePostgresDdl` does not sort its\n * collections, so leaving this in declaration order would make the artifact\n * depend on the order files happened to load in.\n */\nexport const buildCollectionIndexPlan = (\n collections: readonly CollectionConfig[],\n resolveColumnName: ResolveColumnName\n): CollectionIndexSpec[] =>\n collections\n .flatMap(collection => buildCollectionIndexSpecs(collection, resolveColumnName))\n .sort((a, b) =>\n a.schema.localeCompare(b.schema) ||\n a.table.localeCompare(b.table) ||\n a.indexName.localeCompare(b.indexName));\n"],"mappings":"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"}
|
|
@@ -16,5 +16,32 @@ export declare class DatabasePoolManager {
|
|
|
16
16
|
disconnectDatabase(databaseName: string): Promise<void>;
|
|
17
17
|
/** Check if a pool exists for a given database name. */
|
|
18
18
|
hasPool(databaseName: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Does this server serve exactly one database?
|
|
21
|
+
*
|
|
22
|
+
* The managed development database is PGlite, and PGlite serves exactly
|
|
23
|
+
* one. `CREATE DATABASE "rb_feature" TEMPLATE "postgres"` there writes a
|
|
24
|
+
* `pg_database` catalogue row and nothing else: nothing errors, the row is
|
|
25
|
+
* listable, `pg_database_size` answers — and connecting to the "branch"
|
|
26
|
+
* lands you in the parent. Every write made in the belief that it is
|
|
27
|
+
* sandboxed goes to the developer's real development database.
|
|
28
|
+
*
|
|
29
|
+
* The CLI already refuses `rebase db branch` on this ground, but it does it
|
|
30
|
+
* by parsing argv, so Studio — which reaches `BranchService` over the
|
|
31
|
+
* websocket — walked straight past it. The connection is the only thing
|
|
32
|
+
* both callers share, so the answer belongs here.
|
|
33
|
+
*
|
|
34
|
+
* Measured, not assumed: `select version()` over the PGlite socket returns
|
|
35
|
+
* `PostgreSQL 18.3 (PGlite 0.5.6) on wasm32-…`, which is how a wasm build
|
|
36
|
+
* announces itself and what `dev-db/constraints.ts` documents. A real
|
|
37
|
+
* Postgres never carries `(PGlite `.
|
|
38
|
+
*
|
|
39
|
+
* Cached, because a live connection cannot change engine underneath us, and
|
|
40
|
+
* a `PostgresBackendDriver` — which builds a `BranchService` — is
|
|
41
|
+
* constructed per transaction.
|
|
42
|
+
*/
|
|
43
|
+
servesOneDatabase(): Promise<boolean>;
|
|
44
|
+
private oneDatabaseProbe?;
|
|
45
|
+
private probeOneDatabase;
|
|
19
46
|
shutdown(): Promise<void>;
|
|
20
47
|
}
|
package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js}
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
import "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
|
-
import "./
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
4
|
+
import { d as planCollectionPolicies, i as readExistingSchema, t as describeDriverError } from "./ensure-collection-tables-DbAhrjnG.js";
|
|
5
|
+
import { t as REBASE_USER_ROLE } from "./rls-enforcement-ClJoTboT.js";
|
|
6
|
+
import "@rebasepro/types";
|
|
7
7
|
//#region src/security/policy-drift.ts
|
|
8
8
|
/**
|
|
9
9
|
* Does this name look like one the generator produced for this table?
|
|
@@ -85,7 +85,7 @@ async function ensureCollectionPolicies(client, collections, log) {
|
|
|
85
85
|
await client.query(plan.enableRls);
|
|
86
86
|
result.tablesSecured++;
|
|
87
87
|
} catch (err) {
|
|
88
|
-
const error =
|
|
88
|
+
const error = describeDriverError(err);
|
|
89
89
|
let grantWithdrawn = false;
|
|
90
90
|
try {
|
|
91
91
|
await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);
|
|
@@ -113,7 +113,7 @@ async function ensureCollectionPolicies(client, collections, log) {
|
|
|
113
113
|
} catch (err) {
|
|
114
114
|
result.failures.push({
|
|
115
115
|
table: plan.qualified,
|
|
116
|
-
error:
|
|
116
|
+
error: describeDriverError(err)
|
|
117
117
|
});
|
|
118
118
|
}
|
|
119
119
|
}
|
|
@@ -122,4 +122,4 @@ async function ensureCollectionPolicies(client, collections, log) {
|
|
|
122
122
|
//#endregion
|
|
123
123
|
export { ensureCollectionPolicies };
|
|
124
124
|
|
|
125
|
-
//# sourceMappingURL=ensure-collection-policies-
|
|
125
|
+
//# sourceMappingURL=ensure-collection-policies-CagOzEPS.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ensure-collection-policies-UCqgv_8c.js","names":[],"sources":["../src/security/policy-drift.ts","../src/schema/ensure-collection-policies.ts"],"sourcesContent":["/**\n * Compare the RLS policies a database actually has against the ones the\n * collections describe.\n *\n * Policies live in Postgres; the collection config is only their *source*.\n * Nothing reconciled the two, which is how the demo database served empty\n * collections indefinitely: its policies granted `TO authenticated` (a Supabase\n * role name) while requests run as `rebase_user`, so RLS filtered every row.\n * The config was later corrected and the database never noticed — an empty\n * table is indistinguishable from a table with no data.\n *\n * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same\n * function `db push` uses to write `drizzle/policies.sql`, so this compares\n * against exactly what would be applied rather than a reimplementation.\n */\nimport { RLS_UID_SQL, type CollectionConfig } from \"@rebasepro/types\";\n\nimport { generatePostgresPoliciesDdl } from \"../schema/generate-postgres-ddl-logic\";\n\nexport interface PolicyRef {\n schema: string;\n table: string;\n name: string;\n /** Roles in the TO clause. */\n roles: string[];\n /** SELECT / INSERT / UPDATE / DELETE / ALL. */\n command: string;\n /** Whether a USING clause is present at all (not what it says). */\n hasUsing: boolean;\n /** Whether a WITH CHECK clause is present at all (not what it says). */\n hasWithCheck: boolean;\n /**\n * PERMISSIVE or RESTRICTIVE — the `AS` clause.\n *\n * An exact catalogue value on both sides, so it belongs with roles and\n * command rather than with the expression text. It matters more than either:\n * permissive policies are ORed together and restrictive ones ANDed, so a rule\n * declared `mode: \"restrictive\"` whose live policy is PERMISSIVE has had its\n * gate turned from a requirement into an alternative — the maximally\n * permissive way for this to be wrong.\n *\n * The DDL regex captured this from the start and the destructuring threw it\n * away; `pg_policies.permissive` was never selected.\n */\n mode?: \"PERMISSIVE\" | \"RESTRICTIVE\";\n /**\n * The live clause text, when read from `pg_policies`. Present only for live\n * policies (the expected side is parsed from DDL and does not carry it).\n * Used solely for the insecure-tautology scan, not for divergence — Postgres\n * rewrites this text, so it is not safe to diff against expected.\n */\n qual?: string | null;\n withCheck?: string | null;\n}\n\nexport interface PolicyDrift {\n /** Described by the collections, absent from the database. */\n missing: PolicyRef[];\n /** In the database, described by no collection — stale pushes live here. */\n orphaned: PolicyRef[];\n /** Same policy name, different roles or command. */\n diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];\n /**\n * A live policy whose expression is the known-permissive tautology\n * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the\n * user path coerces a blank id to the `'anonymous'` sentinel. This is what\n * `policy.authenticated()` used to compile to, so a database pushed before\n * that fix carries it, and neither the name, roles, command nor clause\n * *presence* differs from the corrected policy — the only thing that changed\n * is the expression text, which this checker otherwise (correctly) ignores.\n * So it is the one drift that hides from every other check here.\n *\n * @see reason a sentence naming the clause and what to do.\n */\n insecure: { policy: PolicyRef; reason: string }[];\n /**\n * A table the collections describe whose RLS switch is off.\n *\n * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in\n * `pg_policies` untouched, so before this category every expected policy\n * still matched on name, roles, command and clause presence and the checker\n * reported clean — on a table Postgres was applying no filter to at all.\n * Requests run as `rebase_user`, which holds full DML, so the table is wide\n * open while `doctor` certifies it.\n *\n * `forced` reports `relforcerowsecurity`, which is what also subjects the\n * table's *owner* to its policies. Its absence is not drift on its own —\n * Rebase does not connect as the owner in the request path — so it is\n * reported for context rather than raised as a failure.\n */\n rlsDisabled: { schema: string; table: string; forced: boolean }[];\n}\n\nexport interface Queryable {\n query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;\n}\n\n// The trailing group captures whichever clause follows the TO list, which is\n// what tells us the clause is present.\nconst CREATE_POLICY = /CREATE POLICY \"([^\"]+)\" ON \"([^\"]+)\"\\.\"([^\"]+)\"\\s+AS (\\w+)\\s+FOR (\\w+)\\s+TO ([^\\n]+?)(\\s+USING\\s*\\(|\\s+WITH CHECK\\s*\\(|;)/gi;\n\nconst WITH_CHECK_NEXT = /^\\s+WITH CHECK\\s*\\(/i;\n\n/**\n * Index just past the `)` closing a clause whose `(` ends at `open`.\n *\n * Needed because a policy expression nests parens and can contain a quoted\n * literal holding either character, so \"find the next `)`\" would stop early and\n * miss the `WITH CHECK` that follows.\n */\nfunction clauseEnd(ddl: string, open: number): number {\n let depth = 1;\n let inQuote = false;\n for (let i = open; i < ddl.length; i++) {\n const c = ddl[i];\n if (inQuote) {\n // '' is an escaped quote inside a string, not a close.\n if (c === \"'\") {\n if (ddl[i + 1] === \"'\") i++;\n else inQuote = false;\n }\n continue;\n }\n if (c === \"'\") inQuote = true;\n else if (c === \"(\") depth++;\n else if (c === \")\" && --depth === 0) return i + 1;\n }\n return ddl.length;\n}\n\n/**\n * `AS PERMISSIVE` / `AS RESTRICTIVE` from either side, or undefined.\n *\n * The DDL spells it as a bare word; `pg_policies.permissive` is the string\n * `\"PERMISSIVE\"`/`\"RESTRICTIVE\"` on modern Postgres but a boolean on some\n * drivers and older servers, so both are accepted. Anything unrecognised\n * becomes `undefined` and the comparison below skips it rather than inventing\n * drift from a shape we did not anticipate.\n */\nfunction normalizeMode(raw: unknown): \"PERMISSIVE\" | \"RESTRICTIVE\" | undefined {\n if (typeof raw === \"boolean\") return raw ? \"PERMISSIVE\" : \"RESTRICTIVE\";\n if (typeof raw !== \"string\") return undefined;\n const upper = raw.trim().toUpperCase();\n if (upper === \"PERMISSIVE\" || upper === \"TRUE\" || upper === \"T\") return \"PERMISSIVE\";\n if (upper === \"RESTRICTIVE\" || upper === \"FALSE\" || upper === \"F\") return \"RESTRICTIVE\";\n return undefined;\n}\n\n/** Parse the generated DDL rather than rebuilding the shape by hand. */\nexport function parseExpectedPolicies(ddl: string): PolicyRef[] {\n const found: PolicyRef[] = [];\n for (const m of ddl.matchAll(CREATE_POLICY)) {\n const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;\n const roles = rolesRaw\n .split(\",\")\n .map((r) => r.trim().replace(/^\"|\"$/g, \"\"))\n .filter(Boolean);\n\n // The generator emits USING before WITH CHECK, so what follows the TO\n // list settles USING; WITH CHECK is then whatever follows that clause.\n const hasUsing = /USING/i.test(clause);\n const hasWithCheck = hasUsing\n ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))\n : /WITH CHECK/i.test(clause);\n\n found.push({\n schema, table, name, roles,\n command: command.toUpperCase(),\n hasUsing, hasWithCheck,\n mode: normalizeMode(modeRaw)\n });\n }\n return found;\n}\n\nasync function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {\n const { rows } = await client.query<{\n schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;\n qual: string | null; with_check: string | null; permissive: string | boolean | null;\n }>(\n `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive\n FROM pg_policies\n WHERE schemaname = ANY($1)`,\n [schemas]\n );\n\n return rows.map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n name: r.policyname,\n // node-postgres yields text[] as an array; some drivers hand back \"{a,b}\".\n roles: Array.isArray(r.roles)\n ? r.roles\n : String(r.roles ?? \"\").replace(/^\\{|\\}$/g, \"\").split(\",\").filter(Boolean),\n command: (r.cmd ?? \"ALL\").toUpperCase(),\n // Presence only. Postgres rewrites the text, but it does not invent or\n // drop a clause: NULL here means the policy genuinely has none.\n hasUsing: r.qual != null,\n hasWithCheck: r.with_check != null,\n mode: normalizeMode(r.permissive),\n qual: r.qual,\n withCheck: r.with_check\n }));\n}\n\n/**\n * Tables the collections describe whose RLS switch is off.\n *\n * Read from `pg_class` because `pg_policies` cannot answer it: disabling RLS\n * leaves the policy rows in place, so every other comparison in this file keeps\n * matching while Postgres applies none of them.\n *\n * Only tables that expect at least one policy are considered. A table with no\n * declared rules is not asserting anything about RLS, and reporting it would\n * make the check noisy on exactly the projects least able to judge the noise.\n */\nasync function readTablesWithRlsOff(\n client: Queryable,\n tables: { schema: string; table: string }[]\n): Promise<{ schema: string; table: string; forced: boolean }[]> {\n if (tables.length === 0) return [];\n const { rows } = await client.query<{\n schemaname: string; tablename: string; relrowsecurity: boolean; relforcerowsecurity: boolean;\n }>(\n `SELECT n.nspname AS schemaname,\n c.relname AS tablename,\n c.relrowsecurity,\n c.relforcerowsecurity\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind = 'r'\n AND (n.nspname || '.' || c.relname) = ANY($1)`,\n [tables.map((t) => `${t.schema}.${t.table}`)]\n );\n\n return rows\n .filter((r) => !r.relrowsecurity)\n .map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n forced: !!r.relforcerowsecurity\n }));\n}\n\n/**\n * The permissive tautology `rebase.uid() IS NOT NULL`, without the\n * `<> 'anonymous'` guard that makes it mean \"signed in\".\n *\n * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The\n * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the\n * corrected policy from the stale one, so its presence clears the text.\n *\n * Both schema spellings are matched. The helpers moved from `auth` to `rebase`\n * in 1.0, and this check reads policies *as the database stored them*: a\n * database pushed before the move still holds `auth.uid()` until it is\n * recompiled, while everything written since — including raw `securityRules`\n * SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.\n * Matching only the pre-1.0 name made the check blind to every policy a current\n * release could write, which is precisely the set still worth scanning.\n */\nfunction isPermissiveAuthTautology(clause: string | null | undefined): boolean {\n if (!clause) return false;\n const flat = clause.toLowerCase().replace(/\\s+/g, \" \");\n if (!/\\b(?:rebase|auth)\\.uid\\s*\\(\\s*\\)\\s*is not null/.test(flat)) return false;\n // The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the\n // literal as `'anonymous'::text`. Either spelling means it is the corrected\n // policy, not the tautology.\n return !/<>\\s*'anonymous'/.test(flat) && !/!=\\s*'anonymous'/.test(flat);\n}\n\nconst keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;\nconst sameRoles = (a: string[], b: string[]) =>\n a.length === b.length && [...a].sort().join(\",\") === [...b].sort().join(\",\");\n\n/**\n * Diff expected against live.\n *\n * Compares names, roles, command, and whether each clause exists — all exact\n * values. Policy expression *text* is deliberately not compared: Postgres\n * rewrites `qual`/`with_check` when storing them (parenthesising, casting,\n * schema-qualifying), so text comparison reports drift that does not exist, and\n * a check that cries wolf gets ignored.\n *\n * Presence is not text, though. A NULL `qual` is not a rewrite of an\n * expression, it is the absence of one, and absence has no false-positive risk:\n * either the generator emitted a clause or it did not. That distinction is worth\n * the extra comparison — a production database was found with a SELECT policy\n * whose `qual` was NULL, matching on every field this checked and denying 100%\n * of reads. The same blindness would hide a policy that fails open.\n */\nexport async function checkPolicyDrift(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<PolicyDrift> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const schemas = [...new Set(expected.map((p) => p.schema))];\n // Nothing expected means nothing to reconcile against; scanning every\n // schema would report the whole database as orphaned.\n if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n const live = await readLivePolicies(client, schemas);\n const liveByKey = new Map(live.map((p) => [keyOf(p), p]));\n const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));\n\n const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n // Before comparing policy against policy: is the switch even on? A table\n // with RLS disabled matches every expected policy on every field compared\n // below, so this has to be asked separately or it cannot be asked at all.\n const expectedTables = [...new Map(\n expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])\n ).values()];\n drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);\n\n // Scan every live policy for the permissive tautology. This is deliberately\n // independent of the name-keyed diff below: a database pushed before the\n // `authenticated()` fix matches its expected policy on name, roles, command\n // and clause presence, so nothing else here would flag it.\n for (const p of live) {\n const clause = isPermissiveAuthTautology(p.qual)\n ? \"USING\"\n : isPermissiveAuthTautology(p.withCheck) ? \"WITH CHECK\" : null;\n if (clause) {\n drift.insecure.push({\n policy: p,\n reason: `${clause} is \\`${RLS_UID_SQL} IS NOT NULL\\`, which is true for anonymous ` +\n `visitors too — this grants access to signed-out requests. It predates the ` +\n `\\`policy.authenticated()\\` fix; re-run \\`rebase db push\\` to tighten it.`\n });\n }\n }\n\n for (const [key, want] of expectedByKey) {\n const got = liveByKey.get(key);\n if (!got) {\n drift.missing.push(want);\n continue;\n }\n const differences: string[] = [];\n if (!sameRoles(want.roles, got.roles)) {\n differences.push(`roles: expected [${want.roles.join(\", \")}], database has [${got.roles.join(\", \")}]`);\n }\n if (want.command !== got.command) {\n differences.push(`command: expected ${want.command}, database has ${got.command}`);\n }\n // Skipped when either side is unknown: an unrecognised catalogue shape\n // should not manufacture drift. See `normalizeMode`.\n if (want.mode && got.mode && want.mode !== got.mode) {\n differences.push(\n `mode: expected ${want.mode}, database has ${got.mode}` +\n (got.mode === \"PERMISSIVE\"\n ? \" — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it\"\n : \" — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it\")\n );\n }\n for (const clause of [\"USING\", \"WITH CHECK\"] as const) {\n const key = clause === \"USING\" ? \"hasUsing\" : \"hasWithCheck\";\n if (want[key] === got[key]) continue;\n differences.push(want[key]\n ? `${clause}: expected an expression, database has none — this policy matches no rows`\n : `${clause}: expected none, database has an expression`);\n }\n if (differences.length > 0) drift.diverged.push({ expected: want, actual: got, differences });\n }\n\n for (const [key, got] of liveByKey) {\n if (!expectedByKey.has(key)) drift.orphaned.push(got);\n }\n\n return drift;\n}\n\n/**\n * Does this name look like one the generator produced for this table?\n *\n * Unnamed rules compile to `<table>_<op>_<sha1[0:7]>` (plus `_<idx>` when one\n * rule spans several operations), and the hash covers the rule's semantics — so\n * *editing* a rule renames its policy. The policy under the old name is left\n * behind by `db push`, which only DROPs the names it is about to CREATE, and\n * Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps\n * granting everything no matter how tight its replacement is.\n *\n * Matching the shape is what makes dropping them safe. A hand-written policy\n * would have to collide with a 7-hex digest to be mistaken for generated one;\n * a policy named anything else is left alone and merely reported, because a\n * custom name is indistinguishable from one someone wrote in SQL on purpose.\n */\nexport function isGeneratedPolicyName(name: string, table: string): boolean {\n return new RegExp(`^${table.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\\\d+)?$`)\n .test(name);\n}\n\nexport interface OrphanCleanup {\n /** Superseded generated policies that were dropped. */\n dropped: PolicyRef[];\n /** Orphans left in place because their names are not generator-shaped. */\n kept: PolicyRef[];\n}\n\n/**\n * Drop the policies an earlier push superseded but never removed.\n *\n * Only touches tables the collections describe — a table with no expected\n * policy is not ours to reconcile, and scanning by schema alone would sweep up\n * policies belonging to something else sharing the database.\n */\nexport async function dropOrphanedPolicies(\n client: Queryable,\n drift: PolicyDrift,\n collections: CollectionConfig[]\n): Promise<OrphanCleanup> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const managed = new Set(expected.map((p) => `${p.schema}.${p.table}`));\n\n const cleanup: OrphanCleanup = { dropped: [], kept: [] };\n for (const p of drift.orphaned) {\n if (!managed.has(`${p.schema}.${p.table}`) || !isGeneratedPolicyName(p.name, p.table)) {\n cleanup.kept.push(p);\n continue;\n }\n // Identifiers are quoted, and the name came from pg_policies rather than\n // from user input, so it is already a valid identifier.\n await client.query(`DROP POLICY IF EXISTS \"${p.name}\" ON \"${p.schema}\".\"${p.table}\"`);\n cleanup.dropped.push(p);\n }\n return cleanup;\n}\n\nexport const hasDrift = (d: PolicyDrift): boolean =>\n d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0\n || d.rlsDisabled.length > 0;\n\n/** Human-readable report; empty string when the database matches the config. */\nexport function formatPolicyDrift(drift: PolicyDrift): string {\n if (!hasDrift(drift)) return \"\";\n const lines: string[] = [];\n\n // First, because it subsumes every other finding on the same table: if RLS\n // is off, the policies listed below are not being applied at all.\n if (drift.rlsDisabled.length > 0) {\n lines.push(\" RLS DISABLED — the table has policies, and Postgres is applying none of them:\");\n for (const t of drift.rlsDisabled) {\n lines.push(` • ${t.schema}.${t.table}${t.forced ? \"\" : \" (and FORCE is off)\"}`);\n }\n lines.push(\" Every row is readable and writable by any request that reaches the table.\");\n lines.push(` Fix: ALTER TABLE \"<schema>\".\"<table>\" ENABLE ROW LEVEL SECURITY; or re-run \\`rebase db push\\`.`);\n }\n if (drift.missing.length > 0) {\n lines.push(\" Missing — described by your collections, absent from the database:\");\n for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Run `rebase db push` to apply them.\");\n }\n if (drift.orphaned.length > 0) {\n lines.push(\" Orphaned — in the database, described by no collection:\");\n for (const p of drift.orphaned) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Left behind by an earlier push. These still filter rows.\");\n }\n if (drift.diverged.length > 0) {\n lines.push(\" Diverged — same policy, different definition:\");\n for (const d of drift.diverged) {\n lines.push(` • ${d.expected.schema}.${d.expected.table} → \"${d.expected.name}\"`);\n for (const diff of d.differences) lines.push(` ${diff}`);\n }\n }\n if (drift.insecure.length > 0) {\n lines.push(\" Insecure — a live policy grants access it should not:\");\n for (const i of drift.insecure) {\n lines.push(` • ${i.policy.schema}.${i.policy.table} → \"${i.policy.name}\"`);\n lines.push(` ${i.reason}`);\n }\n }\n return lines.join(\"\\n\");\n}\n","/**\n * Applying a bundle's RLS policies to a database at boot, idempotently.\n *\n * ## Why this exists\n *\n * {@link ensureCollectionTables} creates the collection *tables* a managed\n * runtime boots against, but a table with row-level security disabled and no\n * policies is not servable: authenticated requests run as the restricted\n * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a\n * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy\n * is denied. The policies live in the collections' `securityRules`; nothing at\n * boot applied them. `rebase db push` does — but it drives Atlas against a\n * local `DATABASE_URL`, and a managed tenant's database is reachable only from\n * inside the cluster, by the runtime that is already connected to it. So the\n * runtime is the only thing that *can* apply them, and this is where it does.\n *\n * ## Why this is safe to run on every boot\n *\n * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once\n * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by\n * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds\n * and replaces; it never drops data. (It does not *reconcile* — a policy a\n * previous push left behind under an old name is not removed here; that stays a\n * `db push` / `db migrate` concern, alongside destructive schema changes.)\n *\n * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a\n * table whose policies could not be applied fails **closed** (denies) rather\n * than leaking rows. One collection's policy failing (e.g. a rule that\n * references a table a real migration has not created yet) must not crash-loop\n * the whole deployment and take the other collections' working routes down with\n * it. Failures are reported loudly and per-table so the operator can see\n * exactly which collection is not yet servable and why.\n */\nimport { type CollectionConfig } from \"@rebasepro/types\";\nimport { planCollectionPolicies, type CollectionPolicyPlan } from \"./generate-postgres-ddl-logic\";\nimport { isGeneratedPolicyName } from \"../security/policy-drift\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\nimport { REBASE_USER_ROLE } from \"../security/rls-enforcement\";\n\nexport interface PolicyEnsureResult {\n /** `CREATE POLICY` statements that ran successfully. */\n policiesApplied: number;\n /** Tables that had RLS enabled. */\n tablesSecured: number;\n /** Declared tables absent from the database — left to a real migration. */\n skipped: { table: string; reason: string }[];\n /**\n * Tables that have RLS on but did not get every policy. They deny — RLS\n * with no matching policy is deny-all — so they are safe but not servable.\n */\n failures: { table: string; error: string }[];\n /**\n * Tables RLS could not be enabled on, whose DML grant was withdrawn instead.\n *\n * This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`\n * failing was recorded as a `failure` and reported with the same \"it stays\n * locked (denies)\" wording as a failed policy — but the two are opposites.\n * A policy statement failing leaves RLS on and the table denying. `enableRls`\n * failing leaves RLS *off*, and the schema-wide grant to the user role has\n * already been made by `ensureRlsEnforcement`, so the table is readable and\n * writable by every authenticated request with no row filtering at all.\n */\n unsecured: { table: string; error: string; grantWithdrawn: boolean }[];\n /**\n * Generated policies removed because no current rule produces them.\n *\n * A policy's name embeds a hash of the rule's semantics, so editing a rule\n * does not update a policy — it creates a new one and abandons the old.\n * Postgres ORs permissive policies, so the abandoned one keeps granting:\n * a `USING (true)` tightened to an owner check went on admitting everyone,\n * forever, while the deploy logged success.\n *\n * `db push` reconciles this, and cannot reach a managed tenant's in-cluster\n * database — which is the reason this module exists. So boot has to do it.\n */\n orphansDropped: number;\n}\n\nconst isCreatePolicy = (statement: string): boolean => /^\\s*CREATE POLICY/i.test(statement);\n\n/**\n * Bring the declared collections' RLS policies up to date. Returns what it did.\n *\n * Only tables that already exist are touched: the boot-time table creator runs\n * first, so anything still missing is a table this additive path is not allowed\n * to create (a junction, or a relation left to a migration). Enabling RLS on a\n * non-existent table would error, so those are recorded as skipped, not failed.\n */\n\n/**\n * Remove generated policies on this table that the current plan does not\n * produce.\n *\n * Scoped hard, because dropping a policy is destructive: only this one table,\n * only names matching the generated `<table>_<op>_<sha1>` shape, and only names\n * absent from the statements just applied. A hand-written policy, or one\n * belonging to another table, is never touched — `isGeneratedPolicyName` is the\n * same predicate `db push` uses to draw that line.\n */\nasync function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {\n const expected = new Set<string>();\n for (const statement of plan.policyStatements) {\n const match = /^\\s*CREATE POLICY\\s+\"([^\"]+)\"/i.exec(statement);\n if (match) expected.add(match[1]);\n }\n\n const existing = await client.query<{ policyname: string }>(\n `SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, \"''\")}' ` +\n `AND tablename = '${plan.table.replace(/'/g, \"''\")}'`\n );\n\n let dropped = 0;\n for (const row of existing.rows) {\n const name = row.policyname;\n if (expected.has(name)) continue;\n if (!isGeneratedPolicyName(name, plan.table)) continue;\n await client.query(`DROP POLICY IF EXISTS \"${name}\" ON \"${plan.schema}\".\"${plan.table}\"`);\n dropped++;\n }\n return dropped;\n}\n\nexport async function ensureCollectionPolicies(\n client: Queryable,\n collections: CollectionConfig[],\n log?: (message: string) => void\n): Promise<PolicyEnsureResult> {\n const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };\n\n const plans = planCollectionPolicies(collections);\n if (plans.length === 0) return result;\n\n const schemas = Array.from(new Set(plans.map(p => p.schema)));\n const existing = await readExistingSchema(client, schemas);\n\n for (const plan of plans) {\n if (!existing.tables.has(plan.qualified)) {\n result.skipped.push({\n table: plan.qualified,\n reason: \"table is not present in the database; create it with `rebase db push` / `rebase db migrate`\"\n });\n continue;\n }\n\n // Enabling RLS is its own step, because its failure is the opposite of\n // every other failure here. Once RLS is on, anything that goes wrong\n // afterwards leaves the table denying; while it is off, the grant made\n // earlier in boot leaves the table wide open. Sharing one `try` meant\n // the dangerous case was reported in the safe case's words.\n try {\n await client.query(plan.enableRls);\n result.tablesSecured++;\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n // Take the privilege back rather than serve an unprotected table.\n // This is the fail-closed step the old code assumed it already had:\n // per-table, so one collection cannot take the rest of the\n // deployment down, but leaving nothing readable without RLS.\n let grantWithdrawn = false;\n try {\n await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);\n grantWithdrawn = true;\n } catch {\n // Fall through: reported below with `grantWithdrawn: false`,\n // which the caller escalates. There is nothing else this\n // function can do to make the table safe.\n }\n result.unsecured.push({ table: plan.qualified,\nerror,\ngrantWithdrawn });\n continue;\n }\n\n try {\n let created = 0;\n for (const statement of plan.policyStatements) {\n await client.query(statement);\n if (isCreatePolicy(statement)) {\n result.policiesApplied++;\n created++;\n }\n }\n\n const orphans = await dropOrphanedPoliciesOn(client, plan);\n result.orphansDropped += orphans;\n\n log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +\n (orphans > 0 ? `, ${orphans} orphan(s) dropped` : \"\"));\n } catch (err) {\n result.failures.push({\n table: plan.qualified,\n error: err instanceof Error ? err.message : String(err)\n });\n }\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAmYA,SAAgB,sBAAsB,MAAc,OAAwB;CACxE,OAAO,IAAI,OAAO,IAAI,MAAM,QAAQ,uBAAuB,MAAM,EAAE,wDAAwD,CAAC,CACvH,KAAK,IAAI;AAClB;;;ACxTA,IAAM,kBAAkB,cAA+B,qBAAqB,KAAK,SAAS;;;;;;;;;;;;;;;;;;;AAqB1F,eAAe,uBAAuB,QAAmB,MAA6C;CAClG,MAAM,2BAAW,IAAI,IAAY;CACjC,KAAK,MAAM,aAAa,KAAK,kBAAkB;EAC3C,MAAM,QAAQ,iCAAiC,KAAK,SAAS;EAC7D,IAAI,OAAO,SAAS,IAAI,MAAM,EAAE;CACpC;CAEA,MAAM,WAAW,MAAM,OAAO,MAC1B,0DAA0D,KAAK,OAAO,QAAQ,MAAM,IAAI,EAAE,qBACtE,KAAK,MAAM,QAAQ,MAAM,IAAI,EAAE,EACvD;CAEA,IAAI,UAAU;CACd,KAAK,MAAM,OAAO,SAAS,MAAM;EAC7B,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,IAAI,IAAI,GAAG;EACxB,IAAI,CAAC,sBAAsB,MAAM,KAAK,KAAK,GAAG;EAC9C,MAAM,OAAO,MAAM,0BAA0B,KAAK,QAAQ,KAAK,OAAO,KAAK,KAAK,MAAM,EAAE;EACxF;CACJ;CACA,OAAO;AACX;AAEA,eAAsB,yBAClB,QACA,aACA,KAC2B;CAC3B,MAAM,SAA6B;EAAE,iBAAiB;EAAG,eAAe;EAAG,SAAS,CAAC;EAAG,UAAU,CAAC;EAAG,WAAW,CAAC;EAAG,gBAAgB;CAAE;CAEvI,MAAM,QAAQ,uBAAuB,WAAW;CAChD,IAAI,MAAM,WAAW,GAAG,OAAO;CAG/B,MAAM,WAAW,MAAM,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,MAAM,KAAI,MAAK,EAAE,MAAM,CAAC,CACT,CAAO;CAEzD,KAAK,MAAM,QAAQ,OAAO;EACtB,IAAI,CAAC,SAAS,OAAO,IAAI,KAAK,SAAS,GAAG;GACtC,OAAO,QAAQ,KAAK;IAChB,OAAO,KAAK;IACZ,QAAQ;GACZ,CAAC;GACD;EACJ;EAOA,IAAI;GACA,MAAM,OAAO,MAAM,KAAK,SAAS;GACjC,OAAO;EACX,SAAS,KAAK;GACV,MAAM,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAK7D,IAAI,iBAAiB;GACrB,IAAI;IACA,MAAM,OAAO,MAAM,4BAA4B,KAAK,UAAU,QAAQ,kBAAkB;IACxF,iBAAiB;GACrB,QAAQ,CAIR;GACA,OAAO,UAAU,KAAK;IAAE,OAAO,KAAK;IAChD;IACA;GAAe,CAAC;GACJ;EACJ;EAEA,IAAI;GACA,IAAI,UAAU;GACd,KAAK,MAAM,aAAa,KAAK,kBAAkB;IAC3C,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,eAAe,SAAS,GAAG;KAC3B,OAAO;KACP;IACJ;GACJ;GAEA,MAAM,UAAU,MAAM,uBAAuB,QAAQ,IAAI;GACzD,OAAO,kBAAkB;GAEzB,MAAM,GAAG,KAAK,UAAU,iBAAiB,QAAQ,yBAC5C,UAAU,IAAI,KAAK,QAAQ,sBAAsB,GAAG;EAC7D,SAAS,KAAK;GACV,OAAO,SAAS,KAAK;IACjB,OAAO,KAAK;IACZ,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC1D,CAAC;EACL;CACJ;CAEA,OAAO;AACX"}
|
|
1
|
+
{"version":3,"file":"ensure-collection-policies-CagOzEPS.js","names":[],"sources":["../src/security/policy-drift.ts","../src/schema/ensure-collection-policies.ts"],"sourcesContent":["/**\n * Compare the RLS policies a database actually has against the ones the\n * collections describe.\n *\n * Policies live in Postgres; the collection config is only their *source*.\n * Nothing reconciled the two, which is how the demo database served empty\n * collections indefinitely: its policies granted `TO authenticated` (a Supabase\n * role name) while requests run as `rebase_user`, so RLS filtered every row.\n * The config was later corrected and the database never noticed — an empty\n * table is indistinguishable from a table with no data.\n *\n * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same\n * function `db push` uses to write `drizzle/policies.sql`, so this compares\n * against exactly what would be applied rather than a reimplementation.\n */\nimport { RLS_UID_SQL, type CollectionConfig } from \"@rebasepro/types\";\n\nimport { generatePostgresPoliciesDdl } from \"../schema/generate-postgres-ddl-logic\";\n\nexport interface PolicyRef {\n schema: string;\n table: string;\n name: string;\n /** Roles in the TO clause. */\n roles: string[];\n /** SELECT / INSERT / UPDATE / DELETE / ALL. */\n command: string;\n /** Whether a USING clause is present at all (not what it says). */\n hasUsing: boolean;\n /** Whether a WITH CHECK clause is present at all (not what it says). */\n hasWithCheck: boolean;\n /**\n * PERMISSIVE or RESTRICTIVE — the `AS` clause.\n *\n * An exact catalogue value on both sides, so it belongs with roles and\n * command rather than with the expression text. It matters more than either:\n * permissive policies are ORed together and restrictive ones ANDed, so a rule\n * declared `mode: \"restrictive\"` whose live policy is PERMISSIVE has had its\n * gate turned from a requirement into an alternative — the maximally\n * permissive way for this to be wrong.\n *\n * The DDL regex captured this from the start and the destructuring threw it\n * away; `pg_policies.permissive` was never selected.\n */\n mode?: \"PERMISSIVE\" | \"RESTRICTIVE\";\n /**\n * The live clause text, when read from `pg_policies`. Present only for live\n * policies (the expected side is parsed from DDL and does not carry it).\n * Used solely for the insecure-tautology scan, not for divergence — Postgres\n * rewrites this text, so it is not safe to diff against expected.\n */\n qual?: string | null;\n withCheck?: string | null;\n}\n\nexport interface PolicyDrift {\n /** Described by the collections, absent from the database. */\n missing: PolicyRef[];\n /** In the database, described by no collection — stale pushes live here. */\n orphaned: PolicyRef[];\n /** Same policy name, different roles or command. */\n diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];\n /**\n * A live policy whose expression is the known-permissive tautology\n * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the\n * user path coerces a blank id to the `'anonymous'` sentinel. This is what\n * `policy.authenticated()` used to compile to, so a database pushed before\n * that fix carries it, and neither the name, roles, command nor clause\n * *presence* differs from the corrected policy — the only thing that changed\n * is the expression text, which this checker otherwise (correctly) ignores.\n * So it is the one drift that hides from every other check here.\n *\n * @see reason a sentence naming the clause and what to do.\n */\n insecure: { policy: PolicyRef; reason: string }[];\n /**\n * A table the collections describe whose RLS switch is off.\n *\n * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in\n * `pg_policies` untouched, so before this category every expected policy\n * still matched on name, roles, command and clause presence and the checker\n * reported clean — on a table Postgres was applying no filter to at all.\n * Requests run as `rebase_user`, which holds full DML, so the table is wide\n * open while `doctor` certifies it.\n *\n * `forced` reports `relforcerowsecurity`, which is what also subjects the\n * table's *owner* to its policies. Its absence is not drift on its own —\n * Rebase does not connect as the owner in the request path — so it is\n * reported for context rather than raised as a failure.\n */\n rlsDisabled: { schema: string; table: string; forced: boolean }[];\n}\n\nexport interface Queryable {\n query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;\n}\n\n// The trailing group captures whichever clause follows the TO list, which is\n// what tells us the clause is present.\nconst CREATE_POLICY = /CREATE POLICY \"([^\"]+)\" ON \"([^\"]+)\"\\.\"([^\"]+)\"\\s+AS (\\w+)\\s+FOR (\\w+)\\s+TO ([^\\n]+?)(\\s+USING\\s*\\(|\\s+WITH CHECK\\s*\\(|;)/gi;\n\nconst WITH_CHECK_NEXT = /^\\s+WITH CHECK\\s*\\(/i;\n\n/**\n * Index just past the `)` closing a clause whose `(` ends at `open`.\n *\n * Needed because a policy expression nests parens and can contain a quoted\n * literal holding either character, so \"find the next `)`\" would stop early and\n * miss the `WITH CHECK` that follows.\n */\nfunction clauseEnd(ddl: string, open: number): number {\n let depth = 1;\n let inQuote = false;\n for (let i = open; i < ddl.length; i++) {\n const c = ddl[i];\n if (inQuote) {\n // '' is an escaped quote inside a string, not a close.\n if (c === \"'\") {\n if (ddl[i + 1] === \"'\") i++;\n else inQuote = false;\n }\n continue;\n }\n if (c === \"'\") inQuote = true;\n else if (c === \"(\") depth++;\n else if (c === \")\" && --depth === 0) return i + 1;\n }\n return ddl.length;\n}\n\n/**\n * `AS PERMISSIVE` / `AS RESTRICTIVE` from either side, or undefined.\n *\n * The DDL spells it as a bare word; `pg_policies.permissive` is the string\n * `\"PERMISSIVE\"`/`\"RESTRICTIVE\"` on modern Postgres but a boolean on some\n * drivers and older servers, so both are accepted. Anything unrecognised\n * becomes `undefined` and the comparison below skips it rather than inventing\n * drift from a shape we did not anticipate.\n */\nfunction normalizeMode(raw: unknown): \"PERMISSIVE\" | \"RESTRICTIVE\" | undefined {\n if (typeof raw === \"boolean\") return raw ? \"PERMISSIVE\" : \"RESTRICTIVE\";\n if (typeof raw !== \"string\") return undefined;\n const upper = raw.trim().toUpperCase();\n if (upper === \"PERMISSIVE\" || upper === \"TRUE\" || upper === \"T\") return \"PERMISSIVE\";\n if (upper === \"RESTRICTIVE\" || upper === \"FALSE\" || upper === \"F\") return \"RESTRICTIVE\";\n return undefined;\n}\n\n/** Parse the generated DDL rather than rebuilding the shape by hand. */\nexport function parseExpectedPolicies(ddl: string): PolicyRef[] {\n const found: PolicyRef[] = [];\n for (const m of ddl.matchAll(CREATE_POLICY)) {\n const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;\n const roles = rolesRaw\n .split(\",\")\n .map((r) => r.trim().replace(/^\"|\"$/g, \"\"))\n .filter(Boolean);\n\n // The generator emits USING before WITH CHECK, so what follows the TO\n // list settles USING; WITH CHECK is then whatever follows that clause.\n const hasUsing = /USING/i.test(clause);\n const hasWithCheck = hasUsing\n ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))\n : /WITH CHECK/i.test(clause);\n\n found.push({\n schema, table, name, roles,\n command: command.toUpperCase(),\n hasUsing, hasWithCheck,\n mode: normalizeMode(modeRaw)\n });\n }\n return found;\n}\n\nasync function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {\n const { rows } = await client.query<{\n schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;\n qual: string | null; with_check: string | null; permissive: string | boolean | null;\n }>(\n `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive\n FROM pg_policies\n WHERE schemaname = ANY($1)`,\n [schemas]\n );\n\n return rows.map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n name: r.policyname,\n // node-postgres yields text[] as an array; some drivers hand back \"{a,b}\".\n roles: Array.isArray(r.roles)\n ? r.roles\n : String(r.roles ?? \"\").replace(/^\\{|\\}$/g, \"\").split(\",\").filter(Boolean),\n command: (r.cmd ?? \"ALL\").toUpperCase(),\n // Presence only. Postgres rewrites the text, but it does not invent or\n // drop a clause: NULL here means the policy genuinely has none.\n hasUsing: r.qual != null,\n hasWithCheck: r.with_check != null,\n mode: normalizeMode(r.permissive),\n qual: r.qual,\n withCheck: r.with_check\n }));\n}\n\n/**\n * Tables the collections describe whose RLS switch is off.\n *\n * Read from `pg_class` because `pg_policies` cannot answer it: disabling RLS\n * leaves the policy rows in place, so every other comparison in this file keeps\n * matching while Postgres applies none of them.\n *\n * Only tables that expect at least one policy are considered. A table with no\n * declared rules is not asserting anything about RLS, and reporting it would\n * make the check noisy on exactly the projects least able to judge the noise.\n */\nasync function readTablesWithRlsOff(\n client: Queryable,\n tables: { schema: string; table: string }[]\n): Promise<{ schema: string; table: string; forced: boolean }[]> {\n if (tables.length === 0) return [];\n const { rows } = await client.query<{\n schemaname: string; tablename: string; relrowsecurity: boolean; relforcerowsecurity: boolean;\n }>(\n `SELECT n.nspname AS schemaname,\n c.relname AS tablename,\n c.relrowsecurity,\n c.relforcerowsecurity\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind = 'r'\n AND (n.nspname || '.' || c.relname) = ANY($1)`,\n [tables.map((t) => `${t.schema}.${t.table}`)]\n );\n\n return rows\n .filter((r) => !r.relrowsecurity)\n .map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n forced: !!r.relforcerowsecurity\n }));\n}\n\n/**\n * The permissive tautology `rebase.uid() IS NOT NULL`, without the\n * `<> 'anonymous'` guard that makes it mean \"signed in\".\n *\n * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The\n * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the\n * corrected policy from the stale one, so its presence clears the text.\n *\n * Both schema spellings are matched. The helpers moved from `auth` to `rebase`\n * in 1.0, and this check reads policies *as the database stored them*: a\n * database pushed before the move still holds `auth.uid()` until it is\n * recompiled, while everything written since — including raw `securityRules`\n * SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.\n * Matching only the pre-1.0 name made the check blind to every policy a current\n * release could write, which is precisely the set still worth scanning.\n */\nfunction isPermissiveAuthTautology(clause: string | null | undefined): boolean {\n if (!clause) return false;\n const flat = clause.toLowerCase().replace(/\\s+/g, \" \");\n if (!/\\b(?:rebase|auth)\\.uid\\s*\\(\\s*\\)\\s*is not null/.test(flat)) return false;\n // The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the\n // literal as `'anonymous'::text`. Either spelling means it is the corrected\n // policy, not the tautology.\n return !/<>\\s*'anonymous'/.test(flat) && !/!=\\s*'anonymous'/.test(flat);\n}\n\nconst keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;\nconst sameRoles = (a: string[], b: string[]) =>\n a.length === b.length && [...a].sort().join(\",\") === [...b].sort().join(\",\");\n\n/**\n * Diff expected against live.\n *\n * Compares names, roles, command, and whether each clause exists — all exact\n * values. Policy expression *text* is deliberately not compared: Postgres\n * rewrites `qual`/`with_check` when storing them (parenthesising, casting,\n * schema-qualifying), so text comparison reports drift that does not exist, and\n * a check that cries wolf gets ignored.\n *\n * Presence is not text, though. A NULL `qual` is not a rewrite of an\n * expression, it is the absence of one, and absence has no false-positive risk:\n * either the generator emitted a clause or it did not. That distinction is worth\n * the extra comparison — a production database was found with a SELECT policy\n * whose `qual` was NULL, matching on every field this checked and denying 100%\n * of reads. The same blindness would hide a policy that fails open.\n */\nexport async function checkPolicyDrift(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<PolicyDrift> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const schemas = [...new Set(expected.map((p) => p.schema))];\n // Nothing expected means nothing to reconcile against; scanning every\n // schema would report the whole database as orphaned.\n if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n const live = await readLivePolicies(client, schemas);\n const liveByKey = new Map(live.map((p) => [keyOf(p), p]));\n const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));\n\n const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n // Before comparing policy against policy: is the switch even on? A table\n // with RLS disabled matches every expected policy on every field compared\n // below, so this has to be asked separately or it cannot be asked at all.\n const expectedTables = [...new Map(\n expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])\n ).values()];\n drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);\n\n // Scan every live policy for the permissive tautology. This is deliberately\n // independent of the name-keyed diff below: a database pushed before the\n // `authenticated()` fix matches its expected policy on name, roles, command\n // and clause presence, so nothing else here would flag it.\n for (const p of live) {\n const clause = isPermissiveAuthTautology(p.qual)\n ? \"USING\"\n : isPermissiveAuthTautology(p.withCheck) ? \"WITH CHECK\" : null;\n if (clause) {\n drift.insecure.push({\n policy: p,\n reason: `${clause} is \\`${RLS_UID_SQL} IS NOT NULL\\`, which is true for anonymous ` +\n `visitors too — this grants access to signed-out requests. It predates the ` +\n `\\`policy.authenticated()\\` fix; re-run \\`rebase db push\\` to tighten it.`\n });\n }\n }\n\n for (const [key, want] of expectedByKey) {\n const got = liveByKey.get(key);\n if (!got) {\n drift.missing.push(want);\n continue;\n }\n const differences: string[] = [];\n if (!sameRoles(want.roles, got.roles)) {\n differences.push(`roles: expected [${want.roles.join(\", \")}], database has [${got.roles.join(\", \")}]`);\n }\n if (want.command !== got.command) {\n differences.push(`command: expected ${want.command}, database has ${got.command}`);\n }\n // Skipped when either side is unknown: an unrecognised catalogue shape\n // should not manufacture drift. See `normalizeMode`.\n if (want.mode && got.mode && want.mode !== got.mode) {\n differences.push(\n `mode: expected ${want.mode}, database has ${got.mode}` +\n (got.mode === \"PERMISSIVE\"\n ? \" — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it\"\n : \" — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it\")\n );\n }\n for (const clause of [\"USING\", \"WITH CHECK\"] as const) {\n const key = clause === \"USING\" ? \"hasUsing\" : \"hasWithCheck\";\n if (want[key] === got[key]) continue;\n differences.push(want[key]\n ? `${clause}: expected an expression, database has none — this policy matches no rows`\n : `${clause}: expected none, database has an expression`);\n }\n if (differences.length > 0) drift.diverged.push({ expected: want, actual: got, differences });\n }\n\n for (const [key, got] of liveByKey) {\n if (!expectedByKey.has(key)) drift.orphaned.push(got);\n }\n\n return drift;\n}\n\n/**\n * Does this name look like one the generator produced for this table?\n *\n * Unnamed rules compile to `<table>_<op>_<sha1[0:7]>` (plus `_<idx>` when one\n * rule spans several operations), and the hash covers the rule's semantics — so\n * *editing* a rule renames its policy. The policy under the old name is left\n * behind by `db push`, which only DROPs the names it is about to CREATE, and\n * Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps\n * granting everything no matter how tight its replacement is.\n *\n * Matching the shape is what makes dropping them safe. A hand-written policy\n * would have to collide with a 7-hex digest to be mistaken for generated one;\n * a policy named anything else is left alone and merely reported, because a\n * custom name is indistinguishable from one someone wrote in SQL on purpose.\n */\nexport function isGeneratedPolicyName(name: string, table: string): boolean {\n return new RegExp(`^${table.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\\\d+)?$`)\n .test(name);\n}\n\nexport interface OrphanCleanup {\n /** Superseded generated policies that were dropped. */\n dropped: PolicyRef[];\n /** Orphans left in place because their names are not generator-shaped. */\n kept: PolicyRef[];\n}\n\n/**\n * Drop the policies an earlier push superseded but never removed.\n *\n * Only touches tables the collections describe — a table with no expected\n * policy is not ours to reconcile, and scanning by schema alone would sweep up\n * policies belonging to something else sharing the database.\n */\nexport async function dropOrphanedPolicies(\n client: Queryable,\n drift: PolicyDrift,\n collections: CollectionConfig[]\n): Promise<OrphanCleanup> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const managed = new Set(expected.map((p) => `${p.schema}.${p.table}`));\n\n const cleanup: OrphanCleanup = { dropped: [], kept: [] };\n for (const p of drift.orphaned) {\n if (!managed.has(`${p.schema}.${p.table}`) || !isGeneratedPolicyName(p.name, p.table)) {\n cleanup.kept.push(p);\n continue;\n }\n // Identifiers are quoted, and the name came from pg_policies rather than\n // from user input, so it is already a valid identifier.\n await client.query(`DROP POLICY IF EXISTS \"${p.name}\" ON \"${p.schema}\".\"${p.table}\"`);\n cleanup.dropped.push(p);\n }\n return cleanup;\n}\n\nexport const hasDrift = (d: PolicyDrift): boolean =>\n d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0\n || d.rlsDisabled.length > 0;\n\n/** Human-readable report; empty string when the database matches the config. */\nexport function formatPolicyDrift(drift: PolicyDrift): string {\n if (!hasDrift(drift)) return \"\";\n const lines: string[] = [];\n\n // First, because it subsumes every other finding on the same table: if RLS\n // is off, the policies listed below are not being applied at all.\n if (drift.rlsDisabled.length > 0) {\n lines.push(\" RLS DISABLED — the table has policies, and Postgres is applying none of them:\");\n for (const t of drift.rlsDisabled) {\n lines.push(` • ${t.schema}.${t.table}${t.forced ? \"\" : \" (and FORCE is off)\"}`);\n }\n lines.push(\" Every row is readable and writable by any request that reaches the table.\");\n lines.push(` Fix: ALTER TABLE \"<schema>\".\"<table>\" ENABLE ROW LEVEL SECURITY; or re-run \\`rebase db push\\`.`);\n }\n if (drift.missing.length > 0) {\n lines.push(\" Missing — described by your collections, absent from the database:\");\n for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Run `rebase db push` to apply them.\");\n }\n if (drift.orphaned.length > 0) {\n lines.push(\" Orphaned — in the database, described by no collection:\");\n for (const p of drift.orphaned) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Left behind by an earlier push. These still filter rows.\");\n }\n if (drift.diverged.length > 0) {\n lines.push(\" Diverged — same policy, different definition:\");\n for (const d of drift.diverged) {\n lines.push(` • ${d.expected.schema}.${d.expected.table} → \"${d.expected.name}\"`);\n for (const diff of d.differences) lines.push(` ${diff}`);\n }\n }\n if (drift.insecure.length > 0) {\n lines.push(\" Insecure — a live policy grants access it should not:\");\n for (const i of drift.insecure) {\n lines.push(` • ${i.policy.schema}.${i.policy.table} → \"${i.policy.name}\"`);\n lines.push(` ${i.reason}`);\n }\n }\n return lines.join(\"\\n\");\n}\n","/**\n * Applying a bundle's RLS policies to a database at boot, idempotently.\n *\n * ## Why this exists\n *\n * {@link ensureCollectionTables} creates the collection *tables* a managed\n * runtime boots against, but a table with row-level security disabled and no\n * policies is not servable: authenticated requests run as the restricted\n * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a\n * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy\n * is denied. The policies live in the collections' `securityRules`; nothing at\n * boot applied them. `rebase db push` does — but it drives Atlas against a\n * local `DATABASE_URL`, and a managed tenant's database is reachable only from\n * inside the cluster, by the runtime that is already connected to it. So the\n * runtime is the only thing that *can* apply them, and this is where it does.\n *\n * ## Why this is safe to run on every boot\n *\n * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once\n * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by\n * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds\n * and replaces; it never drops data. (It does not *reconcile* — a policy a\n * previous push left behind under an old name is not removed here; that stays a\n * `db push` / `db migrate` concern, alongside destructive schema changes.)\n *\n * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a\n * table whose policies could not be applied fails **closed** (denies) rather\n * than leaking rows. One collection's policy failing (e.g. a rule that\n * references a table a real migration has not created yet) must not crash-loop\n * the whole deployment and take the other collections' working routes down with\n * it. Failures are reported loudly and per-table so the operator can see\n * exactly which collection is not yet servable and why.\n */\nimport { type CollectionConfig } from \"@rebasepro/types\";\nimport { planCollectionPolicies, type CollectionPolicyPlan } from \"./generate-postgres-ddl-logic\";\nimport { isGeneratedPolicyName } from \"../security/policy-drift\";\nimport { describeDriverError, readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\nimport { REBASE_USER_ROLE } from \"../security/rls-enforcement\";\n\nexport interface PolicyEnsureResult {\n /** `CREATE POLICY` statements that ran successfully. */\n policiesApplied: number;\n /** Tables that had RLS enabled. */\n tablesSecured: number;\n /** Declared tables absent from the database — left to a real migration. */\n skipped: { table: string; reason: string }[];\n /**\n * Tables that have RLS on but did not get every policy. They deny — RLS\n * with no matching policy is deny-all — so they are safe but not servable.\n */\n failures: { table: string; error: string }[];\n /**\n * Tables RLS could not be enabled on, whose DML grant was withdrawn instead.\n *\n * This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`\n * failing was recorded as a `failure` and reported with the same \"it stays\n * locked (denies)\" wording as a failed policy — but the two are opposites.\n * A policy statement failing leaves RLS on and the table denying. `enableRls`\n * failing leaves RLS *off*, and the schema-wide grant to the user role has\n * already been made by `ensureRlsEnforcement`, so the table is readable and\n * writable by every authenticated request with no row filtering at all.\n */\n unsecured: { table: string; error: string; grantWithdrawn: boolean }[];\n /**\n * Generated policies removed because no current rule produces them.\n *\n * A policy's name embeds a hash of the rule's semantics, so editing a rule\n * does not update a policy — it creates a new one and abandons the old.\n * Postgres ORs permissive policies, so the abandoned one keeps granting:\n * a `USING (true)` tightened to an owner check went on admitting everyone,\n * forever, while the deploy logged success.\n *\n * `db push` reconciles this, and cannot reach a managed tenant's in-cluster\n * database — which is the reason this module exists. So boot has to do it.\n */\n orphansDropped: number;\n}\n\nconst isCreatePolicy = (statement: string): boolean => /^\\s*CREATE POLICY/i.test(statement);\n\n/**\n * Bring the declared collections' RLS policies up to date. Returns what it did.\n *\n * Only tables that already exist are touched: the boot-time table creator runs\n * first, so anything still missing is a table this additive path is not allowed\n * to create (a junction, or a relation left to a migration). Enabling RLS on a\n * non-existent table would error, so those are recorded as skipped, not failed.\n */\n\n/**\n * Remove generated policies on this table that the current plan does not\n * produce.\n *\n * Scoped hard, because dropping a policy is destructive: only this one table,\n * only names matching the generated `<table>_<op>_<sha1>` shape, and only names\n * absent from the statements just applied. A hand-written policy, or one\n * belonging to another table, is never touched — `isGeneratedPolicyName` is the\n * same predicate `db push` uses to draw that line.\n */\nasync function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {\n const expected = new Set<string>();\n for (const statement of plan.policyStatements) {\n const match = /^\\s*CREATE POLICY\\s+\"([^\"]+)\"/i.exec(statement);\n if (match) expected.add(match[1]);\n }\n\n const existing = await client.query<{ policyname: string }>(\n `SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, \"''\")}' ` +\n `AND tablename = '${plan.table.replace(/'/g, \"''\")}'`\n );\n\n let dropped = 0;\n for (const row of existing.rows) {\n const name = row.policyname;\n if (expected.has(name)) continue;\n if (!isGeneratedPolicyName(name, plan.table)) continue;\n await client.query(`DROP POLICY IF EXISTS \"${name}\" ON \"${plan.schema}\".\"${plan.table}\"`);\n dropped++;\n }\n return dropped;\n}\n\nexport async function ensureCollectionPolicies(\n client: Queryable,\n collections: CollectionConfig[],\n log?: (message: string) => void\n): Promise<PolicyEnsureResult> {\n const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };\n\n const plans = planCollectionPolicies(collections);\n if (plans.length === 0) return result;\n\n const schemas = Array.from(new Set(plans.map(p => p.schema)));\n const existing = await readExistingSchema(client, schemas);\n\n for (const plan of plans) {\n if (!existing.tables.has(plan.qualified)) {\n result.skipped.push({\n table: plan.qualified,\n reason: \"table is not present in the database; create it with `rebase db push` / `rebase db migrate`\"\n });\n continue;\n }\n\n // Enabling RLS is its own step, because its failure is the opposite of\n // every other failure here. Once RLS is on, anything that goes wrong\n // afterwards leaves the table denying; while it is off, the grant made\n // earlier in boot leaves the table wide open. Sharing one `try` meant\n // the dangerous case was reported in the safe case's words.\n try {\n await client.query(plan.enableRls);\n result.tablesSecured++;\n } catch (err) {\n // The database's own answer, not the ORM's wrapper — see\n // `describeDriverError`. This is the most consequential message in\n // the file: it is what the operator reads about a table that is now\n // denying every request, and `Failed query: <the statement>` told\n // them nothing they did not already have.\n const error = describeDriverError(err);\n // Take the privilege back rather than serve an unprotected table.\n // This is the fail-closed step the old code assumed it already had:\n // per-table, so one collection cannot take the rest of the\n // deployment down, but leaving nothing readable without RLS.\n let grantWithdrawn = false;\n try {\n await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);\n grantWithdrawn = true;\n } catch {\n // Fall through: reported below with `grantWithdrawn: false`,\n // which the caller escalates. There is nothing else this\n // function can do to make the table safe.\n }\n result.unsecured.push({ table: plan.qualified,\nerror,\ngrantWithdrawn });\n continue;\n }\n\n try {\n let created = 0;\n for (const statement of plan.policyStatements) {\n await client.query(statement);\n if (isCreatePolicy(statement)) {\n result.policiesApplied++;\n created++;\n }\n }\n\n const orphans = await dropOrphanedPoliciesOn(client, plan);\n result.orphansDropped += orphans;\n\n log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +\n (orphans > 0 ? `, ${orphans} orphan(s) dropped` : \"\"));\n } catch (err) {\n result.failures.push({\n table: plan.qualified,\n error: describeDriverError(err)\n });\n }\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAmYA,SAAgB,sBAAsB,MAAc,OAAwB;CACxE,OAAO,IAAI,OAAO,IAAI,MAAM,QAAQ,uBAAuB,MAAM,EAAE,wDAAwD,CAAC,CACvH,KAAK,IAAI;AAClB;;;ACxTA,IAAM,kBAAkB,cAA+B,qBAAqB,KAAK,SAAS;;;;;;;;;;;;;;;;;;;AAqB1F,eAAe,uBAAuB,QAAmB,MAA6C;CAClG,MAAM,2BAAW,IAAI,IAAY;CACjC,KAAK,MAAM,aAAa,KAAK,kBAAkB;EAC3C,MAAM,QAAQ,iCAAiC,KAAK,SAAS;EAC7D,IAAI,OAAO,SAAS,IAAI,MAAM,EAAE;CACpC;CAEA,MAAM,WAAW,MAAM,OAAO,MAC1B,0DAA0D,KAAK,OAAO,QAAQ,MAAM,IAAI,EAAE,qBACtE,KAAK,MAAM,QAAQ,MAAM,IAAI,EAAE,EACvD;CAEA,IAAI,UAAU;CACd,KAAK,MAAM,OAAO,SAAS,MAAM;EAC7B,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,IAAI,IAAI,GAAG;EACxB,IAAI,CAAC,sBAAsB,MAAM,KAAK,KAAK,GAAG;EAC9C,MAAM,OAAO,MAAM,0BAA0B,KAAK,QAAQ,KAAK,OAAO,KAAK,KAAK,MAAM,EAAE;EACxF;CACJ;CACA,OAAO;AACX;AAEA,eAAsB,yBAClB,QACA,aACA,KAC2B;CAC3B,MAAM,SAA6B;EAAE,iBAAiB;EAAG,eAAe;EAAG,SAAS,CAAC;EAAG,UAAU,CAAC;EAAG,WAAW,CAAC;EAAG,gBAAgB;CAAE;CAEvI,MAAM,QAAQ,uBAAuB,WAAW;CAChD,IAAI,MAAM,WAAW,GAAG,OAAO;CAG/B,MAAM,WAAW,MAAM,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,MAAM,KAAI,MAAK,EAAE,MAAM,CAAC,CACT,CAAO;CAEzD,KAAK,MAAM,QAAQ,OAAO;EACtB,IAAI,CAAC,SAAS,OAAO,IAAI,KAAK,SAAS,GAAG;GACtC,OAAO,QAAQ,KAAK;IAChB,OAAO,KAAK;IACZ,QAAQ;GACZ,CAAC;GACD;EACJ;EAOA,IAAI;GACA,MAAM,OAAO,MAAM,KAAK,SAAS;GACjC,OAAO;EACX,SAAS,KAAK;GAMV,MAAM,QAAQ,oBAAoB,GAAG;GAKrC,IAAI,iBAAiB;GACrB,IAAI;IACA,MAAM,OAAO,MAAM,4BAA4B,KAAK,UAAU,QAAQ,kBAAkB;IACxF,iBAAiB;GACrB,QAAQ,CAIR;GACA,OAAO,UAAU,KAAK;IAAE,OAAO,KAAK;IAChD;IACA;GAAe,CAAC;GACJ;EACJ;EAEA,IAAI;GACA,IAAI,UAAU;GACd,KAAK,MAAM,aAAa,KAAK,kBAAkB;IAC3C,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,eAAe,SAAS,GAAG;KAC3B,OAAO;KACP;IACJ;GACJ;GAEA,MAAM,UAAU,MAAM,uBAAuB,QAAQ,IAAI;GACzD,OAAO,kBAAkB;GAEzB,MAAM,GAAG,KAAK,UAAU,iBAAiB,QAAQ,yBAC5C,UAAU,IAAI,KAAK,QAAQ,sBAAsB,GAAG;EAC7D,SAAS,KAAK;GACV,OAAO,SAAS,KAAK;IACjB,OAAO,KAAK;IACZ,OAAO,oBAAoB,GAAG;GAClC,CAAC;EACL;CACJ;CAEA,OAAO;AACX"}
|