@rebasepro/common 0.9.0 → 0.9.1-canary.0fce67c
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 +1 -1
- package/dist/collections/default-collections.d.ts +4 -0
- package/dist/data/buildRebaseData.d.ts +15 -2
- package/dist/index.es.js +696 -49
- package/dist/index.es.js.map +1 -1
- package/dist/util/auth-default-policies.d.ts +22 -0
- package/dist/util/identity.d.ts +83 -0
- package/dist/util/index.d.ts +3 -0
- package/dist/util/junction-policies.d.ts +108 -0
- package/dist/util/policy/evaluatePolicy.d.ts +8 -1
- package/dist/util/policy/index.d.ts +1 -0
- package/dist/util/policy/sqlToPolicy.d.ts +24 -14
- package/package.json +7 -8
- package/src/collections/default-collections.ts +2 -0
- package/src/data/buildRebaseData.ts +119 -24
- package/src/data/filter-dialect.ts +6 -0
- package/src/util/auth-default-policies.ts +152 -0
- package/src/util/identity.ts +166 -0
- package/src/util/index.ts +3 -0
- package/src/util/junction-policies.ts +353 -0
- package/src/util/policy/evaluatePolicy.ts +20 -4
- package/src/util/policy/index.ts +1 -0
- package/src/util/policy/policyToPostgres.ts +38 -12
- package/src/util/policy/sqlToPolicy.ts +190 -13
- package/dist/index.umd.js +0 -3311
- package/dist/index.umd.js.map +0 -1
|
@@ -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)) {
|