@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
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "@jest/globals";
|
|
2
|
-
import type { CollectionConfig } from "@rebasepro/types";
|
|
3
|
-
|
|
4
|
-
import { checkPolicyDrift, parseExpectedPolicies, formatPolicyDrift, hasDrift, dropOrphanedPolicies, isGeneratedPolicyName, type PolicyRef, type Queryable } from "./policy-drift";
|
|
5
|
-
import { generatePostgresPoliciesDdl } from "../schema/generate-postgres-ddl-logic";
|
|
6
|
-
|
|
7
|
-
function collection(slug: string): CollectionConfig {
|
|
8
|
-
return {
|
|
9
|
-
name: slug,
|
|
10
|
-
slug,
|
|
11
|
-
table: slug,
|
|
12
|
-
schema: "public",
|
|
13
|
-
properties: { id: { name: "ID", type: "string", isId: "uuid" } },
|
|
14
|
-
securityRules: [
|
|
15
|
-
{ operation: "select", access: "public" },
|
|
16
|
-
{ operations: ["insert", "update", "delete"], roles: ["admin"] }
|
|
17
|
-
]
|
|
18
|
-
} as unknown as CollectionConfig;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** A collection whose write rule is declared RESTRICTIVE, so the DDL says `AS RESTRICTIVE`. */
|
|
22
|
-
function restrictiveCollection(slug: string): CollectionConfig {
|
|
23
|
-
return {
|
|
24
|
-
name: slug,
|
|
25
|
-
slug,
|
|
26
|
-
table: slug,
|
|
27
|
-
schema: "public",
|
|
28
|
-
properties: { id: { name: "ID", type: "string", isId: "uuid" } },
|
|
29
|
-
securityRules: [
|
|
30
|
-
{ operation: "select", access: "public" },
|
|
31
|
-
{ operations: ["insert", "update", "delete"], roles: ["admin"], mode: "restrictive" }
|
|
32
|
-
]
|
|
33
|
-
} as unknown as CollectionConfig;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Stands in for the two catalogue reads: `pg_policies`, then `pg_class`.
|
|
38
|
-
*
|
|
39
|
-
* The second one is dispatched on the query text rather than call order, so a
|
|
40
|
-
* test that stops caring about ordering does not silently start feeding policy
|
|
41
|
-
* rows to the RLS-status read — which would report every table as RLS-off,
|
|
42
|
-
* since a policy row has no `relrowsecurity` column and `undefined` is falsy.
|
|
43
|
-
*
|
|
44
|
-
* `rlsOff` names tables whose RLS switch is off. Anything not listed is on,
|
|
45
|
-
* which is what a healthy `db push` leaves behind.
|
|
46
|
-
*/
|
|
47
|
-
function dbWith(rows: Record<string, unknown>[], rlsOff: string[] = []): Queryable {
|
|
48
|
-
return {
|
|
49
|
-
query: async (text: string, values?: unknown[]) => {
|
|
50
|
-
if (!text.includes("pg_class")) return { rows: rows as never[] };
|
|
51
|
-
const wanted = (values?.[0] as string[] | undefined) ?? [];
|
|
52
|
-
return {
|
|
53
|
-
rows: wanted.map((qualified) => {
|
|
54
|
-
const [schemaname, tablename] = qualified.split(".");
|
|
55
|
-
return {
|
|
56
|
-
schemaname,
|
|
57
|
-
tablename,
|
|
58
|
-
relrowsecurity: !rlsOff.includes(tablename),
|
|
59
|
-
relforcerowsecurity: !rlsOff.includes(tablename)
|
|
60
|
-
};
|
|
61
|
-
}) as never[]
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** A pg_policies row matching an expected policy, clauses and all. */
|
|
68
|
-
function liveRow(p: PolicyRef, overrides: Record<string, unknown> = {}) {
|
|
69
|
-
return {
|
|
70
|
-
schemaname: p.schema, tablename: p.table, policyname: p.name, roles: p.roles, cmd: p.command,
|
|
71
|
-
// Postgres rewrites the text it stores; only presence is compared.
|
|
72
|
-
qual: p.hasUsing ? "(rewritten by postgres)" : null,
|
|
73
|
-
with_check: p.hasWithCheck ? "(rewritten by postgres)" : null,
|
|
74
|
-
permissive: p.mode ?? "PERMISSIVE",
|
|
75
|
-
...overrides
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
describe("parseExpectedPolicies", () => {
|
|
80
|
-
it("reads the DDL that db push actually applies", () => {
|
|
81
|
-
const ddl = generatePostgresPoliciesDdl([collection("authors")]);
|
|
82
|
-
const parsed = parseExpectedPolicies(ddl);
|
|
83
|
-
|
|
84
|
-
expect(parsed.length).toBeGreaterThan(0);
|
|
85
|
-
const select = parsed.find((p) => p.command === "SELECT" && p.roles.includes("public"));
|
|
86
|
-
expect(select).toMatchObject({ schema: "public", table: "authors", command: "SELECT" });
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("checkPolicyDrift", () => {
|
|
91
|
-
it("reports nothing when the database matches the collections", async () => {
|
|
92
|
-
const cols = [collection("authors")];
|
|
93
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
94
|
-
|
|
95
|
-
const drift = await checkPolicyDrift(dbWith(expected.map((p) => liveRow(p))), cols);
|
|
96
|
-
|
|
97
|
-
expect(hasDrift(drift)).toBe(false);
|
|
98
|
-
expect(formatPolicyDrift(drift)).toBe("");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("flags a table whose RLS switch is off, though every policy still matches", async () => {
|
|
102
|
-
// The whole point: DISABLE ROW LEVEL SECURITY leaves pg_policies
|
|
103
|
-
// untouched, so name, roles, command and clause presence all agree and
|
|
104
|
-
// every other check here passes. Before this category the report was
|
|
105
|
-
// empty on a table Postgres was filtering nothing on.
|
|
106
|
-
const cols = [collection("authors")];
|
|
107
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
108
|
-
|
|
109
|
-
const drift = await checkPolicyDrift(
|
|
110
|
-
dbWith(expected.map((p) => liveRow(p)), ["authors"]),
|
|
111
|
-
cols
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
expect(drift.missing).toHaveLength(0);
|
|
115
|
-
expect(drift.diverged).toHaveLength(0);
|
|
116
|
-
expect(drift.orphaned).toHaveLength(0);
|
|
117
|
-
expect(drift.rlsDisabled).toEqual([{ schema: "public", table: "authors", forced: false }]);
|
|
118
|
-
expect(hasDrift(drift)).toBe(true);
|
|
119
|
-
expect(formatPolicyDrift(drift)).toContain("RLS DISABLED");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("flags a RESTRICTIVE rule the database stores as PERMISSIVE", async () => {
|
|
123
|
-
// A gate that should be ANDed in is now ORed in — it widens access
|
|
124
|
-
// instead of narrowing it, with identical name, roles, command and
|
|
125
|
-
// clauses. Only the `AS` clause differs, and it used to be discarded by
|
|
126
|
-
// the DDL parser and never selected from pg_policies.
|
|
127
|
-
const cols = [restrictiveCollection("ledger")];
|
|
128
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
129
|
-
const restrictive = expected.filter((p) => p.mode === "RESTRICTIVE");
|
|
130
|
-
// Guards the test itself: if the generator stops emitting AS RESTRICTIVE
|
|
131
|
-
// this must fail loudly rather than pass by having nothing to compare.
|
|
132
|
-
expect(restrictive.length).toBeGreaterThan(0);
|
|
133
|
-
|
|
134
|
-
const live = expected.map((p) =>
|
|
135
|
-
p.name === restrictive[0].name ? liveRow(p, { permissive: "PERMISSIVE" }) : liveRow(p)
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
139
|
-
|
|
140
|
-
expect(drift.diverged).toHaveLength(1);
|
|
141
|
-
expect(drift.diverged[0].differences.join(" ")).toContain("mode: expected RESTRICTIVE");
|
|
142
|
-
expect(formatPolicyDrift(drift)).toContain("widens access");
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("does not invent drift when either side's mode is unreadable", async () => {
|
|
146
|
-
// An older server or a driver that hands back a shape we do not know
|
|
147
|
-
// must not turn into a wall of false positives.
|
|
148
|
-
const cols = [collection("authors")];
|
|
149
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
150
|
-
|
|
151
|
-
const drift = await checkPolicyDrift(
|
|
152
|
-
dbWith(expected.map((p) => liveRow(p, { permissive: null }))),
|
|
153
|
-
cols
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
expect(drift.diverged).toHaveLength(0);
|
|
157
|
-
expect(hasDrift(drift)).toBe(false);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("flags a policy the database never received as missing", async () => {
|
|
161
|
-
const drift = await checkPolicyDrift(dbWith([]), [collection("authors")]);
|
|
162
|
-
|
|
163
|
-
expect(drift.missing.length).toBeGreaterThan(0);
|
|
164
|
-
expect(formatPolicyDrift(drift)).toContain("Missing");
|
|
165
|
-
expect(formatPolicyDrift(drift)).toContain("rebase db push");
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("flags a stale policy left by an earlier push as orphaned", async () => {
|
|
169
|
-
// The demo's actual bug: test_policy TO authenticated outlived the
|
|
170
|
-
// config that created it, and kept filtering every row.
|
|
171
|
-
const cols = [collection("customers")];
|
|
172
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
173
|
-
const live = [
|
|
174
|
-
...expected.map((p) => liveRow(p)),
|
|
175
|
-
{ schemaname: "public", tablename: "customers", policyname: "test_policy", roles: ["authenticated"], cmd: "ALL", qual: "true", with_check: null }
|
|
176
|
-
];
|
|
177
|
-
|
|
178
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
179
|
-
|
|
180
|
-
expect(drift.orphaned).toHaveLength(1);
|
|
181
|
-
expect(drift.orphaned[0]).toMatchObject({ name: "test_policy", roles: ["authenticated"] });
|
|
182
|
-
expect(formatPolicyDrift(drift)).toContain("Orphaned");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("flags a policy whose roles were changed underneath it as diverged", async () => {
|
|
186
|
-
const cols = [collection("orders")];
|
|
187
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
188
|
-
// Someone re-granted it to a role requests never run as.
|
|
189
|
-
const live = expected.map((p) => liveRow(p, { roles: ["authenticated"] }));
|
|
190
|
-
|
|
191
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
192
|
-
|
|
193
|
-
expect(drift.diverged.length).toBeGreaterThan(0);
|
|
194
|
-
expect(drift.diverged[0].differences.join(" ")).toContain("roles");
|
|
195
|
-
expect(formatPolicyDrift(drift)).toContain("Diverged");
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("flags a policy whose expression the database is missing entirely", async () => {
|
|
199
|
-
// A real production database had jobs.public_read_published stored as
|
|
200
|
-
// SELECT / {public} / USING NULL: every field this used to compare
|
|
201
|
-
// matched, drift reported zero, and the policy denied 100% of reads.
|
|
202
|
-
const cols = [collection("jobs")];
|
|
203
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
204
|
-
const select = expected.find((p) => p.command === "SELECT" && p.roles.includes("public"))!;
|
|
205
|
-
const live = expected.map((p) => (p === select ? liveRow(p, { qual: null }) : liveRow(p)));
|
|
206
|
-
|
|
207
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
208
|
-
|
|
209
|
-
expect(drift.diverged).toHaveLength(1);
|
|
210
|
-
expect(drift.diverged[0].differences.join(" ")).toContain("USING: expected an expression, database has none");
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("does not mistake an expression Postgres rewrote for a missing one", async () => {
|
|
214
|
-
// The reason expression text is not compared: what comes back out of
|
|
215
|
-
// pg_policies never matches what went in.
|
|
216
|
-
const cols = [collection("posts")];
|
|
217
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
218
|
-
const live = expected.map((p) => liveRow(p, {
|
|
219
|
-
qual: p.hasUsing ? "((auth.uid() IS NOT NULL) AND ((auth.uid())::text <> 'anonymous'::text))" : null
|
|
220
|
-
}));
|
|
221
|
-
|
|
222
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
223
|
-
|
|
224
|
-
expect(hasDrift(drift)).toBe(false);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("flags the pre-fix permissive tautology that every other check misses", async () => {
|
|
228
|
-
// A database pushed before the `policy.authenticated()` fix carries
|
|
229
|
-
// `auth.uid() IS NOT NULL` — true for anonymous visitors. Its name,
|
|
230
|
-
// roles, command and clause presence all match the corrected policy, so
|
|
231
|
-
// this is the only signal that catches it.
|
|
232
|
-
const cols = [collection("posts")];
|
|
233
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
234
|
-
const live = expected.map((p) => liveRow(p, {
|
|
235
|
-
qual: p.hasUsing ? "(auth.uid() IS NOT NULL)" : null
|
|
236
|
-
}));
|
|
237
|
-
|
|
238
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
239
|
-
|
|
240
|
-
expect(drift.insecure.length).toBeGreaterThan(0);
|
|
241
|
-
expect(hasDrift(drift)).toBe(true);
|
|
242
|
-
expect(drift.diverged).toHaveLength(0); // nothing else notices
|
|
243
|
-
expect(formatPolicyDrift(drift)).toContain("anonymous");
|
|
244
|
-
expect(formatPolicyDrift(drift)).toContain("db push");
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("flags the tautology under the post-1.0 schema name too", async () => {
|
|
248
|
-
// The helpers moved from `auth` to `rebase` in 1.0, and the compiler
|
|
249
|
-
// rewrites raw `securityRules` SQL on the way in — so `rebase.uid() IS
|
|
250
|
-
// NOT NULL` is the only spelling a current release can store. A checker
|
|
251
|
-
// anchored on the pre-1.0 name saw none of it.
|
|
252
|
-
const cols = [collection("posts")];
|
|
253
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
254
|
-
const live = expected.map((p) => liveRow(p, {
|
|
255
|
-
qual: p.hasUsing ? "(rebase.uid() IS NOT NULL)" : null
|
|
256
|
-
}));
|
|
257
|
-
|
|
258
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
259
|
-
|
|
260
|
-
expect(drift.insecure.length).toBeGreaterThan(0);
|
|
261
|
-
expect(drift.diverged).toHaveLength(0);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("clears the corrected expression, in either literal spelling", async () => {
|
|
265
|
-
const cols = [collection("posts")];
|
|
266
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
267
|
-
for (const fn of ["auth.uid()", "rebase.uid()"]) {
|
|
268
|
-
for (const guard of ["<> 'anonymous'::text", "<> 'anonymous'", "!= 'anonymous'"]) {
|
|
269
|
-
const live = expected.map((p) => liveRow(p, {
|
|
270
|
-
qual: p.hasUsing ? `((${fn} IS NOT NULL) AND ((${fn})::text ${guard}))` : null
|
|
271
|
-
}));
|
|
272
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
273
|
-
expect(drift.insecure).toHaveLength(0);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it("also flags the tautology in a WITH CHECK clause", async () => {
|
|
279
|
-
const cols = [collection("posts")];
|
|
280
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
281
|
-
const withCheck = expected.filter((p) => p.hasWithCheck);
|
|
282
|
-
// Asserted rather than assumed: if the generator ever stopped emitting a
|
|
283
|
-
// WITH CHECK for these rules the fixture would carry no tautology at
|
|
284
|
-
// all, and every assertion below would be vacuously satisfied.
|
|
285
|
-
expect(withCheck.length).toBeGreaterThan(0);
|
|
286
|
-
|
|
287
|
-
const live = expected.map((p) => liveRow(p, {
|
|
288
|
-
with_check: p.hasWithCheck ? "(auth.uid() IS NOT NULL)" : null
|
|
289
|
-
}));
|
|
290
|
-
|
|
291
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
292
|
-
|
|
293
|
-
// Every WITH CHECK policy carries the tautology and no USING clause
|
|
294
|
-
// does, so anything flagged here came from the WITH CHECK scan — the
|
|
295
|
-
// half that a check reading only `qual` would miss entirely.
|
|
296
|
-
expect(drift.insecure.map((i) => i.policy.name)).toEqual(withCheck.map((p) => p.name));
|
|
297
|
-
expect(drift.insecure.every((i) => /WITH CHECK/.test(i.reason))).toBe(true);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it("parses roles when the driver returns the raw {a,b} text form", async () => {
|
|
301
|
-
const cols = [collection("tags")];
|
|
302
|
-
const live = [{ schemaname: "public", tablename: "tags", policyname: "test_policy", roles: "{authenticated,anon}", cmd: "ALL", qual: "true", with_check: null }];
|
|
303
|
-
|
|
304
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
305
|
-
|
|
306
|
-
expect(drift.orphaned[0].roles).toEqual(["authenticated", "anon"]);
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it("reports nothing when there are no collections at all", async () => {
|
|
310
|
-
// With no collections there is no expectation to reconcile against, so
|
|
311
|
-
// scanning would report every policy in the database as orphaned.
|
|
312
|
-
// Note a collection with no securityRules is NOT this case — it still
|
|
313
|
-
// generates default (admin-only) policies.
|
|
314
|
-
const drift = await checkPolicyDrift(
|
|
315
|
-
dbWith([{ schemaname: "public", tablename: "x", policyname: "p", roles: ["public"], cmd: "ALL", qual: "true", with_check: null }]),
|
|
316
|
-
[]
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
expect(hasDrift(drift)).toBe(false);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it("still expects the default policies a collection without securityRules generates", async () => {
|
|
323
|
-
const bare = { name: "x", slug: "x", table: "x", schema: "public", properties: {} } as unknown as CollectionConfig;
|
|
324
|
-
|
|
325
|
-
const drift = await checkPolicyDrift(dbWith([]), [bare]);
|
|
326
|
-
|
|
327
|
-
// Locked-by-default: absence of rules means admin-only policies, not none.
|
|
328
|
-
expect(drift.missing.length).toBeGreaterThan(0);
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
describe("isGeneratedPolicyName", () => {
|
|
333
|
-
it("recognises the generator's own shape", () => {
|
|
334
|
-
expect(isGeneratedPolicyName("documents_insert_a1b2c3d", "documents")).toBe(true);
|
|
335
|
-
// One rule spanning several operations appends the operation index.
|
|
336
|
-
expect(isGeneratedPolicyName("documents_update_a1b2c3d_1", "documents")).toBe(true);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it("does not claim names a human could have written", () => {
|
|
340
|
-
expect(isGeneratedPolicyName("owner_access", "documents")).toBe(false);
|
|
341
|
-
expect(isGeneratedPolicyName("documents_default_admin_read", "documents")).toBe(false);
|
|
342
|
-
// Right shape, wrong table — belongs to something else.
|
|
343
|
-
expect(isGeneratedPolicyName("teams_insert_a1b2c3d", "documents")).toBe(false);
|
|
344
|
-
// A digest is 7 lowercase hex characters, nothing else.
|
|
345
|
-
expect(isGeneratedPolicyName("documents_insert_notahex", "documents")).toBe(false);
|
|
346
|
-
expect(isGeneratedPolicyName("documents_grant_a1b2c3d", "documents")).toBe(false);
|
|
347
|
-
});
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
describe("dropOrphanedPolicies", () => {
|
|
351
|
-
/** Records the DDL issued so the test can assert on what was dropped. */
|
|
352
|
-
function recordingDb(rows: Record<string, unknown>[]) {
|
|
353
|
-
const executed: string[] = [];
|
|
354
|
-
const db: Queryable = {
|
|
355
|
-
query: async (text: string) => {
|
|
356
|
-
if (!/^SELECT/i.test(text)) executed.push(text);
|
|
357
|
-
return { rows: rows as never[] };
|
|
358
|
-
}
|
|
359
|
-
};
|
|
360
|
-
return { db, executed };
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
it("drops the policy a rule edit superseded", async () => {
|
|
364
|
-
// The reported failure: tightening a rule renames its policy, and the
|
|
365
|
-
// permissive original stays live and keeps ORing itself back in.
|
|
366
|
-
const cols = [collection("documents")];
|
|
367
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
368
|
-
const stale = {
|
|
369
|
-
schemaname: "public", tablename: "documents", policyname: "documents_insert_dead1ee",
|
|
370
|
-
roles: ["public"], cmd: "INSERT", qual: null, with_check: "true"
|
|
371
|
-
};
|
|
372
|
-
const live = [...expected.map((p) => liveRow(p)), stale];
|
|
373
|
-
|
|
374
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
375
|
-
const { db, executed } = recordingDb(live);
|
|
376
|
-
const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
|
|
377
|
-
|
|
378
|
-
expect(dropped).toHaveLength(1);
|
|
379
|
-
expect(dropped[0].name).toBe("documents_insert_dead1ee");
|
|
380
|
-
expect(kept).toHaveLength(0);
|
|
381
|
-
expect(executed).toEqual([
|
|
382
|
-
'DROP POLICY IF EXISTS "documents_insert_dead1ee" ON "public"."documents"'
|
|
383
|
-
]);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
it("leaves a hand-written policy alone and reports it instead", async () => {
|
|
387
|
-
const cols = [collection("documents")];
|
|
388
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
389
|
-
const live = [
|
|
390
|
-
...expected.map((p) => liveRow(p)),
|
|
391
|
-
{ schemaname: "public", tablename: "documents", policyname: "ops_break_glass", roles: ["public"], cmd: "ALL", qual: "true", with_check: null }
|
|
392
|
-
];
|
|
393
|
-
|
|
394
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
395
|
-
const { db, executed } = recordingDb(live);
|
|
396
|
-
const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
|
|
397
|
-
|
|
398
|
-
expect(dropped).toHaveLength(0);
|
|
399
|
-
expect(kept.map((p) => p.name)).toEqual(["ops_break_glass"]);
|
|
400
|
-
expect(executed).toEqual([]);
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it("never touches a table the collections do not describe", async () => {
|
|
404
|
-
// Another application sharing the schema owns this table; a
|
|
405
|
-
// generator-shaped name there is coincidence, not our leftover.
|
|
406
|
-
const cols = [collection("documents")];
|
|
407
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
408
|
-
const live = [
|
|
409
|
-
...expected.map((p) => liveRow(p)),
|
|
410
|
-
{ schemaname: "public", tablename: "legacy", policyname: "legacy_select_a1b2c3d", roles: ["public"], cmd: "SELECT", qual: "true", with_check: null }
|
|
411
|
-
];
|
|
412
|
-
|
|
413
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
414
|
-
const { db, executed } = recordingDb(live);
|
|
415
|
-
const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
|
|
416
|
-
|
|
417
|
-
expect(dropped).toHaveLength(0);
|
|
418
|
-
expect(kept.map((p) => p.name)).toEqual(["legacy_select_a1b2c3d"]);
|
|
419
|
-
expect(executed).toEqual([]);
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
it("does nothing when the database already matches", async () => {
|
|
423
|
-
const cols = [collection("documents")];
|
|
424
|
-
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
425
|
-
const live = expected.map((p) => liveRow(p));
|
|
426
|
-
|
|
427
|
-
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
428
|
-
const { db, executed } = recordingDb(live);
|
|
429
|
-
const { dropped, kept } = await dropOrphanedPolicies(db, drift, cols);
|
|
430
|
-
|
|
431
|
-
expect(dropped).toHaveLength(0);
|
|
432
|
-
expect(kept).toHaveLength(0);
|
|
433
|
-
expect(executed).toEqual([]);
|
|
434
|
-
});
|
|
435
|
-
});
|