@rebasepro/server-postgres 0.0.1-canary.4829d6e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +86 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +150 -0
- package/dist/PostgresBootstrapper.d.ts +51 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +250 -0
- package/dist/backup/backup-cli.d.ts +3 -0
- package/dist/backup/backup-cron.d.ts +53 -0
- package/dist/backup/backup-service.d.ts +85 -0
- package/dist/backup/index.d.ts +12 -0
- package/dist/backup/pg-tools.d.ts +110 -0
- package/dist/backup/retention.d.ts +35 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +23535 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +58 -0
- package/dist/schema/dynamic-tables.d.ts +31 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/introspect-runtime.d.ts +57 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/security/policy-drift.d.ts +57 -0
- package/dist/security/rls-enforcement.d.ts +122 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/FetchService.d.ts +214 -0
- package/dist/services/PersistService.d.ts +39 -0
- package/dist/services/RelationService.d.ts +109 -0
- package/dist/services/cdc/CdcListener.d.ts +54 -0
- package/dist/services/cdc/trigger-cdc.d.ts +64 -0
- package/dist/services/collection-helpers.d.ts +38 -0
- package/dist/services/dataService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +298 -0
- package/dist/src-Eh-CZosp.js +595 -0
- package/dist/src-Eh-CZosp.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +18 -0
- package/package.json +113 -0
- package/src/PostgresAdapter.ts +58 -0
- package/src/PostgresBackendDriver.ts +1387 -0
- package/src/PostgresBootstrapper.ts +581 -0
- package/src/auth/ensure-tables.ts +367 -0
- package/src/auth/services.ts +1321 -0
- package/src/backup/backup-cli.ts +383 -0
- package/src/backup/backup-cron.ts +189 -0
- package/src/backup/backup-service.ts +299 -0
- package/src/backup/index.ts +12 -0
- package/src/backup/pg-tools.ts +231 -0
- package/src/backup/retention.ts +75 -0
- package/src/cli-errors.ts +265 -0
- package/src/cli-helpers.ts +196 -0
- package/src/cli.ts +786 -0
- package/src/collections/PostgresCollectionRegistry.ts +103 -0
- package/src/connection.ts +166 -0
- package/src/data-transformer.ts +733 -0
- package/src/databasePoolManager.ts +87 -0
- package/src/history/HistoryService.ts +272 -0
- package/src/history/ensure-history-table.ts +46 -0
- package/src/index.ts +15 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +41 -0
- package/src/schema/auth-default-policies.ts +125 -0
- package/src/schema/auth-schema.ts +233 -0
- package/src/schema/doctor-cli.ts +113 -0
- package/src/schema/doctor.ts +733 -0
- package/src/schema/dynamic-tables.test.ts +302 -0
- package/src/schema/dynamic-tables.ts +293 -0
- package/src/schema/generate-drizzle-schema-logic.ts +850 -0
- package/src/schema/generate-drizzle-schema.ts +131 -0
- package/src/schema/generate-postgres-ddl-logic.ts +490 -0
- package/src/schema/generate-postgres-ddl.ts +92 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +910 -0
- package/src/schema/introspect-db.ts +266 -0
- package/src/schema/introspect-runtime.test.ts +212 -0
- package/src/schema/introspect-runtime.ts +293 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/security/policy-drift.test.ts +122 -0
- package/src/security/policy-drift.ts +159 -0
- package/src/security/rls-enforcement.ts +295 -0
- package/src/services/BranchService.ts +251 -0
- package/src/services/FetchService.ts +1661 -0
- package/src/services/PersistService.ts +329 -0
- package/src/services/RelationService.ts +1306 -0
- package/src/services/cdc/CdcListener.ts +167 -0
- package/src/services/cdc/trigger-cdc.ts +169 -0
- package/src/services/collection-helpers.ts +151 -0
- package/src/services/dataService.ts +246 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1502 -0
- package/src/types.ts +4 -0
- package/src/utils/drizzle-conditions.ts +1162 -0
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +227 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +640 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { sql as drizzleSql, SQL } from "drizzle-orm";
|
|
2
|
+
import { logger } from "@rebasepro/server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unified RLS enforcement — the "user context vs server context" model.
|
|
6
|
+
*
|
|
7
|
+
* Every operation runs in one of two contexts:
|
|
8
|
+
*
|
|
9
|
+
* - **User context** — a request authenticated (or anonymous) via
|
|
10
|
+
* `driver.withAuth(user)`. Runs as the restricted `rebase_user` role: a
|
|
11
|
+
* non-owner, NOSUPERUSER, NOBYPASSRLS role, so Postgres RLS binds *every*
|
|
12
|
+
* statement (SELECT, INSERT, UPDATE, DELETE). The collection's
|
|
13
|
+
* `securityRules` are the whole authorization model; app-layer callbacks
|
|
14
|
+
* are validation/side-effects, not a security boundary.
|
|
15
|
+
*
|
|
16
|
+
* - **Server context** — the base (owner) connection: auth flows, migrations,
|
|
17
|
+
* background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
|
|
18
|
+
* owner it bypasses RLS. This is the trusted plane, equivalent to
|
|
19
|
+
* Supabase's `service_role`.
|
|
20
|
+
*
|
|
21
|
+
* This module provides the three pieces:
|
|
22
|
+
*
|
|
23
|
+
* 1. {@link detectConnectionPosture} — is the connection subject to RLS at
|
|
24
|
+
* all? (superuser / BYPASSRLS / table owner ⇒ no)
|
|
25
|
+
* 2. {@link ensureAppRole} — idempotently provision `rebase_user` with
|
|
26
|
+
* SELECT/INSERT/UPDATE/DELETE grants (+ default privileges so future
|
|
27
|
+
* tables stay covered).
|
|
28
|
+
* 3. {@link applyAuthContext} — per-transaction: set the `app.*` GUCs the
|
|
29
|
+
* policies read (`auth.uid()` etc.) and `SET LOCAL ROLE rebase_user` so
|
|
30
|
+
* RLS binds. Transaction-scoped, so it composes with poolers.
|
|
31
|
+
*
|
|
32
|
+
* Provisioning runs from the framework's own bootstrap/migrate (which already
|
|
33
|
+
* self-creates the `auth` schema and functions) — enforcement is default-on,
|
|
34
|
+
* not an operator opt-in.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The restricted role every authenticated (user-context) request runs as. */
|
|
38
|
+
export const REBASE_USER_ROLE = "rebase_user";
|
|
39
|
+
|
|
40
|
+
/** Minimal SQL runner so callers can adapt drizzle or pg.Client. */
|
|
41
|
+
export type RawSqlRunner = (sqlText: string) => Promise<Record<string, unknown>[]>;
|
|
42
|
+
|
|
43
|
+
/** Minimal transaction surface needed by {@link applyAuthContext}. */
|
|
44
|
+
export interface SqlTx {
|
|
45
|
+
execute(query: SQL): Promise<unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ConnectionPosture {
|
|
49
|
+
/** The connection's `current_user`. */
|
|
50
|
+
role: string;
|
|
51
|
+
superuser: boolean;
|
|
52
|
+
bypassRLS: boolean;
|
|
53
|
+
/** Owns at least one user table — owners bypass non-FORCE RLS. */
|
|
54
|
+
ownsTables: boolean;
|
|
55
|
+
/** True when RLS would NOT constrain this connection. */
|
|
56
|
+
privileged: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AuthContext {
|
|
60
|
+
userId: string;
|
|
61
|
+
/** Raw roles as carried on the user (strings or `{ id }` objects). */
|
|
62
|
+
roles: unknown[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const quoteIdent = (name: string): string => `"${name.replace(/"/g, "\"\"")}"`;
|
|
66
|
+
|
|
67
|
+
/** DML the user role holds on managed tables (RLS still filters per row). */
|
|
68
|
+
const USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
|
|
69
|
+
|
|
70
|
+
export async function detectConnectionPosture(run: RawSqlRunner): Promise<ConnectionPosture> {
|
|
71
|
+
const rows = await run(`
|
|
72
|
+
SELECT current_user AS role,
|
|
73
|
+
r.rolsuper AS superuser,
|
|
74
|
+
r.rolbypassrls AS bypassrls,
|
|
75
|
+
EXISTS (
|
|
76
|
+
SELECT 1 FROM pg_tables t
|
|
77
|
+
WHERE t.tableowner = current_user
|
|
78
|
+
AND t.schemaname NOT IN ('pg_catalog', 'information_schema')
|
|
79
|
+
) AS owns_tables
|
|
80
|
+
FROM pg_roles r
|
|
81
|
+
WHERE r.rolname = current_user
|
|
82
|
+
`);
|
|
83
|
+
const row = rows[0] ?? {};
|
|
84
|
+
const superuser = row.superuser === true;
|
|
85
|
+
const bypassRLS = row.bypassrls === true;
|
|
86
|
+
const ownsTables = row.owns_tables === true;
|
|
87
|
+
return {
|
|
88
|
+
role: String(row.role ?? "unknown"),
|
|
89
|
+
superuser,
|
|
90
|
+
bypassRLS,
|
|
91
|
+
ownsTables,
|
|
92
|
+
privileged: superuser || bypassRLS || ownsTables
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Human-actionable instructions for when the connection cannot provision the
|
|
98
|
+
* user role itself (no CREATEROLE and role not pre-created by the platform).
|
|
99
|
+
*/
|
|
100
|
+
export function appRoleSetupInstructions(connectionRole: string, schemas: string[]): string {
|
|
101
|
+
const grants = schemas.map((s) =>
|
|
102
|
+
`GRANT USAGE ON SCHEMA ${quoteIdent(s)} TO ${REBASE_USER_ROLE};\n` +
|
|
103
|
+
`GRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${quoteIdent(s)} TO ${REBASE_USER_ROLE};\n` +
|
|
104
|
+
`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${quoteIdent(s)} TO ${REBASE_USER_ROLE};`
|
|
105
|
+
).join("\n");
|
|
106
|
+
return (
|
|
107
|
+
`Rebase enforces row-level security by running authenticated requests as ` +
|
|
108
|
+
`the restricted role "${REBASE_USER_ROLE}", but the connection role ` +
|
|
109
|
+
`"${connectionRole}" bypasses RLS and cannot create that role itself.\n` +
|
|
110
|
+
`Run the following as a database administrator, then restart:\n\n` +
|
|
111
|
+
`CREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT;\n` +
|
|
112
|
+
`GRANT ${REBASE_USER_ROLE} TO ${quoteIdent(connectionRole)};\n` +
|
|
113
|
+
grants
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Idempotently provision the `rebase_user` role, membership for the current
|
|
119
|
+
* connection role, and DML grants (+ default privileges for future tables)
|
|
120
|
+
* on every existing schema in `schemas`.
|
|
121
|
+
*
|
|
122
|
+
* Split into privilege tiers so it works both when the connection is a
|
|
123
|
+
* superuser (creates everything) and when the platform pre-created the role
|
|
124
|
+
* and membership (e.g. CNPG `postInitApplicationSQL`) and the connection is
|
|
125
|
+
* merely the table owner — owners can always run the grant tier themselves.
|
|
126
|
+
*
|
|
127
|
+
* RLS still filters every row: these grants only make the tables *reachable*
|
|
128
|
+
* by the role; the policies decide which rows/commands actually pass.
|
|
129
|
+
*
|
|
130
|
+
* Throws with precise setup instructions when the role is missing and the
|
|
131
|
+
* connection cannot create it.
|
|
132
|
+
*/
|
|
133
|
+
export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promise<void> {
|
|
134
|
+
const uniqueSchemas = Array.from(new Set(schemas.filter(Boolean)));
|
|
135
|
+
|
|
136
|
+
// Tier 1 — role existence.
|
|
137
|
+
const roleRows = await run(`SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}'`);
|
|
138
|
+
if (roleRows.length === 0) {
|
|
139
|
+
try {
|
|
140
|
+
await run(`CREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT`);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Failed to create the "${REBASE_USER_ROLE}" role: ${err instanceof Error ? err.message : String(err)}\n\n` +
|
|
144
|
+
appRoleSetupInstructions("current connection role", uniqueSchemas)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Tier 2 — membership, so a non-superuser connection may SET ROLE to it.
|
|
150
|
+
const memberRows = await run(`
|
|
151
|
+
SELECT (pg_has_role(current_user, '${REBASE_USER_ROLE}', 'MEMBER')
|
|
152
|
+
OR (SELECT rolsuper FROM pg_roles WHERE rolname = current_user)) AS can_set,
|
|
153
|
+
current_user AS role
|
|
154
|
+
`);
|
|
155
|
+
if (memberRows[0]?.can_set !== true) {
|
|
156
|
+
try {
|
|
157
|
+
await run(`GRANT ${REBASE_USER_ROLE} TO CURRENT_USER`);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`The connection role is not a member of "${REBASE_USER_ROLE}" and cannot grant itself membership: ` +
|
|
161
|
+
`${err instanceof Error ? err.message : String(err)}\n\n` +
|
|
162
|
+
appRoleSetupInstructions(String(memberRows[0]?.role ?? "current connection role"), uniqueSchemas)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Tier 3 — grants. Table owners (the expected non-superuser posture) can
|
|
168
|
+
// always grant on their own objects, so this tier needs no extra privilege.
|
|
169
|
+
const nspRows = await run("SELECT nspname FROM pg_namespace");
|
|
170
|
+
const existing = new Set(nspRows.map((r) => String(r.nspname)));
|
|
171
|
+
for (const schema of uniqueSchemas) {
|
|
172
|
+
if (!existing.has(schema)) continue;
|
|
173
|
+
const s = quoteIdent(schema);
|
|
174
|
+
await run(`GRANT USAGE ON SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
|
|
175
|
+
await run(`GRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
|
|
176
|
+
await run(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
|
|
177
|
+
// Cover objects created later by the CURRENT role (the role that runs
|
|
178
|
+
// migrations), so a migrate can never strand the user role.
|
|
179
|
+
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
|
|
180
|
+
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Apply the authenticated context to a transaction: the `app.*` GUCs that RLS
|
|
188
|
+
* policies read via `auth.uid()` / `auth.roles()` / `auth.jwt()`, and — when
|
|
189
|
+
* `userRole` is set — `SET LOCAL ROLE` so RLS binds every statement in this
|
|
190
|
+
* transaction (reads *and* writes).
|
|
191
|
+
*
|
|
192
|
+
* GUCs are set with `is_local = true` and the role switch is `LOCAL`: both
|
|
193
|
+
* reset at commit/rollback, so pooled connections are never polluted.
|
|
194
|
+
*
|
|
195
|
+
* Fails closed by construction: if the role switch errors, the transaction
|
|
196
|
+
* aborts instead of proceeding privileged.
|
|
197
|
+
*
|
|
198
|
+
* SECURITY: this function is only ever called on the **user** path (the server
|
|
199
|
+
* context uses the base/owner driver and never calls it). The default policies
|
|
200
|
+
* treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
|
|
201
|
+
* is `NULLIF(current_setting('app.user_id'), '')` — so an EMPTY user id would
|
|
202
|
+
* be read as NULL and silently escalate a user request to server privileges.
|
|
203
|
+
* Coerce empty/blank ids to a sentinel here, at the single chokepoint, rather
|
|
204
|
+
* than trusting every caller (e.g. realtime subscription auth) to do it.
|
|
205
|
+
*/
|
|
206
|
+
export async function applyAuthContext(tx: SqlTx, auth: AuthContext, userRole?: string): Promise<void> {
|
|
207
|
+
const userId = typeof auth.userId === "string" && auth.userId.trim() !== "" ? auth.userId : "anonymous";
|
|
208
|
+
const normalizedRoles = auth.roles.map((r: unknown) =>
|
|
209
|
+
typeof r === "string" ? r : (r as Record<string, unknown>)?.id ?? String(r)
|
|
210
|
+
);
|
|
211
|
+
await tx.execute(drizzleSql`
|
|
212
|
+
SELECT
|
|
213
|
+
set_config('app.user_id', ${userId}, true),
|
|
214
|
+
set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
|
|
215
|
+
set_config('app.jwt', ${JSON.stringify({ sub: userId, roles: auth.roles })}, true)
|
|
216
|
+
`);
|
|
217
|
+
if (userRole) {
|
|
218
|
+
await tx.execute(drizzleSql.raw(`SET LOCAL ROLE ${quoteIdent(userRole)}`));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Role names from other BaaS platforms that people reach for out of habit. */
|
|
223
|
+
const FOREIGN_CONVENTION_ROLES: Record<string, string> = {
|
|
224
|
+
authenticated: "Supabase",
|
|
225
|
+
anon: "Supabase",
|
|
226
|
+
service_role: "Supabase"
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Reject `pgRoles` that this server can never satisfy.
|
|
231
|
+
*
|
|
232
|
+
* `pgRoles` sets the `TO` clause of a generated policy, so a policy naming a
|
|
233
|
+
* role the request never runs as simply never applies — and RLS then filters
|
|
234
|
+
* every row. The table reads as empty, which is indistinguishable from having
|
|
235
|
+
* no data, so the mistake survives review and ships.
|
|
236
|
+
*
|
|
237
|
+
* Requests run as `rebase_user`, so a policy is only reachable if it targets
|
|
238
|
+
* `public` or a role `rebase_user` holds. Anything else is a configuration
|
|
239
|
+
* error worth failing the boot for.
|
|
240
|
+
*/
|
|
241
|
+
export async function validatePolicyPgRoles(
|
|
242
|
+
run: RawSqlRunner,
|
|
243
|
+
collections: { slug?: string; securityRules?: readonly { name?: string; pgRoles?: readonly string[] }[] }[],
|
|
244
|
+
/** The role requests actually run as: `rebase_user` when the connection is
|
|
245
|
+
* privileged enough to switch, otherwise the connection role itself. */
|
|
246
|
+
requestRole: string = REBASE_USER_ROLE
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
const wanted = new Map<string, string[]>();
|
|
249
|
+
for (const collection of collections) {
|
|
250
|
+
for (const rule of collection.securityRules ?? []) {
|
|
251
|
+
for (const role of rule.pgRoles ?? []) {
|
|
252
|
+
if (role === "public") continue;
|
|
253
|
+
wanted.set(role, [...(wanted.get(role) ?? []), collection.slug ?? "(unnamed)"]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (wanted.size === 0) return;
|
|
258
|
+
|
|
259
|
+
const names = [...wanted.keys()].map((r) => `'${r.replace(/'/g, "''")}'`).join(",");
|
|
260
|
+
const escapedRequestRole = requestRole.replace(/'/g, "''");
|
|
261
|
+
const rows = await run(`
|
|
262
|
+
SELECT r.rolname AS role,
|
|
263
|
+
COALESCE(pg_has_role(to_regrole('${escapedRequestRole}'), r.oid, 'MEMBER'), false) AS reachable
|
|
264
|
+
FROM pg_roles r
|
|
265
|
+
WHERE r.rolname IN (${names})
|
|
266
|
+
`);
|
|
267
|
+
|
|
268
|
+
const reachable = new Map(rows.map((row) => [String(row.role), row.reachable === true]));
|
|
269
|
+
const problems: string[] = [];
|
|
270
|
+
|
|
271
|
+
for (const [role, slugs] of wanted) {
|
|
272
|
+
if (reachable.get(role) === true) continue;
|
|
273
|
+
|
|
274
|
+
const why = reachable.has(role)
|
|
275
|
+
? `"${requestRole}" is not a member of it`
|
|
276
|
+
: "no such role exists in this database";
|
|
277
|
+
const platform = FOREIGN_CONVENTION_ROLES[role];
|
|
278
|
+
const hint = platform
|
|
279
|
+
? `"${role}" is a ${platform} convention, not a PostgreSQL role. Application roles belong in \`roles: ["${role === "service_role" ? "admin" : role}"]\`, which is checked inside the policy via auth.roles().`
|
|
280
|
+
: `Either grant it (GRANT ${role} TO ${requestRole}) or drop \`pgRoles\` so the policy targets \`public\`.`;
|
|
281
|
+
|
|
282
|
+
problems.push(
|
|
283
|
+
` • pgRoles: ["${role}"] on ${slugs.join(", ")} — ${why}.\n ${hint}`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (problems.length > 0) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Security rules target PostgreSQL roles this server cannot use. Requests run as ` +
|
|
290
|
+
`"${requestRole}", so these policies would never apply and every row would be ` +
|
|
291
|
+
`filtered out — the collections would look empty rather than error.\n\n` +
|
|
292
|
+
problems.join("\n\n") + "\n"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BranchService
|
|
3
|
+
*
|
|
4
|
+
* Manages database branching by creating/deleting PostgreSQL databases
|
|
5
|
+
* using `CREATE DATABASE ... TEMPLATE`. Branch metadata is stored in the
|
|
6
|
+
* `rebase.branches` table in the default (main) database, following the
|
|
7
|
+
* same `rebase` schema convention used by entity_history, auth, etc.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { sql } from "drizzle-orm";
|
|
11
|
+
import { BranchInfo } from "@rebasepro/types";
|
|
12
|
+
import { DrizzleClient } from "../interfaces";
|
|
13
|
+
import { DatabasePoolManager } from "../databasePoolManager";
|
|
14
|
+
|
|
15
|
+
/** Internal prefix applied to branch database names to avoid collisions. */
|
|
16
|
+
const BRANCH_DB_PREFIX = "rb_";
|
|
17
|
+
|
|
18
|
+
/** Fully-qualified metadata table in the rebase schema. */
|
|
19
|
+
const BRANCHES_TABLE = "rebase.branches";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate that a user-provided identifier only contains safe characters.
|
|
23
|
+
* Throws if the value contains characters outside [a-zA-Z0-9_-].
|
|
24
|
+
*/
|
|
25
|
+
function validateIdentifier(value: string, label: string): void {
|
|
26
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
|
27
|
+
throw new Error(`Invalid ${label}: only letters, digits, underscores, and hyphens are allowed.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sanitize a user-provided branch name to a safe PostgreSQL identifier.
|
|
33
|
+
* Only allows alphanumeric characters and underscores.
|
|
34
|
+
*/
|
|
35
|
+
function sanitizeBranchName(name: string): string {
|
|
36
|
+
return name.replace(/[^a-zA-Z0-9_]/g, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert a user-facing branch name to the actual PostgreSQL database name.
|
|
41
|
+
*/
|
|
42
|
+
function toBranchDbName(name: string): string {
|
|
43
|
+
const sanitized = sanitizeBranchName(name);
|
|
44
|
+
if (!sanitized) throw new Error("Branch name must contain at least one alphanumeric character.");
|
|
45
|
+
return `${BRANCH_DB_PREFIX}${sanitized}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class BranchService {
|
|
49
|
+
constructor(
|
|
50
|
+
private db: DrizzleClient,
|
|
51
|
+
private poolManager: DatabasePoolManager
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ensure the `rebase.branches` metadata table exists in the default database.
|
|
56
|
+
* Idempotent — safe to call on every startup.
|
|
57
|
+
*/
|
|
58
|
+
async ensureBranchMetadataTable(): Promise<void> {
|
|
59
|
+
// Create the rebase schema (idempotent — may already exist from auth/history init)
|
|
60
|
+
await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
61
|
+
|
|
62
|
+
await this.db.execute(sql.raw(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS ${BRANCHES_TABLE} (
|
|
64
|
+
name TEXT PRIMARY KEY,
|
|
65
|
+
db_name TEXT NOT NULL UNIQUE,
|
|
66
|
+
parent_db TEXT NOT NULL,
|
|
67
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
68
|
+
metadata JSONB DEFAULT '{}'
|
|
69
|
+
);
|
|
70
|
+
`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a new branch database by templating the source database.
|
|
75
|
+
*
|
|
76
|
+
* Uses `CREATE DATABASE ... TEMPLATE` for an instant, full-fidelity copy
|
|
77
|
+
* of both schema and data.
|
|
78
|
+
*
|
|
79
|
+
* @param name User-facing branch name (e.g., "feature_auth")
|
|
80
|
+
* @param options.source Source database to clone; defaults to the main database.
|
|
81
|
+
*/
|
|
82
|
+
async createBranch(name: string, options?: { source?: string }): Promise<BranchInfo> {
|
|
83
|
+
if (options?.source) {
|
|
84
|
+
validateIdentifier(options.source, "source database name");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const dbName = toBranchDbName(name);
|
|
88
|
+
const sanitizedName = sanitizeBranchName(name);
|
|
89
|
+
const sourceDb = options?.source || this.poolManager.defaultDatabaseName;
|
|
90
|
+
|
|
91
|
+
// Check if branch already exists
|
|
92
|
+
const existing = await this.db.execute(
|
|
93
|
+
sql`SELECT name FROM rebase.branches WHERE name = ${sanitizedName} OR db_name = ${dbName}`
|
|
94
|
+
);
|
|
95
|
+
if ((existing.rows as unknown[]).length > 0) {
|
|
96
|
+
throw new Error(`Branch "${sanitizedName}" already exists.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Disconnect any idle pools to the source DB so TEMPLATE works.
|
|
100
|
+
// CREATE DATABASE ... TEMPLATE requires no other connections to the template.
|
|
101
|
+
await this.poolManager.disconnectDatabase(sourceDb);
|
|
102
|
+
|
|
103
|
+
// Create the database using the source as a template.
|
|
104
|
+
// Note: Identifiers must be double-quoted, not parameterized.
|
|
105
|
+
const safeDbName = dbName.replace(/"/g, '""');
|
|
106
|
+
const safeSourceDb = sourceDb.replace(/"/g, '""');
|
|
107
|
+
try {
|
|
108
|
+
await this.db.execute(
|
|
109
|
+
sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`)
|
|
110
|
+
);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
113
|
+
if (msg.includes("already exists")) {
|
|
114
|
+
throw new Error(`Database "${dbName}" already exists on the server. Choose a different branch name.`);
|
|
115
|
+
}
|
|
116
|
+
// If template fails due to active connections, provide a helpful error
|
|
117
|
+
if (msg.includes("being accessed by other users")) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Cannot create branch: the source database "${sourceDb}" has active connections. ` +
|
|
120
|
+
"Close other clients or connections and try again."
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Record metadata in the default database
|
|
127
|
+
const now = new Date();
|
|
128
|
+
await this.db.execute(
|
|
129
|
+
sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
|
|
130
|
+
VALUES (${sanitizedName}, ${dbName}, ${sourceDb}, ${now.toISOString()})`
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
name: sanitizedName,
|
|
135
|
+
parentDatabase: sourceDb,
|
|
136
|
+
createdAt: now
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Delete a branch database and remove its metadata.
|
|
142
|
+
* Cannot delete the main/default database.
|
|
143
|
+
*/
|
|
144
|
+
async deleteBranch(name: string): Promise<void> {
|
|
145
|
+
const sanitizedName = sanitizeBranchName(name);
|
|
146
|
+
const dbName = toBranchDbName(name);
|
|
147
|
+
|
|
148
|
+
// Safety: never delete the default database
|
|
149
|
+
if (dbName === this.poolManager.defaultDatabaseName) {
|
|
150
|
+
throw new Error("Cannot delete the main database.");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Verify the branch exists in our metadata
|
|
154
|
+
const existing = await this.db.execute(
|
|
155
|
+
sql`SELECT db_name FROM rebase.branches WHERE name = ${sanitizedName}`
|
|
156
|
+
);
|
|
157
|
+
if ((existing.rows as unknown[]).length === 0) {
|
|
158
|
+
throw new Error(`Branch "${sanitizedName}" not found.`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Disconnect any pools to this branch before dropping
|
|
162
|
+
await this.poolManager.disconnectDatabase(dbName);
|
|
163
|
+
|
|
164
|
+
// Drop the database
|
|
165
|
+
const safeDbName = dbName.replace(/"/g, '""');
|
|
166
|
+
try {
|
|
167
|
+
await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
170
|
+
if (msg.includes("being accessed by other users")) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Cannot delete branch "${sanitizedName}": the database has active connections. ` +
|
|
173
|
+
"Close other clients and try again."
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Remove metadata
|
|
180
|
+
await this.db.execute(
|
|
181
|
+
sql`DELETE FROM rebase.branches WHERE name = ${sanitizedName}`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* List all branches recorded in the metadata table.
|
|
187
|
+
* Optionally fetches database sizes from pg_database.
|
|
188
|
+
*/
|
|
189
|
+
async listBranches(): Promise<BranchInfo[]> {
|
|
190
|
+
const result = await this.db.execute(sql.raw(`
|
|
191
|
+
SELECT
|
|
192
|
+
b.name,
|
|
193
|
+
b.parent_db,
|
|
194
|
+
b.created_at,
|
|
195
|
+
pg_database_size(b.db_name) as size_bytes
|
|
196
|
+
FROM ${BRANCHES_TABLE} b
|
|
197
|
+
JOIN pg_database d ON d.datname = b.db_name
|
|
198
|
+
ORDER BY b.created_at DESC
|
|
199
|
+
`));
|
|
200
|
+
|
|
201
|
+
return (result.rows as Record<string, unknown>[]).map((row) => ({
|
|
202
|
+
name: row.name as string,
|
|
203
|
+
parentDatabase: row.parent_db as string,
|
|
204
|
+
createdAt: new Date(row.created_at as string),
|
|
205
|
+
sizeBytes: row.size_bytes != null ? Number(row.size_bytes) : undefined
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get info about a specific branch.
|
|
211
|
+
*/
|
|
212
|
+
async getBranchInfo(name: string): Promise<BranchInfo | undefined> {
|
|
213
|
+
const sanitizedName = sanitizeBranchName(name);
|
|
214
|
+
|
|
215
|
+
const result = await this.db.execute(sql`
|
|
216
|
+
SELECT
|
|
217
|
+
b.name,
|
|
218
|
+
b.parent_db,
|
|
219
|
+
b.created_at
|
|
220
|
+
FROM rebase.branches b
|
|
221
|
+
WHERE b.name = ${sanitizedName}
|
|
222
|
+
`);
|
|
223
|
+
|
|
224
|
+
const rows = result.rows as Record<string, unknown>[];
|
|
225
|
+
if (rows.length === 0) return undefined;
|
|
226
|
+
|
|
227
|
+
const row = rows[0];
|
|
228
|
+
|
|
229
|
+
// Attempt to get size — may fail if the DB was externally dropped
|
|
230
|
+
let sizeBytes: number | undefined;
|
|
231
|
+
try {
|
|
232
|
+
const dbName = toBranchDbName(sanitizedName);
|
|
233
|
+
const sizeResult = await this.db.execute(
|
|
234
|
+
sql`SELECT pg_database_size(${dbName}) as size_bytes`
|
|
235
|
+
);
|
|
236
|
+
const sizeRows = sizeResult.rows as Record<string, unknown>[];
|
|
237
|
+
if (sizeRows.length > 0 && sizeRows[0].size_bytes != null) {
|
|
238
|
+
sizeBytes = Number(sizeRows[0].size_bytes);
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
// Database might not exist anymore
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
name: row.name as string,
|
|
246
|
+
parentDatabase: row.parent_db as string,
|
|
247
|
+
createdAt: new Date(row.created_at as string),
|
|
248
|
+
sizeBytes
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|