@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.
- package/dist/collections/default-collections.d.ts +4 -0
- package/dist/data/buildRebaseData.d.ts +15 -2
- package/dist/index.es.js +676 -46
- 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/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 +9 -4
- package/src/util/policy/sqlToPolicy.ts +196 -16
- package/dist/index.umd.js +0 -3328
- package/dist/index.umd.js.map +0 -1
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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":
|
package/src/util/policy/index.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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,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.
|
|
72
|
-
|
|
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
|
|