@rebasepro/common 0.9.1-canary.fd3754b → 0.10.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.
@@ -1,4 +1,4 @@
1
- import { Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from "@rebasepro/types";
2
2
 
3
3
  /**
4
4
  * Result of evaluating a policy client-side. `"unknown"` means the expression
@@ -15,7 +15,14 @@ export type TriState = boolean | "unknown";
15
15
  * being evaluated (or none, for collection-level gating).
16
16
  */
17
17
  export interface PolicyEvalContext {
18
- /** The current user's id, or null/undefined when unauthenticated. */
18
+ /**
19
+ * The current user's id, or null/undefined when no user is signed in.
20
+ *
21
+ * Null here means *anonymous visitor*, not "server context" — a client is
22
+ * never the server context. `authUid` operands therefore resolve to
23
+ * {@link ANONYMOUS_USER_ID} rather than `null`, matching the `auth.uid()`
24
+ * the database would see for the same request.
25
+ */
19
26
  uid?: string | null;
20
27
  /** The current user's application roles. */
21
28
  roles?: string[];
@@ -54,7 +61,12 @@ export function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext):
54
61
  return expr.roles.every(r => r === "public" || userRoles.includes(r));
55
62
  }
56
63
  case "authenticated":
57
- return ctx.uid != null;
64
+ return ctx.uid != null && ctx.uid !== ANONYMOUS_USER_ID;
65
+ case "serverContext":
66
+ // A client is never the server context. Postgres decides this by
67
+ // `auth.uid() IS NULL`, which a client request can never produce:
68
+ // the driver substitutes ANONYMOUS_USER_ID for a missing id.
69
+ return false;
58
70
  case "existsIn":
59
71
  // A membership subquery cannot be run client-side — server-authoritative.
60
72
  return "unknown";
@@ -92,7 +104,11 @@ function resolveOperand(operand: PolicyOperand, ctx: PolicyEvalContext): Resolve
92
104
  case "literal":
93
105
  return { known: true, value: operand.value };
94
106
  case "authUid":
95
- return { known: true, value: ctx.uid ?? null };
107
+ // The sentinel, not null: `auth.uid()` is never NULL for a request
108
+ // that came from a client, so comparing against null here would
109
+ // disagree with the database on exactly the rules that test for it
110
+ // (e.g. `auth.uid() <> 'anonymous'`).
111
+ return { known: true, value: ctx.uid ?? ANONYMOUS_USER_ID };
96
112
  case "authRoles":
97
113
  return { known: true, value: ctx.roles ?? [] };
98
114
  case "field":
@@ -1,3 +1,4 @@
1
1
  export * from "./securityRuleToConditions";
2
+ export * from "./sqlToPolicy";
2
3
  export * from "./policyToPostgres";
3
4
  export * from "./evaluatePolicy";
@@ -1,4 +1,4 @@
1
- import { CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
3
  import { getTableName } from "../relations";
4
4
 
@@ -68,8 +68,6 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
68
68
  ? "false"
69
69
  : expr.operands.map(o => `(${compile(o, scope)})`).join(" OR ");
70
70
  case "not":
71
- // Render the common `auth.uid() IS NULL` (unauthenticated) form directly.
72
- if (expr.operand.kind === "authenticated") return "auth.uid() IS NULL";
73
71
  return `NOT (${compile(expr.operand, scope)})`;
74
72
  case "compare": {
75
73
  // `auth.uid()` returns text; cast the column side so uuid / integer
@@ -88,7 +86,14 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
88
86
  case "rolesContain":
89
87
  return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
90
88
  case "authenticated":
91
- return "auth.uid() IS NOT NULL";
89
+ // `IS NOT NULL` alone is a tautology on the user path: every
90
+ // user-context request sets `app.uid`, 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.uid` unset.
96
+ return "auth.uid() IS NULL";
92
97
  case "existsIn":
93
98
  return compileExistsIn(expr, scope);
94
99
  case "raw":
@@ -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.
@@ -10,16 +10,104 @@ import { PolicyExpression, policy } from "@rebasepro/types";
10
10
  * It handles:
11
11
  * - `field = 'literal'`
12
12
  * - `field != 'literal'`
13
- * - `field = current_setting('app.user_id')`
14
- * - `A AND B`
13
+ * - `field = current_setting('app.uid')` (or the legacy `app.user_id`)
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,9 +150,106 @@ 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
- // current_setting('app.user_id') or auth.uid()
72
- if (/current_setting\s*\(\s*'app\.user_id'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) {
248
+ // current_setting('app.uid') or auth.uid(). `app.user_id` is the
249
+ // pre-rename spelling and stays parseable: policies are data, so a
250
+ // database provisioned before the rename still holds rules written
251
+ // against it, and round-tripping one must not silently drop the operand.
252
+ if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) {
73
253
  return policy.authUid();
74
254
  }
75
255