@rebasepro/common 0.17.3 → 0.18.1
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 -0
- package/dist/collections/CollectionRegistry.d.ts +1 -1
- package/dist/collections/default-collections.d.ts +15 -84
- package/dist/data/buildRebaseData.d.ts +1 -1
- package/dist/data/filter-dialect.d.ts +11 -0
- package/dist/data/sort-dialect.d.ts +15 -3
- package/dist/index.es.js +375 -63
- package/dist/index.es.js.map +1 -1
- package/dist/util/builders.d.ts +69 -24
- package/dist/util/callback-errors.d.ts +77 -0
- package/dist/util/callback-errors.test.d.ts +1 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/policy/evaluatePolicy.d.ts +6 -0
- package/dist/util/relations.d.ts +41 -0
- package/dist/util/table-name.test.d.ts +1 -0
- package/package.json +26 -22
- package/src/collections/CollectionRegistry.ts +0 -485
- package/src/collections/default-collections.ts +0 -109
- package/src/collections/index.ts +0 -2
- package/src/data/buildRebaseData.ts +0 -816
- package/src/data/buildRoutedRebaseData.ts +0 -103
- package/src/data/filter-conditions.ts +0 -46
- package/src/data/filter-dialect.ts +0 -737
- package/src/data/paginate.ts +0 -334
- package/src/data/query_builder.ts +0 -176
- package/src/data/resolveDataSource.ts +0 -135
- package/src/data/sort-dialect.ts +0 -237
- package/src/index.ts +0 -11
- package/src/table-classification.ts +0 -109
- package/src/types/json-logic-js.d.ts +0 -8
- package/src/util/auth-default-policies.ts +0 -215
- package/src/util/builders.ts +0 -82
- package/src/util/callbacks.ts +0 -122
- package/src/util/collections.ts +0 -117
- package/src/util/common.ts +0 -2
- package/src/util/conditions.ts +0 -168
- package/src/util/email.ts +0 -32
- package/src/util/entities.ts +0 -282
- package/src/util/enums.ts +0 -26
- package/src/util/identity.ts +0 -202
- package/src/util/index.ts +0 -21
- package/src/util/internal-tables.test.ts +0 -188
- package/src/util/internal-tables.ts +0 -197
- package/src/util/junction-policies.ts +0 -355
- package/src/util/paths.ts +0 -27
- package/src/util/permissions.test.ts +0 -866
- package/src/util/permissions.ts +0 -206
- package/src/util/pg-column-to-property.ts +0 -377
- package/src/util/policy/evaluatePolicy.ts +0 -194
- package/src/util/policy/index.ts +0 -4
- package/src/util/policy/policyToPostgres.ts +0 -263
- package/src/util/policy/securityRuleToConditions.ts +0 -67
- package/src/util/policy/sqlToPolicy.ts +0 -422
- package/src/util/relations.ts +0 -236
- package/src/util/resolutions.ts +0 -534
- package/src/util/resolve-relation.ts +0 -243
- package/src/util/storage.ts +0 -177
- 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
|
-
}
|
package/src/util/policy/index.ts
DELETED
|
@@ -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
|
-
}
|