@rebasepro/common 0.13.0 → 0.13.1-canary.g06dbe5b
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/data/buildRebaseData.d.ts +10 -1
- package/dist/data/filter-conditions.d.ts +34 -0
- package/dist/data/filter-dialect.d.ts +19 -3
- package/dist/data/paginate.d.ts +20 -0
- package/dist/data/query_builder.d.ts +16 -4
- package/dist/data/resolveDataSource.d.ts +36 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +622 -92
- package/dist/index.es.js.map +1 -1
- package/dist/util/builders.d.ts +2 -2
- package/dist/util/collections.d.ts +17 -0
- package/dist/util/conditions.d.ts +7 -3
- package/dist/util/entities.d.ts +8 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/internal-tables.d.ts +95 -0
- package/dist/util/permissions.d.ts +30 -0
- package/dist/util/policy/sqlToPolicy.d.ts +4 -4
- package/dist/util/relations.d.ts +18 -1
- package/dist/util/resolutions.d.ts +31 -0
- package/package.json +4 -3
- package/src/data/buildRebaseData.ts +161 -27
- package/src/data/filter-conditions.ts +46 -0
- package/src/data/filter-dialect.ts +110 -37
- package/src/data/paginate.ts +30 -0
- package/src/data/query_builder.ts +26 -4
- package/src/data/resolveDataSource.ts +56 -0
- package/src/index.ts +1 -0
- package/src/util/auth-default-policies.ts +8 -2
- package/src/util/builders.ts +3 -3
- package/src/util/collections.ts +17 -1
- package/src/util/conditions.ts +8 -3
- package/src/util/entities.ts +15 -1
- package/src/util/index.ts +1 -0
- package/src/util/internal-tables.ts +154 -0
- package/src/util/permissions.test.ts +23 -2
- package/src/util/permissions.ts +43 -6
- package/src/util/policy/evaluatePolicy.ts +24 -2
- package/src/util/policy/policyToPostgres.ts +23 -10
- package/src/util/policy/sqlToPolicy.ts +127 -26
- package/src/util/relations.ts +31 -0
- package/src/util/resolutions.ts +77 -5
package/src/data/paginate.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DEFAULT_LIST_LIMIT,
|
|
2
3
|
FilterValues,
|
|
3
4
|
FieldPath,
|
|
4
5
|
FindAllParams,
|
|
@@ -70,6 +71,35 @@ export class RebasePaginationError extends Error {
|
|
|
70
71
|
export type PageFinder<M extends Record<string, unknown> = Record<string, unknown>> =
|
|
71
72
|
(params: FindParams<M>) => Promise<FindResult<M>>;
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Resolve `limit`/`offset`/`page` into the window a read will actually use.
|
|
76
|
+
*
|
|
77
|
+
* Lives here, next to the walk, for the reason at the top of this file: every
|
|
78
|
+
* transport has to mean the same thing by "page two". Four of them did not —
|
|
79
|
+
* the REST layer strode by {@link DEFAULT_LIST_LIMIT}, the local-first
|
|
80
|
+
* evaluator by {@link DEFAULT_PAGE_SIZE}, the in-process accessor by 20, and
|
|
81
|
+
* the published type documented a fourth number. Pages that overlap or skip
|
|
82
|
+
* rows are the mildest of those outcomes.
|
|
83
|
+
*
|
|
84
|
+
* `page` wins over `offset`, as {@link FindParams} documents. `driverOffset`
|
|
85
|
+
* is the value to hand a driver: it stays `undefined` when the caller named no
|
|
86
|
+
* offset, because keyset pagination seeks with a `where` clause and must not
|
|
87
|
+
* look like it is paging by offset.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveFindWindow(
|
|
90
|
+
params?: Pick<FindParams, "limit" | "offset" | "page">
|
|
91
|
+
): { limit: number; offset: number; driverOffset: number | undefined } {
|
|
92
|
+
const limit = params?.limit ?? DEFAULT_LIST_LIMIT;
|
|
93
|
+
const offset = params?.page != null
|
|
94
|
+
? Math.max(0, (params.page - 1) * limit)
|
|
95
|
+
: (params?.offset ?? 0);
|
|
96
|
+
return {
|
|
97
|
+
limit,
|
|
98
|
+
offset,
|
|
99
|
+
driverOffset: params?.page != null ? offset : params?.offset
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
function normalizePageSize(raw: number | undefined): number {
|
|
74
104
|
if (raw === undefined || !Number.isFinite(raw)) return DEFAULT_PAGE_SIZE;
|
|
75
105
|
return Math.max(1, Math.floor(raw));
|
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
LogicalCondition,
|
|
7
7
|
QueryBuilderInterface,
|
|
8
8
|
WhereFilterOp,
|
|
9
|
-
|
|
9
|
+
WhereValueFor,
|
|
10
|
+
type ComputedSortField
|
|
10
11
|
} from "@rebasepro/types";
|
|
11
12
|
|
|
12
13
|
export function or(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition {
|
|
@@ -40,7 +41,7 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
40
41
|
* @example
|
|
41
42
|
* client.collection('users').where('age', '>=', 18).find()
|
|
42
43
|
*/
|
|
43
|
-
where<K extends keyof M & string>(column: K, operator:
|
|
44
|
+
where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
|
|
44
45
|
where(logicalCondition: LogicalCondition): this;
|
|
45
46
|
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
|
|
46
47
|
// Handle LogicalCondition signature
|
|
@@ -80,7 +81,7 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
80
81
|
* @example
|
|
81
82
|
* client.collection('users').orderBy('createdAt', 'desc').find()
|
|
82
83
|
*/
|
|
83
|
-
orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
|
|
84
|
+
orderBy(column: (keyof M & string) | ComputedSortField, direction: "asc" | "desc" = "asc"): this {
|
|
84
85
|
this.params.orderBy = [column, direction];
|
|
85
86
|
return this;
|
|
86
87
|
}
|
|
@@ -104,8 +105,29 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
104
105
|
/**
|
|
105
106
|
* Set a free-text search string if supported by the backend.
|
|
106
107
|
*/
|
|
107
|
-
search(searchString: string): this {
|
|
108
|
+
search(searchString: string, options?: { explain?: boolean }): this {
|
|
108
109
|
this.params.searchString = searchString;
|
|
110
|
+
if (options?.explain !== undefined) this.params.searchExplain = options.explain;
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Order rows by nearest-neighbour distance to `vector`, closest first.
|
|
116
|
+
*
|
|
117
|
+
* Postgres only, over a property declared as `type: "vector"`. Rows come
|
|
118
|
+
* back with a `_distance`; `where` filters before the ordering.
|
|
119
|
+
*/
|
|
120
|
+
vectorSearch(
|
|
121
|
+
property: string,
|
|
122
|
+
vector: number[],
|
|
123
|
+
options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
|
|
124
|
+
): this {
|
|
125
|
+
this.params.vectorSearch = {
|
|
126
|
+
property,
|
|
127
|
+
vector,
|
|
128
|
+
...(options?.distance !== undefined && { distance: options.distance }),
|
|
129
|
+
...(options?.threshold !== undefined && { threshold: options.threshold })
|
|
130
|
+
};
|
|
109
131
|
return this;
|
|
110
132
|
}
|
|
111
133
|
|
|
@@ -77,3 +77,59 @@ export function resolveDataSource(
|
|
|
77
77
|
capabilities: getDataSourceCapabilities(engine)
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Does a SQL toolchain own this collection's storage?
|
|
83
|
+
*
|
|
84
|
+
* "Owns the storage" means: something generates a table for it, pushes that
|
|
85
|
+
* table to a database, plans its RLS policies, and reports it as drifted when
|
|
86
|
+
* the two disagree. That is true of a Postgres collection and false of a
|
|
87
|
+
* Firestore or MongoDB one, whose documents live in a store Rebase never
|
|
88
|
+
* migrates — and the two were never told apart. Every stage of the SQL
|
|
89
|
+
* toolchain took "the collections" to mean *all* of them, so a Firestore
|
|
90
|
+
* collection declared next to the Postgres ones got a `pgTable` in the
|
|
91
|
+
* generated schema, a `CREATE TABLE` at boot, RLS policies, and a place in the
|
|
92
|
+
* `db push` include list — where its name shielding a same-named real table
|
|
93
|
+
* from Atlas's exclude list is the one that can lose data.
|
|
94
|
+
*
|
|
95
|
+
* The answer is the resolved engine's {@link DataSourceCapabilities}, not a
|
|
96
|
+
* name check: an engine registered through `registerDataSourceCapabilities`
|
|
97
|
+
* gets the same treatment as the built-in ones.
|
|
98
|
+
*
|
|
99
|
+
* Deliberately answers **true** for an engine nobody has heard of. Build-time
|
|
100
|
+
* tooling (the CLI, the schema generator) has no data-source registry to
|
|
101
|
+
* resolve a `dataSource` key against, so an unknown key resolves to an unknown
|
|
102
|
+
* engine — and the cost of the two mistakes is not symmetric. Wrongly
|
|
103
|
+
* including a collection generates a table nothing writes to; wrongly excluding
|
|
104
|
+
* one silently stops generating a table the app is serving from. Declare
|
|
105
|
+
* `engine` on a collection that is not SQL-backed and this is exact.
|
|
106
|
+
*/
|
|
107
|
+
export function isRelationalCollection(
|
|
108
|
+
collection: DataSourceResolvable | undefined,
|
|
109
|
+
registry?: DataSourceRegistry
|
|
110
|
+
): boolean {
|
|
111
|
+
// The collection's own `engine` wins over a registered definition's. That
|
|
112
|
+
// is the opposite of {@link resolveDataSource}'s precedence, deliberately:
|
|
113
|
+
// there a definition describes where the data *goes*, so it should override;
|
|
114
|
+
// here the question is what the author said this collection is, and a
|
|
115
|
+
// collection declaring `engine: "firestore"` with no `dataSource` must not
|
|
116
|
+
// come back as the default source's engine and be handed a table.
|
|
117
|
+
const engine = collection?.engine
|
|
118
|
+
?? (collection?.dataSource ? resolveDataSource(collection, registry).engine : undefined);
|
|
119
|
+
return getDataSourceCapabilities(engine).supportsRelations;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The subset of `collections` a SQL toolchain owns — see
|
|
124
|
+
* {@link isRelationalCollection}.
|
|
125
|
+
*
|
|
126
|
+
* Every stage that generates SQL from collections starts by calling this, so
|
|
127
|
+
* the rule lives in one place rather than being re-decided per generator. It
|
|
128
|
+
* keeps the input order.
|
|
129
|
+
*/
|
|
130
|
+
export function relationalCollections<C extends DataSourceResolvable>(
|
|
131
|
+
collections: readonly C[],
|
|
132
|
+
registry?: DataSourceRegistry
|
|
133
|
+
): C[] {
|
|
134
|
+
return collections.filter(collection => isRelationalCollection(collection, registry));
|
|
135
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./data/buildRoutedRebaseData";
|
|
|
5
5
|
export * from "./data/resolveDataSource";
|
|
6
6
|
export * from "./data/query_builder";
|
|
7
7
|
export * from "./data/paginate";
|
|
8
|
+
export * from "./data/filter-conditions";
|
|
8
9
|
export * from "./data/filter-dialect";
|
|
9
10
|
export * from "./data/sort-dialect";
|
|
10
11
|
export * from "./table-classification";
|
|
@@ -8,8 +8,14 @@ import { getPolicyNamesForRules } from "@rebasepro/utils";
|
|
|
8
8
|
* Rebase's enforcement model is unified: authenticated (user-context) requests
|
|
9
9
|
* run under the restricted `rebase_user` role, so Postgres RLS binds *every*
|
|
10
10
|
* statement — reads and writes. A collection's `securityRules` are the whole
|
|
11
|
-
* authorization model. The server context (auth flows, migrations,
|
|
12
|
-
* `
|
|
11
|
+
* authorization model. The server context (auth flows, migrations, raw
|
|
12
|
+
* `rebase.sql`) runs as the owner and bypasses RLS.
|
|
13
|
+
*
|
|
14
|
+
* `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as
|
|
15
|
+
* `{ uid: "service", roles: ["admin"] }`, so it runs as `rebase_user` like any
|
|
16
|
+
* other caller and clears the baseline below through the *admin* arm, not the
|
|
17
|
+
* server arm. Which is why `disableDefaultPolicies` plus a lone
|
|
18
|
+
* `policy.serverContext()` rule locks it out too.
|
|
13
19
|
*
|
|
14
20
|
* Because RLS default-denies, every collection is **locked by default**: with
|
|
15
21
|
* no rules, only the server context and admins can touch it. The generator
|
package/src/util/builders.ts
CHANGED
|
@@ -14,14 +14,14 @@ import {
|
|
|
14
14
|
// ── defineCollection ─────────────────────────────────────────────────────
|
|
15
15
|
// A smarter builder that uses `const` type-parameter inference (TS 5.0+)
|
|
16
16
|
// to capture literal property types automatically. This gives you
|
|
17
|
-
// autocomplete on `
|
|
17
|
+
// autocomplete on `display.title`, `sort`, `propertiesOrder`, `fixedFilter`,
|
|
18
18
|
// callbacks, etc. — without writing `as const` or passing manual generics.
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Define a PostgreSQL-backed collection with full type inference.
|
|
22
22
|
*
|
|
23
23
|
* The `const P` generic captures literal property types from your
|
|
24
|
-
* `properties` object, which enables autocomplete on `
|
|
24
|
+
* `properties` object, which enables autocomplete on `display.title`,
|
|
25
25
|
* `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
|
|
26
26
|
*
|
|
27
27
|
* @example
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
* name: { name: "Name", type: "string", validation: { required: true } },
|
|
35
35
|
* price: { name: "Price", type: "number" },
|
|
36
36
|
* },
|
|
37
|
-
*
|
|
37
|
+
* display: { title: "name" }, // ✅ autocomplete: "name" | "price"
|
|
38
38
|
* sort: ["price", "asc"], // ✅ autocomplete on first element
|
|
39
39
|
* });
|
|
40
40
|
* ```
|
package/src/util/collections.ts
CHANGED
|
@@ -83,7 +83,23 @@ export function sortProperties<M extends Record<string, unknown>>(properties: Pr
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* A copy of `collections` ordered by slug.
|
|
88
|
+
*
|
|
89
|
+
* Every generator that turns collections into a file is order-dependent, and
|
|
90
|
+
* every one of them is compared against its own output — `rebase doctor`
|
|
91
|
+
* regenerates in memory and diffs, `generate-sdk && git diff --exit-code` gates
|
|
92
|
+
* CI. While only the *writers* sorted, a project whose `readdirSync` order
|
|
93
|
+
* differed from its slug order was reported permanently out of date, and the
|
|
94
|
+
* fix the message printed rewrote the file in the order it was already in. The
|
|
95
|
+
* generators sort themselves now, so no caller can get this wrong.
|
|
96
|
+
*
|
|
97
|
+
* A slug-less collection is left to the generator's own validation, which names
|
|
98
|
+
* the offending collection; sorting must not throw first.
|
|
99
|
+
*/
|
|
100
|
+
export function sortCollectionsBySlug<C extends { slug?: string }>(collections: readonly C[]): C[] {
|
|
101
|
+
return [...collections].sort((a, b) => (a.slug ?? "").localeCompare(b.slug ?? ""));
|
|
102
|
+
}
|
|
87
103
|
|
|
88
104
|
export function getPrimaryKeys<M extends Record<string, unknown>>(collection: CollectionConfig<M>): Extract<keyof M, string>[] {
|
|
89
105
|
const properties = collection.properties;
|
package/src/util/conditions.ts
CHANGED
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
ArrayProperty,
|
|
4
4
|
AuthState,
|
|
5
5
|
ConditionContext,
|
|
6
|
+
ConditionRule,
|
|
6
7
|
EnumValueConfig,
|
|
7
|
-
JsonLogicRule,
|
|
8
8
|
NumberProperty,
|
|
9
9
|
PropertyConditions,
|
|
10
10
|
Property,
|
|
@@ -66,9 +66,14 @@ export function registerConditionOperations(): void {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Evaluate a
|
|
69
|
+
* Evaluate a condition against the given context.
|
|
70
|
+
*
|
|
71
|
+
* A condition may be stated as a literal instead of a rule — `hidden: true`
|
|
72
|
+
* rather than `hidden: { "==": [1, 1] }` — and a literal is already its own
|
|
73
|
+
* answer, so it is returned rather than handed to the evaluator.
|
|
70
74
|
*/
|
|
71
|
-
export function evaluateCondition(rule:
|
|
75
|
+
export function evaluateCondition(rule: ConditionRule, context: ConditionContext): unknown {
|
|
76
|
+
if (typeof rule === "boolean") return rule;
|
|
72
77
|
// Ensure operations are registered
|
|
73
78
|
registerConditionOperations();
|
|
74
79
|
return jsonLogic.apply(rule, context);
|
package/src/util/entities.ts
CHANGED
|
@@ -143,10 +143,24 @@ export function getRelationFrom<M extends Record<string, unknown>>(entity: Entit
|
|
|
143
143
|
* have `id` and `path` fields — these are relation-shaped objects from
|
|
144
144
|
* edge cases in the data pipeline (REST fallback, stale cache, custom data source).
|
|
145
145
|
*
|
|
146
|
+
* When `targetPath` is given, also accepts a bare id. A relation column is a
|
|
147
|
+
* foreign key, and the REST layer returns it as the scalar it is; only some
|
|
148
|
+
* fetch paths hydrate it into an object. Which form a caller sees therefore
|
|
149
|
+
* depends on how the row was loaded, and a caller that only accepted objects
|
|
150
|
+
* reported half of its own data as a type error. The declared target is the
|
|
151
|
+
* missing half: with it, an id is a relation that has not been fetched yet.
|
|
152
|
+
*
|
|
146
153
|
* Returns null if the value cannot be coerced.
|
|
147
154
|
*/
|
|
148
|
-
export function normalizeToEntityRelation(value: unknown, propertyType?: string): EntityRelation | null {
|
|
155
|
+
export function normalizeToEntityRelation(value: unknown, propertyType?: string, targetPath?: string): EntityRelation | null {
|
|
149
156
|
if (value instanceof EntityRelation) return value;
|
|
157
|
+
|
|
158
|
+
if (targetPath && (typeof value === "string" || typeof value === "number")) {
|
|
159
|
+
// An empty string is an unset foreign key, not row "".
|
|
160
|
+
if (value === "") return null;
|
|
161
|
+
return new EntityRelation(value, targetPath);
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
151
165
|
|
|
152
166
|
const obj = value as Record<string, unknown>;
|
package/src/util/index.ts
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
|
|
3
|
+
* end-user role away from them.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
|
|
8
|
+
* provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
|
|
9
|
+
* in the schemas a project uses — including `rebase`, because a project's own
|
|
10
|
+
* collections are allowed to live there (the scaffold puts `users` there). It
|
|
11
|
+
* also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
|
|
12
|
+
* migrating role inherits the same grant.
|
|
13
|
+
*
|
|
14
|
+
* Every framework-internal table is created later: auth's tables come up during
|
|
15
|
+
* `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
|
|
16
|
+
* job registers, `idempotency_keys` on the first request that carries a key. So
|
|
17
|
+
* they all inherited full DML for the end-user role — and none of them enables
|
|
18
|
+
* row-level security, because none of them is a collection with
|
|
19
|
+
* `securityRules`. Measured on a freshly provisioned database, `SET ROLE
|
|
20
|
+
* rebase_user` could read `rebase.refresh_tokens` (session token hashes),
|
|
21
|
+
* `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
|
|
22
|
+
* `rebase.api_keys` (including its `admin` flag), and insert into
|
|
23
|
+
* `rebase.app_config`.
|
|
24
|
+
*
|
|
25
|
+
* Nothing routes a user-context query at those tables today, so this was not
|
|
26
|
+
* reachable over the API. That is the wrong thing to depend on: the documented
|
|
27
|
+
* model is that RLS is the authorization boundary, and these tables sat outside
|
|
28
|
+
* it. The boundary is now a privilege boundary instead — the role simply cannot
|
|
29
|
+
* address them.
|
|
30
|
+
*
|
|
31
|
+
* ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
|
|
32
|
+
*
|
|
33
|
+
* RLS with no policy denies every row, which is the same outcome, but it is the
|
|
34
|
+
* *weaker* statement: it leaves the grant in place, so a later policy — or a
|
|
35
|
+
* `FORCE` flag cleared by some future migration — reopens the table. There is no
|
|
36
|
+
* row of `refresh_tokens` any end user should ever reach, so the honest encoding
|
|
37
|
+
* is "this role has no privilege here at all". It also keeps the owner
|
|
38
|
+
* connection (which auth actually runs on) completely unaffected.
|
|
39
|
+
*
|
|
40
|
+
* ## Keeping it true
|
|
41
|
+
*
|
|
42
|
+
* `packages/rls-check` scans the `rebase` schema — it used to skip it as a
|
|
43
|
+
* "platform" schema — and its `rls-disabled` check fires on exactly the
|
|
44
|
+
* condition this module removes: RLS off *and* a DML grant to a reachable role.
|
|
45
|
+
* So a table added here without a revoke is caught by `pnpm rls:check`, not by
|
|
46
|
+
* someone re-reading this file.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The Postgres role authenticated requests run as.
|
|
51
|
+
*
|
|
52
|
+
* Defined here rather than in the Postgres driver because both the driver (which
|
|
53
|
+
* provisions the role) and this module (which revokes on its behalf) need it,
|
|
54
|
+
* and a second spelling of a role name is a silent no-op waiting to happen.
|
|
55
|
+
*/
|
|
56
|
+
export const REBASE_USER_ROLE = "rebase_user";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Framework-internal table names, unqualified.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately NOT including `users`: the auth user table is also a collection,
|
|
62
|
+
* with `securityRules`, RLS enabled and policies applied. Users read their own
|
|
63
|
+
* row through it — revoking there would break sign-in.
|
|
64
|
+
*
|
|
65
|
+
* `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
|
|
66
|
+
* because `db migrate apply` passes `--revisions-schema rebase`.
|
|
67
|
+
*/
|
|
68
|
+
export const REBASE_INTERNAL_TABLES: readonly string[] = [
|
|
69
|
+
// auth
|
|
70
|
+
"user_identities",
|
|
71
|
+
"refresh_tokens",
|
|
72
|
+
"password_reset_tokens",
|
|
73
|
+
"magic_link_tokens",
|
|
74
|
+
"mfa_factors",
|
|
75
|
+
"mfa_challenges",
|
|
76
|
+
"recovery_codes",
|
|
77
|
+
"app_config",
|
|
78
|
+
"schema_meta",
|
|
79
|
+
// platform services
|
|
80
|
+
"api_keys",
|
|
81
|
+
"cron_logs",
|
|
82
|
+
"cron_claims",
|
|
83
|
+
"idempotency_keys",
|
|
84
|
+
"entity_history",
|
|
85
|
+
"branches",
|
|
86
|
+
// realtime channels — authorization for these lives in the channel rules the
|
|
87
|
+
// server evaluates before it reads or writes, never in a row policy
|
|
88
|
+
"channel_messages",
|
|
89
|
+
"channel_cursors",
|
|
90
|
+
"channel_presence",
|
|
91
|
+
// migration bookkeeping
|
|
92
|
+
"atlas_schema_revisions"
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/** Postgres identifiers this module is willing to interpolate. */
|
|
96
|
+
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A single statement that takes every privilege on `schema.table` away from the
|
|
100
|
+
* end-user role.
|
|
101
|
+
*
|
|
102
|
+
* Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
|
|
103
|
+
* happen in practice:
|
|
104
|
+
*
|
|
105
|
+
* - the role does not exist when the connection is unprivileged (Rebase then
|
|
106
|
+
* relies on native RLS rather than a role switch), and a bare `REVOKE` on a
|
|
107
|
+
* missing role is an error, not a no-op;
|
|
108
|
+
* - the table may not exist yet — `cron_logs` never appears in a project with
|
|
109
|
+
* no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
|
|
110
|
+
*
|
|
111
|
+
* One command, so it is safe on handles that speak the extended query protocol
|
|
112
|
+
* and reject multi-statement strings.
|
|
113
|
+
*/
|
|
114
|
+
export function revokeInternalTableSql(schema: string, table: string): string {
|
|
115
|
+
if (!SAFE_IDENTIFIER.test(schema)) {
|
|
116
|
+
throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
|
|
117
|
+
}
|
|
118
|
+
if (!SAFE_IDENTIFIER.test(table)) {
|
|
119
|
+
throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
|
|
120
|
+
}
|
|
121
|
+
const qualified = `"${schema}"."${table}"`;
|
|
122
|
+
return `
|
|
123
|
+
DO $rebase_revoke$
|
|
124
|
+
BEGIN
|
|
125
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')
|
|
126
|
+
AND to_regclass('${qualified}') IS NOT NULL THEN
|
|
127
|
+
EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
|
|
128
|
+
END IF;
|
|
129
|
+
END
|
|
130
|
+
$rebase_revoke$;
|
|
131
|
+
`.trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Revoke on every internal table in `schema`, one statement at a time.
|
|
136
|
+
*
|
|
137
|
+
* Best-effort per table: a connection that does not own one of them (a
|
|
138
|
+
* pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
|
|
139
|
+
* that must not take down a boot. The caller decides how loud to be — `onError`
|
|
140
|
+
* exists so the driver can warn without this module importing a logger.
|
|
141
|
+
*/
|
|
142
|
+
export async function revokeInternalTableAccess(
|
|
143
|
+
execute: (sql: string) => Promise<unknown>,
|
|
144
|
+
schema: string,
|
|
145
|
+
options?: { tables?: readonly string[]; onError?: (table: string, error: unknown) => void }
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) {
|
|
148
|
+
try {
|
|
149
|
+
await execute(revokeInternalTableSql(schema, table));
|
|
150
|
+
} catch (error) {
|
|
151
|
+
options?.onError?.(table, error);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { canCreateEntity, canEditEntity, canDeleteEntity, canReadCollection } from "./permissions";
|
|
1
|
+
import { canCreateEntity, canEditEntity, canDeleteEntity, canReadCollection, checkOperation } from "./permissions";
|
|
2
2
|
import { AuthState, CollectionConfig, Entity, User, SecurityRule } from "@rebasepro/types";
|
|
3
3
|
|
|
4
4
|
describe("Permissions Evaluator", () => {
|
|
@@ -823,7 +823,28 @@ roles: ["author"],
|
|
|
823
823
|
using: "status = 'draft'" }
|
|
824
824
|
]);
|
|
825
825
|
expect(canEditEntity(collection, unauthenticatedController, "test", draftOwned)).toBe(true);
|
|
826
|
-
|
|
826
|
+
|
|
827
|
+
// `true`, not `false`, and the change is in `evaluatePolicy` rather
|
|
828
|
+
// than here. The ownerField rule compares `user_id` (null on this
|
|
829
|
+
// row) against the uid, and a comparison against NULL is now
|
|
830
|
+
// "unknown" — SQL's answer — where it used to be a flat `false`.
|
|
831
|
+
// `canEditEntity` gates optimistically (`onUnknown: "allow"`), so
|
|
832
|
+
// an undecided rule shows the button.
|
|
833
|
+
//
|
|
834
|
+
// The old `false` matched Postgres here by accident and diverged
|
|
835
|
+
// from it under negation; see the note on the null branch in
|
|
836
|
+
// `evaluatePolicy`. What this now exposes is a separate, real gap:
|
|
837
|
+
// "the client cannot evaluate this" (raw SQL, a membership
|
|
838
|
+
// subquery) and "SQL would answer NULL, so the row is definitively
|
|
839
|
+
// not granted" are both spelled "unknown", and only the first
|
|
840
|
+
// deserves optimism. Recorded in docs/verification.md.
|
|
841
|
+
expect(canEditEntity(restrictedCollection, unauthenticatedController, "test", draftOwned)).toBe(true);
|
|
842
|
+
|
|
843
|
+
// Fail-closed callers — every enforcement path — are unaffected:
|
|
844
|
+
// an undecided rule denies.
|
|
845
|
+
expect(checkOperation(
|
|
846
|
+
restrictedCollection, unauthenticatedController, draftOwned, "update", { onUnknown: "deny" }
|
|
847
|
+
)).toBe(false);
|
|
827
848
|
});
|
|
828
849
|
|
|
829
850
|
test("58. Missing authController ('blankAuth') handled gracefully", () => {
|
package/src/util/permissions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthState, Entity, CollectionConfig,
|
|
1
|
+
import { AuthState, Entity, CollectionConfig, SecurityOperation, SecurityRule, User } from "@rebasepro/types";
|
|
2
2
|
import { securityRuleToConditions } from "./policy/securityRuleToConditions";
|
|
3
3
|
import { evaluatePolicy, PolicyEvalContext, TriState } from "./policy/evaluatePolicy";
|
|
4
4
|
|
|
@@ -26,8 +26,28 @@ export type AuthContext<USER extends User = User> = AuthState<USER>;
|
|
|
26
26
|
*/
|
|
27
27
|
export type UnknownResolution = "allow" | "deny";
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Which half of a rule to evaluate.
|
|
31
|
+
*
|
|
32
|
+
* Postgres evaluates `USING` against the row as it is *now* and `WITH CHECK`
|
|
33
|
+
* against the row as it *will be*, both inside the transaction. A driver
|
|
34
|
+
* enforcing an update in-process has two different rows in hand and therefore
|
|
35
|
+
* needs to ask the two questions separately — asking one question about one row
|
|
36
|
+
* either checks the new values against the old row's ownership or the reverse.
|
|
37
|
+
*
|
|
38
|
+
* - `"both"` (default): what a single-row decision means (`USING ∧ WITH CHECK`).
|
|
39
|
+
* - `"using"`: the read/target clause only — ask it about the stored row.
|
|
40
|
+
* - `"withCheck"`: the write clause only — ask it about the row being written.
|
|
41
|
+
*
|
|
42
|
+
* Rule *selection* is unaffected: the target operation still decides which rules
|
|
43
|
+
* apply, so `"using"` on an `update` evaluates the update rules' USING clause,
|
|
44
|
+
* not the delete rules'.
|
|
45
|
+
*/
|
|
46
|
+
export type PolicyClauses = "both" | "using" | "withCheck";
|
|
47
|
+
|
|
29
48
|
export interface CheckOperationOptions {
|
|
30
49
|
onUnknown?: UnknownResolution;
|
|
50
|
+
clauses?: PolicyClauses;
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
/** Combine clause results with AND under three-valued (Kleene) logic. */
|
|
@@ -57,12 +77,17 @@ function ruleApplies(rule: SecurityRule, targetOperation: SecurityOperation): bo
|
|
|
57
77
|
* in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
|
|
58
78
|
* INSERT/UPDATE; both must pass for UPDATE.
|
|
59
79
|
*/
|
|
60
|
-
function evaluateRuleForOperation(
|
|
80
|
+
function evaluateRuleForOperation(
|
|
81
|
+
rule: SecurityRule,
|
|
82
|
+
ctx: PolicyEvalContext,
|
|
83
|
+
targetOperation: SecurityOperation,
|
|
84
|
+
clauses: PolicyClauses = "both"
|
|
85
|
+
): TriState {
|
|
61
86
|
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
62
87
|
const clause = (expr: typeof usingExpr): TriState => expr === null ? false : evaluatePolicy(expr, ctx);
|
|
63
88
|
|
|
64
|
-
const needsUsing = targetOperation !== "insert";
|
|
65
|
-
const needsWithCheck = targetOperation === "insert" || targetOperation === "update";
|
|
89
|
+
const needsUsing = targetOperation !== "insert" && clauses !== "withCheck";
|
|
90
|
+
const needsWithCheck = (targetOperation === "insert" || targetOperation === "update") && clauses !== "using";
|
|
66
91
|
|
|
67
92
|
const results: TriState[] = [];
|
|
68
93
|
if (needsUsing) results.push(clause(usingExpr));
|
|
@@ -81,9 +106,20 @@ function resolveTriState(value: TriState, onUnknown: UnknownResolution): boolean
|
|
|
81
106
|
* the same model compiled to Postgres RLS DDL, so the decision matches database
|
|
82
107
|
* enforcement for every non-raw rule.
|
|
83
108
|
*
|
|
109
|
+
* Engine-independent by design. `securityRules` are a declaration about the
|
|
110
|
+
* data, not about Postgres: the engine decides *who* enforces them (Postgres
|
|
111
|
+
* compiles them to RLS DDL, a document driver applies them in-process), never
|
|
112
|
+
* *whether* they hold. Gating this function on the engine's `supportsRLS`
|
|
113
|
+
* capability is what made every `{ onUnknown: "deny" }` call site in the Mongo
|
|
114
|
+
* driver return `true` before it evaluated anything — and it did so only for
|
|
115
|
+
* collections that spelled their engine out, so declaring `engine: "mongodb"`
|
|
116
|
+
* was what switched authorization off.
|
|
117
|
+
*
|
|
84
118
|
* @param options.onUnknown how to treat rules that cannot be decided
|
|
85
119
|
* client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
|
|
86
120
|
* for optimistic UI gating; enforcement callers should pass `"deny"`.
|
|
121
|
+
* @param options.clauses which half of each rule to evaluate. See
|
|
122
|
+
* {@link PolicyClauses}; defaults to `"both"`.
|
|
87
123
|
*/
|
|
88
124
|
export function checkOperation<M extends Record<string, unknown>, USER extends User>(
|
|
89
125
|
collection: CollectionConfig<M>,
|
|
@@ -93,7 +129,8 @@ export function checkOperation<M extends Record<string, unknown>, USER extends U
|
|
|
93
129
|
options?: CheckOperationOptions
|
|
94
130
|
): boolean {
|
|
95
131
|
const onUnknown = options?.onUnknown ?? "allow";
|
|
96
|
-
const
|
|
132
|
+
const clauses = options?.clauses ?? "both";
|
|
133
|
+
const securityRules = collection.securityRules;
|
|
97
134
|
if (!securityRules || securityRules.length === 0) {
|
|
98
135
|
return true;
|
|
99
136
|
}
|
|
@@ -113,7 +150,7 @@ export function checkOperation<M extends Record<string, unknown>, USER extends U
|
|
|
113
150
|
|
|
114
151
|
for (const rule of applicableRules) {
|
|
115
152
|
const mode = rule.mode || "permissive";
|
|
116
|
-
const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
|
|
153
|
+
const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation, clauses), onUnknown);
|
|
117
154
|
|
|
118
155
|
if (mode === "restrictive") {
|
|
119
156
|
if (!passed) {
|
|
@@ -139,8 +139,30 @@ function evaluateCompare(
|
|
|
139
139
|
const b = r.value;
|
|
140
140
|
|
|
141
141
|
if (a === null || b === null) {
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
// SQL answers NULL for *every* comparison against NULL, and a policy
|
|
143
|
+
// that answers NULL does not grant the row. So the only question here
|
|
144
|
+
// is which JavaScript answer reproduces that outcome.
|
|
145
|
+
//
|
|
146
|
+
// This used to answer `false` for `eq` and `true` for `neq`, which is
|
|
147
|
+
// JavaScript's two-valued reading of a three-valued question.
|
|
148
|
+
//
|
|
149
|
+
// `neq` was a grant the database does not give:
|
|
150
|
+
// `owner_id != rebase.uid()` on a row whose `owner_id` is NULL read as
|
|
151
|
+
// *permitted* in the admin panel and was refused by Postgres — on every
|
|
152
|
+
// row where the column is null, which for a nullable column is usually
|
|
153
|
+
// most of them.
|
|
154
|
+
//
|
|
155
|
+
// `false` for `eq` looked safe, because false denies and NULL denies.
|
|
156
|
+
// It is not, because it does not survive negation: `not(a = NULL)`
|
|
157
|
+
// became `true` while `NOT NULL` stays NULL, so the same grant reappears
|
|
158
|
+
// one operator up. A local answer that is only right in a positive
|
|
159
|
+
// position is not right — it just moves.
|
|
160
|
+
//
|
|
161
|
+
// "unknown" is what SQL actually says, it composes correctly through
|
|
162
|
+
// Kleene negation, and enforcement callers already resolve it
|
|
163
|
+
// fail-closed. Both were found by the exhaustive Postgres differential
|
|
164
|
+
// in `policy-agreement-exhaustive.test.ts`, the second only after the
|
|
165
|
+
// first was fixed.
|
|
144
166
|
return "unknown";
|
|
145
167
|
}
|
|
146
168
|
|