@rebasepro/common 0.7.0 → 0.8.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.
Files changed (42) hide show
  1. package/dist/collections/CollectionRegistry.d.ts +17 -2
  2. package/dist/collections/default-collections.d.ts +255 -2
  3. package/dist/data/filter-dialect.d.ts +61 -0
  4. package/dist/data/query_builder.d.ts +4 -4
  5. package/dist/data/resolveDataSource.d.ts +7 -7
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +604 -188
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/index.umd.js +611 -186
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/util/builders.d.ts +48 -1
  12. package/dist/util/callbacks.d.ts +6 -1
  13. package/dist/util/index.d.ts +1 -0
  14. package/dist/util/permissions.d.ts +26 -2
  15. package/dist/util/policy/evaluatePolicy.d.ts +31 -0
  16. package/dist/util/policy/index.d.ts +3 -0
  17. package/dist/util/policy/policyToPostgres.d.ts +10 -0
  18. package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
  19. package/dist/util/policy/sqlToPolicy.d.ts +20 -0
  20. package/dist/util/storage.d.ts +26 -1
  21. package/package.json +13 -13
  22. package/src/collections/CollectionRegistry.ts +59 -28
  23. package/src/collections/default-collections.ts +4 -4
  24. package/src/data/buildRebaseData.ts +9 -120
  25. package/src/data/filter-dialect.ts +318 -0
  26. package/src/data/query_builder.ts +10 -10
  27. package/src/data/resolveDataSource.ts +9 -9
  28. package/src/index.ts +1 -0
  29. package/src/util/builders.ts +78 -1
  30. package/src/util/callbacks.ts +8 -1
  31. package/src/util/index.ts +1 -0
  32. package/src/util/permissions.test.ts +5 -3
  33. package/src/util/permissions.ts +85 -158
  34. package/src/util/policy/evaluatePolicy.ts +146 -0
  35. package/src/util/policy/index.ts +3 -0
  36. package/src/util/policy/policyToPostgres.ts +85 -0
  37. package/src/util/policy/securityRuleToConditions.ts +67 -0
  38. package/src/util/policy/sqlToPolicy.ts +88 -0
  39. package/src/util/references.ts +1 -1
  40. package/src/util/relations.ts +8 -9
  41. package/src/util/resolutions.ts +6 -6
  42. package/src/util/storage.ts +34 -1
@@ -1,4 +1,6 @@
1
- import { CollectionWithRelations, Entity, EntityCollection, getDataSourceCapabilities, SecurityRule, User } from "@rebasepro/types";
1
+ import { Entity, EntityCollection, 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,195 +11,120 @@ export interface AuthContext<USER extends User = User> {
9
11
  user: USER | null;
10
12
  }
11
13
 
12
- function evaluateAST<USER extends User, M extends Record<string, unknown>>(sqlString: string, auth: AuthContext<USER>, entity: Entity<M> | null): boolean {
13
- // This is a client-side SQL evaluator used *only* for optimistic UI updates.
14
- // It parses basic AND / OR statements to evaluate RLS without backend roundtrips.
15
- if (!entity) return true;
16
-
17
- // 1. Clean outer parentheses
18
- let cleanedSQL = sqlString.trim();
19
- while (cleanedSQL.startsWith("(") && cleanedSQL.endsWith(")")) {
20
- let openCount = 0;
21
- let isEnclosing = true;
22
- for (let i = 0; i < cleanedSQL.length - 1; i++) {
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
- const match2 = cleanedSQL.match(pattern2);
104
- if (match2 && match2[1]) {
105
- return entity.values[match2[1]] === auth.user?.uid;
106
- }
26
+ export interface CheckOperationOptions {
27
+ onUnknown?: UnknownResolution;
28
+ }
107
29
 
108
- // 6. Simple equality
109
- // Pattern: `field = 'value'` or `{field} != 'value'`
110
- const simpleEqualityMatch = cleanedSQL.match(/^\{?([\w_]+)\}?\s*(=|!=)\s*'([^']+)'$/i);
111
- if (simpleEqualityMatch) {
112
- const field = simpleEqualityMatch[1];
113
- const operator = simpleEqualityMatch[2];
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
- return true; // Optimistic fallback for anything else
37
+ /** The operations a rule covers, mirroring the Postgres generator's resolution. */
38
+ function ruleOperations(rule: SecurityRule): SecurityOperation[] {
39
+ return rule.operations && rule.operations.length > 0
40
+ ? rule.operations
41
+ : [rule.operation ?? "all"];
121
42
  }
122
43
 
123
- function evaluateRule<USER extends User, M extends Record<string, unknown>>(rule: SecurityRule, auth: AuthContext<USER>, entity: Entity<M> | null): boolean {
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
- if (rule.access === "public") return true;
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
- if (rule.ownerField) {
128
- if (!entity) {
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
- // In PostgreSQL RLS, USING and WITH CHECK have distinct semantics:
138
- // USING applies to existing rows (SELECT/UPDATE/DELETE read phase)
139
- // WITH CHECK applies to new/modified values (INSERT/UPDATE write phase)
140
- // Both must pass. We evaluate both independently.
141
- if (rule.using && !evaluateAST(rule.using, auth, entity)) return false;
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
- return true;
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
86
  collection: EntityCollection<M>,
149
87
  authContext: AuthContext<USER>,
150
88
  entity: Entity<M> | null,
151
- targetOperation: "select" | "insert" | "update" | "delete"
89
+ targetOperation: SecurityOperation,
90
+ options?: CheckOperationOptions
152
91
  ): boolean {
153
- const securityRules = getDataSourceCapabilities(collection.driver).supportsRLS ? (collection as CollectionWithRelations).securityRules : undefined;
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 userRoleIds = authContext.user?.roles ?? [];
168
- const userRoles = [...userRoleIds, "public"];
169
- const roleApplicableRules = applicableRules.filter((rule: SecurityRule) => {
170
- if (!rule.roles || rule.roles.length === 0) return true;
171
- return rule.roles.some((r: string) => userRoles.includes(r));
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 roleApplicableRules) {
111
+ for (const rule of applicableRules) {
180
112
  const mode = rule.mode || "permissive";
181
- const passed = evaluateRule(rule, authContext, entity);
113
+ const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
182
114
 
183
- if (mode === "restrictive" && !passed) {
184
- deniedByRestrictive = true;
185
- break;
186
- }
187
-
188
- if (mode === "permissive" && passed) {
189
- grantedByPermissive = true;
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>
@@ -0,0 +1,146 @@
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 "raw":
59
+ // Arbitrary SQL cannot be evaluated client-side — never guess.
60
+ return "unknown";
61
+ }
62
+ }
63
+
64
+ // ── Three-valued logic ───────────────────────────────────────────────
65
+
66
+ function kleeneAnd(values: TriState[]): TriState {
67
+ if (values.some(v => v === false)) return false;
68
+ if (values.some(v => v === "unknown")) return "unknown";
69
+ return true;
70
+ }
71
+
72
+ function kleeneOr(values: TriState[]): TriState {
73
+ if (values.some(v => v === true)) return true;
74
+ if (values.some(v => v === "unknown")) return "unknown";
75
+ return false;
76
+ }
77
+
78
+ function kleeneNot(value: TriState): TriState {
79
+ if (value === "unknown") return "unknown";
80
+ return !value;
81
+ }
82
+
83
+ // ── Comparison ───────────────────────────────────────────────────────
84
+
85
+ type ResolvedOperand = { known: false } | { known: true; value: unknown };
86
+
87
+ function resolveOperand(operand: PolicyOperand, ctx: PolicyEvalContext): ResolvedOperand {
88
+ switch (operand.kind) {
89
+ case "literal":
90
+ return { known: true, value: operand.value };
91
+ case "authUid":
92
+ return { known: true, value: ctx.uid ?? null };
93
+ case "authRoles":
94
+ return { known: true, value: ctx.roles ?? [] };
95
+ case "field":
96
+ // Can't resolve a row column without the row.
97
+ if (!ctx.entity) return { known: false };
98
+ return { known: true, value: ctx.entity.values[operand.name] };
99
+ }
100
+ }
101
+
102
+ function evaluateCompare(
103
+ op: PolicyCompareOperator,
104
+ left: PolicyOperand,
105
+ right: PolicyOperand,
106
+ ctx: PolicyEvalContext
107
+ ): TriState {
108
+ const l = resolveOperand(left, ctx);
109
+ const r = resolveOperand(right, ctx);
110
+ if (!l.known || !r.known) return "unknown";
111
+
112
+ const a = l.value;
113
+ const b = r.value;
114
+
115
+ if (a === null || b === null) {
116
+ if (op === "eq") return false;
117
+ if (op === "neq") return true;
118
+ return "unknown";
119
+ }
120
+
121
+ if (op === "eq") return a === b;
122
+ if (op === "neq") return a !== b;
123
+
124
+ if (typeof a === "string" && typeof b === "string") {
125
+ if (op === "lt") return a < b;
126
+ if (op === "lte") return a <= b;
127
+ if (op === "gt") return a > b;
128
+ if (op === "gte") return a >= b;
129
+ }
130
+
131
+ if (typeof a === "number" && typeof b === "number") {
132
+ if (op === "lt") return a < b;
133
+ if (op === "lte") return a <= b;
134
+ if (op === "gt") return a > b;
135
+ if (op === "gte") return a >= b;
136
+ }
137
+
138
+ if (typeof a === "bigint" && typeof b === "bigint") {
139
+ if (op === "lt") return a < b;
140
+ if (op === "lte") return a <= b;
141
+ if (op === "gt") return a > b;
142
+ if (op === "gte") return a >= b;
143
+ }
144
+
145
+ return "unknown";
146
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./securityRuleToConditions";
2
+ export * from "./policyToPostgres";
3
+ export * from "./evaluatePolicy";
@@ -0,0 +1,85 @@
1
+ import { EntityCollection, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property } from "@rebasepro/types";
2
+ import { toSnakeCase } from "@rebasepro/utils";
3
+
4
+ /**
5
+ * Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,
6
+ * suitable for a `USING (...)` / `WITH CHECK (...)` clause.
7
+ *
8
+ * This is one of the two consumers of the shared policy model (the other being
9
+ * {@link evaluatePolicy}); the Postgres schema generators call it so that DDL
10
+ * and the admin UI derive from the exact same expression.
11
+ */
12
+ export function policyToPostgres(expr: PolicyExpression, collection?: EntityCollection): string {
13
+ switch (expr.kind) {
14
+ case "true":
15
+ return "true";
16
+ case "false":
17
+ return "false";
18
+ case "and":
19
+ return expr.operands.length === 0
20
+ ? "true"
21
+ : expr.operands.map(o => `(${policyToPostgres(o, collection)})`).join(" AND ");
22
+ case "or":
23
+ return expr.operands.length === 0
24
+ ? "false"
25
+ : expr.operands.map(o => `(${policyToPostgres(o, collection)})`).join(" OR ");
26
+ case "not":
27
+ // Render the common `auth.uid() IS NULL` (unauthenticated) form directly.
28
+ if (expr.operand.kind === "authenticated") return "auth.uid() IS NULL";
29
+ return `NOT (${policyToPostgres(expr.operand, collection)})`;
30
+ case "compare":
31
+ return `${operandToSql(expr.left, collection)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, collection)}`;
32
+ case "rolesOverlap":
33
+ return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
34
+ case "rolesContain":
35
+ return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
36
+ case "authenticated":
37
+ return "auth.uid() IS NOT NULL";
38
+ case "raw":
39
+ // Full-power escape hatch: `{column}` references resolve to the bare
40
+ // column name (matching the previous raw-SQL behavior).
41
+ return expr.sql.replace(/\{(\w+)\}/g, (_, col) => col);
42
+ }
43
+ }
44
+
45
+ const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
46
+ eq: "=",
47
+ neq: "!=",
48
+ lt: "<",
49
+ lte: "<=",
50
+ gt: ">",
51
+ gte: ">="
52
+ };
53
+
54
+ function operandToSql(operand: PolicyOperand, collection?: EntityCollection): string {
55
+ switch (operand.kind) {
56
+ case "field":
57
+ return resolveColumnName(operand.name, collection);
58
+ case "literal":
59
+ return quoteLiteral(operand.value);
60
+ case "authUid":
61
+ return "auth.uid()";
62
+ case "authRoles":
63
+ return "string_to_array(auth.roles(), ',')";
64
+ }
65
+ }
66
+
67
+ function resolveColumnName(propName: string, collection?: EntityCollection): string {
68
+ const prop = collection?.properties?.[propName] as Property | undefined;
69
+ if (prop && "columnName" in prop && typeof (prop as { columnName?: unknown }).columnName === "string") {
70
+ return (prop as { columnName: string }).columnName;
71
+ }
72
+ return toSnakeCase(propName);
73
+ }
74
+
75
+ function quoteLiteral(value: string | number | boolean | null): string {
76
+ if (value === null) return "NULL";
77
+ if (typeof value === "boolean") return value ? "true" : "false";
78
+ if (typeof value === "number") return String(value);
79
+ return `'${value.replace(/'/g, "''")}'`;
80
+ }
81
+
82
+ /** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
83
+ function rolesArraySql(roles: string[]): string {
84
+ return `ARRAY[${[...roles].sort().map(r => `'${r}'`).join(",")}]`;
85
+ }
@@ -0,0 +1,67 @@
1
+ import { PolicyExpression, SecurityRule, policy } from "@rebasepro/types";
2
+ import { sqlToPolicy } from "./sqlToPolicy";
3
+
4
+ /**
5
+ * The normalized `USING` / `WITH CHECK` conditions for a single security rule,
6
+ * expressed in the engine-agnostic {@link PolicyExpression} model.
7
+ *
8
+ * A `null` clause means "this rule contributes no condition for that clause";
9
+ * consumers apply the default (Postgres denies with `false`).
10
+ */
11
+ export interface RuleConditions {
12
+ usingExpr: PolicyExpression | null;
13
+ withCheckExpr: PolicyExpression | null;
14
+ }
15
+
16
+ /**
17
+ * Desugars a {@link SecurityRule} — its `access`/`ownerField`/`roles` shortcuts,
18
+ * structured `condition`/`check`, and raw `using`/`withCheck` — into a single
19
+ * normalized {@link PolicyExpression} pair.
20
+ *
21
+ * **This is the linchpin against drift:** both the Postgres DDL generators and
22
+ * the client-side evaluator consume this one function, so there is exactly one
23
+ * definition of what a rule means. In particular, application `roles` are folded
24
+ * into the expression here (AND'd with the base condition, matching how Postgres
25
+ * generates the clause) rather than being handled separately by each consumer.
26
+ */
27
+ export function securityRuleToConditions(rule: SecurityRule): RuleConditions {
28
+ return {
29
+ usingExpr: withRoles(baseUsing(rule), rule),
30
+ withCheckExpr: withRoles(baseWithCheck(rule), rule)
31
+ };
32
+ }
33
+
34
+ function baseUsing(rule: SecurityRule): PolicyExpression | null {
35
+ if (rule.condition) return rule.condition;
36
+ if (rule.using != null) return sqlToPolicy(rule.using);
37
+ if (rule.access === "public") return policy.true();
38
+ if (rule.ownerField) return policy.compare(policy.field(rule.ownerField), "eq", policy.authUid());
39
+ return null;
40
+ }
41
+
42
+ function baseWithCheck(rule: SecurityRule): PolicyExpression | null {
43
+ if (rule.check) return rule.check;
44
+ if (rule.withCheck != null) return sqlToPolicy(rule.withCheck);
45
+ // No explicit WITH CHECK → fall back to the USING condition, matching
46
+ // PostgreSQL's own default behavior.
47
+ return baseUsing(rule);
48
+ }
49
+
50
+ /**
51
+ * AND the base condition with an application-role check, or produce a roles-only
52
+ * condition when there is no base. Mirrors the Postgres generator so that a
53
+ * role-scoped restrictive rule denies exactly the same set of users on both
54
+ * sides.
55
+ */
56
+ function withRoles(base: PolicyExpression | null, rule: SecurityRule): PolicyExpression | null {
57
+ if (!rule.roles || rule.roles.length === 0) return base;
58
+ const rolesExpr = policy.rolesOverlap(rule.roles);
59
+ if (rule.mode === "restrictive") {
60
+ // Restrictive rule: applies ONLY if user has the roles.
61
+ // If user DOES NOT have the roles, they are NOT restricted (passes).
62
+ // If user HAS the roles, they must pass the base condition.
63
+ // Logical equivalent: NOT(roles) OR base
64
+ return base ? policy.or(policy.not(rolesExpr), base) : policy.not(rolesExpr);
65
+ }
66
+ return base ? policy.and(base, rolesExpr) : rolesExpr;
67
+ }