@rebasepro/common 0.12.0 → 0.12.1-canary.g06f263c
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 +66 -9
- package/dist/index.es.js.map +1 -1
- package/dist/util/auth-default-policies.d.ts +18 -0
- package/dist/util/index.d.ts +1 -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/util/auth-default-policies.ts +22 -0
- package/src/util/index.ts +1 -0
- 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/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/index.d.ts
CHANGED
|
@@ -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.12.
|
|
4
|
+
"version": "0.12.1-canary.g06f263c",
|
|
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.12.
|
|
44
|
-
"@rebasepro/utils": "0.12.
|
|
43
|
+
"@rebasepro/types": "0.12.1-canary.g06f263c",
|
|
44
|
+
"@rebasepro/utils": "0.12.1-canary.g06f263c"
|
|
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",
|
|
@@ -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/index.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { StringProperty } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The length a bounded string column is declared with when the property does
|
|
5
|
+
* not say. Historical: it is what the DDL generator hardcoded, kept so that
|
|
6
|
+
* regenerating an existing schema does not silently redefine its columns.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_STRING_COLUMN_LENGTH = 255;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How wide a `varchar`/`char` column should be for a given property.
|
|
12
|
+
*
|
|
13
|
+
* One definition, three call sites, because they used to disagree. For the same
|
|
14
|
+
* `columnType: "varchar"` property the DDL generator emitted `VARCHAR(255)`
|
|
15
|
+
* while the Drizzle generator emitted a bare `varchar("col")` — which Postgres
|
|
16
|
+
* reads as *unbounded* — so which of the two you ran decided whether the column
|
|
17
|
+
* had a limit at all. Introspection then dropped the length entirely, so reading
|
|
18
|
+
* an existing `character varying(500)` column back and regenerating it produced
|
|
19
|
+
* a `VARCHAR(255)`: a silent narrowing of a column with data already in it.
|
|
20
|
+
*
|
|
21
|
+
* `validation.max` is the property's own statement about how long the value may
|
|
22
|
+
* be, so it is the only sensible source for the column's width — and it keeps
|
|
23
|
+
* the constraint the database enforces in step with the one the app enforces,
|
|
24
|
+
* rather than inventing a second, different limit underneath it.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveStringColumnLength(prop: Pick<StringProperty, "validation">): number {
|
|
27
|
+
const max = prop.validation?.max;
|
|
28
|
+
return typeof max === "number" && Number.isInteger(max) && max > 0
|
|
29
|
+
? max
|
|
30
|
+
: DEFAULT_STRING_COLUMN_LENGTH;
|
|
31
|
+
}
|