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