@rebasepro/common 0.17.3 → 0.18.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 (58) hide show
  1. package/README.md +4 -0
  2. package/dist/collections/CollectionRegistry.d.ts +1 -1
  3. package/dist/collections/default-collections.d.ts +15 -84
  4. package/dist/data/buildRebaseData.d.ts +1 -1
  5. package/dist/data/filter-dialect.d.ts +11 -0
  6. package/dist/data/sort-dialect.d.ts +15 -3
  7. package/dist/index.es.js +375 -63
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/util/builders.d.ts +69 -24
  10. package/dist/util/callback-errors.d.ts +77 -0
  11. package/dist/util/callback-errors.test.d.ts +1 -0
  12. package/dist/util/index.d.ts +1 -0
  13. package/dist/util/policy/evaluatePolicy.d.ts +6 -0
  14. package/dist/util/relations.d.ts +41 -0
  15. package/dist/util/table-name.test.d.ts +1 -0
  16. package/package.json +26 -22
  17. package/src/collections/CollectionRegistry.ts +0 -485
  18. package/src/collections/default-collections.ts +0 -109
  19. package/src/collections/index.ts +0 -2
  20. package/src/data/buildRebaseData.ts +0 -816
  21. package/src/data/buildRoutedRebaseData.ts +0 -103
  22. package/src/data/filter-conditions.ts +0 -46
  23. package/src/data/filter-dialect.ts +0 -737
  24. package/src/data/paginate.ts +0 -334
  25. package/src/data/query_builder.ts +0 -176
  26. package/src/data/resolveDataSource.ts +0 -135
  27. package/src/data/sort-dialect.ts +0 -237
  28. package/src/index.ts +0 -11
  29. package/src/table-classification.ts +0 -109
  30. package/src/types/json-logic-js.d.ts +0 -8
  31. package/src/util/auth-default-policies.ts +0 -215
  32. package/src/util/builders.ts +0 -82
  33. package/src/util/callbacks.ts +0 -122
  34. package/src/util/collections.ts +0 -117
  35. package/src/util/common.ts +0 -2
  36. package/src/util/conditions.ts +0 -168
  37. package/src/util/email.ts +0 -32
  38. package/src/util/entities.ts +0 -282
  39. package/src/util/enums.ts +0 -26
  40. package/src/util/identity.ts +0 -202
  41. package/src/util/index.ts +0 -21
  42. package/src/util/internal-tables.test.ts +0 -188
  43. package/src/util/internal-tables.ts +0 -197
  44. package/src/util/junction-policies.ts +0 -355
  45. package/src/util/paths.ts +0 -27
  46. package/src/util/permissions.test.ts +0 -866
  47. package/src/util/permissions.ts +0 -206
  48. package/src/util/pg-column-to-property.ts +0 -377
  49. package/src/util/policy/evaluatePolicy.ts +0 -194
  50. package/src/util/policy/index.ts +0 -4
  51. package/src/util/policy/policyToPostgres.ts +0 -263
  52. package/src/util/policy/securityRuleToConditions.ts +0 -67
  53. package/src/util/policy/sqlToPolicy.ts +0 -422
  54. package/src/util/relations.ts +0 -236
  55. package/src/util/resolutions.ts +0 -534
  56. package/src/util/resolve-relation.ts +0 -243
  57. package/src/util/storage.ts +0 -177
  58. package/src/util/string-column-length.ts +0 -31
@@ -1,194 +0,0 @@
1
- import { ANONYMOUS_USER_ID, isAnonymousUid, 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
- /**
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 `rebase.uid()`
24
- * the database would see for the same request.
25
- */
26
- uid?: string | null;
27
- /** The current user's application roles. */
28
- roles?: string[];
29
- /** The row being evaluated, or null when no specific row is available. */
30
- entity: Entity | null;
31
- }
32
-
33
- /**
34
- * Evaluates a {@link PolicyExpression} against a user + row, using three-valued
35
- * (Kleene) logic so that `"unknown"` sub-results propagate soundly.
36
- *
37
- * This is the JavaScript twin of {@link policyToPostgres}: both derive from the
38
- * same expression, so the admin UI matches database enforcement by construction
39
- * for every non-raw rule.
40
- */
41
- export function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext): TriState {
42
- switch (expr.kind) {
43
- case "true":
44
- return true;
45
- case "false":
46
- return false;
47
- case "and":
48
- return kleeneAnd(expr.operands.map(o => evaluatePolicy(o, ctx)));
49
- case "or":
50
- return kleeneOr(expr.operands.map(o => evaluatePolicy(o, ctx)));
51
- case "not":
52
- return kleeneNot(evaluatePolicy(expr.operand, ctx));
53
- case "compare":
54
- return evaluateCompare(expr.op, expr.left, expr.right, ctx);
55
- case "rolesOverlap": {
56
- const userRoles = ctx.roles ?? [];
57
- return expr.roles.some(r => r === "public" || userRoles.includes(r));
58
- }
59
- case "rolesContain": {
60
- const userRoles = ctx.roles ?? [];
61
- return expr.roles.every(r => r === "public" || userRoles.includes(r));
62
- }
63
- case "authenticated":
64
- // Every anonymous spelling, matching what this node compiles to in
65
- // Postgres — the two evaluators disagreeing about who is signed in
66
- // is the client optimistically rendering a row the database will
67
- // refuse, or hiding one it would have allowed.
68
- return ctx.uid != null && !isAnonymousUid(ctx.uid);
69
- case "serverContext":
70
- // A client is never the server context. Postgres decides this by
71
- // `rebase.uid() IS NULL`, which a client request can never produce:
72
- // the driver substitutes ANONYMOUS_USER_ID for a missing id.
73
- return false;
74
- case "existsIn":
75
- // A membership subquery cannot be run client-side — server-authoritative.
76
- return "unknown";
77
- case "raw":
78
- // Arbitrary SQL cannot be evaluated client-side — never guess.
79
- return "unknown";
80
- }
81
- }
82
-
83
- // ── Three-valued logic ───────────────────────────────────────────────
84
-
85
- function kleeneAnd(values: TriState[]): TriState {
86
- if (values.some(v => v === false)) return false;
87
- if (values.some(v => v === "unknown")) return "unknown";
88
- return true;
89
- }
90
-
91
- function kleeneOr(values: TriState[]): TriState {
92
- if (values.some(v => v === true)) return true;
93
- if (values.some(v => v === "unknown")) return "unknown";
94
- return false;
95
- }
96
-
97
- function kleeneNot(value: TriState): TriState {
98
- if (value === "unknown") return "unknown";
99
- return !value;
100
- }
101
-
102
- // ── Comparison ───────────────────────────────────────────────────────
103
-
104
- type ResolvedOperand = { known: false } | { known: true; value: unknown };
105
-
106
- function resolveOperand(operand: PolicyOperand, ctx: PolicyEvalContext): ResolvedOperand {
107
- switch (operand.kind) {
108
- case "literal":
109
- return { known: true, value: operand.value };
110
- case "authUid":
111
- // The sentinel, not null: `rebase.uid()` is never NULL for a request
112
- // that came from a client, so comparing against null here would
113
- // disagree with the database on exactly the rules that test for it
114
- // (e.g. `rebase.uid() <> 'anonymous'`).
115
- return { known: true, value: ctx.uid ?? ANONYMOUS_USER_ID };
116
- case "authRoles":
117
- return { known: true, value: ctx.roles ?? [] };
118
- case "field":
119
- // Can't resolve a row column without the row.
120
- if (!ctx.entity) return { known: false };
121
- return { known: true, value: ctx.entity.values[operand.name] };
122
- case "outerField":
123
- // Only meaningful inside an `existsIn` subquery (server-authoritative).
124
- return { known: false };
125
- }
126
- }
127
-
128
- function evaluateCompare(
129
- op: PolicyCompareOperator,
130
- left: PolicyOperand,
131
- right: PolicyOperand,
132
- ctx: PolicyEvalContext
133
- ): TriState {
134
- const l = resolveOperand(left, ctx);
135
- const r = resolveOperand(right, ctx);
136
- if (!l.known || !r.known) return "unknown";
137
-
138
- const a = l.value;
139
- const b = r.value;
140
-
141
- if (a === null || b === null) {
142
- // SQL answers NULL for *every* comparison against NULL, and a policy
143
- // that answers NULL does not grant the row. So the only question here
144
- // is which JavaScript answer reproduces that outcome.
145
- //
146
- // This used to answer `false` for `eq` and `true` for `neq`, which is
147
- // JavaScript's two-valued reading of a three-valued question.
148
- //
149
- // `neq` was a grant the database does not give:
150
- // `owner_id != rebase.uid()` on a row whose `owner_id` is NULL read as
151
- // *permitted* in the admin panel and was refused by Postgres — on every
152
- // row where the column is null, which for a nullable column is usually
153
- // most of them.
154
- //
155
- // `false` for `eq` looked safe, because false denies and NULL denies.
156
- // It is not, because it does not survive negation: `not(a = NULL)`
157
- // became `true` while `NOT NULL` stays NULL, so the same grant reappears
158
- // one operator up. A local answer that is only right in a positive
159
- // position is not right — it just moves.
160
- //
161
- // "unknown" is what SQL actually says, it composes correctly through
162
- // Kleene negation, and enforcement callers already resolve it
163
- // fail-closed. Both were found by the exhaustive Postgres differential
164
- // in `policy-agreement-exhaustive.test.ts`, the second only after the
165
- // first was fixed.
166
- return "unknown";
167
- }
168
-
169
- if (op === "eq") return a === b;
170
- if (op === "neq") return a !== b;
171
-
172
- if (typeof a === "string" && typeof b === "string") {
173
- if (op === "lt") return a < b;
174
- if (op === "lte") return a <= b;
175
- if (op === "gt") return a > b;
176
- if (op === "gte") return a >= b;
177
- }
178
-
179
- if (typeof a === "number" && typeof b === "number") {
180
- if (op === "lt") return a < b;
181
- if (op === "lte") return a <= b;
182
- if (op === "gt") return a > b;
183
- if (op === "gte") return a >= b;
184
- }
185
-
186
- if (typeof a === "bigint" && typeof b === "bigint") {
187
- if (op === "lt") return a < b;
188
- if (op === "lte") return a <= b;
189
- if (op === "gt") return a > b;
190
- if (op === "gte") return a >= b;
191
- }
192
-
193
- return "unknown";
194
- }
@@ -1,4 +0,0 @@
1
- export * from "./securityRuleToConditions";
2
- export * from "./sqlToPolicy";
3
- export * from "./policyToPostgres";
4
- export * from "./evaluatePolicy";
@@ -1,263 +0,0 @@
1
- import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression, RLS_ROLES_SQL, RLS_UID_SQL, rewriteLegacyRlsFunctions } 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
- return `NOT (${compile(expr.operand, scope)})`;
72
- case "compare": {
73
- // `rebase.uid()` returns text; cast the column side so uuid / integer
74
- // id columns compare cleanly instead of failing with
75
- // "operator does not exist: uuid = text" at CREATE POLICY time.
76
- const castForAuthUid = (operand: PolicyOperand, sqlText: string, other: PolicyOperand): string =>
77
- other.kind === "authUid" && (operand.kind === "field" || operand.kind === "outerField")
78
- ? `(${sqlText})::text`
79
- : sqlText;
80
- const leftSql = castForAuthUid(expr.left, operandToSql(expr.left, scope), expr.right);
81
- const rightSql = castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);
82
- return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
83
- }
84
- case "rolesOverlap":
85
- return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
86
- case "rolesContain":
87
- return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;
88
- case "authenticated":
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 a sentinel. Excluding the sentinels is what makes this mean
92
- // "signed in" rather than "anyone at all".
93
- //
94
- // Every sentinel, not just the current one. This clause is written
95
- // into the database and outlives the server that generated it: a
96
- // policy compiled here may be enforced against an older server that
97
- // still reports `'anon'`, which is exactly how excluding one
98
- // spelling turned this helper into a grant. See ANONYMOUS_USER_IDS.
99
- return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
100
- case "serverContext":
101
- // Only the built-in server flows leave `app.uid` unset.
102
- return `${RLS_UID_SQL} IS NULL`;
103
- case "existsIn":
104
- return compileExistsIn(expr, scope);
105
- case "raw": {
106
- // A project written against a pre-1.0 release may still spell the
107
- // helpers `auth.uid()`. Rewritten rather than rejected: the rule
108
- // means exactly the same thing, the developer cannot be expected to
109
- // have read a changelog mid-deploy, and the alternative is a policy
110
- // that compiles cleanly and then denies every row at runtime because
111
- // it calls a function that no longer exists.
112
- //
113
- // The counterpart is `warnOnLegacyRlsFunctions`, which says so once
114
- // at boot with the file to edit — silence here would leave the old
115
- // spelling working forever and make the migration permanent.
116
- const sqlText = rewriteLegacyRlsFunctions(expr.sql);
117
-
118
- // Full-power escape hatch: `{column}` denotes a column of the outer
119
- // RLS row. It must be table-qualified, not bare: raw SQL may open its
120
- // own subquery over the same table, and there a bare name binds to the
121
- // inner scope, collapsing `m.x = {x}` into the tautology `m.x = m.x`.
122
- return sqlText.replace(/\{(\w+)\}/g, (_, col) =>
123
- `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
124
- }
125
- }
126
- }
127
-
128
- /**
129
- * Compiles `existsIn` to a correlated `EXISTS (SELECT 1 FROM <join> WHERE ...)`.
130
- * Inside the subquery, `field` operands bind to the aliased join table and
131
- * `outerField` operands bind to the (table-qualified) outer RLS row.
132
- */
133
- function compileExistsIn(expr: ExistsInPolicyExpression, scope: CompileScope): string {
134
- const join = scope.resolveCollection?.(expr.collection);
135
- const joinTable = join ? getTableName(join) : toSnakeCase(expr.collection);
136
- const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? "public";
137
- const alias = `_ex${scope.alias.n++}`;
138
-
139
- // `outerField` inside the subquery must be qualified with the outer table,
140
- // otherwise a bare column name would bind to the joined table instead.
141
- const outerPrefix = outerQualifier(scope);
142
-
143
- const innerScope: CompileScope = {
144
- fieldCollection: join,
145
- fieldPrefix: `"${alias}".`,
146
- outerCollection: scope.outerCollection,
147
- outerPrefix,
148
- resolveCollection: scope.resolveCollection,
149
- alias: scope.alias
150
- };
151
- return `EXISTS (SELECT 1 FROM "${joinSchema}"."${joinTable}" "${alias}" WHERE ${compile(expr.where, innerScope)})`;
152
- }
153
-
154
- const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
155
- eq: "=",
156
- neq: "!=",
157
- lt: "<",
158
- lte: "<=",
159
- gt: ">",
160
- gte: ">="
161
- };
162
-
163
- function operandToSql(operand: PolicyOperand, scope: CompileScope): string {
164
- switch (operand.kind) {
165
- case "field":
166
- return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
167
- case "outerField":
168
- return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
169
- case "literal":
170
- return quoteLiteral(operand.value);
171
- case "authUid":
172
- return RLS_UID_SQL;
173
- case "authRoles":
174
- return `string_to_array(${RLS_ROLES_SQL}, ',')`;
175
- }
176
- }
177
-
178
- /**
179
- * SQL prefix that qualifies a column of the outer RLS row (`"schema"."table".`),
180
- * or `""` when the collection is unknown.
181
- */
182
- function outerQualifier(scope: CompileScope): string {
183
- const table = scope.outerCollection ? getTableName(scope.outerCollection) : undefined;
184
- if (!table) return "";
185
- return `"${schemaOf(scope.outerCollection) ?? "public"}"."${table}".`;
186
- }
187
-
188
- function schemaOf(collection?: CollectionConfig): string | undefined {
189
- return (collection as { schema?: string } | undefined)?.schema || undefined;
190
- }
191
-
192
- function resolveColumnName(propName: string, collection?: CollectionConfig): string {
193
- const prop = collection?.properties?.[propName] as Property | undefined;
194
- if (prop && "columnName" in prop && typeof (prop as { columnName?: unknown }).columnName === "string") {
195
- return quoteColumnIdentifier((prop as { columnName: string }).columnName);
196
- }
197
- return quoteColumnIdentifier(toSnakeCase(propName));
198
- }
199
-
200
- /**
201
- * Every PostgreSQL keyword that cannot stand as a bare column reference.
202
- * Appendix C's two reserved categories — plain "reserved", and "reserved (can
203
- * be function or type name)" — since neither may name a column unquoted.
204
- */
205
- const RESERVED_SQL_WORDS = new Set([
206
- "all", "analyse", "analyze", "and", "any", "array", "as", "asc", "asymmetric", "authorization",
207
- "binary", "both", "case", "cast", "check", "collate", "collation", "column", "concurrently",
208
- "constraint", "create", "cross", "current_catalog", "current_date", "current_role",
209
- "current_schema", "current_time", "current_timestamp", "current_user", "default", "deferrable",
210
- "desc", "distinct", "do", "else", "end", "except", "false", "fetch", "for", "foreign", "freeze",
211
- "from", "full", "grant", "group", "having", "ilike", "in", "initially", "inner", "intersect",
212
- "into", "is", "isnull", "join", "lateral", "leading", "left", "like", "limit", "localtime",
213
- "localtimestamp", "natural", "not", "notnull", "null", "offset", "on", "only", "or", "order",
214
- "outer", "overlaps", "placing", "primary", "references", "returning", "right", "select",
215
- "session_user", "similar", "some", "symmetric", "system_user", "table", "tablesample", "then",
216
- "to", "trailing", "true", "union", "unique", "user", "using", "variadic", "verbose", "when",
217
- "where", "window", "with"
218
- ]);
219
-
220
- /** An identifier Postgres reads back unchanged without quotes. */
221
- const BARE_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/;
222
-
223
- /**
224
- * Quote a column reference when Postgres would not read the bare name as that
225
- * column — and only then.
226
- *
227
- * Three ways a bare name goes wrong, in ascending order of how long it takes to
228
- * notice:
229
- *
230
- * - **Case.** `columnName` is used verbatim, and `rebase schema introspect`
231
- * populates it from a live database, so a legacy `"createdAt"` column arrives
232
- * spelled exactly that way. Unquoted, Postgres folds it to `createdat` and
233
- * `CREATE POLICY` fails with "column does not exist" — the collection keeps
234
- * RLS enabled with no policy, which denies every row.
235
- * - **Syntax.** A column named `order` or `default` is a syntax error mid-clause.
236
- * - **Silent rebinding.** `user`, `current_user`, `session_user`, `current_date`
237
- * and friends are *valid bare expressions*, so the policy compiles, applies,
238
- * and is reported as a success — while comparing against the connected role
239
- * or the wall clock instead of the column. Under RLS every request runs as the
240
- * same `rebase_user` role, so `USING (user = rebase.uid())` is a constant: it
241
- * denies everything, and its negation admits everything.
242
- *
243
- * Only the names that need it are quoted, so an ordinary snake_case policy body
244
- * is emitted byte-for-byte as before. That keeps generated artifacts and the
245
- * policies already stored in shipped databases stable — this fix reaches the
246
- * clauses that were broken and no others.
247
- */
248
- function quoteColumnIdentifier(name: string): string {
249
- if (BARE_IDENTIFIER.test(name) && !RESERVED_SQL_WORDS.has(name)) return name;
250
- return `"${name.replace(/"/g, "\"\"")}"`;
251
- }
252
-
253
- function quoteLiteral(value: string | number | boolean | null): string {
254
- if (value === null) return "NULL";
255
- if (typeof value === "boolean") return value ? "true" : "false";
256
- if (typeof value === "number") return String(value);
257
- return `'${value.replace(/'/g, "''")}'`;
258
- }
259
-
260
- /** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
261
- function rolesArraySql(roles: readonly string[]): string {
262
- return `ARRAY[${[...roles].sort().map(r => `'${r}'`).join(",")}]`;
263
- }
@@ -1,67 +0,0 @@
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
- }