@rebasepro/common 0.12.1-canary.gf5f1d39 → 0.13.0
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/index.es.js +170 -37
- package/dist/index.es.js.map +1 -1
- package/dist/util/auth-default-policies.d.ts +18 -0
- package/dist/util/builders.d.ts +1 -20
- package/dist/util/email.d.ts +29 -0
- package/dist/util/identity.d.ts +14 -0
- package/dist/util/index.d.ts +2 -0
- package/dist/util/policy/sqlToPolicy.d.ts +6 -1
- package/dist/util/string-column-length.d.ts +24 -0
- package/package.json +7 -7
- package/src/data/buildRebaseData.ts +1 -1
- package/src/util/auth-default-policies.ts +22 -0
- package/src/util/builders.ts +0 -50
- package/src/util/email.ts +32 -0
- package/src/util/identity.ts +36 -0
- package/src/util/index.ts +2 -0
- package/src/util/permissions.test.ts +17 -3
- package/src/util/pg-column-to-property.ts +14 -1
- package/src/util/policy/evaluatePolicy.ts +6 -2
- package/src/util/policy/policyToPostgres.ts +9 -3
- package/src/util/policy/sqlToPolicy.ts +10 -3
- package/src/util/resolve-relation.ts +74 -9
- package/src/util/string-column-length.ts +31 -0
|
@@ -20,3 +20,21 @@ export declare function getEffectiveSecurityRules(collection: CollectionConfig):
|
|
|
20
20
|
* DDL, which policies are injected and how to take them off.
|
|
21
21
|
*/
|
|
22
22
|
export declare function getInjectedSecurityRules(collection: CollectionConfig): SecurityRule[];
|
|
23
|
+
/**
|
|
24
|
+
* Every policy name `rebase db push` would write for a collection.
|
|
25
|
+
*
|
|
26
|
+
* This is the answer to "did the codebase produce this live policy?", and it is
|
|
27
|
+
* more than `securityRules.map(r => r.name)` for two reasons:
|
|
28
|
+
*
|
|
29
|
+
* - a rule without an explicit `name` compiles to `<table>_<op>_<hash>`, one
|
|
30
|
+
* per operation, so comparing `rule.name` to `policyname` never matches it;
|
|
31
|
+
* - the generator also injects the safe-by-default baseline
|
|
32
|
+
* (`<table>_default_admin_*`), which is in no collection's `securityRules`.
|
|
33
|
+
*
|
|
34
|
+
* Every UI that flags drift has to get both right, and each one that derived it
|
|
35
|
+
* by hand got a different subset — which is how four policies *Rebase itself
|
|
36
|
+
* wrote* came to be badged as hand-written drift on every table in a project,
|
|
37
|
+
* with a button offering to import them back into the codebase that produced
|
|
38
|
+
* them. There is one derivation now, and this is it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getGeneratedPolicyNames(collection: CollectionConfig): Set<string>;
|
package/dist/util/builders.d.ts
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
3
|
-
* @deprecated Use {@link defineCollection} instead — it infers property
|
|
4
|
-
* types automatically (autocomplete on `titleProperty`, `sort`,
|
|
5
|
-
* `propertiesOrder`, callbacks) without manual generics.
|
|
6
|
-
* `buildCollection` is kept for FireCMS migration compatibility and will
|
|
7
|
-
* be removed before 1.0.
|
|
8
|
-
*
|
|
9
|
-
* @group Builder
|
|
10
|
-
*/
|
|
11
|
-
export declare function buildCollection<M extends Record<string, unknown> = Record<string, unknown>, USER extends User = User>(collection: CollectionConfig<M, USER>): CollectionConfig<M, USER>;
|
|
1
|
+
import { FirebaseCollectionConfig, FirebaseProperties, InferEntityType, MongoDBCollectionConfig, MongoProperties, PostgresCollectionConfig, PostgresProperties, User } from "@rebasepro/types";
|
|
12
2
|
/**
|
|
13
3
|
* Define a PostgreSQL-backed collection with full type inference.
|
|
14
4
|
*
|
|
@@ -56,12 +46,3 @@ export declare function defineCollection<const P extends MongoProperties, USER e
|
|
|
56
46
|
}): MongoDBCollectionConfig<InferEntityType<P>, USER> & {
|
|
57
47
|
properties: P;
|
|
58
48
|
};
|
|
59
|
-
/**
|
|
60
|
-
* @deprecated Use plain typed property objects with {@link defineCollection}
|
|
61
|
-
* instead — `defineCollection` infers property types automatically, making
|
|
62
|
-
* this wrapper unnecessary. `buildProperty` is kept for FireCMS migration
|
|
63
|
-
* compatibility and will be removed before 1.0.
|
|
64
|
-
*
|
|
65
|
-
* @group Builder
|
|
66
|
-
*/
|
|
67
|
-
export declare function buildProperty<T, P extends Property = Property>(property: P): P extends StringProperty ? StringProperty : P extends NumberProperty ? NumberProperty : P extends BooleanProperty ? BooleanProperty : P extends DateProperty ? DateProperty : P extends GeopointProperty ? GeopointProperty : P extends ReferenceProperty ? ReferenceProperty : P extends ArrayProperty ? ArrayProperty : P extends MapProperty ? MapProperty : never;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email normalization — one implementation, because the database enforces it.
|
|
3
|
+
*
|
|
4
|
+
* `ensureAuthTablesExist` puts a `UNIQUE INDEX ON users (lower(email))` on the
|
|
5
|
+
* auth table. That index decides what "the same address" means, and it does not
|
|
6
|
+
* trim: to Postgres, `' foo@bar.com'` and `'foo@bar.com'` are two addresses and
|
|
7
|
+
* both may exist. So every write that reaches the column has to agree with
|
|
8
|
+
* every read, exactly, or the two disagree in the one direction that matters —
|
|
9
|
+
* a row that exists and cannot be found.
|
|
10
|
+
*
|
|
11
|
+
* That is not hypothetical. The lookup path trimmed and the admin create paths
|
|
12
|
+
* did not, so a user created through `POST /api/data/users` or
|
|
13
|
+
* `POST /api/auth/admin/users` with a stray space was stored untrimmed,
|
|
14
|
+
* survived the unique index alongside the real address, and was unreachable by
|
|
15
|
+
* login forever after. The HTTP auth routes were unaffected only because Zod's
|
|
16
|
+
* `.email()` happens to reject surrounding whitespace — a guard on a different
|
|
17
|
+
* layer, for a different reason, that the admin paths do not sit behind.
|
|
18
|
+
*
|
|
19
|
+
* It lives in `common` because `server`, `server-postgres` and `server-mongo`
|
|
20
|
+
* all write this column and must agree exactly, and `common` is the only
|
|
21
|
+
* package all three already depend on.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Canonical form of an email address: trimmed, lower-cased.
|
|
25
|
+
*
|
|
26
|
+
* Non-strings pass through untouched, so this is safe to apply to a value out
|
|
27
|
+
* of a partial update payload whose type is not known yet.
|
|
28
|
+
*/
|
|
29
|
+
export declare function normalizeEmail<T>(email: T): T | string;
|
package/dist/util/identity.d.ts
CHANGED
|
@@ -26,6 +26,20 @@ export interface PrimaryKeyInfo {
|
|
|
26
26
|
}
|
|
27
27
|
/** Separator between the parts of a composite address. */
|
|
28
28
|
export declare const COMPOSITE_ID_SEPARATOR = ":::";
|
|
29
|
+
/**
|
|
30
|
+
* Whether an address could name a row at all, before asking the database.
|
|
31
|
+
*
|
|
32
|
+
* A `uuid` column cannot hold `"new"`, and an `integer` column cannot hold
|
|
33
|
+
* `"abc"` — so the answer to "which row is this" is "none", and that is a 404,
|
|
34
|
+
* not a failure. Postgres cannot say so politely: the comparison never runs, it
|
|
35
|
+
* raises `22P02` and aborts the enclosing transaction, after which every
|
|
36
|
+
* further statement returns the far less helpful `25P02`.
|
|
37
|
+
*
|
|
38
|
+
* `isUUID` must come from the column, not from `isId: "uuid"` in a config: the
|
|
39
|
+
* config is a claim about a key, and a `text` column that holds ids of some
|
|
40
|
+
* other shape is a working app this must not start rejecting.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isAddressableId(idValue: string | number, primaryKeys: PrimaryKeyInfo[]): boolean;
|
|
29
43
|
/**
|
|
30
44
|
* Derive a row's address from its key columns.
|
|
31
45
|
*
|
package/dist/util/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./collections";
|
|
|
2
2
|
export * from "./common";
|
|
3
3
|
export * from "./entities";
|
|
4
4
|
export * from "./identity";
|
|
5
|
+
export * from "./email";
|
|
5
6
|
export * from "./enums";
|
|
6
7
|
export * from "./paths";
|
|
7
8
|
export * from "./resolutions";
|
|
@@ -16,3 +17,4 @@ export * from "./auth-default-policies";
|
|
|
16
17
|
export * from "./junction-policies";
|
|
17
18
|
export * from "./conditions";
|
|
18
19
|
export * from "./pg-column-to-property";
|
|
20
|
+
export * from "./string-column-length";
|
|
@@ -18,7 +18,12 @@ export interface AnonymousGrantRisk {
|
|
|
18
18
|
* is how the trusted *server* context is recognised), so:
|
|
19
19
|
*
|
|
20
20
|
* - `auth.uid() IS NOT NULL` is a tautology on the user path, and
|
|
21
|
-
* - `auth.uid() != 'anon'`
|
|
21
|
+
* - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
|
|
22
|
+
* other. This one is not hypothetical and was not only a foreign habit:
|
|
23
|
+
* rebase's own request path reported `'anon'` while everything that compiled
|
|
24
|
+
* or checked a policy used `'anonymous'`, so whichever literal an author
|
|
25
|
+
* picked, half the anonymous callers walked through. See
|
|
26
|
+
* {@link ANONYMOUS_USER_IDS}.
|
|
22
27
|
*
|
|
23
28
|
* Either one turns a lockdown into a full grant, and neither looks wrong. No
|
|
24
29
|
* real user id is ever one of these literals, and a user-context request is
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { StringProperty } from "@rebasepro/types";
|
|
2
|
+
/**
|
|
3
|
+
* The length a bounded string column is declared with when the property does
|
|
4
|
+
* not say. Historical: it is what the DDL generator hardcoded, kept so that
|
|
5
|
+
* regenerating an existing schema does not silently redefine its columns.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_STRING_COLUMN_LENGTH = 255;
|
|
8
|
+
/**
|
|
9
|
+
* How wide a `varchar`/`char` column should be for a given property.
|
|
10
|
+
*
|
|
11
|
+
* One definition, three call sites, because they used to disagree. For the same
|
|
12
|
+
* `columnType: "varchar"` property the DDL generator emitted `VARCHAR(255)`
|
|
13
|
+
* while the Drizzle generator emitted a bare `varchar("col")` — which Postgres
|
|
14
|
+
* reads as *unbounded* — so which of the two you ran decided whether the column
|
|
15
|
+
* had a limit at all. Introspection then dropped the length entirely, so reading
|
|
16
|
+
* an existing `character varying(500)` column back and regenerating it produced
|
|
17
|
+
* a `VARCHAR(255)`: a silent narrowing of a column with data already in it.
|
|
18
|
+
*
|
|
19
|
+
* `validation.max` is the property's own statement about how long the value may
|
|
20
|
+
* be, so it is the only sensible source for the column's width — and it keeps
|
|
21
|
+
* the constraint the database enforces in step with the one the app enforces,
|
|
22
|
+
* rather than inventing a second, different limit underneath it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveStringColumnLength(prop: Pick<StringProperty, "validation">): number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/common",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.13.0",
|
|
5
5
|
"description": "Awesome Firebase/Firestore-based headless open-source CMS",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -38,26 +38,26 @@
|
|
|
38
38
|
"./package.json": "./package.json"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"fast-equals": "6.0.
|
|
41
|
+
"fast-equals": "6.0.2",
|
|
42
42
|
"json-logic-js": "^2.0.5",
|
|
43
|
-
"@rebasepro/types": "0.
|
|
44
|
-
"@rebasepro/utils": "0.
|
|
43
|
+
"@rebasepro/types": "0.13.0",
|
|
44
|
+
"@rebasepro/utils": "0.13.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@jest/globals": "^30.4.1",
|
|
48
48
|
"@testing-library/react": "^16.3.2",
|
|
49
49
|
"@testing-library/user-event": "^14.6.1",
|
|
50
50
|
"@types/jest": "^30.0.0",
|
|
51
|
-
"@types/node": "^
|
|
51
|
+
"@types/node": "^26.1.2",
|
|
52
52
|
"@types/object-hash": "^3.0.6",
|
|
53
53
|
"@types/react-measure": "^2.0.12",
|
|
54
54
|
"babel-plugin-react-compiler": "beta",
|
|
55
55
|
"cross-env": "^10.1.0",
|
|
56
56
|
"jest": "^30.4.2",
|
|
57
|
-
"ts-jest": "^29.4.
|
|
57
|
+
"ts-jest": "^29.4.12",
|
|
58
58
|
"tsd": "^0.33.0",
|
|
59
59
|
"typescript": "^6.0.3",
|
|
60
|
-
"vite": "^8.
|
|
60
|
+
"vite": "^8.1.5"
|
|
61
61
|
},
|
|
62
62
|
"files": [
|
|
63
63
|
"dist",
|
|
@@ -99,7 +99,7 @@ function rowToEntity<M extends Record<string, unknown>>(
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* The relation envelope `
|
|
102
|
+
* The relation envelope `toFlatRow` writes where a relation was:
|
|
103
103
|
* `{ id, path, __type: "relation", data: { id, path, values } }`. It is the
|
|
104
104
|
* admin's view-model, and the only pipeline that produces one is postgres'.
|
|
105
105
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from "@rebasepro/types";
|
|
2
2
|
import { getTableName } from "./relations";
|
|
3
|
+
import { getPolicyNamesForRules } from "@rebasepro/utils";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Default RLS policies injected by the schema generator.
|
|
@@ -150,3 +151,24 @@ export function getInjectedSecurityRules(collection: CollectionConfig): Security
|
|
|
150
151
|
// so everything past the author's count is injected.
|
|
151
152
|
return getEffectiveSecurityRules(collection).slice(explicitCount);
|
|
152
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Every policy name `rebase db push` would write for a collection.
|
|
157
|
+
*
|
|
158
|
+
* This is the answer to "did the codebase produce this live policy?", and it is
|
|
159
|
+
* more than `securityRules.map(r => r.name)` for two reasons:
|
|
160
|
+
*
|
|
161
|
+
* - a rule without an explicit `name` compiles to `<table>_<op>_<hash>`, one
|
|
162
|
+
* per operation, so comparing `rule.name` to `policyname` never matches it;
|
|
163
|
+
* - the generator also injects the safe-by-default baseline
|
|
164
|
+
* (`<table>_default_admin_*`), which is in no collection's `securityRules`.
|
|
165
|
+
*
|
|
166
|
+
* Every UI that flags drift has to get both right, and each one that derived it
|
|
167
|
+
* by hand got a different subset — which is how four policies *Rebase itself
|
|
168
|
+
* wrote* came to be badged as hand-written drift on every table in a project,
|
|
169
|
+
* with a button offering to import them back into the codebase that produced
|
|
170
|
+
* them. There is one derivation now, and this is it.
|
|
171
|
+
*/
|
|
172
|
+
export function getGeneratedPolicyNames(collection: CollectionConfig): Set<string> {
|
|
173
|
+
return getPolicyNamesForRules(getEffectiveSecurityRules(collection), getTableName(collection));
|
|
174
|
+
}
|
package/src/util/builders.ts
CHANGED
|
@@ -1,43 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ArrayProperty,
|
|
3
|
-
BooleanProperty,
|
|
4
|
-
DateProperty,
|
|
5
2
|
CollectionConfig,
|
|
6
3
|
FirebaseCollectionConfig,
|
|
7
4
|
FirebaseProperties,
|
|
8
|
-
GeopointProperty,
|
|
9
5
|
InferEntityType,
|
|
10
|
-
MapProperty,
|
|
11
6
|
MongoDBCollectionConfig,
|
|
12
7
|
MongoProperties,
|
|
13
|
-
NumberProperty,
|
|
14
8
|
PostgresCollectionConfig,
|
|
15
9
|
PostgresProperties,
|
|
16
|
-
Property,
|
|
17
|
-
ReferenceProperty,
|
|
18
|
-
StringProperty,
|
|
19
10
|
User
|
|
20
11
|
} from "@rebasepro/types";
|
|
21
12
|
|
|
22
13
|
|
|
23
|
-
/**
|
|
24
|
-
* @deprecated Use {@link defineCollection} instead — it infers property
|
|
25
|
-
* types automatically (autocomplete on `titleProperty`, `sort`,
|
|
26
|
-
* `propertiesOrder`, callbacks) without manual generics.
|
|
27
|
-
* `buildCollection` is kept for FireCMS migration compatibility and will
|
|
28
|
-
* be removed before 1.0.
|
|
29
|
-
*
|
|
30
|
-
* @group Builder
|
|
31
|
-
*/
|
|
32
|
-
export function buildCollection<
|
|
33
|
-
M extends Record<string, unknown> = Record<string, unknown>,
|
|
34
|
-
USER extends User = User>
|
|
35
|
-
(
|
|
36
|
-
collection: CollectionConfig<M, USER>
|
|
37
|
-
): CollectionConfig<M, USER> {
|
|
38
|
-
return collection;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
14
|
// ── defineCollection ─────────────────────────────────────────────────────
|
|
42
15
|
// A smarter builder that uses `const` type-parameter inference (TS 5.0+)
|
|
43
16
|
// to capture literal property types automatically. This gives you
|
|
@@ -107,26 +80,3 @@ export function defineCollection(
|
|
|
107
80
|
return collection;
|
|
108
81
|
}
|
|
109
82
|
|
|
110
|
-
/**
|
|
111
|
-
* @deprecated Use plain typed property objects with {@link defineCollection}
|
|
112
|
-
* instead — `defineCollection` infers property types automatically, making
|
|
113
|
-
* this wrapper unnecessary. `buildProperty` is kept for FireCMS migration
|
|
114
|
-
* compatibility and will be removed before 1.0.
|
|
115
|
-
*
|
|
116
|
-
* @group Builder
|
|
117
|
-
*/
|
|
118
|
-
export function buildProperty<T, P extends Property = Property>(
|
|
119
|
-
property: P
|
|
120
|
-
):
|
|
121
|
-
P extends StringProperty ? StringProperty :
|
|
122
|
-
P extends NumberProperty ? NumberProperty :
|
|
123
|
-
P extends BooleanProperty ? BooleanProperty :
|
|
124
|
-
P extends DateProperty ? DateProperty :
|
|
125
|
-
P extends GeopointProperty ? GeopointProperty :
|
|
126
|
-
P extends ReferenceProperty ? ReferenceProperty :
|
|
127
|
-
P extends ArrayProperty ? ArrayProperty :
|
|
128
|
-
P extends MapProperty ? MapProperty : never {
|
|
129
|
-
|
|
130
|
-
// SAFETY: Identity function — P is a subtype of the conditional return type by definition
|
|
131
|
-
return property as unknown as ReturnType<typeof buildProperty<T, P>>;
|
|
132
|
-
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email normalization — one implementation, because the database enforces it.
|
|
3
|
+
*
|
|
4
|
+
* `ensureAuthTablesExist` puts a `UNIQUE INDEX ON users (lower(email))` on the
|
|
5
|
+
* auth table. That index decides what "the same address" means, and it does not
|
|
6
|
+
* trim: to Postgres, `' foo@bar.com'` and `'foo@bar.com'` are two addresses and
|
|
7
|
+
* both may exist. So every write that reaches the column has to agree with
|
|
8
|
+
* every read, exactly, or the two disagree in the one direction that matters —
|
|
9
|
+
* a row that exists and cannot be found.
|
|
10
|
+
*
|
|
11
|
+
* That is not hypothetical. The lookup path trimmed and the admin create paths
|
|
12
|
+
* did not, so a user created through `POST /api/data/users` or
|
|
13
|
+
* `POST /api/auth/admin/users` with a stray space was stored untrimmed,
|
|
14
|
+
* survived the unique index alongside the real address, and was unreachable by
|
|
15
|
+
* login forever after. The HTTP auth routes were unaffected only because Zod's
|
|
16
|
+
* `.email()` happens to reject surrounding whitespace — a guard on a different
|
|
17
|
+
* layer, for a different reason, that the admin paths do not sit behind.
|
|
18
|
+
*
|
|
19
|
+
* It lives in `common` because `server`, `server-postgres` and `server-mongo`
|
|
20
|
+
* all write this column and must agree exactly, and `common` is the only
|
|
21
|
+
* package all three already depend on.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Canonical form of an email address: trimmed, lower-cased.
|
|
26
|
+
*
|
|
27
|
+
* Non-strings pass through untouched, so this is safe to apply to a value out
|
|
28
|
+
* of a partial update payload whose type is not known yet.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeEmail<T>(email: T): T | string {
|
|
31
|
+
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
|
32
|
+
}
|
package/src/util/identity.ts
CHANGED
|
@@ -29,6 +29,42 @@ export interface PrimaryKeyInfo {
|
|
|
29
29
|
/** Separator between the parts of a composite address. */
|
|
30
30
|
export const COMPOSITE_ID_SEPARATOR = ":::";
|
|
31
31
|
|
|
32
|
+
/** The eight-four-four-four-twelve shape of a UUID, any version. */
|
|
33
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
34
|
+
|
|
35
|
+
/** Whether one address part can be a value of the column it addresses. */
|
|
36
|
+
function partIsAddressable(part: string | number, pk: PrimaryKeyInfo): boolean {
|
|
37
|
+
if (pk.isUUID) return UUID_PATTERN.test(String(part));
|
|
38
|
+
if (pk.type === "number") {
|
|
39
|
+
return typeof part === "number"
|
|
40
|
+
? Number.isFinite(part)
|
|
41
|
+
: !isNaN(parseInt(String(part), 10));
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether an address could name a row at all, before asking the database.
|
|
48
|
+
*
|
|
49
|
+
* A `uuid` column cannot hold `"new"`, and an `integer` column cannot hold
|
|
50
|
+
* `"abc"` — so the answer to "which row is this" is "none", and that is a 404,
|
|
51
|
+
* not a failure. Postgres cannot say so politely: the comparison never runs, it
|
|
52
|
+
* raises `22P02` and aborts the enclosing transaction, after which every
|
|
53
|
+
* further statement returns the far less helpful `25P02`.
|
|
54
|
+
*
|
|
55
|
+
* `isUUID` must come from the column, not from `isId: "uuid"` in a config: the
|
|
56
|
+
* config is a claim about a key, and a `text` column that holds ids of some
|
|
57
|
+
* other shape is a working app this must not start rejecting.
|
|
58
|
+
*/
|
|
59
|
+
export function isAddressableId(idValue: string | number, primaryKeys: PrimaryKeyInfo[]): boolean {
|
|
60
|
+
if (primaryKeys.length === 0) return false;
|
|
61
|
+
if (primaryKeys.length === 1) return partIsAddressable(idValue, primaryKeys[0]);
|
|
62
|
+
|
|
63
|
+
const parts = String(idValue).split(COMPOSITE_ID_SEPARATOR);
|
|
64
|
+
if (parts.length !== primaryKeys.length) return false;
|
|
65
|
+
return parts.every((part, i) => partIsAddressable(part, primaryKeys[i]));
|
|
66
|
+
}
|
|
67
|
+
|
|
32
68
|
/**
|
|
33
69
|
* Derive a row's address from its key columns.
|
|
34
70
|
*
|
package/src/util/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./collections";
|
|
|
2
2
|
export * from "./common";
|
|
3
3
|
export * from "./entities";
|
|
4
4
|
export * from "./identity";
|
|
5
|
+
export * from "./email";
|
|
5
6
|
export * from "./enums";
|
|
6
7
|
export * from "./paths";
|
|
7
8
|
export * from "./resolutions";
|
|
@@ -16,3 +17,4 @@ export * from "./auth-default-policies";
|
|
|
16
17
|
export * from "./junction-policies";
|
|
17
18
|
export * from "./conditions";
|
|
18
19
|
export * from "./pg-column-to-property";
|
|
20
|
+
export * from "./string-column-length";
|
|
@@ -148,13 +148,27 @@ roles: ["admin", "editor"] }
|
|
|
148
148
|
expect(canReadCollection(collection, adminAuthController)).toBe(true); // admin
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
test
|
|
151
|
+
// The test that used to sit here claimed roles arrive as `{ id, name }`
|
|
152
|
+
// objects and are mapped to strings. `User.roles` is `string[]` and no such
|
|
153
|
+
// mapping exists anywhere, so the fixture was plain strings and the
|
|
154
|
+
// assertion was test 9 again with a different rule. What is worth pinning
|
|
155
|
+
// instead is that the match is an exact string membership test.
|
|
156
|
+
test("10. Role IDs match exactly — not by prefix, substring or case", () => {
|
|
152
157
|
const collection = createMockCollection([
|
|
153
158
|
{ operation: "select",
|
|
154
159
|
roles: ["author"] }
|
|
155
160
|
]);
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
const withRoles = (roles: string[] | undefined): AuthState<User> => ({
|
|
162
|
+
user: { ...mockUser,
|
|
163
|
+
roles }
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(canReadCollection(collection, withRoles(["author", "user"]))).toBe(true);
|
|
167
|
+
expect(canReadCollection(collection, withRoles(["authors"]))).toBe(false);
|
|
168
|
+
expect(canReadCollection(collection, withRoles(["auth"]))).toBe(false);
|
|
169
|
+
expect(canReadCollection(collection, withRoles(["Author"]))).toBe(false);
|
|
170
|
+
expect(canReadCollection(collection, withRoles([]))).toBe(false);
|
|
171
|
+
expect(canReadCollection(collection, withRoles(undefined))).toBe(false);
|
|
158
172
|
});
|
|
159
173
|
|
|
160
174
|
test("11. Empty roles array [] adds no role restriction (public rule stays public)", () => {
|
|
@@ -42,6 +42,7 @@ function pgTypeToRebaseProperty(column: TableColumnInfo): Property | null {
|
|
|
42
42
|
udt_name,
|
|
43
43
|
is_nullable,
|
|
44
44
|
column_default,
|
|
45
|
+
character_maximum_length,
|
|
45
46
|
enum_values
|
|
46
47
|
} = column;
|
|
47
48
|
|
|
@@ -78,11 +79,23 @@ label: prettifyIdentifier(v) })),
|
|
|
78
79
|
let colType: "varchar" | "text" | "char" = "varchar";
|
|
79
80
|
if (dt === "text" || dt === "citext") colType = "text";
|
|
80
81
|
if (dt === "char" || dt === "character") colType = "char";
|
|
82
|
+
// Carry the declared width across. Dropping it made introspection
|
|
83
|
+
// lossy in the one direction that costs data: a `character
|
|
84
|
+
// varying(500)` column read back as a bare `varchar` regenerates as
|
|
85
|
+
// `VARCHAR(255)`, narrowing a column that already holds longer
|
|
86
|
+
// values. TEXT has no width, and reporting one would invent a limit
|
|
87
|
+
// the database does not have.
|
|
88
|
+
const declaredLength = colType === "text" ? null : character_maximum_length;
|
|
81
89
|
const prop: StringProperty = {
|
|
82
90
|
type: "string",
|
|
83
91
|
name: prettifiedName,
|
|
84
92
|
columnType: colType,
|
|
85
|
-
validation: required
|
|
93
|
+
validation: required || declaredLength
|
|
94
|
+
? {
|
|
95
|
+
...(required ? { required: true } : {}),
|
|
96
|
+
...(declaredLength ? { max: declaredLength } : {})
|
|
97
|
+
}
|
|
98
|
+
: undefined
|
|
86
99
|
};
|
|
87
100
|
if (isAutoId) {
|
|
88
101
|
prop.isId = "manual";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ANONYMOUS_USER_ID, Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from "@rebasepro/types";
|
|
1
|
+
import { ANONYMOUS_USER_ID, isAnonymousUid, Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from "@rebasepro/types";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Result of evaluating a policy client-side. `"unknown"` means the expression
|
|
@@ -61,7 +61,11 @@ export function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext):
|
|
|
61
61
|
return expr.roles.every(r => r === "public" || userRoles.includes(r));
|
|
62
62
|
}
|
|
63
63
|
case "authenticated":
|
|
64
|
-
|
|
64
|
+
// Every anonymous spelling, matching what this node compiles to in
|
|
65
|
+
// Postgres — the two evaluators disagreeing about who is signed in
|
|
66
|
+
// is the client optimistically rendering a row the database will
|
|
67
|
+
// refuse, or hiding one it would have allowed.
|
|
68
|
+
return ctx.uid != null && !isAnonymousUid(ctx.uid);
|
|
65
69
|
case "serverContext":
|
|
66
70
|
// A client is never the server context. Postgres decides this by
|
|
67
71
|
// `auth.uid() IS NULL`, which a client request can never produce:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
|
|
2
2
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
3
3
|
import { getTableName } from "../relations";
|
|
4
4
|
|
|
@@ -88,9 +88,15 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
|
|
|
88
88
|
case "authenticated":
|
|
89
89
|
// `IS NOT NULL` alone is a tautology on the user path: every
|
|
90
90
|
// user-context request sets `app.uid`, and an anonymous one sets
|
|
91
|
-
// it to
|
|
91
|
+
// it to a sentinel. Excluding the sentinels is what makes this mean
|
|
92
92
|
// "signed in" rather than "anyone at all".
|
|
93
|
-
|
|
93
|
+
//
|
|
94
|
+
// Every sentinel, not just the current one. This clause is written
|
|
95
|
+
// into the database and outlives the server that generated it: a
|
|
96
|
+
// policy compiled here may be enforced against an older server that
|
|
97
|
+
// still reports `'anon'`, which is exactly how excluding one
|
|
98
|
+
// spelling turned this helper into a grant. See ANONYMOUS_USER_IDS.
|
|
99
|
+
return `auth.uid() IS NOT NULL AND auth.uid() NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
|
|
94
100
|
case "serverContext":
|
|
95
101
|
// Only the built-in server flows leave `app.uid` unset.
|
|
96
102
|
return "auth.uid() IS NULL";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ANONYMOUS_USER_ID, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
|
|
1
|
+
import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A tiny, regex-based SQL "parser" for security rules.
|
|
@@ -185,7 +185,12 @@ const UID_NOT_NULL = /auth\.uid\(\)\s+IS\s+NOT\s+NULL/i;
|
|
|
185
185
|
* is how the trusted *server* context is recognised), so:
|
|
186
186
|
*
|
|
187
187
|
* - `auth.uid() IS NOT NULL` is a tautology on the user path, and
|
|
188
|
-
* - `auth.uid() != 'anon'`
|
|
188
|
+
* - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
|
|
189
|
+
* other. This one is not hypothetical and was not only a foreign habit:
|
|
190
|
+
* rebase's own request path reported `'anon'` while everything that compiled
|
|
191
|
+
* or checked a policy used `'anonymous'`, so whichever literal an author
|
|
192
|
+
* picked, half the anonymous callers walked through. See
|
|
193
|
+
* {@link ANONYMOUS_USER_IDS}.
|
|
189
194
|
*
|
|
190
195
|
* Either one turns a lockdown into a full grant, and neither looks wrong. No
|
|
191
196
|
* real user id is ever one of these literals, and a user-context request is
|
|
@@ -231,7 +236,9 @@ export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[
|
|
|
231
236
|
detail: literal.value,
|
|
232
237
|
explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous ` +
|
|
233
238
|
`request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for ` +
|
|
234
|
-
"every caller. Use `condition: policy.authenticated()` to mean \"signed in\"
|
|
239
|
+
"every caller. Use `condition: policy.authenticated()` to mean \"signed in\" — it " +
|
|
240
|
+
`compiles to NOT IN (${ANONYMOUS_USER_IDS.map(v => `'${v}'`).join(", ")}), covering ` +
|
|
241
|
+
"every spelling rebase has reported rather than whichever one you remember."
|
|
235
242
|
});
|
|
236
243
|
return;
|
|
237
244
|
}
|