@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g1822133
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-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
- 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 +57 -1
- 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-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1815 -1023
- 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 +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -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 +34 -7
- 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 +5 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +164 -23
- 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 +9 -8
- 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 +132 -13
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +371 -161
- package/src/collections/buildRegistry.ts +3 -1
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +142 -28
- 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 +156 -77
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +152 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +171 -0
- 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/non-sql-collections.test.ts +131 -0
- 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 +253 -115
- package/src/services/PersistService.ts +82 -43
- 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 +5 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +382 -118
- 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-ViG8XiPn.js +0 -57
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
- package/dist/ensure-collection-tables-CBQdOETu.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-DlPBctw_.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,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one description of what an auth user table's columns must be.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Three different code paths create `rebase.users`, and before this module they
|
|
7
|
+
* disagreed about it:
|
|
8
|
+
*
|
|
9
|
+
* | column | `db push` (generator) | `ensureAuthTablesExist` | `ensureCollectionTables` |
|
|
10
|
+
* |------------------|-----------------------|-------------------------|--------------------------|
|
|
11
|
+
* | `email` | `TEXT UNIQUE NOT NULL`| `TEXT NOT NULL` + CHECK | `TEXT` (nullable) |
|
|
12
|
+
* | `roles` | `TEXT[]` | `NOT NULL DEFAULT '{}'` | nullable, no default |
|
|
13
|
+
* | `email_verified` | `BOOLEAN` | `NOT NULL DEFAULT FALSE`| nullable |
|
|
14
|
+
* | `created_at` | `DEFAULT now()` | `NOT NULL DEFAULT NOW()`| no default |
|
|
15
|
+
*
|
|
16
|
+
* They are all `CREATE TABLE IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`, so
|
|
17
|
+
* whichever ran first decided the table and the other two silently no-op'd.
|
|
18
|
+
* Boot order therefore chose the constraints: a managed deploy (collection
|
|
19
|
+
* tables first) got a users table whose `email` was nullable and whose
|
|
20
|
+
* `email_verified` had no default, while the same project pushed from a
|
|
21
|
+
* checkout got the strict one. In the same table, `is_anonymous` came out
|
|
22
|
+
* `NOT NULL DEFAULT false` — because *that* column existed in only one of the
|
|
23
|
+
* three lists, so its owner's definition won by default.
|
|
24
|
+
*
|
|
25
|
+
* A second consequence was drift in the other direction. The scaffold's
|
|
26
|
+
* `users.ts` describes 12 columns; auth needs 14. `db push` is declarative and
|
|
27
|
+
* builds its desired state from the collection alone, so the two columns only
|
|
28
|
+
* auth knows about (`is_anonymous`, `tokens_valid_after`) read as unmanaged
|
|
29
|
+
* drift, and a push run after the server had booted once planned to DROP them.
|
|
30
|
+
*
|
|
31
|
+
* Both problems are the same problem: no single place said what this table is.
|
|
32
|
+
* This is that place. Every creator asks here first and only then falls back to
|
|
33
|
+
* the collection's own property definitions, so a column auth owns has one
|
|
34
|
+
* definition regardless of who gets there first, and a column the developer
|
|
35
|
+
* added to their users collection still behaves like any other field.
|
|
36
|
+
*
|
|
37
|
+
* ## What belongs here
|
|
38
|
+
*
|
|
39
|
+
* Only columns the auth services read or write. A developer's own additions to
|
|
40
|
+
* their users collection (`bio`, `stripe_customer_id`) are ordinary columns and
|
|
41
|
+
* must NOT be listed — they are generated from the collection like every other
|
|
42
|
+
* field, and listing them here would freeze a user's schema.
|
|
43
|
+
*
|
|
44
|
+
* `id` is deliberately absent: its type comes from the collection's id property
|
|
45
|
+
* (uuid / increment / text) and each creator already derives it.
|
|
46
|
+
*
|
|
47
|
+
* Keep in step with {@link AUTH_SCHEMA_VERSION} when a change here makes an
|
|
48
|
+
* older runtime unable to work against a migrated table.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A column auth owns.
|
|
53
|
+
*
|
|
54
|
+
* Structured rather than one SQL string because the same facts are needed in
|
|
55
|
+
* three grammars — a `CREATE TABLE` column list, an `ADD COLUMN IF NOT EXISTS`,
|
|
56
|
+
* and a pair of `ALTER COLUMN … SET DEFAULT` / `SET NOT NULL` reconciles for a
|
|
57
|
+
* table that already exists with the wrong shape. Parsing a string back apart
|
|
58
|
+
* for the third of those is how the copies drifted in the first place.
|
|
59
|
+
*/
|
|
60
|
+
export interface AuthUsersColumn {
|
|
61
|
+
/** Physical column name. */
|
|
62
|
+
column: string;
|
|
63
|
+
/** Postgres type. */
|
|
64
|
+
type: string;
|
|
65
|
+
/** Default expression, verbatim, or absent for no default. */
|
|
66
|
+
default?: string;
|
|
67
|
+
/** Whether the column is NOT NULL. */
|
|
68
|
+
notNull?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
|
|
73
|
+
* — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
|
|
74
|
+
* The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
|
|
75
|
+
* separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
|
|
76
|
+
* table that already holds a longer row.
|
|
77
|
+
*/
|
|
78
|
+
export const AUTH_USERS_COLUMNS: readonly AuthUsersColumn[] = [
|
|
79
|
+
{ column: "email", type: "TEXT", notNull: true },
|
|
80
|
+
{ column: "display_name", type: "TEXT" },
|
|
81
|
+
{ column: "photo_url", type: "TEXT" },
|
|
82
|
+
{ column: "roles", type: "TEXT[]", default: "'{}'", notNull: true },
|
|
83
|
+
{ column: "password_hash", type: "TEXT" },
|
|
84
|
+
{ column: "email_verified", type: "BOOLEAN", default: "FALSE", notNull: true },
|
|
85
|
+
{ column: "email_verification_token", type: "TEXT" },
|
|
86
|
+
{ column: "email_verification_sent_at", type: "TIMESTAMP WITH TIME ZONE" },
|
|
87
|
+
{ column: "is_anonymous", type: "BOOLEAN", default: "FALSE", notNull: true },
|
|
88
|
+
{ column: "metadata", type: "JSONB", default: "'{}'", notNull: true },
|
|
89
|
+
{ column: "tokens_valid_after", type: "TIMESTAMP WITH TIME ZONE" },
|
|
90
|
+
{ column: "created_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true },
|
|
91
|
+
{ column: "updated_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true }
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const BY_COLUMN = new Map(AUTH_USERS_COLUMNS.map(c => [c.column, c]));
|
|
95
|
+
|
|
96
|
+
/** Type + inline constraints, as they appear after the column name. */
|
|
97
|
+
export function authUsersColumnSql(spec: AuthUsersColumn): string {
|
|
98
|
+
return [
|
|
99
|
+
spec.type,
|
|
100
|
+
spec.default !== undefined ? `DEFAULT ${spec.default}` : "",
|
|
101
|
+
spec.notNull ? "NOT NULL" : ""
|
|
102
|
+
].filter(Boolean).join(" ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The auth-owned definition for a physical column name, or `undefined` when
|
|
107
|
+
* auth does not own it.
|
|
108
|
+
*
|
|
109
|
+
* Callers pass the RESOLVED column name (after `columnName` mapping), because
|
|
110
|
+
* that is the only name the three creators agree on: the scaffold's users
|
|
111
|
+
* collection spells the property `displayName` and the column `display_name`.
|
|
112
|
+
*/
|
|
113
|
+
export function authUsersColumnDefinition(column: string): string | undefined {
|
|
114
|
+
const spec = BY_COLUMN.get(column);
|
|
115
|
+
return spec ? authUsersColumnSql(spec) : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a collection is an auth collection, i.e. whether the definitions in
|
|
120
|
+
* this module apply to its table at all.
|
|
121
|
+
*
|
|
122
|
+
* Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
|
|
123
|
+
* that one takes a `CollectionConfig`, this one is called from DDL code paths
|
|
124
|
+
* that hold looser objects, and both spellings must accept `auth: true` as well
|
|
125
|
+
* as `auth: { enabled: true }`.
|
|
126
|
+
*/
|
|
127
|
+
export function isAuthCollection(collection: unknown): boolean {
|
|
128
|
+
const auth = (collection as { auth?: unknown } | undefined)?.auth;
|
|
129
|
+
if (auth === true) return true;
|
|
130
|
+
return typeof auth === "object" && auth !== null && (auth as { enabled?: unknown }).enabled === true;
|
|
131
|
+
}
|
package/src/schema/doctor-cli.ts
CHANGED
|
@@ -5,62 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import path from "path";
|
|
7
7
|
import chalk from "chalk";
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import { checkPolicyDrift, formatPolicyDrift, hasDrift } from "../security/policy-drift";
|
|
11
|
-
import { validatePolicyPgRoles, warnOnAnonymousGrants } from "../security/rls-enforcement";
|
|
12
|
-
import { logger } from "@rebasepro/server";
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The RLS half of the doctor: policies actually deployed vs the ones the
|
|
16
|
-
* collections describe, plus policy roles this server could never satisfy.
|
|
17
|
-
*
|
|
18
|
-
* Returns true when something is wrong, so callers can set the exit code.
|
|
19
|
-
*/
|
|
20
|
-
async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): Promise<boolean> {
|
|
21
|
-
if (!databaseUrl) {
|
|
22
|
-
logger.warn(chalk.yellow(" ⚠ No DATABASE_URL — skipping RLS policy checks"));
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
let problems = false;
|
|
27
|
-
const { Pool } = await import("pg");
|
|
28
|
-
const pool = new Pool({ connectionString: databaseUrl });
|
|
29
|
-
try {
|
|
30
|
-
const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
|
|
31
|
-
const runSql = async (text: string) => (await pool.query(text)).rows as Record<string, unknown>[];
|
|
32
|
-
|
|
33
|
-
// A policy naming a role the server never runs as filters every row, so
|
|
34
|
-
// the collection reads as empty. Report it without booting a server.
|
|
35
|
-
try {
|
|
36
|
-
await validatePolicyPgRoles(runSql, collections as never);
|
|
37
|
-
logger.info(chalk.green(" ✓ Policy roles are usable by this server"));
|
|
38
|
-
} catch (err) {
|
|
39
|
-
problems = true;
|
|
40
|
-
logger.info("");
|
|
41
|
-
logger.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// A rule that reads as a lockdown but is true for every caller compiles
|
|
45
|
-
// to a grant. Report it without booting a server.
|
|
46
|
-
warnOnAnonymousGrants(collections as never);
|
|
47
|
-
|
|
48
|
-
const drift = await checkPolicyDrift(pool as never, collections);
|
|
49
|
-
logger.info("");
|
|
50
|
-
if (hasDrift(drift)) {
|
|
51
|
-
problems = true;
|
|
52
|
-
logger.info(chalk.yellow(" RLS policies: database does not match your collections"));
|
|
53
|
-
logger.info(formatPolicyDrift(drift));
|
|
54
|
-
} else {
|
|
55
|
-
logger.info(chalk.green(" ✓ RLS policies match your collections"));
|
|
56
|
-
}
|
|
57
|
-
} catch (err) {
|
|
58
|
-
logger.warn(chalk.yellow(" ⚠ Could not check RLS policies"), { error: err });
|
|
59
|
-
} finally {
|
|
60
|
-
await pool.end();
|
|
61
|
-
}
|
|
62
|
-
return problems;
|
|
63
|
-
}
|
|
8
|
+
import { runDoctor } from "./doctor";
|
|
9
|
+
import { exitCodeForPolicyGate, runPolicyChecks } from "./doctor-policy-checks";
|
|
64
10
|
|
|
65
11
|
async function main() {
|
|
66
12
|
const collectionsArg = process.argv.find((a) => a.startsWith("--collections="));
|
|
@@ -80,9 +26,9 @@ async function main() {
|
|
|
80
26
|
const dotenv = await import("dotenv");
|
|
81
27
|
const envPath = process.env.DOTENV_CONFIG_PATH;
|
|
82
28
|
if (envPath) {
|
|
83
|
-
dotenv.config({ path: envPath });
|
|
29
|
+
dotenv.config({ path: envPath, quiet: true });
|
|
84
30
|
} else {
|
|
85
|
-
dotenv.config();
|
|
31
|
+
dotenv.config({ quiet: true });
|
|
86
32
|
}
|
|
87
33
|
} catch {
|
|
88
34
|
// dotenv may not be installed
|
|
@@ -91,9 +37,9 @@ async function main() {
|
|
|
91
37
|
const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
|
|
92
38
|
|
|
93
39
|
if (policiesOnly) {
|
|
94
|
-
// Non-zero so this can gate CI — the whole point of the flag
|
|
95
|
-
|
|
96
|
-
|
|
40
|
+
// Non-zero so this can gate CI — the whole point of the flag, and a
|
|
41
|
+
// check that could not run has not passed. See exitCodeForPolicyGate.
|
|
42
|
+
process.exit(exitCodeForPolicyGate(await runPolicyChecks(collectionsPath, databaseUrl)));
|
|
97
43
|
}
|
|
98
44
|
|
|
99
45
|
const report = await runDoctor({
|
|
@@ -103,15 +49,18 @@ async function main() {
|
|
|
103
49
|
databaseUrl: databaseUrl ?? undefined
|
|
104
50
|
});
|
|
105
51
|
|
|
106
|
-
const
|
|
52
|
+
const policyStatus = await runPolicyChecks(collectionsPath, databaseUrl);
|
|
107
53
|
|
|
108
|
-
// Exit
|
|
109
|
-
|
|
54
|
+
// Exit non-zero if there are errors. A policy run that could not happen is
|
|
55
|
+
// reported loudly above but does not fail the interactive command — the
|
|
56
|
+
// same treatment the skipped database phase gets. `--policies` is the gate,
|
|
57
|
+
// and that one fails closed.
|
|
58
|
+
if (report.summary.errors > 0 || policyStatus === "problems") {
|
|
110
59
|
process.exit(1);
|
|
111
60
|
}
|
|
112
61
|
}
|
|
113
62
|
|
|
114
63
|
main().catch((err) => {
|
|
115
|
-
|
|
64
|
+
console.error(chalk.red(" ✗ Doctor failed"), err instanceof Error ? (err.stack ?? err.message) : String(err));
|
|
116
65
|
process.exit(1);
|
|
117
66
|
});
|
|
@@ -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
|
+
}
|