@rebasepro/common 0.8.0 → 0.9.1-canary.09aaf62
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/README.md +5 -5
- package/dist/collections/CollectionRegistry.d.ts +16 -16
- package/dist/collections/default-collections.d.ts +5 -1
- package/dist/data/buildRebaseData.d.ts +44 -3
- package/dist/data/buildRoutedRebaseData.d.ts +14 -9
- package/dist/data/filter-dialect.d.ts +18 -4
- package/dist/data/query_builder.d.ts +1 -1
- package/dist/data/resolveDataSource.d.ts +1 -1
- package/dist/data/sort-dialect.d.ts +41 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1236 -179
- package/dist/index.es.js.map +1 -1
- package/dist/util/auth-default-policies.d.ts +22 -0
- package/dist/util/builders.d.ts +19 -56
- package/dist/util/callbacks.d.ts +3 -3
- package/dist/util/collections.d.ts +4 -4
- package/dist/util/entities.d.ts +2 -2
- package/dist/util/filter-operator-resolution.d.ts +32 -0
- package/dist/util/identity.d.ts +83 -0
- package/dist/util/index.d.ts +4 -0
- package/dist/util/junction-policies.d.ts +108 -0
- package/dist/util/navigation_from_path.d.ts +4 -4
- package/dist/util/navigation_utils.d.ts +3 -3
- package/dist/util/parent_references_from_path.d.ts +2 -2
- package/dist/util/permissions.d.ts +6 -6
- package/dist/util/policy/evaluatePolicy.d.ts +8 -1
- package/dist/util/policy/index.d.ts +1 -0
- package/dist/util/policy/policyToPostgres.d.ts +14 -2
- package/dist/util/policy/sqlToPolicy.d.ts +24 -14
- package/dist/util/references.d.ts +2 -2
- package/dist/util/relations.d.ts +5 -5
- package/dist/util/resolutions.d.ts +2 -2
- package/package.json +7 -8
- package/src/collections/CollectionRegistry.ts +36 -36
- package/src/collections/default-collections.ts +2 -0
- package/src/data/buildRebaseData.ts +430 -60
- package/src/data/buildRoutedRebaseData.ts +22 -16
- package/src/data/filter-dialect.ts +151 -60
- package/src/data/query_builder.ts +11 -2
- package/src/data/resolveDataSource.ts +1 -1
- package/src/data/sort-dialect.ts +56 -0
- package/src/index.ts +1 -0
- package/src/util/auth-default-policies.ts +152 -0
- package/src/util/builders.ts +25 -99
- package/src/util/callbacks.ts +8 -8
- package/src/util/collections.ts +4 -4
- package/src/util/entities.ts +4 -4
- package/src/util/filter-operator-resolution.ts +81 -0
- package/src/util/identity.ts +166 -0
- package/src/util/index.ts +4 -0
- package/src/util/junction-policies.ts +353 -0
- package/src/util/navigation_from_path.ts +4 -4
- package/src/util/navigation_utils.ts +8 -8
- package/src/util/parent_references_from_path.ts +3 -3
- package/src/util/permissions.test.ts +2 -2
- package/src/util/permissions.ts +7 -7
- package/src/util/policy/evaluatePolicy.ts +26 -4
- package/src/util/policy/index.ts +1 -0
- package/src/util/policy/policyToPostgres.ts +123 -17
- package/src/util/policy/sqlToPolicy.ts +190 -13
- package/src/util/references.ts +2 -2
- package/src/util/relations.ts +12 -12
- package/src/util/resolutions.ts +5 -5
- package/dist/index.umd.js +0 -2901
- package/dist/index.umd.js.map +0 -1
|
@@ -1,5 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ANONYMOUS_USER_ID, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
|
|
2
2
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
3
|
+
import { getTableName } from "../relations";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for {@link policyToPostgres}.
|
|
7
|
+
*/
|
|
8
|
+
export interface PolicyCompileOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a collection by slug. Required to compile
|
|
11
|
+
* {@link ExistsInPolicyExpression} (`policy.existsIn`) — the compiler needs
|
|
12
|
+
* the joined collection to derive its table name / schema. When omitted, the
|
|
13
|
+
* join table falls back to a snake_cased slug.
|
|
14
|
+
*/
|
|
15
|
+
resolveCollection?: (slug: string) => CollectionConfig | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The lexical scope threaded through compilation. It changes when we descend
|
|
20
|
+
* into an `existsIn` subquery: inside it, `field` refers to the joined table
|
|
21
|
+
* (aliased) while `outerField` refers to the outer RLS row (table-qualified).
|
|
22
|
+
*/
|
|
23
|
+
interface CompileScope {
|
|
24
|
+
/** Collection whose columns a bare `field` operand resolves against. */
|
|
25
|
+
fieldCollection?: CollectionConfig;
|
|
26
|
+
/** SQL prefix for `field` operands (`""` at top level, `"alias".` in a subquery). */
|
|
27
|
+
fieldPrefix: string;
|
|
28
|
+
/** The outer RLS collection, for `outerField` operands. */
|
|
29
|
+
outerCollection?: CollectionConfig;
|
|
30
|
+
/** SQL prefix for `outerField` operands (`""` at top level, `"schema"."table".` in a subquery). */
|
|
31
|
+
outerPrefix: string;
|
|
32
|
+
resolveCollection?: (slug: string) => CollectionConfig | undefined;
|
|
33
|
+
/** Monotonic counter for generating unique subquery aliases. */
|
|
34
|
+
alias: { n: number };
|
|
35
|
+
}
|
|
3
36
|
|
|
4
37
|
/**
|
|
5
38
|
* Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,
|
|
@@ -9,7 +42,18 @@ import { toSnakeCase } from "@rebasepro/utils";
|
|
|
9
42
|
* {@link evaluatePolicy}); the Postgres schema generators call it so that DDL
|
|
10
43
|
* and the admin UI derive from the exact same expression.
|
|
11
44
|
*/
|
|
12
|
-
export function policyToPostgres(expr: PolicyExpression, collection?:
|
|
45
|
+
export function policyToPostgres(expr: PolicyExpression, collection?: CollectionConfig, options?: PolicyCompileOptions): string {
|
|
46
|
+
return compile(expr, {
|
|
47
|
+
fieldCollection: collection,
|
|
48
|
+
fieldPrefix: "",
|
|
49
|
+
outerCollection: collection,
|
|
50
|
+
outerPrefix: "",
|
|
51
|
+
resolveCollection: options?.resolveCollection,
|
|
52
|
+
alias: { n: 0 }
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function compile(expr: PolicyExpression, scope: CompileScope): string {
|
|
13
57
|
switch (expr.kind) {
|
|
14
58
|
case "true":
|
|
15
59
|
return "true";
|
|
@@ -18,30 +62,76 @@ export function policyToPostgres(expr: PolicyExpression, collection?: EntityColl
|
|
|
18
62
|
case "and":
|
|
19
63
|
return expr.operands.length === 0
|
|
20
64
|
? "true"
|
|
21
|
-
: expr.operands.map(o => `(${
|
|
65
|
+
: expr.operands.map(o => `(${compile(o, scope)})`).join(" AND ");
|
|
22
66
|
case "or":
|
|
23
67
|
return expr.operands.length === 0
|
|
24
68
|
? "false"
|
|
25
|
-
: expr.operands.map(o => `(${
|
|
69
|
+
: expr.operands.map(o => `(${compile(o, scope)})`).join(" OR ");
|
|
26
70
|
case "not":
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
71
|
+
return `NOT (${compile(expr.operand, scope)})`;
|
|
72
|
+
case "compare": {
|
|
73
|
+
// `auth.uid()` returns text; cast the column side so uuid / integer
|
|
74
|
+
// id columns compare cleanly instead of failing with
|
|
75
|
+
// "operator does not exist: uuid = text" at CREATE POLICY time.
|
|
76
|
+
const castForAuthUid = (operand: PolicyOperand, sqlText: string, other: PolicyOperand): string =>
|
|
77
|
+
other.kind === "authUid" && (operand.kind === "field" || operand.kind === "outerField")
|
|
78
|
+
? `(${sqlText})::text`
|
|
79
|
+
: sqlText;
|
|
80
|
+
const leftSql = castForAuthUid(expr.left, operandToSql(expr.left, scope), expr.right);
|
|
81
|
+
const rightSql = castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);
|
|
82
|
+
return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
|
|
83
|
+
}
|
|
32
84
|
case "rolesOverlap":
|
|
33
85
|
return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
|
|
34
86
|
case "rolesContain":
|
|
35
87
|
return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
|
|
36
88
|
case "authenticated":
|
|
37
|
-
|
|
89
|
+
// `IS NOT NULL` alone is a tautology on the user path: every
|
|
90
|
+
// user-context request sets `app.user_id`, and an anonymous one sets
|
|
91
|
+
// it to the sentinel. Excluding the sentinel is what makes this mean
|
|
92
|
+
// "signed in" rather than "anyone at all".
|
|
93
|
+
return `auth.uid() IS NOT NULL AND auth.uid() <> ${quoteLiteral(ANONYMOUS_USER_ID)}`;
|
|
94
|
+
case "serverContext":
|
|
95
|
+
// Only the built-in server flows leave `app.user_id` unset.
|
|
96
|
+
return "auth.uid() IS NULL";
|
|
97
|
+
case "existsIn":
|
|
98
|
+
return compileExistsIn(expr, scope);
|
|
38
99
|
case "raw":
|
|
39
|
-
// Full-power escape hatch: `{column}`
|
|
40
|
-
//
|
|
41
|
-
|
|
100
|
+
// Full-power escape hatch: `{column}` denotes a column of the outer
|
|
101
|
+
// RLS row. It must be table-qualified, not bare: raw SQL may open its
|
|
102
|
+
// own subquery over the same table, and there a bare name binds to the
|
|
103
|
+
// inner scope, collapsing `m.x = {x}` into the tautology `m.x = m.x`.
|
|
104
|
+
return expr.sql.replace(/\{(\w+)\}/g, (_, col) =>
|
|
105
|
+
`${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
|
|
42
106
|
}
|
|
43
107
|
}
|
|
44
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Compiles `existsIn` to a correlated `EXISTS (SELECT 1 FROM <join> WHERE ...)`.
|
|
111
|
+
* Inside the subquery, `field` operands bind to the aliased join table and
|
|
112
|
+
* `outerField` operands bind to the (table-qualified) outer RLS row.
|
|
113
|
+
*/
|
|
114
|
+
function compileExistsIn(expr: ExistsInPolicyExpression, scope: CompileScope): string {
|
|
115
|
+
const join = scope.resolveCollection?.(expr.collection);
|
|
116
|
+
const joinTable = join ? getTableName(join) : toSnakeCase(expr.collection);
|
|
117
|
+
const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? "public";
|
|
118
|
+
const alias = `_ex${scope.alias.n++}`;
|
|
119
|
+
|
|
120
|
+
// `outerField` inside the subquery must be qualified with the outer table,
|
|
121
|
+
// otherwise a bare column name would bind to the joined table instead.
|
|
122
|
+
const outerPrefix = outerQualifier(scope);
|
|
123
|
+
|
|
124
|
+
const innerScope: CompileScope = {
|
|
125
|
+
fieldCollection: join,
|
|
126
|
+
fieldPrefix: `"${alias}".`,
|
|
127
|
+
outerCollection: scope.outerCollection,
|
|
128
|
+
outerPrefix,
|
|
129
|
+
resolveCollection: scope.resolveCollection,
|
|
130
|
+
alias: scope.alias
|
|
131
|
+
};
|
|
132
|
+
return `EXISTS (SELECT 1 FROM "${joinSchema}"."${joinTable}" "${alias}" WHERE ${compile(expr.where, innerScope)})`;
|
|
133
|
+
}
|
|
134
|
+
|
|
45
135
|
const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
|
|
46
136
|
eq: "=",
|
|
47
137
|
neq: "!=",
|
|
@@ -51,10 +141,12 @@ const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
|
|
|
51
141
|
gte: ">="
|
|
52
142
|
};
|
|
53
143
|
|
|
54
|
-
function operandToSql(operand: PolicyOperand,
|
|
144
|
+
function operandToSql(operand: PolicyOperand, scope: CompileScope): string {
|
|
55
145
|
switch (operand.kind) {
|
|
56
146
|
case "field":
|
|
57
|
-
return resolveColumnName(operand.name,
|
|
147
|
+
return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
|
|
148
|
+
case "outerField":
|
|
149
|
+
return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
|
|
58
150
|
case "literal":
|
|
59
151
|
return quoteLiteral(operand.value);
|
|
60
152
|
case "authUid":
|
|
@@ -64,7 +156,21 @@ function operandToSql(operand: PolicyOperand, collection?: EntityCollection): st
|
|
|
64
156
|
}
|
|
65
157
|
}
|
|
66
158
|
|
|
67
|
-
|
|
159
|
+
/**
|
|
160
|
+
* SQL prefix that qualifies a column of the outer RLS row (`"schema"."table".`),
|
|
161
|
+
* or `""` when the collection is unknown.
|
|
162
|
+
*/
|
|
163
|
+
function outerQualifier(scope: CompileScope): string {
|
|
164
|
+
const table = scope.outerCollection ? getTableName(scope.outerCollection) : undefined;
|
|
165
|
+
if (!table) return "";
|
|
166
|
+
return `"${schemaOf(scope.outerCollection) ?? "public"}"."${table}".`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function schemaOf(collection?: CollectionConfig): string | undefined {
|
|
170
|
+
return (collection as { schema?: string } | undefined)?.schema || undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveColumnName(propName: string, collection?: CollectionConfig): string {
|
|
68
174
|
const prop = collection?.properties?.[propName] as Property | undefined;
|
|
69
175
|
if (prop && "columnName" in prop && typeof (prop as { columnName?: unknown }).columnName === "string") {
|
|
70
176
|
return (prop as { columnName: string }).columnName;
|
|
@@ -80,6 +186,6 @@ function quoteLiteral(value: string | number | boolean | null): string {
|
|
|
80
186
|
}
|
|
81
187
|
|
|
82
188
|
/** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
|
|
83
|
-
function rolesArraySql(roles: string[]): string {
|
|
189
|
+
function rolesArraySql(roles: readonly string[]): string {
|
|
84
190
|
return `ARRAY[${[...roles].sort().map(r => `'${r}'`).join(",")}]`;
|
|
85
191
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PolicyExpression, policy } from "@rebasepro/types";
|
|
1
|
+
import { ANONYMOUS_USER_ID, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A tiny, regex-based SQL "parser" for security rules.
|
|
@@ -11,15 +11,103 @@ import { PolicyExpression, policy } from "@rebasepro/types";
|
|
|
11
11
|
* - `field = 'literal'`
|
|
12
12
|
* - `field != 'literal'`
|
|
13
13
|
* - `field = current_setting('app.user_id')`
|
|
14
|
-
* - `A AND B`
|
|
14
|
+
* - `A AND B`, `A OR B` — only where the keyword is at the top level
|
|
15
15
|
* - `true`
|
|
16
16
|
* - `IN (...)` (as optimistic true)
|
|
17
17
|
*
|
|
18
18
|
* For anything it doesn't understand, it returns a `raw` expression, which
|
|
19
19
|
* the evaluator treats as "unknown" (and usually optimistic true).
|
|
20
|
+
*
|
|
21
|
+
* **This output also round-trips back into DDL** via `policyToPostgres` (the
|
|
22
|
+
* schema/policy generators), so decomposing a clause the parser only partly
|
|
23
|
+
* understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
|
|
24
|
+
* prefer `raw`: it is reproduced verbatim.
|
|
20
25
|
*/
|
|
26
|
+
/** True when `keyword` starts at `i` as a standalone word. */
|
|
27
|
+
function isKeywordAt(upper: string, i: number, keyword: string): boolean {
|
|
28
|
+
if (!upper.startsWith(keyword, i)) return false;
|
|
29
|
+
const before = i === 0 ? " " : upper[i - 1];
|
|
30
|
+
const after = upper[i + keyword.length] ?? " ";
|
|
31
|
+
return /[\s()]/.test(before) && /[\s()]/.test(after);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
|
|
36
|
+
* outside a string literal. Returns null when it never does, so the caller
|
|
37
|
+
* leaves the clause alone.
|
|
38
|
+
*
|
|
39
|
+
* This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
|
|
40
|
+
* `AND` inside
|
|
41
|
+
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
|
|
42
|
+
* split the expression, and re-emitting the halves produced
|
|
43
|
+
* `(EXISTS (...) AND m.user_id = auth.uid())`
|
|
44
|
+
* where `m` is no longer in scope — SQL that Postgres rejects outright with
|
|
45
|
+
* "missing FROM-clause entry for table". Returning null instead keeps such a
|
|
46
|
+
* clause as a `raw` expression, which round-trips verbatim.
|
|
47
|
+
*/
|
|
48
|
+
function splitTopLevel(sql: string, keyword: "AND" | "OR"): string[] | null {
|
|
49
|
+
const upper = sql.toUpperCase();
|
|
50
|
+
const parts: string[] = [];
|
|
51
|
+
let depth = 0;
|
|
52
|
+
let inString = false;
|
|
53
|
+
let start = 0;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < sql.length; i++) {
|
|
56
|
+
const ch = sql[i];
|
|
57
|
+
if (inString) {
|
|
58
|
+
if (ch === "'") {
|
|
59
|
+
if (sql[i + 1] === "'") i++; // '' escapes a quote inside a literal
|
|
60
|
+
else inString = false;
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (ch === "'") { inString = true; continue; }
|
|
65
|
+
if (ch === "(") { depth++; continue; }
|
|
66
|
+
if (ch === ")") { depth--; continue; }
|
|
67
|
+
if (depth === 0 && isKeywordAt(upper, i, keyword)) {
|
|
68
|
+
parts.push(sql.slice(start, i));
|
|
69
|
+
i += keyword.length - 1;
|
|
70
|
+
start = i + 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (parts.length === 0) return null;
|
|
75
|
+
parts.push(sql.slice(start));
|
|
76
|
+
const trimmedParts = parts.map(p => p.trim()).filter(p => p.length > 0);
|
|
77
|
+
return trimmedParts.length > 1 ? trimmedParts : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
|
|
81
|
+
function stripOuterParens(sql: string): string {
|
|
82
|
+
let s = sql.trim();
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (!s.startsWith("(") || !s.endsWith(")")) return s;
|
|
85
|
+
let depth = 0;
|
|
86
|
+
let inString = false;
|
|
87
|
+
let wraps = true;
|
|
88
|
+
for (let i = 0; i < s.length; i++) {
|
|
89
|
+
const ch = s[i];
|
|
90
|
+
if (inString) {
|
|
91
|
+
if (ch === "'") {
|
|
92
|
+
if (s[i + 1] === "'") i++;
|
|
93
|
+
else inString = false;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === "'") { inString = true; continue; }
|
|
98
|
+
if (ch === "(") depth++;
|
|
99
|
+
else if (ch === ")") {
|
|
100
|
+
depth--;
|
|
101
|
+
if (depth === 0 && i < s.length - 1) { wraps = false; break; }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!wraps) return s;
|
|
105
|
+
s = s.slice(1, -1).trim();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
21
109
|
export function sqlToPolicy(sql: string): PolicyExpression {
|
|
22
|
-
const trimmed = sql.trim();
|
|
110
|
+
const trimmed = stripOuterParens(sql.trim());
|
|
23
111
|
|
|
24
112
|
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
25
113
|
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
@@ -40,17 +128,12 @@ export function sqlToPolicy(sql: string): PolicyExpression {
|
|
|
40
128
|
return policy.rolesContain(roles);
|
|
41
129
|
}
|
|
42
130
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return policy.or(...parts.map(sqlToPolicy));
|
|
47
|
-
}
|
|
131
|
+
// OR binds looser than AND, so it splits first.
|
|
132
|
+
const orParts = splitTopLevel(trimmed, "OR");
|
|
133
|
+
if (orParts) return policy.or(...orParts.map(sqlToPolicy));
|
|
48
134
|
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
const parts = trimmed.split(/ AND /i);
|
|
52
|
-
return policy.and(...parts.map(sqlToPolicy));
|
|
53
|
-
}
|
|
135
|
+
const andParts = splitTopLevel(trimmed, "AND");
|
|
136
|
+
if (andParts) return policy.and(...andParts.map(sqlToPolicy));
|
|
54
137
|
|
|
55
138
|
// Handle = and !=
|
|
56
139
|
const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
|
|
@@ -67,6 +150,100 @@ export function sqlToPolicy(sql: string): PolicyExpression {
|
|
|
67
150
|
return policy.raw(sql);
|
|
68
151
|
}
|
|
69
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Literals from other BaaS platforms that people compare `auth.uid()` against
|
|
155
|
+
* out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
|
|
156
|
+
* `pgRoles`, one surface over: the same muscle memory inside a `using:` string
|
|
157
|
+
* is the more dangerous spelling, because it inverts a rule instead of
|
|
158
|
+
* emptying a table.
|
|
159
|
+
*/
|
|
160
|
+
const FOREIGN_CONVENTION_UIDS: Record<string, string> = {
|
|
161
|
+
anon: "Supabase",
|
|
162
|
+
authenticated: "Supabase",
|
|
163
|
+
service_role: "Supabase"
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** A clause that reads as a lockdown but admits anonymous callers. */
|
|
167
|
+
export interface AnonymousGrantRisk {
|
|
168
|
+
/** Which spelling was found. */
|
|
169
|
+
pattern: "foreign-uid-literal" | "uid-not-null";
|
|
170
|
+
/** The offending fragment — the literal, or the SQL that is a tautology. */
|
|
171
|
+
detail: string;
|
|
172
|
+
/** Why it admits anonymous callers, and what to write instead. */
|
|
173
|
+
explanation: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `auth.uid() IS NOT NULL` in raw SQL, the clause that is always true. */
|
|
177
|
+
const UID_NOT_NULL = /auth\.uid\(\)\s+IS\s+NOT\s+NULL/i;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Find clauses that read as "signed-in users only" but admit anonymous callers.
|
|
181
|
+
*
|
|
182
|
+
* Both spellings come from the same place — Supabase, where `auth.uid()` really
|
|
183
|
+
* is NULL for an anonymous request. Rebase substitutes
|
|
184
|
+
* {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
|
|
185
|
+
* is how the trusted *server* context is recognised), so:
|
|
186
|
+
*
|
|
187
|
+
* - `auth.uid() IS NOT NULL` is a tautology on the user path, and
|
|
188
|
+
* - `auth.uid() != 'anon'` compares against a string no caller ever has.
|
|
189
|
+
*
|
|
190
|
+
* Either one turns a lockdown into a full grant, and neither looks wrong. No
|
|
191
|
+
* real user id is ever one of these literals, and a user-context request is
|
|
192
|
+
* never NULL, so a match is always a mistake rather than a deliberate check.
|
|
193
|
+
*
|
|
194
|
+
* Structured expressions are checked too, not just parsed SQL: `policy.compare`
|
|
195
|
+
* can spell the same mistake.
|
|
196
|
+
*/
|
|
197
|
+
export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[] {
|
|
198
|
+
const found: AnonymousGrantRisk[] = [];
|
|
199
|
+
|
|
200
|
+
const visit = (e: PolicyExpression): void => {
|
|
201
|
+
switch (e.kind) {
|
|
202
|
+
case "and":
|
|
203
|
+
case "or":
|
|
204
|
+
e.operands.forEach(visit);
|
|
205
|
+
return;
|
|
206
|
+
case "not":
|
|
207
|
+
visit(e.operand);
|
|
208
|
+
return;
|
|
209
|
+
case "existsIn":
|
|
210
|
+
visit(e.where);
|
|
211
|
+
return;
|
|
212
|
+
case "raw":
|
|
213
|
+
if (UID_NOT_NULL.test(e.sql)) {
|
|
214
|
+
found.push({
|
|
215
|
+
pattern: "uid-not-null",
|
|
216
|
+
detail: e.sql,
|
|
217
|
+
explanation: "`auth.uid() IS NOT NULL` is true for every request that came from a client, " +
|
|
218
|
+
`including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. ` +
|
|
219
|
+
"Use `condition: policy.authenticated()` to mean \"signed in\"."
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
case "compare": {
|
|
224
|
+
const literal = [e.left, e.right].find(o => o.kind === "literal") as LiteralPolicyOperand | undefined;
|
|
225
|
+
const comparesUid = e.left.kind === "authUid" || e.right.kind === "authUid";
|
|
226
|
+
if (!comparesUid || typeof literal?.value !== "string") return;
|
|
227
|
+
const platform = FOREIGN_CONVENTION_UIDS[literal.value];
|
|
228
|
+
if (!platform) return;
|
|
229
|
+
found.push({
|
|
230
|
+
pattern: "foreign-uid-literal",
|
|
231
|
+
detail: literal.value,
|
|
232
|
+
explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous ` +
|
|
233
|
+
`request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for ` +
|
|
234
|
+
"every caller. Use `condition: policy.authenticated()` to mean \"signed in\"."
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
default:
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
visit(expr);
|
|
244
|
+
return found;
|
|
245
|
+
}
|
|
246
|
+
|
|
70
247
|
function parseOperand(str: string) {
|
|
71
248
|
// current_setting('app.user_id') or auth.uid()
|
|
72
249
|
if (/current_setting\s*\(\s*'app\.user_id'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) {
|
package/src/util/references.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
2
2
|
|
|
3
|
-
export function getEntityImagePreviewPropertyKey<M extends Record<string, unknown>>(collection:
|
|
3
|
+
export function getEntityImagePreviewPropertyKey<M extends Record<string, unknown>>(collection: CollectionConfig<M>): string | undefined {
|
|
4
4
|
|
|
5
5
|
// find first storage property of type image
|
|
6
6
|
for (const key in collection.properties) {
|
package/src/util/relations.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CollectionConfig, getDataSourceCapabilities, Property, Relation, RelationProperty } from "@rebasepro/types";
|
|
2
2
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
3
3
|
import { generateForeignKeyName } from "@rebasepro/utils";
|
|
4
4
|
|
|
5
5
|
export function sanitizeRelation(
|
|
6
6
|
relation: Partial<Relation>,
|
|
7
|
-
sourceCollection:
|
|
8
|
-
resolveCollection?: (slugOrTable: string) =>
|
|
7
|
+
sourceCollection: CollectionConfig,
|
|
8
|
+
resolveCollection?: (slugOrTable: string) => CollectionConfig | undefined
|
|
9
9
|
): Relation {
|
|
10
10
|
if (!relation.target) {
|
|
11
11
|
throw new Error("Relation is missing a `target` collection.");
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const rawTarget = relation.target;
|
|
15
|
-
let targetCollection:
|
|
15
|
+
let targetCollection: CollectionConfig | undefined;
|
|
16
16
|
|
|
17
17
|
if (typeof rawTarget === "string") {
|
|
18
18
|
if (resolveCollection) {
|
|
@@ -20,7 +20,7 @@ export function sanitizeRelation(
|
|
|
20
20
|
}
|
|
21
21
|
if (!targetCollection) {
|
|
22
22
|
targetCollection = { slug: rawTarget,
|
|
23
|
-
name: rawTarget } as
|
|
23
|
+
name: rawTarget } as CollectionConfig;
|
|
24
24
|
}
|
|
25
25
|
} else if (typeof rawTarget === "function") {
|
|
26
26
|
const evaluated = rawTarget();
|
|
@@ -30,13 +30,13 @@ name: rawTarget } as EntityCollection;
|
|
|
30
30
|
}
|
|
31
31
|
if (!targetCollection) {
|
|
32
32
|
targetCollection = { slug: evaluated,
|
|
33
|
-
name: evaluated } as
|
|
33
|
+
name: evaluated } as CollectionConfig;
|
|
34
34
|
}
|
|
35
35
|
} else {
|
|
36
36
|
targetCollection = evaluated;
|
|
37
37
|
}
|
|
38
38
|
} else if (rawTarget && typeof rawTarget === "object") {
|
|
39
|
-
targetCollection = rawTarget as
|
|
39
|
+
targetCollection = rawTarget as CollectionConfig;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
if (!targetCollection) {
|
|
@@ -198,10 +198,10 @@ name: evaluated } as EntityCollection;
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
/** WeakMap cache — same collection instance always yields the same relation map. */
|
|
201
|
-
const _resolvedRelationsCache = new WeakMap<
|
|
201
|
+
const _resolvedRelationsCache = new WeakMap<CollectionConfig, Record<string, Relation>>();
|
|
202
202
|
|
|
203
203
|
export function resolveCollectionRelations(
|
|
204
|
-
collection:
|
|
204
|
+
collection: CollectionConfig
|
|
205
205
|
): Record<string, Relation> {
|
|
206
206
|
const cached = _resolvedRelationsCache.get(collection);
|
|
207
207
|
if (cached) return cached;
|
|
@@ -251,7 +251,7 @@ export function resolveCollectionRelations(
|
|
|
251
251
|
|
|
252
252
|
// We previously skipped if the underlying relation was already registered under
|
|
253
253
|
// its canonical relationName in section 1. But we need to keep the property mapping
|
|
254
|
-
// for
|
|
254
|
+
// for FetchService to hydrate the relation back to the correct property key.
|
|
255
255
|
// Deduplication for Drizzle schema generation is handled in generate-drizzle-schema-logic.ts.
|
|
256
256
|
|
|
257
257
|
if (!relation.relationName) {
|
|
@@ -275,7 +275,7 @@ export function resolvePropertyRelation({
|
|
|
275
275
|
}: {
|
|
276
276
|
propertyKey: string;
|
|
277
277
|
property: Property;
|
|
278
|
-
sourceCollection:
|
|
278
|
+
sourceCollection: CollectionConfig;
|
|
279
279
|
}): Relation | undefined {
|
|
280
280
|
if (property.type !== "relation") return undefined;
|
|
281
281
|
|
|
@@ -304,7 +304,7 @@ export function resolvePropertyRelation({
|
|
|
304
304
|
return undefined;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
export function getTableName(collection:
|
|
307
|
+
export function getTableName(collection: CollectionConfig): string {
|
|
308
308
|
if (getDataSourceCapabilities(collection.engine).supportsRelations) {
|
|
309
309
|
return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
|
|
310
310
|
}
|
package/src/util/resolutions.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArrayProperty,
|
|
3
3
|
AuthController,
|
|
4
|
-
|
|
4
|
+
CollectionConfig,
|
|
5
5
|
EnumValueConfig,
|
|
6
6
|
EnumValues,
|
|
7
7
|
NumberProperty,
|
|
@@ -341,7 +341,7 @@ export function resolveEnumValues(input: EnumValues): EnumValueConfig[] | undefi
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
|
|
344
|
-
export function getSubcollections<M extends Record<string, unknown> = Record<string, unknown>>(collection:
|
|
344
|
+
export function getSubcollections<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): CollectionConfig<Record<string, unknown>>[] {
|
|
345
345
|
if (collection.childCollections) {
|
|
346
346
|
return collection.childCollections() ?? [];
|
|
347
347
|
}
|
|
@@ -371,7 +371,7 @@ export function getSubcollections<M extends Record<string, unknown> = Record<str
|
|
|
371
371
|
}
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
-
const baseOverrides: Partial<
|
|
374
|
+
const baseOverrides: Partial<CollectionConfig> = { slug: relationKey };
|
|
375
375
|
if (customName) {
|
|
376
376
|
baseOverrides.name = customName;
|
|
377
377
|
baseOverrides.singularName = customName;
|
|
@@ -379,8 +379,8 @@ export function getSubcollections<M extends Record<string, unknown> = Record<str
|
|
|
379
379
|
|
|
380
380
|
const targetWithOverrides = { ...target,
|
|
381
381
|
...baseOverrides };
|
|
382
|
-
return (r.overrides ? mergeDeep(targetWithOverrides, r.overrides) : targetWithOverrides) as
|
|
383
|
-
}).filter((c:
|
|
382
|
+
return (r.overrides ? mergeDeep(targetWithOverrides, r.overrides) : targetWithOverrides) as CollectionConfig<Record<string, unknown>>;
|
|
383
|
+
}).filter((c: CollectionConfig<Record<string, unknown>> | undefined): c is CollectionConfig<Record<string, unknown>> => Boolean(c));
|
|
384
384
|
}
|
|
385
385
|
|
|
386
386
|
return [];
|