@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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RLS half of `rebase doctor`, and the exit code `--policies` gates CI on.
|
|
3
|
+
*
|
|
4
|
+
* Lives beside `doctor-cli.ts` rather than inside it because that file runs
|
|
5
|
+
* `main()` on import: a gate whose failure modes cannot be unit-tested is how
|
|
6
|
+
* this one shipped reporting success for work it never did.
|
|
7
|
+
*/
|
|
8
|
+
import path from "path";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
11
|
+
import { loadCollections } from "./doctor";
|
|
12
|
+
import { checkPolicyDrift, formatPolicyDrift, hasDrift } from "../security/policy-drift";
|
|
13
|
+
import { validatePolicyPgRoles, warnOnAnonymousGrants } from "../security/rls-enforcement";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What the RLS checks concluded.
|
|
17
|
+
*
|
|
18
|
+
* `unchecked` exists because "we could not look" and "we looked and it is fine"
|
|
19
|
+
* used to be reported identically: a collections path that did not resolve made
|
|
20
|
+
* the loader return `[]` (it warns, it does not throw), `checkPolicyDrift`
|
|
21
|
+
* early-returned an empty diff, and the gate printed
|
|
22
|
+
* `✓ RLS policies match your collections` having compared zero policies against
|
|
23
|
+
* zero collections. Any exception at all — a collection file that throws on
|
|
24
|
+
* import, a `pg_policies` read the CI role is not granted, a connection reset —
|
|
25
|
+
* did the same thing through a `warn`, and exited 0.
|
|
26
|
+
*/
|
|
27
|
+
export type PolicyCheckStatus = "ok" | "problems" | "unchecked";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The exit code for `rebase doctor --policies`.
|
|
31
|
+
*
|
|
32
|
+
* A gate that could not run has not passed. Only a completed, clean check
|
|
33
|
+
* exits 0 — anything else, including "we never opened a connection", is a
|
|
34
|
+
* failure, or the flag certifies a database nobody looked at.
|
|
35
|
+
*/
|
|
36
|
+
export function exitCodeForPolicyGate(status: PolicyCheckStatus): 0 | 1 {
|
|
37
|
+
return status === "ok" ? 0 : 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Policies actually deployed vs the ones the collections describe, plus policy
|
|
42
|
+
* roles this server could never satisfy.
|
|
43
|
+
*
|
|
44
|
+
* Never reports `ok` for work it did not do — see {@link PolicyCheckStatus}.
|
|
45
|
+
*/
|
|
46
|
+
export async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): Promise<PolicyCheckStatus> {
|
|
47
|
+
if (!databaseUrl) {
|
|
48
|
+
console.error(chalk.yellow(" ⚠ No DATABASE_URL — RLS policies were NOT checked"));
|
|
49
|
+
return "unchecked";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const resolvedPath = path.resolve(process.cwd(), collectionsPath);
|
|
53
|
+
let problems = false;
|
|
54
|
+
const { Pool } = await import("pg");
|
|
55
|
+
const pool = new Pool({ connectionString: databaseUrl });
|
|
56
|
+
try {
|
|
57
|
+
const collections: CollectionConfig[] = await loadCollections(resolvedPath);
|
|
58
|
+
|
|
59
|
+
// Zero collections is not "no drift", it is nothing to compare. The
|
|
60
|
+
// loader returns `[]` for a path that does not exist, so the commonest
|
|
61
|
+
// way to get here is a `--collections` path resolved against the wrong
|
|
62
|
+
// directory — which used to render as a green tick.
|
|
63
|
+
if (collections.length === 0) {
|
|
64
|
+
console.error(chalk.red(` ✗ No collections found in ${resolvedPath}`));
|
|
65
|
+
console.error(chalk.gray(" RLS policies were NOT checked. Pass --collections=<dir> if your collections live elsewhere."));
|
|
66
|
+
return "unchecked";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const runSql = async (text: string) => (await pool.query(text)).rows as Record<string, unknown>[];
|
|
70
|
+
|
|
71
|
+
// A policy naming a role the server never runs as filters every row, so
|
|
72
|
+
// the collection reads as empty. Report it without booting a server.
|
|
73
|
+
try {
|
|
74
|
+
await validatePolicyPgRoles(runSql, collections as never);
|
|
75
|
+
console.log(chalk.green(" ✓ Policy roles are usable by this server"));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
problems = true;
|
|
78
|
+
console.log("");
|
|
79
|
+
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A rule that reads as a lockdown but is true for every caller compiles
|
|
83
|
+
// to a grant. Report it without booting a server.
|
|
84
|
+
warnOnAnonymousGrants(collections as never);
|
|
85
|
+
|
|
86
|
+
const drift = await checkPolicyDrift(pool as never, collections);
|
|
87
|
+
console.log("");
|
|
88
|
+
if (hasDrift(drift)) {
|
|
89
|
+
problems = true;
|
|
90
|
+
console.log(chalk.yellow(" RLS policies: database does not match your collections"));
|
|
91
|
+
console.log(formatPolicyDrift(drift));
|
|
92
|
+
} else {
|
|
93
|
+
console.log(chalk.green(` ✓ RLS policies match your collections (${collections.length} collection(s) checked)`));
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Fail closed. This catch covers every query and every collection import
|
|
97
|
+
// above, so returning the pre-catch verdict made the documented CI gate
|
|
98
|
+
// green on exactly the runs it exists to catch.
|
|
99
|
+
console.error(chalk.red(" ✗ Could not check RLS policies:"), err instanceof Error ? err.message : String(err));
|
|
100
|
+
return "unchecked";
|
|
101
|
+
} finally {
|
|
102
|
+
await pool.end();
|
|
103
|
+
}
|
|
104
|
+
return problems ? "problems" : "ok";
|
|
105
|
+
}
|
package/src/schema/doctor.ts
CHANGED
|
@@ -17,7 +17,9 @@ import { generateSchema } from "./generate-drizzle-schema-logic";
|
|
|
17
17
|
import { generateTypedefs } from "@rebasepro/codegen";
|
|
18
18
|
import { getTableName, resolveCollectionRelations, findRelation, relationalCollections } from "@rebasepro/common";
|
|
19
19
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
20
|
-
import {
|
|
20
|
+
import { loadCollectionsFromDirectory } from "@rebasepro/server";
|
|
21
|
+
// The report is CLI output, not application logging — see cli-output.ts.
|
|
22
|
+
import { out, outError } from "../cli-output";
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Resolve the SQL column name for a property.
|
|
@@ -37,7 +39,7 @@ export type IssueSeverity = "error" | "warning" | "info";
|
|
|
37
39
|
|
|
38
40
|
export interface DoctorIssue {
|
|
39
41
|
severity: IssueSeverity;
|
|
40
|
-
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated";
|
|
42
|
+
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated" | "sdk_ungeneratable";
|
|
41
43
|
table?: string;
|
|
42
44
|
column?: string;
|
|
43
45
|
expected?: string;
|
|
@@ -46,11 +48,44 @@ export interface DoctorIssue {
|
|
|
46
48
|
fix: string;
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
export interface DoctorPhase {
|
|
52
|
+
passed: boolean;
|
|
53
|
+
issues: DoctorIssue[];
|
|
54
|
+
/**
|
|
55
|
+
* Why this phase never ran, when it did not.
|
|
56
|
+
*
|
|
57
|
+
* A check that did not happen is a third state, not a passing one. While a
|
|
58
|
+
* skipped phase initialised to `{ passed: true, issues: [] }` it rendered as
|
|
59
|
+
* `✅ Collections → Database: In sync` and counted towards
|
|
60
|
+
* `✓ All schemas are in sync!` — so a project whose connection string was
|
|
61
|
+
* spelled `POSTGRES_URL`, or a CI job that never exported one, got two green
|
|
62
|
+
* ticks and exit 0 against a database with no tables in it.
|
|
63
|
+
*/
|
|
64
|
+
skipped?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Why this phase had nothing to compare against, when it had nothing.
|
|
67
|
+
*
|
|
68
|
+
* Distinct from `skipped`, which means "the check could not run, and that
|
|
69
|
+
* is probably worth fixing". This one means "the artifact is optional and
|
|
70
|
+
* you have not asked for it": no drift is possible, so the run is still a
|
|
71
|
+
* clean bill of health.
|
|
72
|
+
*
|
|
73
|
+
* It exists because the alternative was a contradiction. The typed-SDK
|
|
74
|
+
* phase returned `{ passed: true }` when `generated/sdk/database.types.ts`
|
|
75
|
+
* did not exist, so a fresh project's report read
|
|
76
|
+
* `✅ Collections → SDK Types: In sync` directly above
|
|
77
|
+
* `ℹ Typed SDK not generated (optional).` — one line calling a file
|
|
78
|
+
* synchronised and the next saying it is absent. "In sync" is a claim about
|
|
79
|
+
* a comparison, and no comparison happened.
|
|
80
|
+
*/
|
|
81
|
+
notApplicable?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
49
84
|
export interface DoctorReport {
|
|
50
|
-
collectionsToSchema:
|
|
51
|
-
collectionsToSdk:
|
|
52
|
-
schemaToDatabase:
|
|
53
|
-
summary: { passed: number; warnings: number; errors: number };
|
|
85
|
+
collectionsToSchema: DoctorPhase;
|
|
86
|
+
collectionsToSdk: DoctorPhase;
|
|
87
|
+
schemaToDatabase: DoctorPhase;
|
|
88
|
+
summary: { passed: number; skipped: number; notApplicable: number; warnings: number; errors: number };
|
|
54
89
|
}
|
|
55
90
|
|
|
56
91
|
// ── Column type mapping (mirrors generate-drizzle-schema-logic.ts) ───────
|
|
@@ -149,7 +184,7 @@ export async function loadCollections(collectionsPath: string): Promise<Collecti
|
|
|
149
184
|
export async function checkCollectionsVsSchema(
|
|
150
185
|
collections: CollectionConfig[],
|
|
151
186
|
schemaFilePath: string
|
|
152
|
-
): Promise<
|
|
187
|
+
): Promise<DoctorPhase> {
|
|
153
188
|
const issues: DoctorIssue[] = [];
|
|
154
189
|
|
|
155
190
|
// Check if schema file exists
|
|
@@ -210,7 +245,7 @@ issues };
|
|
|
210
245
|
export async function checkCollectionsVsSdk(
|
|
211
246
|
collections: CollectionConfig[],
|
|
212
247
|
sdkFilePath: string
|
|
213
|
-
): Promise<
|
|
248
|
+
): Promise<DoctorPhase> {
|
|
214
249
|
const issues: DoctorIssue[] = [];
|
|
215
250
|
|
|
216
251
|
// The typed SDK is opt-in — nothing in a scaffolded project imports it until
|
|
@@ -225,7 +260,8 @@ export async function checkCollectionsVsSdk(
|
|
|
225
260
|
fix: "Run `rebase generate-sdk` if you want typed collection access"
|
|
226
261
|
});
|
|
227
262
|
return { passed: true,
|
|
228
|
-
issues
|
|
263
|
+
issues,
|
|
264
|
+
notApplicable: "not generated (optional)" };
|
|
229
265
|
}
|
|
230
266
|
|
|
231
267
|
try {
|
|
@@ -250,12 +286,27 @@ issues };
|
|
|
250
286
|
}
|
|
251
287
|
} catch (err: unknown) {
|
|
252
288
|
const message = err instanceof Error ? err.message : String(err);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
289
|
+
|
|
290
|
+
// A `CodegenError` is not "the comparison did not run" — it is the
|
|
291
|
+
// schema saying it cannot produce a valid typed client at all, most
|
|
292
|
+
// often two slugs collapsing onto one accessor. Reporting that as a
|
|
293
|
+
// warning about staleness described the wrong problem and pointed at a
|
|
294
|
+
// command that would fail the same way.
|
|
295
|
+
if (err instanceof Error && err.name === "CodegenError") {
|
|
296
|
+
issues.push({
|
|
297
|
+
severity: "error",
|
|
298
|
+
category: "sdk_ungeneratable",
|
|
299
|
+
message: `The typed SDK cannot be generated from these collections: ${message}`,
|
|
300
|
+
fix: "Fix the collection definitions named above, then run `rebase generate-sdk`"
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
issues.push({
|
|
304
|
+
severity: "warning",
|
|
305
|
+
category: "sdk_stale",
|
|
306
|
+
message: `Could not regenerate SDK types for comparison: ${message}`,
|
|
307
|
+
fix: "Run `rebase generate-sdk` to verify"
|
|
308
|
+
});
|
|
309
|
+
}
|
|
259
310
|
}
|
|
260
311
|
|
|
261
312
|
return { passed: issues.length === 0,
|
|
@@ -282,7 +333,7 @@ interface DbEnumValue {
|
|
|
282
333
|
export async function checkCollectionsVsDatabase(
|
|
283
334
|
collections: CollectionConfig[],
|
|
284
335
|
databaseUrl: string
|
|
285
|
-
): Promise<
|
|
336
|
+
): Promise<DoctorPhase> {
|
|
286
337
|
const issues: DoctorIssue[] = [];
|
|
287
338
|
|
|
288
339
|
// Dynamic import to avoid loading pg when not needed
|
|
@@ -597,96 +648,112 @@ issues };
|
|
|
597
648
|
// ── Report Rendering ─────────────────────────────────────────────────────
|
|
598
649
|
|
|
599
650
|
export function renderReport(report: DoctorReport): void {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
651
|
+
out();
|
|
652
|
+
out(chalk.bold(" 🩺 Rebase Schema Doctor"));
|
|
653
|
+
out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
654
|
+
out();
|
|
604
655
|
|
|
605
656
|
// Phase 1
|
|
606
|
-
renderPhase(
|
|
607
|
-
"Collections → Generated Schema",
|
|
608
|
-
report.collectionsToSchema.passed,
|
|
609
|
-
report.collectionsToSchema.issues
|
|
610
|
-
);
|
|
657
|
+
renderPhase("Collections → Generated Schema", report.collectionsToSchema);
|
|
611
658
|
|
|
612
659
|
// Phase 2
|
|
613
|
-
renderPhase(
|
|
614
|
-
"Collections → Database",
|
|
615
|
-
report.schemaToDatabase.passed,
|
|
616
|
-
report.schemaToDatabase.issues
|
|
617
|
-
);
|
|
660
|
+
renderPhase("Collections → Database", report.schemaToDatabase);
|
|
618
661
|
|
|
619
662
|
// Phase 3
|
|
620
|
-
renderPhase(
|
|
621
|
-
"Collections → SDK Types",
|
|
622
|
-
report.collectionsToSdk.passed,
|
|
623
|
-
report.collectionsToSdk.issues
|
|
624
|
-
);
|
|
663
|
+
renderPhase("Collections → SDK Types", report.collectionsToSdk);
|
|
625
664
|
|
|
626
665
|
// Summary
|
|
627
|
-
|
|
628
|
-
const { passed, warnings, errors } = report.summary;
|
|
666
|
+
out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
667
|
+
const { passed, skipped, notApplicable, warnings, errors } = report.summary;
|
|
629
668
|
|
|
630
669
|
const parts: string[] = [];
|
|
631
670
|
parts.push(chalk.green(`${passed} passed`));
|
|
671
|
+
if (skipped > 0) parts.push(chalk.yellow(`${skipped} skipped`));
|
|
672
|
+
if (notApplicable > 0) parts.push(chalk.gray(`${notApplicable} not applicable`));
|
|
632
673
|
if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
|
|
633
674
|
if (errors > 0) parts.push(chalk.red(`${errors} errors`));
|
|
634
675
|
|
|
635
|
-
|
|
636
|
-
|
|
676
|
+
out(` Summary: ${parts.join(", ")}`);
|
|
677
|
+
out();
|
|
637
678
|
|
|
638
679
|
if (errors > 0) {
|
|
639
|
-
|
|
680
|
+
out(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
|
|
640
681
|
} else if (warnings > 0) {
|
|
641
|
-
|
|
682
|
+
out(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
|
|
683
|
+
} else if (skipped > 0) {
|
|
684
|
+
// Never "all schemas are in sync" off the back of a check that did not
|
|
685
|
+
// run: a clean bill of health has to come from measurement.
|
|
686
|
+
out(chalk.yellow.bold(` ⚠ Nothing wrong in the checks that ran, but ${skipped} did not run — this is not a clean bill of health.`));
|
|
642
687
|
} else {
|
|
643
|
-
|
|
688
|
+
out(chalk.green.bold(" ✓ All schemas are in sync!"));
|
|
644
689
|
}
|
|
645
|
-
|
|
690
|
+
out();
|
|
646
691
|
}
|
|
647
692
|
|
|
648
|
-
function renderPhase(label: string,
|
|
693
|
+
function renderPhase(label: string, phase: DoctorPhase): void {
|
|
694
|
+
// A phase that never ran gets its own marker and its reason. It is neither
|
|
695
|
+
// "In sync" nor a drift report; conflating it with the former is what let
|
|
696
|
+
// doctor certify a database it had not opened.
|
|
697
|
+
if (phase.skipped) {
|
|
698
|
+
out(` ${chalk.yellow("⏭")} ${label}: ${chalk.yellow(`skipped (${phase.skipped})`)}`);
|
|
699
|
+
out();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Nothing to compare against. Its own marker, and only the remedy under it
|
|
704
|
+
// — the phase header already carries the reason, so re-printing the note
|
|
705
|
+
// would say the same thing twice. See DoctorPhase.notApplicable.
|
|
706
|
+
if (phase.notApplicable) {
|
|
707
|
+
out(` ${chalk.gray("➖")} ${label}: ${chalk.gray(phase.notApplicable)}`);
|
|
708
|
+
for (const issue of phase.issues.filter((i) => i.severity === "info")) {
|
|
709
|
+
out(` ${chalk.gray(issue.fix)}`);
|
|
710
|
+
}
|
|
711
|
+
out();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const issues = phase.issues;
|
|
649
716
|
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
650
717
|
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
651
718
|
const infoIssues = issues.filter((i) => i.severity === "info");
|
|
652
719
|
|
|
653
720
|
// Informational notes don't make a phase unhealthy, so key the header off
|
|
654
|
-
// real problems rather than `passed` alone.
|
|
721
|
+
// real problems rather than `phase.passed` alone.
|
|
655
722
|
if (errorCount === 0 && warnCount === 0) {
|
|
656
|
-
|
|
723
|
+
out(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
|
|
657
724
|
} else {
|
|
658
725
|
const parts: string[] = [];
|
|
659
726
|
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
660
727
|
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
661
|
-
|
|
728
|
+
out(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
|
|
662
729
|
}
|
|
663
|
-
|
|
730
|
+
out();
|
|
664
731
|
|
|
665
732
|
// Notes render as a quiet one-liner, not a full drift box.
|
|
666
733
|
for (const issue of infoIssues) {
|
|
667
734
|
const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
|
|
668
|
-
|
|
669
|
-
|
|
735
|
+
out(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
|
|
736
|
+
out();
|
|
670
737
|
}
|
|
671
738
|
|
|
672
739
|
for (const issue of issues.filter((i) => i.severity !== "info")) {
|
|
673
740
|
const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
|
|
674
741
|
const categoryLabel = formatCategory(issue.category);
|
|
675
|
-
|
|
742
|
+
out(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
|
|
676
743
|
|
|
677
744
|
if (issue.table) {
|
|
678
745
|
const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
|
|
679
|
-
|
|
746
|
+
out(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
|
|
680
747
|
}
|
|
681
748
|
|
|
682
749
|
if (issue.expected && issue.actual) {
|
|
683
|
-
|
|
750
|
+
out(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
|
|
684
751
|
}
|
|
685
752
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
753
|
+
out(` ${chalk.gray("│")} ${issue.message}`);
|
|
754
|
+
out(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
|
|
755
|
+
out(` ${chalk.gray("└" + "─".repeat(48))}`);
|
|
756
|
+
out();
|
|
690
757
|
}
|
|
691
758
|
}
|
|
692
759
|
|
|
@@ -701,7 +768,8 @@ function formatCategory(cat: DoctorIssue["category"]): string {
|
|
|
701
768
|
enum_value_mismatch: "Enum Value Mismatch",
|
|
702
769
|
missing_foreign_key: "Missing Foreign Key",
|
|
703
770
|
sdk_stale: "Stale SDK Types",
|
|
704
|
-
sdk_not_generated: "SDK Types Not Generated"
|
|
771
|
+
sdk_not_generated: "SDK Types Not Generated",
|
|
772
|
+
sdk_ungeneratable: "SDK Cannot Be Generated"
|
|
705
773
|
};
|
|
706
774
|
return labels[cat];
|
|
707
775
|
}
|
|
@@ -714,38 +782,47 @@ export async function runDoctor(options: {
|
|
|
714
782
|
sdkPath: string;
|
|
715
783
|
databaseUrl?: string;
|
|
716
784
|
}): Promise<DoctorReport> {
|
|
717
|
-
|
|
718
|
-
|
|
785
|
+
out();
|
|
786
|
+
out(chalk.bold(" 🩺 Loading collections..."));
|
|
719
787
|
const collections = await loadCollections(options.collectionsPath);
|
|
720
788
|
if (collections.length === 0) {
|
|
721
|
-
|
|
789
|
+
outError(chalk.red(" ✗ No collections found."));
|
|
722
790
|
process.exit(1);
|
|
723
791
|
}
|
|
724
|
-
|
|
725
|
-
|
|
792
|
+
out(chalk.gray(` Found ${collections.length} collection(s)`));
|
|
793
|
+
out();
|
|
726
794
|
|
|
727
795
|
// Phase 1: Collections ↔ Generated Schema
|
|
728
|
-
|
|
796
|
+
out(chalk.gray(" Checking Collections → Generated Schema..."));
|
|
729
797
|
const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
|
|
730
798
|
|
|
731
799
|
// Phase 2: Collections ↔ Database (only if we have a DATABASE_URL)
|
|
732
|
-
let schemaToDatabase:
|
|
733
|
-
issues: [] };
|
|
800
|
+
let schemaToDatabase: DoctorPhase;
|
|
734
801
|
if (options.databaseUrl) {
|
|
735
|
-
|
|
802
|
+
out(chalk.gray(" Checking Collections → Database..."));
|
|
736
803
|
schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
|
|
737
804
|
} else {
|
|
738
|
-
|
|
739
|
-
|
|
805
|
+
// Not `{ passed: true }`: see DoctorPhase.skipped.
|
|
806
|
+
schemaToDatabase = { passed: false,
|
|
807
|
+
issues: [],
|
|
808
|
+
skipped: "DATABASE_URL not set" };
|
|
809
|
+
out(chalk.yellow(" ⚠ DATABASE_URL not set — skipping database comparison."));
|
|
810
|
+
out(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
|
|
740
811
|
}
|
|
741
812
|
|
|
742
813
|
// Phase 3: Collections ↔ SDK Types
|
|
743
|
-
|
|
814
|
+
out(chalk.gray(" Checking Collections → SDK Types..."));
|
|
744
815
|
const collectionsToSdk = await checkCollectionsVsSdk(collections, options.sdkPath);
|
|
745
816
|
|
|
746
|
-
const
|
|
817
|
+
const phases = [collectionsToSchema, schemaToDatabase, collectionsToSdk];
|
|
818
|
+
const allIssues = phases.flatMap((p) => p.issues);
|
|
747
819
|
const summary = {
|
|
748
|
-
|
|
820
|
+
// A skipped phase is not a passing one, however few issues it collected.
|
|
821
|
+
// Nor is one with nothing to compare against: "passed" would put a
|
|
822
|
+
// never-generated SDK in the same column as a verified one.
|
|
823
|
+
passed: phases.filter((p) => p.passed && !p.skipped && !p.notApplicable).length,
|
|
824
|
+
skipped: phases.filter((p) => p.skipped).length,
|
|
825
|
+
notApplicable: phases.filter((p) => p.notApplicable && !p.skipped).length,
|
|
749
826
|
warnings: allIssues.filter((i) => i.severity === "warning").length,
|
|
750
827
|
errors: allIssues.filter((i) => i.severity === "error").length
|
|
751
828
|
};
|
|
@@ -32,8 +32,10 @@
|
|
|
32
32
|
* exactly which collection is not yet servable and why.
|
|
33
33
|
*/
|
|
34
34
|
import { type CollectionConfig } from "@rebasepro/types";
|
|
35
|
-
import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
|
|
35
|
+
import { planCollectionPolicies, type CollectionPolicyPlan } from "./generate-postgres-ddl-logic";
|
|
36
|
+
import { isGeneratedPolicyName } from "../security/policy-drift";
|
|
36
37
|
import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
|
|
38
|
+
import { REBASE_USER_ROLE } from "../security/rls-enforcement";
|
|
37
39
|
|
|
38
40
|
export interface PolicyEnsureResult {
|
|
39
41
|
/** `CREATE POLICY` statements that ran successfully. */
|
|
@@ -42,8 +44,36 @@ export interface PolicyEnsureResult {
|
|
|
42
44
|
tablesSecured: number;
|
|
43
45
|
/** Declared tables absent from the database — left to a real migration. */
|
|
44
46
|
skipped: { table: string; reason: string }[];
|
|
45
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Tables that have RLS on but did not get every policy. They deny — RLS
|
|
49
|
+
* with no matching policy is deny-all — so they are safe but not servable.
|
|
50
|
+
*/
|
|
46
51
|
failures: { table: string; error: string }[];
|
|
52
|
+
/**
|
|
53
|
+
* Tables RLS could not be enabled on, whose DML grant was withdrawn instead.
|
|
54
|
+
*
|
|
55
|
+
* This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`
|
|
56
|
+
* failing was recorded as a `failure` and reported with the same "it stays
|
|
57
|
+
* locked (denies)" wording as a failed policy — but the two are opposites.
|
|
58
|
+
* A policy statement failing leaves RLS on and the table denying. `enableRls`
|
|
59
|
+
* failing leaves RLS *off*, and the schema-wide grant to the user role has
|
|
60
|
+
* already been made by `ensureRlsEnforcement`, so the table is readable and
|
|
61
|
+
* writable by every authenticated request with no row filtering at all.
|
|
62
|
+
*/
|
|
63
|
+
unsecured: { table: string; error: string; grantWithdrawn: boolean }[];
|
|
64
|
+
/**
|
|
65
|
+
* Generated policies removed because no current rule produces them.
|
|
66
|
+
*
|
|
67
|
+
* A policy's name embeds a hash of the rule's semantics, so editing a rule
|
|
68
|
+
* does not update a policy — it creates a new one and abandons the old.
|
|
69
|
+
* Postgres ORs permissive policies, so the abandoned one keeps granting:
|
|
70
|
+
* a `USING (true)` tightened to an owner check went on admitting everyone,
|
|
71
|
+
* forever, while the deploy logged success.
|
|
72
|
+
*
|
|
73
|
+
* `db push` reconciles this, and cannot reach a managed tenant's in-cluster
|
|
74
|
+
* database — which is the reason this module exists. So boot has to do it.
|
|
75
|
+
*/
|
|
76
|
+
orphansDropped: number;
|
|
47
77
|
}
|
|
48
78
|
|
|
49
79
|
const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
|
|
@@ -56,12 +86,46 @@ const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test
|
|
|
56
86
|
* to create (a junction, or a relation left to a migration). Enabling RLS on a
|
|
57
87
|
* non-existent table would error, so those are recorded as skipped, not failed.
|
|
58
88
|
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Remove generated policies on this table that the current plan does not
|
|
92
|
+
* produce.
|
|
93
|
+
*
|
|
94
|
+
* Scoped hard, because dropping a policy is destructive: only this one table,
|
|
95
|
+
* only names matching the generated `<table>_<op>_<sha1>` shape, and only names
|
|
96
|
+
* absent from the statements just applied. A hand-written policy, or one
|
|
97
|
+
* belonging to another table, is never touched — `isGeneratedPolicyName` is the
|
|
98
|
+
* same predicate `db push` uses to draw that line.
|
|
99
|
+
*/
|
|
100
|
+
async function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {
|
|
101
|
+
const expected = new Set<string>();
|
|
102
|
+
for (const statement of plan.policyStatements) {
|
|
103
|
+
const match = /^\s*CREATE POLICY\s+"([^"]+)"/i.exec(statement);
|
|
104
|
+
if (match) expected.add(match[1]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const existing = await client.query<{ policyname: string }>(
|
|
108
|
+
`SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, "''")}' ` +
|
|
109
|
+
`AND tablename = '${plan.table.replace(/'/g, "''")}'`
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
let dropped = 0;
|
|
113
|
+
for (const row of existing.rows) {
|
|
114
|
+
const name = row.policyname;
|
|
115
|
+
if (expected.has(name)) continue;
|
|
116
|
+
if (!isGeneratedPolicyName(name, plan.table)) continue;
|
|
117
|
+
await client.query(`DROP POLICY IF EXISTS "${name}" ON "${plan.schema}"."${plan.table}"`);
|
|
118
|
+
dropped++;
|
|
119
|
+
}
|
|
120
|
+
return dropped;
|
|
121
|
+
}
|
|
122
|
+
|
|
59
123
|
export async function ensureCollectionPolicies(
|
|
60
124
|
client: Queryable,
|
|
61
125
|
collections: CollectionConfig[],
|
|
62
126
|
log?: (message: string) => void
|
|
63
127
|
): Promise<PolicyEnsureResult> {
|
|
64
|
-
const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
|
|
128
|
+
const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };
|
|
65
129
|
|
|
66
130
|
const plans = planCollectionPolicies(collections);
|
|
67
131
|
if (plans.length === 0) return result;
|
|
@@ -78,12 +142,36 @@ export async function ensureCollectionPolicies(
|
|
|
78
142
|
continue;
|
|
79
143
|
}
|
|
80
144
|
|
|
145
|
+
// Enabling RLS is its own step, because its failure is the opposite of
|
|
146
|
+
// every other failure here. Once RLS is on, anything that goes wrong
|
|
147
|
+
// afterwards leaves the table denying; while it is off, the grant made
|
|
148
|
+
// earlier in boot leaves the table wide open. Sharing one `try` meant
|
|
149
|
+
// the dangerous case was reported in the safe case's words.
|
|
81
150
|
try {
|
|
82
|
-
// Enable first: if a later policy statement fails, the table is left
|
|
83
|
-
// locked (deny-all for the user role) rather than open.
|
|
84
151
|
await client.query(plan.enableRls);
|
|
85
152
|
result.tablesSecured++;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
155
|
+
// Take the privilege back rather than serve an unprotected table.
|
|
156
|
+
// This is the fail-closed step the old code assumed it already had:
|
|
157
|
+
// per-table, so one collection cannot take the rest of the
|
|
158
|
+
// deployment down, but leaving nothing readable without RLS.
|
|
159
|
+
let grantWithdrawn = false;
|
|
160
|
+
try {
|
|
161
|
+
await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);
|
|
162
|
+
grantWithdrawn = true;
|
|
163
|
+
} catch {
|
|
164
|
+
// Fall through: reported below with `grantWithdrawn: false`,
|
|
165
|
+
// which the caller escalates. There is nothing else this
|
|
166
|
+
// function can do to make the table safe.
|
|
167
|
+
}
|
|
168
|
+
result.unsecured.push({ table: plan.qualified,
|
|
169
|
+
error,
|
|
170
|
+
grantWithdrawn });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
86
173
|
|
|
174
|
+
try {
|
|
87
175
|
let created = 0;
|
|
88
176
|
for (const statement of plan.policyStatements) {
|
|
89
177
|
await client.query(statement);
|
|
@@ -92,7 +180,12 @@ export async function ensureCollectionPolicies(
|
|
|
92
180
|
created++;
|
|
93
181
|
}
|
|
94
182
|
}
|
|
95
|
-
|
|
183
|
+
|
|
184
|
+
const orphans = await dropOrphanedPoliciesOn(client, plan);
|
|
185
|
+
result.orphansDropped += orphans;
|
|
186
|
+
|
|
187
|
+
log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +
|
|
188
|
+
(orphans > 0 ? `, ${orphans} orphan(s) dropped` : ""));
|
|
96
189
|
} catch (err) {
|
|
97
190
|
result.failures.push({
|
|
98
191
|
table: plan.qualified,
|