@rebasepro/common 0.7.0 → 0.9.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/README.md +4 -4
- package/dist/collections/CollectionRegistry.d.ts +30 -15
- package/dist/collections/default-collections.d.ts +255 -2
- package/dist/data/buildRebaseData.d.ts +30 -2
- package/dist/data/buildRoutedRebaseData.d.ts +14 -9
- package/dist/data/filter-dialect.d.ts +75 -0
- package/dist/data/query_builder.d.ts +4 -4
- package/dist/data/resolveDataSource.d.ts +8 -8
- package/dist/data/sort-dialect.d.ts +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +1125 -299
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1138 -303
- package/dist/index.umd.js.map +1 -1
- package/dist/util/builders.d.ts +52 -42
- package/dist/util/callbacks.d.ts +8 -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/index.d.ts +2 -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 +30 -6
- package/dist/util/policy/evaluatePolicy.d.ts +31 -0
- package/dist/util/policy/index.d.ts +3 -0
- package/dist/util/policy/policyToPostgres.d.ts +22 -0
- package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
- package/dist/util/policy/sqlToPolicy.d.ts +20 -0
- 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/dist/util/storage.d.ts +26 -1
- package/package.json +13 -13
- package/src/collections/CollectionRegistry.ts +92 -61
- package/src/collections/default-collections.ts +4 -4
- package/src/data/buildRebaseData.ts +336 -172
- package/src/data/buildRoutedRebaseData.ts +22 -16
- package/src/data/filter-dialect.ts +403 -0
- package/src/data/query_builder.ts +19 -10
- package/src/data/resolveDataSource.ts +10 -10
- package/src/data/sort-dialect.ts +56 -0
- package/src/index.ts +2 -0
- package/src/util/builders.ts +87 -84
- package/src/util/callbacks.ts +15 -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/index.ts +2 -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 +7 -5
- package/src/util/permissions.ts +90 -163
- package/src/util/policy/evaluatePolicy.ts +152 -0
- package/src/util/policy/index.ts +3 -0
- package/src/util/policy/policyToPostgres.ts +165 -0
- package/src/util/policy/securityRuleToConditions.ts +67 -0
- package/src/util/policy/sqlToPolicy.ts +88 -0
- package/src/util/references.ts +3 -3
- package/src/util/relations.ts +19 -20
- package/src/util/resolutions.ts +11 -11
- package/src/util/storage.ts +34 -1
package/src/util/permissions.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Entity, CollectionConfig, getDataSourceCapabilities, SecurityOperation, SecurityRule, User } from "@rebasepro/types";
|
|
2
|
+
import { securityRuleToConditions } from "./policy/securityRuleToConditions";
|
|
3
|
+
import { evaluatePolicy, PolicyEvalContext, TriState } from "./policy/evaluatePolicy";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Minimal auth context for permission checking.
|
|
@@ -9,200 +11,125 @@ export interface AuthContext<USER extends User = User> {
|
|
|
9
11
|
user: USER | null;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (cleanedSQL[i] === "(") openCount++;
|
|
24
|
-
else if (cleanedSQL[i] === ")") openCount--;
|
|
25
|
-
if (openCount === 0) {
|
|
26
|
-
isEnclosing = false;
|
|
27
|
-
break;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
if (isEnclosing) {
|
|
31
|
-
cleanedSQL = cleanedSQL.substring(1, cleanedSQL.length - 1).trim();
|
|
32
|
-
} else {
|
|
33
|
-
break;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// 2. Split top-level OR / AND
|
|
38
|
-
const splitByTopLevel = (str: string, delimiter: string) => {
|
|
39
|
-
const parts: string[] = [];
|
|
40
|
-
let current = "";
|
|
41
|
-
let openCount = 0;
|
|
42
|
-
let i = 0;
|
|
43
|
-
while (i < str.length) {
|
|
44
|
-
if (str[i] === "(") openCount++;
|
|
45
|
-
else if (str[i] === ")") openCount--;
|
|
46
|
-
|
|
47
|
-
if (openCount === 0 && str.substring(i).toUpperCase().startsWith(delimiter)) {
|
|
48
|
-
parts.push(current);
|
|
49
|
-
current = "";
|
|
50
|
-
i += delimiter.length;
|
|
51
|
-
} else {
|
|
52
|
-
current += str[i];
|
|
53
|
-
i++;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
parts.push(current);
|
|
57
|
-
return parts;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const orParts = splitByTopLevel(cleanedSQL, " OR ");
|
|
61
|
-
if (orParts.length > 1) {
|
|
62
|
-
return orParts.some(part => evaluateAST(part, auth, entity));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const andParts = splitByTopLevel(cleanedSQL, " AND ");
|
|
66
|
-
if (andParts.length > 1) {
|
|
67
|
-
return andParts.every(part => evaluateAST(part, auth, entity));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const upperSQL = cleanedSQL.toUpperCase();
|
|
71
|
-
|
|
72
|
-
// 3. Fallback for unparseable complex queries
|
|
73
|
-
if (upperSQL.includes(" IN ") || upperSQL.includes(" EXISTS ")) {
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// 4. Role array checks
|
|
78
|
-
// Pattern: `string_to_array(auth.roles(), ',') && ARRAY['admin', 'editor']`
|
|
79
|
-
const roleIntersectMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\[(.*?)\]/i);
|
|
80
|
-
if (roleIntersectMatch && roleIntersectMatch[1]) {
|
|
81
|
-
const requiredRoles = roleIntersectMatch[1].split(",").map(r => r.trim().replace(/'/g, ""));
|
|
82
|
-
const userRoles = auth.user?.roles || [];
|
|
83
|
-
return requiredRoles.some(r => userRoles.includes(r));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Pattern: `string_to_array(auth.roles(), ',') @> ARRAY['admin']`
|
|
87
|
-
const roleContainMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\[(.*?)\]/i);
|
|
88
|
-
if (roleContainMatch && roleContainMatch[1]) {
|
|
89
|
-
const requiredRoles = roleContainMatch[1].split(",").map(r => r.trim().replace(/'/g, ""));
|
|
90
|
-
const userRoles = auth.user?.roles || [];
|
|
91
|
-
return requiredRoles.every(r => userRoles.includes(r));
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 5. Existing ID patterns
|
|
95
|
-
const pattern1 = new RegExp("^\\{?([a-zA-Z0-9_]+)\\}?\\s*=\\s*(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))");
|
|
96
|
-
const pattern2 = new RegExp("^(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))\\s*=\\s*\\{?([a-zA-Z0-9_]+)\\}?");
|
|
97
|
-
|
|
98
|
-
const match1 = cleanedSQL.match(pattern1);
|
|
99
|
-
if (match1 && match1[1]) {
|
|
100
|
-
return entity.values[match1[1]] === auth.user?.uid;
|
|
101
|
-
}
|
|
14
|
+
/**
|
|
15
|
+
* How to resolve a policy result that cannot be decided client-side (a raw-SQL
|
|
16
|
+
* escape-hatch rule, or a row-column reference with no row in hand).
|
|
17
|
+
*
|
|
18
|
+
* - `"allow"` (default): optimistic — used for admin-UI gating, where Postgres
|
|
19
|
+
* remains the authoritative gate and hiding a working action is worse than
|
|
20
|
+
* showing one the server may reject.
|
|
21
|
+
* - `"deny"`: fail-closed — used by real enforcement callers (e.g. a driver
|
|
22
|
+
* applying policies in-process), so an undecidable rule never silently allows.
|
|
23
|
+
*/
|
|
24
|
+
export type UnknownResolution = "allow" | "deny";
|
|
102
25
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
26
|
+
export interface CheckOperationOptions {
|
|
27
|
+
onUnknown?: UnknownResolution;
|
|
28
|
+
}
|
|
107
29
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const value = simpleEqualityMatch[3];
|
|
115
|
-
const entityValue = entity.values[field];
|
|
116
|
-
if (operator === "=") return entityValue === value;
|
|
117
|
-
if (operator === "!=") return entityValue !== value;
|
|
118
|
-
}
|
|
30
|
+
/** Combine clause results with AND under three-valued (Kleene) logic. */
|
|
31
|
+
function kleeneAnd(values: TriState[]): TriState {
|
|
32
|
+
if (values.some(v => v === false)) return false;
|
|
33
|
+
if (values.some(v => v === "unknown")) return "unknown";
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
119
36
|
|
|
120
|
-
|
|
37
|
+
/** The operations a rule covers, mirroring the Postgres generator's resolution. */
|
|
38
|
+
function ruleOperations(rule: SecurityRule): readonly SecurityOperation[] {
|
|
39
|
+
return rule.operations && rule.operations.length > 0
|
|
40
|
+
? rule.operations
|
|
41
|
+
: [rule.operation ?? "all"];
|
|
121
42
|
}
|
|
122
43
|
|
|
123
|
-
function
|
|
44
|
+
function ruleApplies(rule: SecurityRule, targetOperation: SecurityOperation): boolean {
|
|
45
|
+
const ops = ruleOperations(rule);
|
|
46
|
+
return ops.includes(targetOperation) || ops.includes("all");
|
|
47
|
+
}
|
|
124
48
|
|
|
125
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Evaluate a single rule for one operation, returning a tri-state.
|
|
51
|
+
*
|
|
52
|
+
* A `null` clause (the rule contributes no condition for a required clause)
|
|
53
|
+
* denies — matching Postgres, which emits `USING (false)` / `WITH CHECK (false)`
|
|
54
|
+
* in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
|
|
55
|
+
* INSERT/UPDATE; both must pass for UPDATE.
|
|
56
|
+
*/
|
|
57
|
+
function evaluateRuleForOperation(rule: SecurityRule, ctx: PolicyEvalContext, targetOperation: SecurityOperation): TriState {
|
|
58
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
59
|
+
const clause = (expr: typeof usingExpr): TriState => expr === null ? false : evaluatePolicy(expr, ctx);
|
|
126
60
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// null entity: optimistic — we can't evaluate ownership without data
|
|
130
|
-
// Fall through to SQL checks below (if any). If none, will return true.
|
|
131
|
-
} else {
|
|
132
|
-
// Entity present: strictly check ownership. Fail immediately if mismatch.
|
|
133
|
-
if (entity.values[rule.ownerField] !== auth.user?.uid) return false;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
61
|
+
const needsUsing = targetOperation !== "insert";
|
|
62
|
+
const needsWithCheck = targetOperation === "insert" || targetOperation === "update";
|
|
136
63
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (rule.withCheck && !evaluateAST(rule.withCheck, auth, entity)) return false;
|
|
64
|
+
const results: TriState[] = [];
|
|
65
|
+
if (needsUsing) results.push(clause(usingExpr));
|
|
66
|
+
if (needsWithCheck) results.push(clause(withCheckExpr));
|
|
67
|
+
return kleeneAnd(results);
|
|
68
|
+
}
|
|
143
69
|
|
|
144
|
-
|
|
70
|
+
function resolveTriState(value: TriState, onUnknown: UnknownResolution): boolean {
|
|
71
|
+
if (value === "unknown") return onUnknown === "allow";
|
|
72
|
+
return value;
|
|
145
73
|
}
|
|
146
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Decide whether an operation is permitted for a user on a (possibly null) row,
|
|
77
|
+
* by evaluating the collection's security rules with the shared policy model —
|
|
78
|
+
* the same model compiled to Postgres RLS DDL, so the decision matches database
|
|
79
|
+
* enforcement for every non-raw rule.
|
|
80
|
+
*
|
|
81
|
+
* @param options.onUnknown how to treat rules that cannot be decided
|
|
82
|
+
* client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
|
|
83
|
+
* for optimistic UI gating; enforcement callers should pass `"deny"`.
|
|
84
|
+
*/
|
|
147
85
|
export function checkOperation<M extends Record<string, unknown>, USER extends User>(
|
|
148
|
-
collection:
|
|
86
|
+
collection: CollectionConfig<M>,
|
|
149
87
|
authContext: AuthContext<USER>,
|
|
150
88
|
entity: Entity<M> | null,
|
|
151
|
-
targetOperation:
|
|
89
|
+
targetOperation: SecurityOperation,
|
|
90
|
+
options?: CheckOperationOptions
|
|
152
91
|
): boolean {
|
|
153
|
-
const
|
|
92
|
+
const onUnknown = options?.onUnknown ?? "allow";
|
|
93
|
+
const securityRules = getDataSourceCapabilities(collection.engine).supportsRLS ? collection.securityRules : undefined;
|
|
154
94
|
if (!securityRules || securityRules.length === 0) {
|
|
155
95
|
return true;
|
|
156
96
|
}
|
|
157
97
|
|
|
158
|
-
const applicableRules = securityRules.filter((r: SecurityRule) =>
|
|
159
|
-
r.operation === targetOperation ||
|
|
160
|
-
r.operation === "all" ||
|
|
161
|
-
r.operations?.includes(targetOperation) ||
|
|
162
|
-
r.operations?.includes("all")
|
|
163
|
-
);
|
|
164
|
-
|
|
98
|
+
const applicableRules = securityRules.filter((r: SecurityRule) => ruleApplies(r, targetOperation));
|
|
165
99
|
if (applicableRules.length === 0) return false;
|
|
166
100
|
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
if (roleApplicableRules.length === 0) return false;
|
|
101
|
+
const ctx: PolicyEvalContext = {
|
|
102
|
+
uid: authContext.user?.uid,
|
|
103
|
+
roles: authContext.user?.roles ?? [],
|
|
104
|
+
entity
|
|
105
|
+
};
|
|
175
106
|
|
|
176
107
|
let grantedByPermissive = false;
|
|
177
108
|
let deniedByRestrictive = false;
|
|
109
|
+
let hasPermissive = false;
|
|
178
110
|
|
|
179
|
-
for (const rule of
|
|
111
|
+
for (const rule of applicableRules) {
|
|
180
112
|
const mode = rule.mode || "permissive";
|
|
181
|
-
const passed =
|
|
113
|
+
const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
|
|
182
114
|
|
|
183
|
-
if (mode === "restrictive"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
115
|
+
if (mode === "restrictive") {
|
|
116
|
+
if (!passed) {
|
|
117
|
+
deniedByRestrictive = true;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
hasPermissive = true;
|
|
122
|
+
if (passed) grantedByPermissive = true;
|
|
190
123
|
}
|
|
191
124
|
}
|
|
192
125
|
|
|
193
126
|
if (deniedByRestrictive) return false;
|
|
194
|
-
|
|
195
|
-
const hasPermissive = roleApplicableRules.some((r: SecurityRule) => (r.mode || "permissive") === "permissive");
|
|
196
|
-
if (hasPermissive) {
|
|
197
|
-
return grantedByPermissive;
|
|
198
|
-
} else {
|
|
199
|
-
return false;
|
|
200
|
-
}
|
|
127
|
+
return hasPermissive ? grantedByPermissive : false;
|
|
201
128
|
}
|
|
202
129
|
|
|
203
130
|
export function canReadCollection<M extends Record<string, unknown>, USER extends User>
|
|
204
131
|
(
|
|
205
|
-
collection:
|
|
132
|
+
collection: CollectionConfig<M>,
|
|
206
133
|
authContext: AuthContext<USER>
|
|
207
134
|
): boolean {
|
|
208
135
|
return checkOperation(collection, authContext, null, "select");
|
|
@@ -210,7 +137,7 @@ export function canReadCollection<M extends Record<string, unknown>, USER extend
|
|
|
210
137
|
|
|
211
138
|
export function canEditEntity<M extends Record<string, unknown>, USER extends User>
|
|
212
139
|
(
|
|
213
|
-
collection:
|
|
140
|
+
collection: CollectionConfig<M>,
|
|
214
141
|
authContext: AuthContext<USER>,
|
|
215
142
|
path: string,
|
|
216
143
|
entity: Entity<M> | null
|
|
@@ -220,7 +147,7 @@ export function canEditEntity<M extends Record<string, unknown>, USER extends Us
|
|
|
220
147
|
|
|
221
148
|
export function canCreateEntity<M extends Record<string, unknown>, USER extends User>
|
|
222
149
|
(
|
|
223
|
-
collection:
|
|
150
|
+
collection: CollectionConfig<M>,
|
|
224
151
|
authContext: AuthContext<USER>,
|
|
225
152
|
path: string,
|
|
226
153
|
entity: Entity<M> | null
|
|
@@ -230,7 +157,7 @@ export function canCreateEntity<M extends Record<string, unknown>, USER extends
|
|
|
230
157
|
|
|
231
158
|
export function canDeleteEntity<M extends Record<string, unknown>, USER extends User>
|
|
232
159
|
(
|
|
233
|
-
collection:
|
|
160
|
+
collection: CollectionConfig<M>,
|
|
234
161
|
authContext: AuthContext<USER>,
|
|
235
162
|
path: string,
|
|
236
163
|
entity: Entity<M> | null
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Result of evaluating a policy client-side. `"unknown"` means the expression
|
|
5
|
+
* could not be decided without more information — either a raw-SQL escape-hatch
|
|
6
|
+
* node (which the client deliberately never guesses) or a row-column reference
|
|
7
|
+
* with no entity in hand (e.g. list-level gating). Callers decide how to resolve
|
|
8
|
+
* `"unknown"`: fail-closed for an enforcement decision, optimistic for pure
|
|
9
|
+
* visibility gating.
|
|
10
|
+
*/
|
|
11
|
+
export type TriState = boolean | "unknown";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Context for {@link evaluatePolicy}: the acting user (or none) and the row
|
|
15
|
+
* being evaluated (or none, for collection-level gating).
|
|
16
|
+
*/
|
|
17
|
+
export interface PolicyEvalContext {
|
|
18
|
+
/** The current user's id, or null/undefined when unauthenticated. */
|
|
19
|
+
uid?: string | null;
|
|
20
|
+
/** The current user's application roles. */
|
|
21
|
+
roles?: string[];
|
|
22
|
+
/** The row being evaluated, or null when no specific row is available. */
|
|
23
|
+
entity: Entity | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Evaluates a {@link PolicyExpression} against a user + row, using three-valued
|
|
28
|
+
* (Kleene) logic so that `"unknown"` sub-results propagate soundly.
|
|
29
|
+
*
|
|
30
|
+
* This is the JavaScript twin of {@link policyToPostgres}: both derive from the
|
|
31
|
+
* same expression, so the admin UI matches database enforcement by construction
|
|
32
|
+
* for every non-raw rule.
|
|
33
|
+
*/
|
|
34
|
+
export function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext): TriState {
|
|
35
|
+
switch (expr.kind) {
|
|
36
|
+
case "true":
|
|
37
|
+
return true;
|
|
38
|
+
case "false":
|
|
39
|
+
return false;
|
|
40
|
+
case "and":
|
|
41
|
+
return kleeneAnd(expr.operands.map(o => evaluatePolicy(o, ctx)));
|
|
42
|
+
case "or":
|
|
43
|
+
return kleeneOr(expr.operands.map(o => evaluatePolicy(o, ctx)));
|
|
44
|
+
case "not":
|
|
45
|
+
return kleeneNot(evaluatePolicy(expr.operand, ctx));
|
|
46
|
+
case "compare":
|
|
47
|
+
return evaluateCompare(expr.op, expr.left, expr.right, ctx);
|
|
48
|
+
case "rolesOverlap": {
|
|
49
|
+
const userRoles = ctx.roles ?? [];
|
|
50
|
+
return expr.roles.some(r => r === "public" || userRoles.includes(r));
|
|
51
|
+
}
|
|
52
|
+
case "rolesContain": {
|
|
53
|
+
const userRoles = ctx.roles ?? [];
|
|
54
|
+
return expr.roles.every(r => r === "public" || userRoles.includes(r));
|
|
55
|
+
}
|
|
56
|
+
case "authenticated":
|
|
57
|
+
return ctx.uid != null;
|
|
58
|
+
case "existsIn":
|
|
59
|
+
// A membership subquery cannot be run client-side — server-authoritative.
|
|
60
|
+
return "unknown";
|
|
61
|
+
case "raw":
|
|
62
|
+
// Arbitrary SQL cannot be evaluated client-side — never guess.
|
|
63
|
+
return "unknown";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Three-valued logic ───────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function kleeneAnd(values: TriState[]): TriState {
|
|
70
|
+
if (values.some(v => v === false)) return false;
|
|
71
|
+
if (values.some(v => v === "unknown")) return "unknown";
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function kleeneOr(values: TriState[]): TriState {
|
|
76
|
+
if (values.some(v => v === true)) return true;
|
|
77
|
+
if (values.some(v => v === "unknown")) return "unknown";
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function kleeneNot(value: TriState): TriState {
|
|
82
|
+
if (value === "unknown") return "unknown";
|
|
83
|
+
return !value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Comparison ───────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
type ResolvedOperand = { known: false } | { known: true; value: unknown };
|
|
89
|
+
|
|
90
|
+
function resolveOperand(operand: PolicyOperand, ctx: PolicyEvalContext): ResolvedOperand {
|
|
91
|
+
switch (operand.kind) {
|
|
92
|
+
case "literal":
|
|
93
|
+
return { known: true, value: operand.value };
|
|
94
|
+
case "authUid":
|
|
95
|
+
return { known: true, value: ctx.uid ?? null };
|
|
96
|
+
case "authRoles":
|
|
97
|
+
return { known: true, value: ctx.roles ?? [] };
|
|
98
|
+
case "field":
|
|
99
|
+
// Can't resolve a row column without the row.
|
|
100
|
+
if (!ctx.entity) return { known: false };
|
|
101
|
+
return { known: true, value: ctx.entity.values[operand.name] };
|
|
102
|
+
case "outerField":
|
|
103
|
+
// Only meaningful inside an `existsIn` subquery (server-authoritative).
|
|
104
|
+
return { known: false };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function evaluateCompare(
|
|
109
|
+
op: PolicyCompareOperator,
|
|
110
|
+
left: PolicyOperand,
|
|
111
|
+
right: PolicyOperand,
|
|
112
|
+
ctx: PolicyEvalContext
|
|
113
|
+
): TriState {
|
|
114
|
+
const l = resolveOperand(left, ctx);
|
|
115
|
+
const r = resolveOperand(right, ctx);
|
|
116
|
+
if (!l.known || !r.known) return "unknown";
|
|
117
|
+
|
|
118
|
+
const a = l.value;
|
|
119
|
+
const b = r.value;
|
|
120
|
+
|
|
121
|
+
if (a === null || b === null) {
|
|
122
|
+
if (op === "eq") return false;
|
|
123
|
+
if (op === "neq") return true;
|
|
124
|
+
return "unknown";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (op === "eq") return a === b;
|
|
128
|
+
if (op === "neq") return a !== b;
|
|
129
|
+
|
|
130
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
131
|
+
if (op === "lt") return a < b;
|
|
132
|
+
if (op === "lte") return a <= b;
|
|
133
|
+
if (op === "gt") return a > b;
|
|
134
|
+
if (op === "gte") return a >= b;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
138
|
+
if (op === "lt") return a < b;
|
|
139
|
+
if (op === "lte") return a <= b;
|
|
140
|
+
if (op === "gt") return a > b;
|
|
141
|
+
if (op === "gte") return a >= b;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof a === "bigint" && typeof b === "bigint") {
|
|
145
|
+
if (op === "lt") return a < b;
|
|
146
|
+
if (op === "lte") return a <= b;
|
|
147
|
+
if (op === "gt") return a > b;
|
|
148
|
+
if (op === "gte") return a >= b;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return "unknown";
|
|
152
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
|
|
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
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,
|
|
39
|
+
* suitable for a `USING (...)` / `WITH CHECK (...)` clause.
|
|
40
|
+
*
|
|
41
|
+
* This is one of the two consumers of the shared policy model (the other being
|
|
42
|
+
* {@link evaluatePolicy}); the Postgres schema generators call it so that DDL
|
|
43
|
+
* and the admin UI derive from the exact same expression.
|
|
44
|
+
*/
|
|
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 {
|
|
57
|
+
switch (expr.kind) {
|
|
58
|
+
case "true":
|
|
59
|
+
return "true";
|
|
60
|
+
case "false":
|
|
61
|
+
return "false";
|
|
62
|
+
case "and":
|
|
63
|
+
return expr.operands.length === 0
|
|
64
|
+
? "true"
|
|
65
|
+
: expr.operands.map(o => `(${compile(o, scope)})`).join(" AND ");
|
|
66
|
+
case "or":
|
|
67
|
+
return expr.operands.length === 0
|
|
68
|
+
? "false"
|
|
69
|
+
: expr.operands.map(o => `(${compile(o, scope)})`).join(" OR ");
|
|
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
|
+
return `NOT (${compile(expr.operand, scope)})`;
|
|
74
|
+
case "compare":
|
|
75
|
+
return `${operandToSql(expr.left, scope)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, scope)}`;
|
|
76
|
+
case "rolesOverlap":
|
|
77
|
+
return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
|
|
78
|
+
case "rolesContain":
|
|
79
|
+
return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
|
|
80
|
+
case "authenticated":
|
|
81
|
+
return "auth.uid() IS NOT NULL";
|
|
82
|
+
case "existsIn":
|
|
83
|
+
return compileExistsIn(expr, scope);
|
|
84
|
+
case "raw":
|
|
85
|
+
// Full-power escape hatch: `{column}` references resolve to the bare
|
|
86
|
+
// column name (matching the previous raw-SQL behavior).
|
|
87
|
+
return expr.sql.replace(/\{(\w+)\}/g, (_, col) => col);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compiles `existsIn` to a correlated `EXISTS (SELECT 1 FROM <join> WHERE ...)`.
|
|
93
|
+
* Inside the subquery, `field` operands bind to the aliased join table and
|
|
94
|
+
* `outerField` operands bind to the (table-qualified) outer RLS row.
|
|
95
|
+
*/
|
|
96
|
+
function compileExistsIn(expr: ExistsInPolicyExpression, scope: CompileScope): string {
|
|
97
|
+
const join = scope.resolveCollection?.(expr.collection);
|
|
98
|
+
const joinTable = join ? getTableName(join) : toSnakeCase(expr.collection);
|
|
99
|
+
const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? "public";
|
|
100
|
+
const alias = `_ex${scope.alias.n++}`;
|
|
101
|
+
|
|
102
|
+
// `outerField` inside the subquery must be qualified with the outer table,
|
|
103
|
+
// otherwise a bare column name would bind to the joined table instead.
|
|
104
|
+
const outerTable = scope.outerCollection ? getTableName(scope.outerCollection) : undefined;
|
|
105
|
+
const outerSchema = schemaOf(scope.outerCollection) ?? "public";
|
|
106
|
+
const outerPrefix = outerTable ? `"${outerSchema}"."${outerTable}".` : "";
|
|
107
|
+
|
|
108
|
+
const innerScope: CompileScope = {
|
|
109
|
+
fieldCollection: join,
|
|
110
|
+
fieldPrefix: `"${alias}".`,
|
|
111
|
+
outerCollection: scope.outerCollection,
|
|
112
|
+
outerPrefix,
|
|
113
|
+
resolveCollection: scope.resolveCollection,
|
|
114
|
+
alias: scope.alias
|
|
115
|
+
};
|
|
116
|
+
return `EXISTS (SELECT 1 FROM "${joinSchema}"."${joinTable}" "${alias}" WHERE ${compile(expr.where, innerScope)})`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
|
|
120
|
+
eq: "=",
|
|
121
|
+
neq: "!=",
|
|
122
|
+
lt: "<",
|
|
123
|
+
lte: "<=",
|
|
124
|
+
gt: ">",
|
|
125
|
+
gte: ">="
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
function operandToSql(operand: PolicyOperand, scope: CompileScope): string {
|
|
129
|
+
switch (operand.kind) {
|
|
130
|
+
case "field":
|
|
131
|
+
return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
|
|
132
|
+
case "outerField":
|
|
133
|
+
return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
|
|
134
|
+
case "literal":
|
|
135
|
+
return quoteLiteral(operand.value);
|
|
136
|
+
case "authUid":
|
|
137
|
+
return "auth.uid()";
|
|
138
|
+
case "authRoles":
|
|
139
|
+
return "string_to_array(auth.roles(), ',')";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function schemaOf(collection?: CollectionConfig): string | undefined {
|
|
144
|
+
return (collection as { schema?: string } | undefined)?.schema || undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveColumnName(propName: string, collection?: CollectionConfig): string {
|
|
148
|
+
const prop = collection?.properties?.[propName] as Property | undefined;
|
|
149
|
+
if (prop && "columnName" in prop && typeof (prop as { columnName?: unknown }).columnName === "string") {
|
|
150
|
+
return (prop as { columnName: string }).columnName;
|
|
151
|
+
}
|
|
152
|
+
return toSnakeCase(propName);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function quoteLiteral(value: string | number | boolean | null): string {
|
|
156
|
+
if (value === null) return "NULL";
|
|
157
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
158
|
+
if (typeof value === "number") return String(value);
|
|
159
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
|
|
163
|
+
function rolesArraySql(roles: readonly string[]): string {
|
|
164
|
+
return `ARRAY[${[...roles].sort().map(r => `'${r}'`).join(",")}]`;
|
|
165
|
+
}
|