@rebasepro/server-postgres 0.9.1-canary.ad25bc0 → 0.9.1-canary.ad870eb
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 +2 -25
- package/dist/index.es.js +275 -656
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/services/FetchService.d.ts +2 -10
- package/dist/services/PersistService.d.ts +1 -27
- package/dist/services/collection-helpers.d.ts +15 -4
- package/dist/services/dataService.d.ts +1 -3
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +2 -88
- package/src/schema/auth-default-policies.ts +132 -0
- package/src/schema/generate-drizzle-schema-logic.ts +26 -4
- package/src/schema/generate-postgres-ddl-logic.ts +26 -14
- package/src/services/BranchService.ts +10 -42
- package/src/services/FetchService.ts +67 -41
- package/src/services/PersistService.ts +12 -121
- package/src/services/collection-helpers.ts +55 -9
- package/src/services/dataService.ts +2 -3
- package/src/websocket.ts +1 -4
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { CollectionConfig, SecurityRule } from "@rebasepro/types";
|
|
2
|
+
/**
|
|
3
|
+
* Returns the security rules that should be applied to a collection: the
|
|
4
|
+
* author's explicit `securityRules` plus the framework defaults described in
|
|
5
|
+
* the module doc (baseline server/admin read for all collections; self-read
|
|
6
|
+
* and the admin write gate for auth collections).
|
|
7
|
+
*
|
|
8
|
+
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[];
|
|
@@ -52,12 +52,10 @@ export declare class FetchService {
|
|
|
52
52
|
/**
|
|
53
53
|
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
54
54
|
* Handles:
|
|
55
|
+
* - Placing `id` at the top level as a string
|
|
55
56
|
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
56
57
|
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
57
58
|
* - Flattening junction-table many-to-many results
|
|
58
|
-
*
|
|
59
|
-
* The row's own address is not among them: it is derived by the consumer
|
|
60
|
-
* from the collection's primary keys.
|
|
61
59
|
*/
|
|
62
60
|
private drizzleResultToRow;
|
|
63
61
|
/**
|
|
@@ -77,13 +75,7 @@ export declare class FetchService {
|
|
|
77
75
|
*/
|
|
78
76
|
private resolveJoinPathRelationsBatchRest;
|
|
79
77
|
/**
|
|
80
|
-
* Convert a db.query result row to a flat REST-style
|
|
81
|
-
*
|
|
82
|
-
* Every column is copied through under its own name, with the value Postgres
|
|
83
|
-
* returned. This used to open with a synthesized `id` and then skip the key
|
|
84
|
-
* column, which renamed it (a `sku` primary key was served as `id`, and `sku`
|
|
85
|
-
* did not appear at all) and restringified it (`42` → `"42"`). Consumers that
|
|
86
|
-
* need an address derive it from the collection's primary keys.
|
|
78
|
+
* Convert a db.query result row to a flat REST-style object with populated relations.
|
|
87
79
|
*/
|
|
88
80
|
private drizzleResultToRestRow;
|
|
89
81
|
/**
|
|
@@ -12,24 +12,6 @@ export declare class PersistService {
|
|
|
12
12
|
private relationService;
|
|
13
13
|
private fetchService;
|
|
14
14
|
constructor(db: DrizzleClient, registry: PostgresCollectionRegistry);
|
|
15
|
-
/**
|
|
16
|
-
* Explain a write that matched no rows.
|
|
17
|
-
*
|
|
18
|
-
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
19
|
-
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
20
|
-
* like a successful one that happened to match nothing. Left unchecked, a
|
|
21
|
-
* caller cannot tell "denied" from "done" — the write returns 200/204 and
|
|
22
|
-
* the row is untouched.
|
|
23
|
-
*
|
|
24
|
-
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
25
|
-
* cases. A visible row means the policy rejected the write (403); an
|
|
26
|
-
* invisible one means there is nothing there to write for this caller (404,
|
|
27
|
-
* matching what a GET would say). The re-read is bound by the caller's own
|
|
28
|
-
* policies, so it discloses nothing a plain read wouldn't.
|
|
29
|
-
*
|
|
30
|
-
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
31
|
-
*/
|
|
32
|
-
private explainZeroRowWrite;
|
|
33
15
|
/**
|
|
34
16
|
* Delete an row by ID
|
|
35
17
|
*/
|
|
@@ -40,16 +22,8 @@ export declare class PersistService {
|
|
|
40
22
|
deleteAll(collectionPath: string, _databaseId?: string): Promise<void>;
|
|
41
23
|
/**
|
|
42
24
|
* Save an row (create or update)
|
|
43
|
-
*
|
|
44
|
-
* With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
|
|
45
|
-
* UPDATE against the primary key rather than a plain UPDATE. That is one
|
|
46
|
-
* statement, so it cannot lose a race the way a read-then-write can, and it
|
|
47
|
-
* does not care whether the row already exists — which is what a re-runnable
|
|
48
|
-
* import needs.
|
|
49
25
|
*/
|
|
50
|
-
save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string,
|
|
51
|
-
upsert?: boolean;
|
|
52
|
-
}): Promise<Record<string, unknown>>;
|
|
26
|
+
save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string): Promise<Record<string, unknown>>;
|
|
53
27
|
/**
|
|
54
28
|
* Get the RelationService instance for external use
|
|
55
29
|
*/
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
2
2
|
import { CollectionConfig } from "@rebasepro/types";
|
|
3
3
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
4
|
-
export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
|
|
5
|
-
export type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
6
|
-
import type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
7
4
|
/**
|
|
8
5
|
* Shared helper functions for row operations.
|
|
9
6
|
* These are used by FetchService, PersistService, and RelationService.
|
|
@@ -24,4 +21,18 @@ export interface DrizzleColumnMeta {
|
|
|
24
21
|
export declare function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta;
|
|
25
22
|
export declare function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig;
|
|
26
23
|
export declare function getTableForCollection(collection: CollectionConfig, registry: PostgresCollectionRegistry): PgTable<any>;
|
|
27
|
-
export declare function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry):
|
|
24
|
+
export declare function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): {
|
|
25
|
+
fieldName: string;
|
|
26
|
+
type: "string" | "number";
|
|
27
|
+
isUUID?: boolean;
|
|
28
|
+
}[];
|
|
29
|
+
export declare function parseIdValues(idValue: string | number, primaryKeys: {
|
|
30
|
+
fieldName: string;
|
|
31
|
+
type: "string" | "number";
|
|
32
|
+
isUUID?: boolean;
|
|
33
|
+
}[]): Record<string, string | number>;
|
|
34
|
+
export declare function buildCompositeId(values: Record<string, unknown>, primaryKeys: {
|
|
35
|
+
fieldName: string;
|
|
36
|
+
type: "string" | "number";
|
|
37
|
+
isUUID?: boolean;
|
|
38
|
+
}[]): string;
|
|
@@ -82,9 +82,7 @@ export declare class DataService implements DataRepository {
|
|
|
82
82
|
/**
|
|
83
83
|
* Save an row (create or update)
|
|
84
84
|
*/
|
|
85
|
-
save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string,
|
|
86
|
-
upsert?: boolean;
|
|
87
|
-
}): Promise<Record<string, unknown>>;
|
|
85
|
+
save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string): Promise<Record<string, unknown>>;
|
|
88
86
|
/**
|
|
89
87
|
* Delete an row by ID
|
|
90
88
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgres",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.9.1-canary.
|
|
4
|
+
"version": "0.9.1-canary.ad870eb",
|
|
5
5
|
"description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -69,13 +69,14 @@
|
|
|
69
69
|
"dotenv": "^17.4.2",
|
|
70
70
|
"drizzle-orm": "^0.45.2",
|
|
71
71
|
"execa": "^9.6.1",
|
|
72
|
+
"hono": "^4.12.25",
|
|
72
73
|
"pg": "^8.21.0",
|
|
73
74
|
"ws": "^8.21.0",
|
|
74
|
-
"@rebasepro/
|
|
75
|
-
"@rebasepro/
|
|
76
|
-
"@rebasepro/
|
|
77
|
-
"@rebasepro/
|
|
78
|
-
"@rebasepro/
|
|
75
|
+
"@rebasepro/common": "0.9.1-canary.ad870eb",
|
|
76
|
+
"@rebasepro/server": "0.9.1-canary.ad870eb",
|
|
77
|
+
"@rebasepro/codegen": "0.9.1-canary.ad870eb",
|
|
78
|
+
"@rebasepro/utils": "0.9.1-canary.ad870eb",
|
|
79
|
+
"@rebasepro/types": "0.9.1-canary.ad870eb"
|
|
79
80
|
},
|
|
80
81
|
"devDependencies": {
|
|
81
82
|
"@types/jest": "^30.0.0",
|
|
@@ -99,7 +100,7 @@
|
|
|
99
100
|
],
|
|
100
101
|
"scripts": {
|
|
101
102
|
"watch": "vite build --watch",
|
|
102
|
-
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json
|
|
103
|
+
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
|
|
103
104
|
"test:lint": "eslint \"src/**\" --quiet",
|
|
104
105
|
"test": "jest --passWithNoTests",
|
|
105
106
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
RebaseData,
|
|
18
18
|
RebaseSdkData,
|
|
19
19
|
RestFetchService,
|
|
20
|
-
SaveManyProps,
|
|
21
20
|
SaveProps,
|
|
22
21
|
TableColumnInfo,
|
|
23
22
|
TableForeignKeyInfo,
|
|
@@ -533,8 +532,7 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
533
532
|
id,
|
|
534
533
|
values,
|
|
535
534
|
collection,
|
|
536
|
-
status
|
|
537
|
-
upsert
|
|
535
|
+
status
|
|
538
536
|
}: SaveProps<M>): Promise<Record<string, unknown>> {
|
|
539
537
|
|
|
540
538
|
const {
|
|
@@ -618,8 +616,7 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
618
616
|
path,
|
|
619
617
|
updatedValues,
|
|
620
618
|
id,
|
|
621
|
-
resolvedCollection?.databaseId
|
|
622
|
-
{ upsert }
|
|
619
|
+
resolvedCollection?.databaseId
|
|
623
620
|
);
|
|
624
621
|
|
|
625
622
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
@@ -767,76 +764,6 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
767
764
|
}
|
|
768
765
|
}
|
|
769
766
|
|
|
770
|
-
/**
|
|
771
|
-
* Write many rows through the same pipeline as {@link save}.
|
|
772
|
-
*
|
|
773
|
-
* The batch runs in one transaction of its own, so a failure part-way leaves
|
|
774
|
-
* nothing behind — the point of a batch is that a re-run starts from a known
|
|
775
|
-
* state. When this driver is already inside a transaction (the authenticated
|
|
776
|
-
* path, via `withTransaction`) the nested call becomes a savepoint, which is
|
|
777
|
-
* still atomic and still commits once.
|
|
778
|
-
*
|
|
779
|
-
* Rows are applied in order, so a batch that touches the same key twice ends
|
|
780
|
-
* with the last write winning, exactly as separate calls would.
|
|
781
|
-
*/
|
|
782
|
-
async saveMany<M extends Record<string, unknown>>({
|
|
783
|
-
path,
|
|
784
|
-
rows,
|
|
785
|
-
collection,
|
|
786
|
-
upsert
|
|
787
|
-
}: SaveManyProps<M>): Promise<Record<string, unknown>[]> {
|
|
788
|
-
return this.db.transaction(async (tx) => {
|
|
789
|
-
// Bind the whole batch to the transaction handle. Without this the
|
|
790
|
-
// rows would be written through `this.db` and survive a rollback.
|
|
791
|
-
const txDriver = new PostgresBackendDriver(
|
|
792
|
-
tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService
|
|
793
|
-
);
|
|
794
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
795
|
-
txDriver.client = this.client;
|
|
796
|
-
// Carry the caller's notification batching through, so a bulk write
|
|
797
|
-
// nested in an outer transaction still holds its events until commit.
|
|
798
|
-
txDriver._deferNotifications = this._deferNotifications;
|
|
799
|
-
txDriver._pendingNotifications = this._pendingNotifications;
|
|
800
|
-
|
|
801
|
-
const saved: Record<string, unknown>[] = [];
|
|
802
|
-
|
|
803
|
-
for (let i = 0; i < rows.length; i++) {
|
|
804
|
-
const values = rows[i];
|
|
805
|
-
const id = (values as Record<string, unknown>)?.id as string | number | undefined;
|
|
806
|
-
try {
|
|
807
|
-
saved.push(await txDriver.save<M>({
|
|
808
|
-
path,
|
|
809
|
-
values,
|
|
810
|
-
// No `id` argument, deliberately: passing one selects the
|
|
811
|
-
// UPDATE path, and an import's rows usually carry a natural
|
|
812
|
-
// key for a row that does not exist yet — which would 404 on
|
|
813
|
-
// every one. Leaving the key inside `values` is what
|
|
814
|
-
// single-row `create(data, id)` does, and it inserts.
|
|
815
|
-
// Callers who want existing rows overwritten pass `upsert`.
|
|
816
|
-
collection,
|
|
817
|
-
status: "new",
|
|
818
|
-
upsert
|
|
819
|
-
}));
|
|
820
|
-
} catch (error) {
|
|
821
|
-
// One bad row in ten thousand is impossible to find from a
|
|
822
|
-
// message that only says the batch failed. Say which row, and
|
|
823
|
-
// keep the original error as the cause so its status survives.
|
|
824
|
-
const label = id !== undefined ? `id ${JSON.stringify(id)}` : "no id";
|
|
825
|
-
throw Object.assign(
|
|
826
|
-
new Error(`Row ${i} of ${rows.length} (${label}) failed: ${(error as Error)?.message ?? error}`, { cause: error }),
|
|
827
|
-
{
|
|
828
|
-
statusCode: (error as { statusCode?: number })?.statusCode,
|
|
829
|
-
code: (error as { code?: string })?.code,
|
|
830
|
-
name: (error as Error)?.name
|
|
831
|
-
}
|
|
832
|
-
);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
return saved;
|
|
837
|
-
});
|
|
838
|
-
}
|
|
839
|
-
|
|
840
767
|
async delete<M extends Record<string, unknown>>({
|
|
841
768
|
row,
|
|
842
769
|
collection
|
|
@@ -1435,19 +1362,6 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
|
|
|
1435
1362
|
return this.withTransaction((delegate) => delegate.save(props));
|
|
1436
1363
|
}
|
|
1437
1364
|
|
|
1438
|
-
/**
|
|
1439
|
-
* One transaction for the whole batch, rather than one per row.
|
|
1440
|
-
*
|
|
1441
|
-
* This is the point of the method: `save` opens a transaction per call, so
|
|
1442
|
-
* importing 10k rows through it means 10k transactions (and, over HTTP, 10k
|
|
1443
|
-
* round trips). Here the RLS context is established once and every row lands
|
|
1444
|
-
* or none does. Realtime notifications are already deferred to commit by
|
|
1445
|
-
* `withTransaction`, so a batch does not flood subscribers mid-flight.
|
|
1446
|
-
*/
|
|
1447
|
-
async saveMany<M extends Record<string, unknown>>(props: SaveManyProps<M>): Promise<Record<string, unknown>[]> {
|
|
1448
|
-
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
1365
|
async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
|
|
1452
1366
|
return this.withTransaction((delegate) => delegate.delete(props));
|
|
1453
1367
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from "@rebasepro/types";
|
|
2
|
+
import { getTableName } from "@rebasepro/common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default RLS policies injected by the schema generator.
|
|
6
|
+
*
|
|
7
|
+
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
8
|
+
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
9
|
+
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
10
|
+
* authorization model. The server context (auth flows, migrations,
|
|
11
|
+
* `dataAsAdmin`) runs as the owner and bypasses RLS.
|
|
12
|
+
*
|
|
13
|
+
* Because RLS default-denies, every collection is **locked by default**: with
|
|
14
|
+
* no rules, only the server context and admins can touch it. The generator
|
|
15
|
+
* injects that safe baseline:
|
|
16
|
+
*
|
|
17
|
+
* **For every collection**
|
|
18
|
+
* 1. A permissive **server-or-admin SELECT** grant.
|
|
19
|
+
* 2. A permissive **server-or-admin write** grant (insert/update/delete).
|
|
20
|
+
*
|
|
21
|
+
* Author `securityRules` are permissive and OR together, so explicit rules only
|
|
22
|
+
* *broaden* access from this locked baseline (e.g. "users read/write their own
|
|
23
|
+
* rows").
|
|
24
|
+
*
|
|
25
|
+
* **For auth collections additionally**
|
|
26
|
+
* 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
|
|
27
|
+
* their own row (profile, session bootstrap) without every app re-declaring
|
|
28
|
+
* it.
|
|
29
|
+
* 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
|
|
30
|
+
* every other policy, so a write is rejected unless the caller is an admin
|
|
31
|
+
* (or the server context) — even if the author also wrote a permissive rule
|
|
32
|
+
* such as "a user may edit their own row". Without this, a permissive owner
|
|
33
|
+
* rule would let a user change their own `roles`.
|
|
34
|
+
*
|
|
35
|
+
* The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
|
|
36
|
+
* — the built-in flows that run without a user (signup, migrations) set no user
|
|
37
|
+
* GUC — which also lets the owner connection satisfy these policies even under
|
|
38
|
+
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
39
|
+
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
40
|
+
*
|
|
41
|
+
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
42
|
+
* the collection's RLS.
|
|
43
|
+
*/
|
|
44
|
+
// Expressed structurally (not as raw SQL) so the admin UI can evaluate it
|
|
45
|
+
// exactly — the framework's most security-critical policies must be reflected
|
|
46
|
+
// precisely, not left as un-evaluable raw clauses. Compiles to
|
|
47
|
+
// `auth.uid() IS NULL OR (string_to_array(auth.roles(), ',') && ARRAY['admin'])`.
|
|
48
|
+
//
|
|
49
|
+
// `serverContext()`, emphatically not `not(authenticated())`: the server arm of
|
|
50
|
+
// this grant must match the server context and nothing else. Anonymous visitors
|
|
51
|
+
// are not signed in either, so a negated `authenticated()` would hand them the
|
|
52
|
+
// server-or-admin grant on every collection's default policy.
|
|
53
|
+
const SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(
|
|
54
|
+
policy.serverContext(),
|
|
55
|
+
policy.rolesOverlap(["admin"])
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/** Write operations that must be admin-gated by default on auth collections. */
|
|
59
|
+
const DEFAULT_GUARDED_OPS: SecurityOperation[] = ["insert", "update", "delete"];
|
|
60
|
+
|
|
61
|
+
/** Whether a collection is flagged as an authentication collection. */
|
|
62
|
+
function isAuthCollection(collection: CollectionConfig): boolean {
|
|
63
|
+
const auth = collection.auth;
|
|
64
|
+
return auth === true || (typeof auth === "object" && (auth as AuthCollectionConfig)?.enabled === true);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The property marked as the row id (falls back to `id`). */
|
|
68
|
+
function getIdPropertyName(collection: CollectionConfig): string {
|
|
69
|
+
for (const [name, prop] of Object.entries(collection.properties ?? {})) {
|
|
70
|
+
if (prop && typeof prop === "object" && "isId" in prop && (prop as { isId?: unknown }).isId) {
|
|
71
|
+
return name;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return "id";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Returns the security rules that should be applied to a collection: the
|
|
79
|
+
* author's explicit `securityRules` plus the framework defaults described in
|
|
80
|
+
* the module doc (baseline server/admin read for all collections; self-read
|
|
81
|
+
* and the admin write gate for auth collections).
|
|
82
|
+
*
|
|
83
|
+
* Collections that opt out via `disableDefaultPolicies` are returned unchanged.
|
|
84
|
+
*/
|
|
85
|
+
export function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[] {
|
|
86
|
+
const explicit = [...((isPostgresCollectionConfig(collection) ? collection.securityRules : undefined) ?? [])];
|
|
87
|
+
|
|
88
|
+
if (collection.disableDefaultPolicies) {
|
|
89
|
+
return explicit;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const tableName = getTableName(collection);
|
|
93
|
+
const injected: SecurityRule[] = [];
|
|
94
|
+
|
|
95
|
+
// Baseline read + write: the server context and admins can always operate.
|
|
96
|
+
// RLS default-denies under the user role, so without these a rule-less
|
|
97
|
+
// collection would be locked to everyone — including the admin studio.
|
|
98
|
+
// Author rules are permissive and broaden access from here.
|
|
99
|
+
injected.push({
|
|
100
|
+
name: `${tableName}_default_admin_read`,
|
|
101
|
+
operations: ["select"],
|
|
102
|
+
condition: SERVER_OR_ADMIN_EXPR
|
|
103
|
+
});
|
|
104
|
+
injected.push({
|
|
105
|
+
name: `${tableName}_default_admin_write`,
|
|
106
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
107
|
+
condition: SERVER_OR_ADMIN_EXPR,
|
|
108
|
+
check: SERVER_OR_ADMIN_EXPR
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (isAuthCollection(collection)) {
|
|
112
|
+
// Self-read: a user can always read their own row.
|
|
113
|
+
injected.push({
|
|
114
|
+
name: `${tableName}_default_self_read`,
|
|
115
|
+
operations: ["select"],
|
|
116
|
+
condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Restrictive gate: AND'd with all other policies, so no permissive rule
|
|
120
|
+
// (e.g. an owner "edit your own row" rule) can let a non-admin change
|
|
121
|
+
// privileged columns like `roles`.
|
|
122
|
+
injected.push({
|
|
123
|
+
name: `${tableName}_require_admin_write`,
|
|
124
|
+
mode: "restrictive",
|
|
125
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
126
|
+
condition: SERVER_OR_ADMIN_EXPR,
|
|
127
|
+
check: SERVER_OR_ADMIN_EXPR
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return [...explicit, ...injected];
|
|
132
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
|
|
2
2
|
import { getPrimaryKeys } from "../services/collection-helpers";
|
|
3
|
-
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres
|
|
4
|
-
import { toSnakeCase
|
|
3
|
+
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
|
|
4
|
+
import { toSnakeCase } from "@rebasepro/utils";
|
|
5
|
+
import { createHash } from "crypto";
|
|
5
6
|
import { logger } from "@rebasepro/server";
|
|
7
|
+
import { getEffectiveSecurityRules } from "./auth-default-policies";
|
|
6
8
|
// --- Helper Functions ---
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -313,6 +315,22 @@ const wrapSql = (clause: string): string => `sql\`${clause}\``;
|
|
|
313
315
|
/**
|
|
314
316
|
* Generates a deterministic hash based on the rule configuration.
|
|
315
317
|
*/
|
|
318
|
+
const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
319
|
+
const data = JSON.stringify({
|
|
320
|
+
a: rule.access,
|
|
321
|
+
m: rule.mode,
|
|
322
|
+
op: rule.operation,
|
|
323
|
+
ops: rule.operations?.slice().sort(),
|
|
324
|
+
own: rule.ownerField,
|
|
325
|
+
rol: rule.roles?.slice().sort(),
|
|
326
|
+
pg: rule.pgRoles?.slice().sort(),
|
|
327
|
+
u: rule.using,
|
|
328
|
+
w: rule.withCheck,
|
|
329
|
+
c: rule.condition,
|
|
330
|
+
ch: rule.check
|
|
331
|
+
});
|
|
332
|
+
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
333
|
+
};
|
|
316
334
|
|
|
317
335
|
/**
|
|
318
336
|
* Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
|
|
@@ -333,11 +351,15 @@ const generatePolicyCode = (collection: CollectionConfig, rule: SecurityRule, in
|
|
|
333
351
|
? rule.operations
|
|
334
352
|
: [rule.operation ?? "all"];
|
|
335
353
|
|
|
336
|
-
const
|
|
354
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
337
355
|
|
|
338
356
|
// Generate one pgPolicy per operation
|
|
339
357
|
return ops.map((op, opIdx) => {
|
|
340
|
-
|
|
358
|
+
const policyName = rule.name
|
|
359
|
+
? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
|
|
360
|
+
: `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
|
|
361
|
+
|
|
362
|
+
return generateSinglePolicyCode(collection, rule, op, policyName, resolveCollection);
|
|
341
363
|
}).join("");
|
|
342
364
|
};
|
|
343
365
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
|
|
2
|
-
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres
|
|
3
|
-
import { toSnakeCase
|
|
2
|
+
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
|
|
3
|
+
import { toSnakeCase } from "@rebasepro/utils";
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
import { getEffectiveSecurityRules } from "./auth-default-policies";
|
|
4
6
|
|
|
5
7
|
// --- Helper Functions ---
|
|
6
8
|
|
|
@@ -42,6 +44,22 @@ const isIdProperty = (propName: string, prop: Property, collection: CollectionCo
|
|
|
42
44
|
return !hasExplicitId && propName === "id";
|
|
43
45
|
};
|
|
44
46
|
|
|
47
|
+
const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
48
|
+
const data = JSON.stringify({
|
|
49
|
+
a: rule.access,
|
|
50
|
+
m: rule.mode,
|
|
51
|
+
op: rule.operation,
|
|
52
|
+
ops: rule.operations?.slice().sort(),
|
|
53
|
+
own: rule.ownerField,
|
|
54
|
+
rol: rule.roles?.slice().sort(),
|
|
55
|
+
pg: rule.pgRoles?.slice().sort(),
|
|
56
|
+
u: rule.using,
|
|
57
|
+
w: rule.withCheck,
|
|
58
|
+
c: rule.condition,
|
|
59
|
+
ch: rule.check
|
|
60
|
+
});
|
|
61
|
+
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
62
|
+
};
|
|
45
63
|
|
|
46
64
|
type ResolveCollection = (slug: string) => CollectionConfig | undefined;
|
|
47
65
|
|
|
@@ -51,10 +69,14 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
|
|
|
51
69
|
? rule.operations
|
|
52
70
|
: [rule.operation ?? "all"];
|
|
53
71
|
|
|
54
|
-
const
|
|
72
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
55
73
|
|
|
56
74
|
return ops.map((op, opIdx) => {
|
|
57
|
-
|
|
75
|
+
const policyName = rule.name
|
|
76
|
+
? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
|
|
77
|
+
: `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
|
|
78
|
+
|
|
79
|
+
return generateSinglePolicyDdl(collection, rule, op, policyName, resolveCollection);
|
|
58
80
|
}).join("");
|
|
59
81
|
};
|
|
60
82
|
|
|
@@ -456,17 +478,7 @@ export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): st
|
|
|
456
478
|
const securityRules = getEffectiveSecurityRules(collection);
|
|
457
479
|
if (securityRules.length > 0) {
|
|
458
480
|
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
459
|
-
const injectedNames = new Set(getInjectedSecurityRules(collection).map((rule) => rule.name));
|
|
460
|
-
|
|
461
481
|
securityRules.forEach((rule: SecurityRule) => {
|
|
462
|
-
// Say which policies the author did not write. They are permissive,
|
|
463
|
-
// so they OR with the declared rules and widen the final ACL beyond
|
|
464
|
-
// what `securityRules` reads like — and re-appear after any manual
|
|
465
|
-
// DROP, because a push asserts the declared state.
|
|
466
|
-
if (rule.name && injectedNames.has(rule.name)) {
|
|
467
|
-
ddl += `-- Injected by Rebase (not from this collection's securityRules).\n`;
|
|
468
|
-
ddl += `-- Set \`disableDefaultPolicies: true\` on "${collection.slug}" to drop these and own its RLS outright.\n`;
|
|
469
|
-
}
|
|
470
482
|
ddl += generatePolicyDdl(collection, rule, resolveCollection);
|
|
471
483
|
});
|
|
472
484
|
ddl += "\n";
|
|
@@ -11,45 +11,10 @@ import { sql } from "drizzle-orm";
|
|
|
11
11
|
import { BranchInfo } from "@rebasepro/types";
|
|
12
12
|
import { DrizzleClient } from "../interfaces";
|
|
13
13
|
import { DatabasePoolManager } from "../databasePoolManager";
|
|
14
|
-
import { extractPgError, extractCauseMessage } from "../utils/pg-error-utils";
|
|
15
14
|
|
|
16
15
|
/** Internal prefix applied to branch database names to avoid collisions. */
|
|
17
16
|
const BRANCH_DB_PREFIX = "rb_";
|
|
18
17
|
|
|
19
|
-
/** `duplicate_database` — the target database name is already taken. */
|
|
20
|
-
const PG_DUPLICATE_DATABASE = "42P04";
|
|
21
|
-
|
|
22
|
-
/** `object_in_use` — the database still has connections attached. */
|
|
23
|
-
const PG_OBJECT_IN_USE = "55006";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Describe a failed branch DDL statement in terms a user can act on.
|
|
27
|
-
*
|
|
28
|
-
* Drizzle reports failures as `Failed query: <sql> params:` and hides the real
|
|
29
|
-
* PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
|
|
30
|
-
* the actual problem. Match on the PG error code instead — it survives wrapping
|
|
31
|
-
* and, unlike the message text, is not locale-dependent.
|
|
32
|
-
*/
|
|
33
|
-
function describeBranchDdlError(err: unknown, fallbackContext: string): Error {
|
|
34
|
-
const pgError = extractPgError(err);
|
|
35
|
-
|
|
36
|
-
if (pgError?.code === PG_DUPLICATE_DATABASE) {
|
|
37
|
-
return new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
|
|
38
|
-
}
|
|
39
|
-
if (pgError?.code === PG_OBJECT_IN_USE) {
|
|
40
|
-
return new Error(
|
|
41
|
-
`Cannot complete the operation: the database "${fallbackContext}" has active connections. ` +
|
|
42
|
-
"Close other clients or connections and try again."
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Unknown failure: surface the real PG message rather than the Drizzle
|
|
47
|
-
// wrapper, which would otherwise show the raw SQL and no reason at all.
|
|
48
|
-
const detail = pgError?.message ?? extractCauseMessage(err);
|
|
49
|
-
if (detail) return new Error(detail);
|
|
50
|
-
return err instanceof Error ? err : new Error(String(err));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
18
|
/** Fully-qualified metadata table in the rebase schema. */
|
|
54
19
|
const BRANCHES_TABLE = "rebase.branches";
|
|
55
20
|
|
|
@@ -144,15 +109,18 @@ export class BranchService {
|
|
|
144
109
|
sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`)
|
|
145
110
|
);
|
|
146
111
|
} catch (err) {
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
|
|
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")) {
|
|
150
118
|
throw new Error(
|
|
151
119
|
`Cannot create branch: the source database "${sourceDb}" has active connections. ` +
|
|
152
120
|
"Close other clients or connections and try again."
|
|
153
121
|
);
|
|
154
122
|
}
|
|
155
|
-
throw
|
|
123
|
+
throw err;
|
|
156
124
|
}
|
|
157
125
|
|
|
158
126
|
// Record metadata in the default database
|
|
@@ -198,14 +166,14 @@ export class BranchService {
|
|
|
198
166
|
try {
|
|
199
167
|
await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
|
|
200
168
|
} catch (err) {
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
169
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
170
|
+
if (msg.includes("being accessed by other users")) {
|
|
203
171
|
throw new Error(
|
|
204
172
|
`Cannot delete branch "${sanitizedName}": the database has active connections. ` +
|
|
205
173
|
"Close other clients and try again."
|
|
206
174
|
);
|
|
207
175
|
}
|
|
208
|
-
throw
|
|
176
|
+
throw err;
|
|
209
177
|
}
|
|
210
178
|
|
|
211
179
|
// Remove metadata
|