@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.
@@ -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
- // Handle OR
44
- if (trimmed.toUpperCase().includes(" OR ")) {
45
- const parts = trimmed.split(/ OR /i);
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
- // Handle AND (very basic split, doesn't handle nested parens properly)
50
- if (trimmed.toUpperCase().includes(" AND ")) {
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)) {