@rebasepro/server-postgres 0.12.0 → 0.12.1-canary.g181d0fe
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/auth/services.d.ts +16 -0
- package/dist/backup-service-DH9kPg-E.js +8866 -0
- package/dist/backup-service-DH9kPg-E.js.map +1 -0
- package/dist/connection-B5Wndbr1.js +196 -0
- package/dist/connection-B5Wndbr1.js.map +1 -0
- package/dist/ensure-collection-policies-CT-zIUWA.js +57 -0
- package/dist/ensure-collection-policies-CT-zIUWA.js.map +1 -0
- package/dist/{ensure-collection-tables-CNTcZGvn.js → ensure-collection-tables-Vu-GRELM.js} +83 -6
- package/dist/ensure-collection-tables-Vu-GRELM.js.map +1 -0
- package/dist/index.es.js +420 -9598
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +43 -1
- package/dist/{src-BbFOPJ1S.js → src-DihrDFuP.js} +160 -150
- package/dist/src-DihrDFuP.js.map +1 -0
- package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
- package/dist/src-DoU9yPqq.js.map +1 -0
- package/dist/utils/pg-error-utils.d.ts +19 -0
- package/dist/websocket-BKcGvILX.js +528 -0
- package/dist/websocket-BKcGvILX.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +14 -0
- package/src/PostgresBootstrapper.ts +104 -12
- package/src/auth/ensure-tables.ts +164 -9
- package/src/auth/services.ts +21 -2
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/generate-drizzle-schema-logic.ts +7 -3
- package/src/schema/generate-postgres-ddl-logic.ts +100 -13
- package/src/utils/pg-error-utils.ts +46 -0
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
package/src/auth/services.ts
CHANGED
|
@@ -54,6 +54,25 @@ function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebaseP
|
|
|
54
54
|
return key ? table[key] : undefined;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The single definition of what an email address looks like in storage.
|
|
59
|
+
*
|
|
60
|
+
* Reads have always folded case; writes did not, and normalising was left to
|
|
61
|
+
* each caller. That asymmetry is only ever one forgotten `.toLowerCase()` away
|
|
62
|
+
* from a row no lookup can find — the account exists, every sign-in path
|
|
63
|
+
* reports no such user, and the byte-exact UNIQUE on the column does not stop a
|
|
64
|
+
* duplicate differing only in case. Applied on both sides here so the guarantee
|
|
65
|
+
* belongs to the repository rather than to its callers' discipline; the
|
|
66
|
+
* `lower(email)` unique index added in `ensureAuthTablesExist` is the database
|
|
67
|
+
* half of the same rule.
|
|
68
|
+
*
|
|
69
|
+
* Whitespace goes too: a trailing space survives the fold and reproduces the
|
|
70
|
+
* problem exactly.
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeEmail<T>(email: T): T | string {
|
|
73
|
+
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
/**
|
|
58
77
|
* PostgreSQL implementation of UserRepository.
|
|
59
78
|
* Handles all user-related database operations using Drizzle ORM.
|
|
@@ -184,7 +203,7 @@ export class UserService implements UserRepository {
|
|
|
184
203
|
const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
|
|
185
204
|
|
|
186
205
|
if ("id" in data) payload[idKey] = data.id;
|
|
187
|
-
if ("email" in data) payload[emailKey] = data.email;
|
|
206
|
+
if ("email" in data) payload[emailKey] = normalizeEmail(data.email);
|
|
188
207
|
if ("passwordHash" in data) payload[passwordHashKey] = data.passwordHash;
|
|
189
208
|
if ("displayName" in data) payload[displayNameKey] = data.displayName;
|
|
190
209
|
if ("photoUrl" in data) payload[photoUrlKey] = data.photoUrl;
|
|
@@ -244,7 +263,7 @@ export class UserService implements UserRepository {
|
|
|
244
263
|
async getUserByEmail(email: string): Promise<UserData | null> {
|
|
245
264
|
const emailCol = getColumn(this.usersTable, "email");
|
|
246
265
|
if (!emailCol) return null;
|
|
247
|
-
const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, email
|
|
266
|
+
const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, normalizeEmail(email)));
|
|
248
267
|
return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
|
|
249
268
|
}
|
|
250
269
|
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { pgSchema, pgTable,
|
|
1
|
+
import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
|
|
2
2
|
import { relations } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Factory function to dynamically create the auth tables bound to the specified schema names.
|
|
6
|
+
*
|
|
7
|
+
* This module builds queries; it does not create tables. `ensureAuthTablesExist`
|
|
8
|
+
* owns the DDL, which makes everything here a *claim* about a database it cannot
|
|
9
|
+
* enforce — and the claims drifted. Every column below was declared
|
|
10
|
+
* `varchar(n)` while the DDL created it as `TEXT`: `user_agent` as varchar(500),
|
|
11
|
+
* `ip_address` as varchar(45), `secret_encrypted` as varchar(500), every
|
|
12
|
+
* `token_hash` as varchar(255). None of it was true of any database this
|
|
13
|
+
* framework ever provisioned. Harmless at runtime — drizzle does not enforce a
|
|
14
|
+
* length client-side, so the widths only ever misled the next reader — but a
|
|
15
|
+
* schema module that describes columns that do not exist is worse than no
|
|
16
|
+
* schema module. They are `text` here now because they are TEXT there.
|
|
6
17
|
*/
|
|
7
18
|
export function createAuthSchema(usersSchemaName = "rebase") {
|
|
8
19
|
const usersSchema = usersSchemaName === "public" ? null : pgSchema(usersSchemaName);
|
|
@@ -15,12 +26,12 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
15
26
|
*/
|
|
16
27
|
const users = usersTableCreator("users", {
|
|
17
28
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
18
|
-
email:
|
|
19
|
-
passwordHash:
|
|
20
|
-
displayName:
|
|
21
|
-
photoUrl:
|
|
29
|
+
email: text("email").notNull().unique(),
|
|
30
|
+
passwordHash: text("password_hash"), // NULL for OAuth-only users
|
|
31
|
+
displayName: text("display_name"),
|
|
32
|
+
photoUrl: text("photo_url"),
|
|
22
33
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
23
|
-
emailVerificationToken:
|
|
34
|
+
emailVerificationToken: text("email_verification_token"),
|
|
24
35
|
emailVerificationSentAt: timestamp("email_verification_sent_at"),
|
|
25
36
|
isAnonymous: boolean("is_anonymous").default(false).notNull(),
|
|
26
37
|
roles: text("roles").array().default([]).notNull(),
|
|
@@ -65,7 +76,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
65
76
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
66
77
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
67
78
|
sessionId: uuid("session_id").defaultRandom().notNull(),
|
|
68
|
-
tokenHash:
|
|
79
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
69
80
|
expiresAt: timestamp("expires_at").notNull(),
|
|
70
81
|
revoked: boolean("revoked").default(false).notNull(),
|
|
71
82
|
rotatedAt: timestamp("rotated_at"),
|
|
@@ -76,8 +87,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
76
87
|
* that rotates immediately after it.
|
|
77
88
|
*/
|
|
78
89
|
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
79
|
-
userAgent:
|
|
80
|
-
ipAddress:
|
|
90
|
+
userAgent: text("user_agent"),
|
|
91
|
+
ipAddress: text("ip_address"),
|
|
81
92
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
82
93
|
}, (table) => ({
|
|
83
94
|
sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
|
|
@@ -89,7 +100,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
89
100
|
const passwordResetTokens = tableCreator("password_reset_tokens", {
|
|
90
101
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
91
102
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
92
|
-
tokenHash:
|
|
103
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
93
104
|
expiresAt: timestamp("expires_at").notNull(),
|
|
94
105
|
usedAt: timestamp("used_at"),
|
|
95
106
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -99,7 +110,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
99
110
|
* App config - key/value store for custom settings
|
|
100
111
|
*/
|
|
101
112
|
const appConfig = tableCreator("app_config", {
|
|
102
|
-
key:
|
|
113
|
+
key: text("key").primaryKey(),
|
|
103
114
|
value: jsonb("value").notNull(),
|
|
104
115
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
105
116
|
});
|
|
@@ -110,8 +121,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
110
121
|
const userIdentities = tableCreator("user_identities", {
|
|
111
122
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
112
123
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
113
|
-
provider:
|
|
114
|
-
providerId:
|
|
124
|
+
provider: text("provider").notNull(), // e.g. 'google', 'linkedin'
|
|
125
|
+
providerId: text("provider_id").notNull(),
|
|
115
126
|
profileData: jsonb("profile_data"),
|
|
116
127
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
117
128
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
@@ -125,9 +136,9 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
125
136
|
const mfaFactors = tableCreator("mfa_factors", {
|
|
126
137
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
127
138
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
128
|
-
factorType:
|
|
129
|
-
secretEncrypted:
|
|
130
|
-
friendlyName:
|
|
139
|
+
factorType: text("factor_type").notNull(), // 'totp'
|
|
140
|
+
secretEncrypted: text("secret_encrypted").notNull(),
|
|
141
|
+
friendlyName: text("friendly_name"),
|
|
131
142
|
verified: boolean("verified").default(false).notNull(),
|
|
132
143
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
133
144
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
@@ -141,7 +152,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
141
152
|
factorId: uuid("factor_id").notNull().references(() => mfaFactors.id, { onDelete: "cascade" }),
|
|
142
153
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
143
154
|
verifiedAt: timestamp("verified_at"),
|
|
144
|
-
ipAddress:
|
|
155
|
+
ipAddress: text("ip_address"),
|
|
145
156
|
expiresAt: timestamp("expires_at").notNull()
|
|
146
157
|
});
|
|
147
158
|
|
|
@@ -151,7 +162,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
151
162
|
const recoveryCodes = tableCreator("recovery_codes", {
|
|
152
163
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
153
164
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
154
|
-
codeHash:
|
|
165
|
+
codeHash: text("code_hash").notNull(),
|
|
155
166
|
usedAt: timestamp("used_at"),
|
|
156
167
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
157
168
|
});
|
|
@@ -162,7 +173,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
162
173
|
const magicLinkTokens = tableCreator("magic_link_tokens", {
|
|
163
174
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
164
175
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
165
|
-
tokenHash:
|
|
176
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
166
177
|
expiresAt: timestamp("expires_at").notNull(),
|
|
167
178
|
usedAt: timestamp("used_at"),
|
|
168
179
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applying a bundle's RLS policies to a database at boot, idempotently.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* {@link ensureCollectionTables} creates the collection *tables* a managed
|
|
7
|
+
* runtime boots against, but a table with row-level security disabled and no
|
|
8
|
+
* policies is not servable: authenticated requests run as the restricted
|
|
9
|
+
* `rebase_user` role, so a read with no `SELECT` policy returns nothing (a
|
|
10
|
+
* public collection answered 401) and a write with no `INSERT`/`UPDATE` policy
|
|
11
|
+
* is denied. The policies live in the collections' `securityRules`; nothing at
|
|
12
|
+
* boot applied them. `rebase db push` does — but it drives Atlas against a
|
|
13
|
+
* local `DATABASE_URL`, and a managed tenant's database is reachable only from
|
|
14
|
+
* inside the cluster, by the runtime that is already connected to it. So the
|
|
15
|
+
* runtime is the only thing that *can* apply them, and this is where it does.
|
|
16
|
+
*
|
|
17
|
+
* ## Why this is safe to run on every boot
|
|
18
|
+
*
|
|
19
|
+
* Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once
|
|
20
|
+
* enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by
|
|
21
|
+
* a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds
|
|
22
|
+
* and replaces; it never drops data. (It does not *reconcile* — a policy a
|
|
23
|
+
* previous push left behind under an old name is not removed here; that stays a
|
|
24
|
+
* `db push` / `db migrate` concern, alongside destructive schema changes.)
|
|
25
|
+
*
|
|
26
|
+
* Unlike table creation, a failure here is not fatal: RLS stays enabled, so a
|
|
27
|
+
* table whose policies could not be applied fails **closed** (denies) rather
|
|
28
|
+
* than leaking rows. One collection's policy failing (e.g. a rule that
|
|
29
|
+
* references a table a real migration has not created yet) must not crash-loop
|
|
30
|
+
* the whole deployment and take the other collections' working routes down with
|
|
31
|
+
* it. Failures are reported loudly and per-table so the operator can see
|
|
32
|
+
* exactly which collection is not yet servable and why.
|
|
33
|
+
*/
|
|
34
|
+
import { type CollectionConfig } from "@rebasepro/types";
|
|
35
|
+
import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
|
|
36
|
+
import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
|
|
37
|
+
|
|
38
|
+
export interface PolicyEnsureResult {
|
|
39
|
+
/** `CREATE POLICY` statements that ran successfully. */
|
|
40
|
+
policiesApplied: number;
|
|
41
|
+
/** Tables that had RLS enabled. */
|
|
42
|
+
tablesSecured: number;
|
|
43
|
+
/** Declared tables absent from the database — left to a real migration. */
|
|
44
|
+
skipped: { table: string; reason: string }[];
|
|
45
|
+
/** Tables whose RLS could not be fully applied (fail closed). */
|
|
46
|
+
failures: { table: string; error: string }[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bring the declared collections' RLS policies up to date. Returns what it did.
|
|
53
|
+
*
|
|
54
|
+
* Only tables that already exist are touched: the boot-time table creator runs
|
|
55
|
+
* first, so anything still missing is a table this additive path is not allowed
|
|
56
|
+
* to create (a junction, or a relation left to a migration). Enabling RLS on a
|
|
57
|
+
* non-existent table would error, so those are recorded as skipped, not failed.
|
|
58
|
+
*/
|
|
59
|
+
export async function ensureCollectionPolicies(
|
|
60
|
+
client: Queryable,
|
|
61
|
+
collections: CollectionConfig[],
|
|
62
|
+
log?: (message: string) => void
|
|
63
|
+
): Promise<PolicyEnsureResult> {
|
|
64
|
+
const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
|
|
65
|
+
|
|
66
|
+
const plans = planCollectionPolicies(collections);
|
|
67
|
+
if (plans.length === 0) return result;
|
|
68
|
+
|
|
69
|
+
const schemas = Array.from(new Set(plans.map(p => p.schema)));
|
|
70
|
+
const existing = await readExistingSchema(client, schemas);
|
|
71
|
+
|
|
72
|
+
for (const plan of plans) {
|
|
73
|
+
if (!existing.tables.has(plan.qualified)) {
|
|
74
|
+
result.skipped.push({
|
|
75
|
+
table: plan.qualified,
|
|
76
|
+
reason: "table is not present in the database; create it with `rebase db push` / `rebase db migrate`"
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
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
|
+
await client.query(plan.enableRls);
|
|
85
|
+
result.tablesSecured++;
|
|
86
|
+
|
|
87
|
+
let created = 0;
|
|
88
|
+
for (const statement of plan.policyStatements) {
|
|
89
|
+
await client.query(statement);
|
|
90
|
+
if (isCreatePolicy(statement)) {
|
|
91
|
+
result.policiesApplied++;
|
|
92
|
+
created++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
result.failures.push({
|
|
98
|
+
table: plan.qualified,
|
|
99
|
+
error: err instanceof Error ? err.message : String(err)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
|
|
2
2
|
import { getPrimaryKeys } from "../services/collection-helpers";
|
|
3
|
-
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
|
|
3
|
+
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
|
|
4
4
|
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
5
5
|
import { logger } from "@rebasepro/server";
|
|
6
6
|
// --- Helper Functions ---
|
|
@@ -98,9 +98,13 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
|
|
|
98
98
|
} else if (stringProp.columnType === "uuid") {
|
|
99
99
|
columnDefinition = `uuid("${colName}")`;
|
|
100
100
|
} else if (stringProp.columnType === "char") {
|
|
101
|
-
columnDefinition = `char("${colName}")`;
|
|
101
|
+
columnDefinition = `char("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
102
102
|
} else if (stringProp.columnType === "varchar") {
|
|
103
|
-
|
|
103
|
+
// The length is not optional decoration: `varchar("col")` with
|
|
104
|
+
// no length is an UNBOUNDED varchar in Postgres, which is what
|
|
105
|
+
// this emitted while the DDL generator emitted VARCHAR(255) for
|
|
106
|
+
// the very same property.
|
|
107
|
+
columnDefinition = `varchar("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
104
108
|
} else {
|
|
105
109
|
// `text` is the default, and the only length-unbounded choice.
|
|
106
110
|
// Ask for `varchar` explicitly if you want the length constraint.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo } from "@rebasepro/types";
|
|
2
|
-
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
|
|
2
|
+
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
|
|
3
3
|
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
4
4
|
|
|
5
5
|
// --- Helper Functions ---
|
|
@@ -45,7 +45,28 @@ export const isIdProperty = (propName: string, prop: Property, collection: Colle
|
|
|
45
45
|
|
|
46
46
|
type ResolveCollection = (slug: string) => CollectionConfig | undefined;
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Render statements produced by {@link generatePolicyStatements} back into the
|
|
50
|
+
* exact string the DDL/policies files have always carried: each statement on
|
|
51
|
+
* its own line, terminated by a newline. Keeping the string form derived from
|
|
52
|
+
* the statement array means the two can never drift — the boot-time applier and
|
|
53
|
+
* the generated `policies.sql` emit the same SQL, from the same source.
|
|
54
|
+
*/
|
|
55
|
+
const statementsToDdl = (statements: string[]): string => statements.map(s => `${s}\n`).join("");
|
|
56
|
+
|
|
57
|
+
const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string =>
|
|
58
|
+
statementsToDdl(generatePolicyStatements(collection, rule, resolveCollection));
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The individual SQL statements a single security rule compiles to: a
|
|
62
|
+
* `DROP POLICY IF EXISTS` / `CREATE POLICY` pair per operation, each a complete
|
|
63
|
+
* statement (terminated by `;`, no trailing newline).
|
|
64
|
+
*
|
|
65
|
+
* This is the primitive the boot-time RLS applier runs one statement at a time
|
|
66
|
+
* (the runtime's DB handle speaks the extended query protocol, which forbids
|
|
67
|
+
* multiple commands in one execute), while `db push` writes the joined string.
|
|
68
|
+
*/
|
|
69
|
+
export const generatePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string[] => {
|
|
49
70
|
const tableName = getTableName(collection);
|
|
50
71
|
const ops: readonly SecurityOperation[] = rule.operations && rule.operations.length > 0
|
|
51
72
|
? rule.operations
|
|
@@ -53,12 +74,12 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
|
|
|
53
74
|
|
|
54
75
|
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
55
76
|
|
|
56
|
-
return ops.
|
|
57
|
-
return
|
|
58
|
-
})
|
|
77
|
+
return ops.flatMap((op, opIdx) => {
|
|
78
|
+
return generateSinglePolicyStatements(collection, rule, op, policyNames[opIdx], resolveCollection);
|
|
79
|
+
});
|
|
59
80
|
};
|
|
60
81
|
|
|
61
|
-
const
|
|
82
|
+
const generateSinglePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string[] => {
|
|
62
83
|
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
63
84
|
const tableName = getTableName(collection);
|
|
64
85
|
const mode = (rule.mode ?? "permissive").toUpperCase();
|
|
@@ -83,11 +104,12 @@ const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRul
|
|
|
83
104
|
withCheckClause = "false";
|
|
84
105
|
}
|
|
85
106
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (usingClause)
|
|
89
|
-
if (withCheckClause)
|
|
90
|
-
|
|
107
|
+
const drop = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";`;
|
|
108
|
+
let create = `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
|
|
109
|
+
if (usingClause) create += ` USING (${usingClause})`;
|
|
110
|
+
if (withCheckClause) create += ` WITH CHECK (${withCheckClause})`;
|
|
111
|
+
create += ";";
|
|
112
|
+
return [drop, create];
|
|
91
113
|
};
|
|
92
114
|
|
|
93
115
|
export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
|
|
@@ -103,11 +125,15 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
|
|
|
103
125
|
if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
|
|
104
126
|
return "UUID";
|
|
105
127
|
}
|
|
128
|
+
// Width comes from `validation.max` when the property states one.
|
|
129
|
+
// It used to be a hardcoded 255 here and *absent* on the Drizzle
|
|
130
|
+
// path, so the same property produced a bounded column down one
|
|
131
|
+
// generator and an unbounded one down the other.
|
|
106
132
|
if (stringProp.columnType === "char") {
|
|
107
|
-
return
|
|
133
|
+
return `CHAR(${resolveStringColumnLength(stringProp)})`;
|
|
108
134
|
}
|
|
109
135
|
if (stringProp.columnType === "varchar") {
|
|
110
|
-
return
|
|
136
|
+
return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
|
|
111
137
|
}
|
|
112
138
|
// `text` is the default. The two generators disagreed here before:
|
|
113
139
|
// this one emitted VARCHAR(255) while the drizzle path emitted a bare
|
|
@@ -470,6 +496,67 @@ export const generatePostgresDdl = async (
|
|
|
470
496
|
return ddl;
|
|
471
497
|
};
|
|
472
498
|
|
|
499
|
+
/** The RLS statements one declared collection's table needs, ready to run. */
|
|
500
|
+
export interface CollectionPolicyPlan {
|
|
501
|
+
/** The table's schema (e.g. `public`, `rebase`). */
|
|
502
|
+
schema: string;
|
|
503
|
+
/** The bare table name, no schema prefix. */
|
|
504
|
+
table: string;
|
|
505
|
+
/** `schema.table` — matches the keys `readExistingSchema` returns. */
|
|
506
|
+
qualified: string;
|
|
507
|
+
/** `ALTER TABLE … ENABLE ROW LEVEL SECURITY;` — locked by default. */
|
|
508
|
+
enableRls: string;
|
|
509
|
+
/** `DROP POLICY IF EXISTS` / `CREATE POLICY` statements, in order. */
|
|
510
|
+
policyStatements: string[];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The per-table RLS plan for the *declared* collections, as executable
|
|
515
|
+
* statements — what the managed runtime applies at boot so a freshly
|
|
516
|
+
* provisioned tenant database serves data instead of 401ing every read.
|
|
517
|
+
*
|
|
518
|
+
* Mirrors the non-junction half of {@link generatePostgresPoliciesDdl} exactly
|
|
519
|
+
* (same `generatePolicyStatements`, same enable-RLS, same effective rules), so
|
|
520
|
+
* boot and `db push` produce identical policies from identical collections.
|
|
521
|
+
*
|
|
522
|
+
* Junction tables are deliberately excluded: they are derived from `through`
|
|
523
|
+
* relations, not declared collections, and the boot-time *table* creator
|
|
524
|
+
* (`ensureCollectionTables`) does not create them either — enabling RLS on a
|
|
525
|
+
* table that boot never created would fail. Their RLS stays a `db push` /
|
|
526
|
+
* `db migrate` concern, which is where those tables get created in the first
|
|
527
|
+
* place. `db push` still applies junction policies via the string generator.
|
|
528
|
+
*/
|
|
529
|
+
export const planCollectionPolicies = (collections: CollectionConfig[]): CollectionPolicyPlan[] => {
|
|
530
|
+
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
531
|
+
const plans: CollectionPolicyPlan[] = [];
|
|
532
|
+
const seen = new Set<string>();
|
|
533
|
+
|
|
534
|
+
for (const collection of collections) {
|
|
535
|
+
const tableName = getTableName(collection);
|
|
536
|
+
if (!tableName) continue;
|
|
537
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
538
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
539
|
+
const qualified = `${schema}.${baseTableName}`;
|
|
540
|
+
if (seen.has(qualified)) continue;
|
|
541
|
+
seen.add(qualified);
|
|
542
|
+
|
|
543
|
+
const policyStatements: string[] = [];
|
|
544
|
+
for (const rule of getEffectiveSecurityRules(collection)) {
|
|
545
|
+
policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
plans.push({
|
|
549
|
+
schema,
|
|
550
|
+
table: baseTableName,
|
|
551
|
+
qualified,
|
|
552
|
+
enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
|
|
553
|
+
policyStatements
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return plans;
|
|
558
|
+
};
|
|
559
|
+
|
|
473
560
|
export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): string => {
|
|
474
561
|
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
475
562
|
|
|
@@ -97,6 +97,52 @@ export function extractCauseMessage(error: unknown): string | null {
|
|
|
97
97
|
return null;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Codes that mean "this connection will never work as configured".
|
|
102
|
+
*
|
|
103
|
+
* A wrong password or a database that does not exist is a settled fact about
|
|
104
|
+
* the connection string, not a transient fault — retrying produces the same
|
|
105
|
+
* answer forever.
|
|
106
|
+
*/
|
|
107
|
+
const UNRECOVERABLE_CONNECT_CODES = new Set([
|
|
108
|
+
"28P01", // invalid_password
|
|
109
|
+
"28000", // invalid_authorization_specification
|
|
110
|
+
"3D000", // invalid_catalog_name — the database does not exist
|
|
111
|
+
"42501" // insufficient_privilege
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
export interface ConnectFailure {
|
|
115
|
+
/** True when retrying cannot help: the connection string itself is wrong. */
|
|
116
|
+
fatal: boolean;
|
|
117
|
+
/** The deepest message available — the Postgres one where there is one. */
|
|
118
|
+
reason: string;
|
|
119
|
+
/** The `SQLSTATE`, when the failure came from Postgres rather than the socket. */
|
|
120
|
+
code?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Describe a failed connection attempt in terms a developer can act on.
|
|
125
|
+
*
|
|
126
|
+
* The error a caller catches is Drizzle's wrapper: its message is
|
|
127
|
+
* `Failed query: SELECT 1` and its stack runs through drizzle internals, while
|
|
128
|
+
* the sentence that says what is actually wrong — "password authentication
|
|
129
|
+
* failed for user …", "database … does not exist" — sits in `.cause`. Logging
|
|
130
|
+
* the wrapper, as the bootstrapper used to, tells a developer with a typo in
|
|
131
|
+
* their `DATABASE_URL` nothing at all.
|
|
132
|
+
*/
|
|
133
|
+
export function classifyConnectFailure(error: unknown): ConnectFailure {
|
|
134
|
+
const pgError = extractPgError(error);
|
|
135
|
+
const reason =
|
|
136
|
+
pgError?.message ??
|
|
137
|
+
extractCauseMessage(error) ??
|
|
138
|
+
(error instanceof Error ? error.message : String(error));
|
|
139
|
+
return {
|
|
140
|
+
fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
|
|
141
|
+
reason,
|
|
142
|
+
code: pgError?.code
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
100
146
|
/**
|
|
101
147
|
* Detect whether an error is specifically a role-switching permission failure
|
|
102
148
|
* (e.g. "permission denied to set role" or "must be member of role"),
|
package/dist/chunk-DSJWtz9O.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { createRequire as __createRequire } from "module";
|
|
2
|
-
import "process";
|
|
3
|
-
const require = __createRequire(import.meta.url);
|
|
4
|
-
//#region \0rolldown/runtime.js
|
|
5
|
-
var __create = Object.create;
|
|
6
|
-
var __defProp = Object.defineProperty;
|
|
7
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
8
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
9
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
10
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
11
|
-
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
12
|
-
var __exportAll = (all, no_symbols) => {
|
|
13
|
-
let target = {};
|
|
14
|
-
for (var name in all) __defProp(target, name, {
|
|
15
|
-
get: all[name],
|
|
16
|
-
enumerable: true
|
|
17
|
-
});
|
|
18
|
-
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
19
|
-
return target;
|
|
20
|
-
};
|
|
21
|
-
var __copyProps = (to, from, except, desc) => {
|
|
22
|
-
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
23
|
-
key = keys[i];
|
|
24
|
-
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
25
|
-
get: ((k) => from[k]).bind(null, key),
|
|
26
|
-
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
return to;
|
|
30
|
-
};
|
|
31
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
32
|
-
value: mod,
|
|
33
|
-
enumerable: true
|
|
34
|
-
}) : target, mod));
|
|
35
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
|
|
36
|
-
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
37
|
-
throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
|
|
38
|
-
});
|
|
39
|
-
//#endregion
|
|
40
|
-
export { __toESM as i, __exportAll as n, __require as r, __commonJSMin as t };
|