@askdb/core 1.0.0-beta.53 → 1.0.0-beta.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ask.js +1 -1
- package/dist/ask.js.map +1 -1
- package/dist/errors.d.ts +5 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/sql/bind.d.ts +10 -2
- package/dist/sql/bind.d.ts.map +1 -1
- package/dist/sql/bind.js +33 -12
- package/dist/sql/bind.js.map +1 -1
- package/dist/sql/mask-literals.d.ts +15 -0
- package/dist/sql/mask-literals.d.ts.map +1 -0
- package/dist/sql/mask-literals.js +69 -0
- package/dist/sql/mask-literals.js.map +1 -0
- package/dist/sql/readings.d.ts +32 -0
- package/dist/sql/readings.d.ts.map +1 -1
- package/dist/sql/readings.js +66 -0
- package/dist/sql/readings.js.map +1 -1
- package/dist/sql/tenant-guardrail.d.ts +8 -6
- package/dist/sql/tenant-guardrail.d.ts.map +1 -1
- package/dist/sql/tenant-guardrail.js +626 -105
- package/dist/sql/tenant-guardrail.js.map +1 -1
- package/dist/sql/tenant-prompt.d.ts.map +1 -1
- package/dist/sql/tenant-prompt.js +7 -1
- package/dist/sql/tenant-prompt.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { placeholderForTenantRoot, } from "../schema/v2/tenant-policy.js";
|
|
|
2
2
|
import { SQLSERVER_DIALECT } from "./dialect-spec.js";
|
|
3
3
|
import { decide, tenantFindings, throwIfDenied } from "./guardrail-decide.js";
|
|
4
4
|
import { findPostgresOnlyForms } from "./lexer.js";
|
|
5
|
-
import { mayBeSqlite, readSql, QUERY_START_WORDS, stringsSqliteReadsAsNames, tenantCodeReadings, tenantStatementReadings, unmatchedNonAsciiName, } from "./readings.js";
|
|
5
|
+
import { lowerEachLetter, mayBeSqlite, readSql, QUERY_START_WORDS, stringsSqliteReadsAsNames, tenantCodeReadings, tenantStatementReadings, nameEnginesFoldApart, unmatchedNonAsciiName, } from "./readings.js";
|
|
6
6
|
import { EXPRESSION_KEYWORDS, EXPRESSION_PUNCTUATION } from "./tsql-batch.js";
|
|
7
7
|
import { denylistFor } from "./validate.js";
|
|
8
8
|
/**
|
|
@@ -21,18 +21,20 @@ import { denylistFor } from "./validate.js";
|
|
|
21
21
|
* enclosing level, under `NOT`, or anywhere but a filter clause (the select list,
|
|
22
22
|
* a `CASE`) doesn't count, and neither does a literal ID or a bare mention of the
|
|
23
23
|
* column. A root table the scope covers (`org.agency` under an `agency` scope)
|
|
24
|
-
* needs the same predicate on its tenant ID column
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* needs the same predicate on its tenant ID column, or a join on it to a read whose
|
|
25
|
+
* column holding its IDs is filtered. Down a join path, each hop must be joined on the
|
|
26
|
+
* columns the policy declares, up to a read filtered by the path's root or one of its
|
|
27
|
+
* ancestors in the hierarchy; a join on other columns links nothing (#400). A
|
|
28
|
+
* polymorphic table also needs `typeColumn = '<key>'` ANDed into
|
|
27
29
|
* the same `SELECT`, with a `mapping` key of the root whose placeholder filters
|
|
28
30
|
* its id column. Each read of a table needs its own predicate in its own `SELECT`
|
|
29
31
|
* (#399): a CTE body, a derived table and a subquery are each their own, and a
|
|
30
32
|
* qualified predicate counts only for the read its qualifier names. A read in a
|
|
31
33
|
* derived table or CTE also counts as filtered when every reference to that source
|
|
32
34
|
* is filtered on the column, which the source passes through unrenamed without
|
|
33
|
-
* computing across its rows.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
35
|
+
* computing across its rows. A join it can't follow (through a derived table or CTE,
|
|
36
|
+
* or across `SELECT`s) fails closed; database-side row-level security is the sound
|
|
37
|
+
* boundary.
|
|
36
38
|
*
|
|
37
39
|
* **This is not a security boundary.** Its purpose is to catch model mistakes
|
|
38
40
|
* (a forgotten or widened tenant filter, an unclassified table) early and
|
|
@@ -112,6 +114,15 @@ export function tenantRuleWarnings(sql, policy, scope, dialect, schemaNames) {
|
|
|
112
114
|
"SQL Server reads names through its collation, which ignores some characters and reads others as ASCII letters, so the " +
|
|
113
115
|
"tenant check can't tell which table or column it names. Spell the name exactly as the schema does."));
|
|
114
116
|
}
|
|
117
|
+
// Some engine folds a few letters into ASCII ones (CockroachDB and MySQL read U+0130 as `i`), and
|
|
118
|
+
// CockroachDB normalizes a name to NFC, so the checks below can't match such a name the engines'
|
|
119
|
+
// way: unless the schema spells it exactly, refuse it.
|
|
120
|
+
const apart = nameEnginesFoldApart(read, dialect, knownNames(policy, schemaNames));
|
|
121
|
+
if (apart !== undefined) {
|
|
122
|
+
warnings.push(warn("UNICODE_ESCAPED_IDENTIFIER", "", `The SQL uses the name ${apart}, which holds a letter some database engines read as an ASCII letter (U+0130, U+0131, U+017F or U+212A) ` +
|
|
123
|
+
"or isn't in Unicode normalization form C, and isn't spelled that way in the schema. The tenant check can't tell which table or column it names. " +
|
|
124
|
+
"Spell the name exactly as the schema does."));
|
|
125
|
+
}
|
|
115
126
|
// Past an E'…' string continued on a later line, Postgres and the checks below read different
|
|
116
127
|
// strings, so a table or call could sit in what they take for a string: refuse it (#703).
|
|
117
128
|
const continued = postgresOnly.escapeStringContinuation;
|
|
@@ -141,9 +152,11 @@ export function tenantRuleWarnings(sql, policy, scope, dialect, schemaNames) {
|
|
|
141
152
|
if (!root || !mentionsTable(views, extractTableName(root.id)))
|
|
142
153
|
continue;
|
|
143
154
|
const column = extractColumnName(root.tenantIdColumn);
|
|
144
|
-
// Filtered on its own tenant ID column, or on a column that
|
|
145
|
-
// that joins the root for a label, filtered on the scoped table's tenant column).
|
|
146
|
-
|
|
155
|
+
// Filtered on its own tenant ID column, or joined on it to a column that holds its IDs (a
|
|
156
|
+
// query that joins the root for a label, filtered on the scoped table's tenant column; #400).
|
|
157
|
+
const placeholder = placeholderForTenantRoot(root);
|
|
158
|
+
const ownIdTarget = { placeholder, column, table: extractTableName(root.id) };
|
|
159
|
+
if (!hasTenantPredicate(views, ownIdTarget.table, [ownIdTarget], { policy, allowed: new Set([placeholder]) })) {
|
|
147
160
|
warnings.push(warn("MISSING_TENANT_PREDICATE", root.id, `Tenant root table '${extractTableName(root.id)}' is missing required tenant predicate. ` +
|
|
148
161
|
`Expected: ${column} = ${placeholderForTenantRoot(root)}`));
|
|
149
162
|
}
|
|
@@ -181,20 +194,19 @@ function checkScopedTable(sql, st, policy, warnings) {
|
|
|
181
194
|
for (const path of st.scopeThrough) {
|
|
182
195
|
const placeholder = placeholderFor(policy, path.root);
|
|
183
196
|
if ("column" in path) {
|
|
184
|
-
// The table's tenant column compared with its root's placeholder.
|
|
185
|
-
|
|
197
|
+
// The table's tenant column compared with its root's placeholder, directly or through a join.
|
|
198
|
+
// Through a join, only from that column: another path's column on this table (a join-path step's
|
|
199
|
+
// key) proves nothing about the rows this path scopes.
|
|
200
|
+
const target = { placeholder, column: extractColumnName(path.column), table };
|
|
201
|
+
if (hasTenantPredicate(sql, table, [target], { policy, allowed: new Set([placeholder]), start: target.column }))
|
|
186
202
|
return;
|
|
187
203
|
}
|
|
188
204
|
else {
|
|
189
|
-
// Inherited via JOINs:
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (!allStepsPresent)
|
|
195
|
-
continue;
|
|
196
|
-
const targets = selfAndAncestors(policy, path.root).flatMap((rootId) => idCarriers(policy, rootId));
|
|
197
|
-
if (hasTenantPredicate(sql, table, targets))
|
|
205
|
+
// Inherited via JOINs (#400): each hop joined on the columns the policy declares, to a read
|
|
206
|
+
// filtered by the path's root or, up the hierarchy, one of its ancestors. A root outside that
|
|
207
|
+
// chain filters nothing here, even one the scope binds.
|
|
208
|
+
const allowed = new Set(selfAndAncestors(policy, path.root).map((rootId) => placeholderFor(policy, rootId)));
|
|
209
|
+
if (hasTenantPredicate(sql, table, [], { policy, allowed, join: path.join }))
|
|
198
210
|
return;
|
|
199
211
|
}
|
|
200
212
|
}
|
|
@@ -580,11 +592,13 @@ function codeView(sql, tokens, names) {
|
|
|
580
592
|
return out.join("");
|
|
581
593
|
}
|
|
582
594
|
/**
|
|
583
|
-
* A table counts as referenced when any reading sees its name in code,
|
|
584
|
-
*
|
|
595
|
+
* A table counts as referenced when any reading sees its name in code, in any case and between
|
|
596
|
+
* characters that can't continue a name in any script, except as a word of `ORDER BY` or
|
|
597
|
+
* `GROUP BY` (#828).
|
|
585
598
|
*/
|
|
586
599
|
function mentionsTable(views, tableName) {
|
|
587
|
-
|
|
600
|
+
// A name's edges are letters, digits, `_` or `$` in any script, matched in any case.
|
|
601
|
+
const pattern = new RegExp(`(?<![\\p{L}\\p{M}\\p{N}_$])${escapeRegex(tableName)}(?![\\p{L}\\p{M}\\p{N}_$])`, "giu");
|
|
588
602
|
return views.some((view) => [...view.text.matchAll(pattern)].some((m) => !inOrderOrGroupBy(view.text, m.index, m.index + m[0].length)));
|
|
589
603
|
}
|
|
590
604
|
/**
|
|
@@ -635,6 +649,9 @@ function tokenize(view) {
|
|
|
635
649
|
const tokens = [];
|
|
636
650
|
const split = lexerWordEnds(view);
|
|
637
651
|
const foldsCase = view.reading.profile.id === "postgres" || view.reading.profile.id === "generic";
|
|
652
|
+
// Postgres folds only ASCII letters in an unquoted name and CockroachDB every letter: a name with
|
|
653
|
+
// an upper-case non-ASCII letter is read apart, so it names nothing ({@link READ_APART}).
|
|
654
|
+
const readsApart = view.reading.profile.id === "postgres";
|
|
638
655
|
const quotedAt = new Map();
|
|
639
656
|
for (const t of view.tokens) {
|
|
640
657
|
if (t.kind === "quoted_identifier")
|
|
@@ -663,7 +680,7 @@ function tokenize(view) {
|
|
|
663
680
|
if (t === ")")
|
|
664
681
|
depth = Math.max(0, depth - 1);
|
|
665
682
|
const lower = t.toLowerCase();
|
|
666
|
-
const name = foldsCase ? t
|
|
683
|
+
const name = !foldsCase ? t : readsApart && lower !== asciiLower(t) ? `${READ_APART}${offset}` : asciiLower(t);
|
|
667
684
|
tokens.push({ text: t, lower, name, kw: lower, depth, offset });
|
|
668
685
|
if (t === "(")
|
|
669
686
|
depth++;
|
|
@@ -814,7 +831,7 @@ function scopeOpen(scope) {
|
|
|
814
831
|
* spellings finds more reads, so the check only gets stricter on engines that keep it.
|
|
815
832
|
*/
|
|
816
833
|
function tableNameOf(t) {
|
|
817
|
-
return t.quoted === true ? t.
|
|
834
|
+
return lowerEachLetter(t.quoted === true ? t.name.trimEnd() : t.text);
|
|
818
835
|
}
|
|
819
836
|
/** Whether `token` may be a name: a quoted name, whatever it holds, or a word that starts with a letter or `_`. */
|
|
820
837
|
function isWord(token) {
|
|
@@ -1035,10 +1052,13 @@ function occurrences(a, targets) {
|
|
|
1035
1052
|
* `SELECT`, and tied to that read by its qualifier. Each branch of a `UNION` / `INTERSECT` /
|
|
1036
1053
|
* `EXCEPT` is its own `SELECT`, and so is every CTE body, derived table and subquery. A read in a
|
|
1037
1054
|
* derived table or CTE is also filtered from outside, when that source passes the column through
|
|
1038
|
-
* unrenamed and every reference to it is filtered on it.
|
|
1055
|
+
* unrenamed and every reference to it is filtered on it. With `joinProof`, a read also counts when
|
|
1056
|
+
* the policy's keys prove it in scope through joins in its `SELECT` (#400): {@link pathInScope} for
|
|
1057
|
+
* a join path, else {@link inScope}. `checkScopedTable` passes one for each scope path, and
|
|
1058
|
+
* {@link validateTenantGuardrails} one for each root the scope covers.
|
|
1039
1059
|
*/
|
|
1040
|
-
function hasTenantPredicate(views, table, targets) {
|
|
1041
|
-
const name = table
|
|
1060
|
+
function hasTenantPredicate(views, table, targets, joinProof) {
|
|
1061
|
+
const name = lowerEachLetter(table);
|
|
1042
1062
|
return views.every((view) => {
|
|
1043
1063
|
const a = analyze(view);
|
|
1044
1064
|
// Read the Postgres way, a column list after a read's alias, or after a parenthesized source it
|
|
@@ -1054,9 +1074,19 @@ function hasTenantPredicate(views, table, targets) {
|
|
|
1054
1074
|
const tableReads = reads.filter((r) => startsSource(a.tokens, qualifiedStart(a.tokens, r.at)));
|
|
1055
1075
|
return reads.every((read) => {
|
|
1056
1076
|
const sameSelect = tableReads.filter((r) => r.scope === read.scope);
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1077
|
+
const selfJoin = tableReads.includes(read) && sameSelect.length > 1;
|
|
1078
|
+
const filtered = selfJoin ? selfJoinReadFiltered(a, found, targets, read, name, sameSelect) : readFiltered(a, found, targets, read, name);
|
|
1079
|
+
// A join proof names reads too, so a self-join's reads must be told apart for it as well.
|
|
1080
|
+
if (filtered)
|
|
1081
|
+
return true;
|
|
1082
|
+
if (joinProof === undefined || (selfJoin && !distinctAliases(a, sameSelect)))
|
|
1083
|
+
return false;
|
|
1084
|
+
// The policy's names, read the way this view's reading compares a written name ({@link columnKey}).
|
|
1085
|
+
const reading = a.view.reading.profile.id;
|
|
1086
|
+
const proof = proofFor(a, policyGraph(joinProof.policy, reading));
|
|
1087
|
+
return joinProof.join !== undefined
|
|
1088
|
+
? pathInScope(proof, { ...read, table: name }, joinPathProof(joinProof.policy, table, joinProof.join, reading), joinProof.allowed)
|
|
1089
|
+
: inScope(proof, { ...read, table: name }, joinProof.allowed, undefined, joinProof.start === undefined ? undefined : columnKey(joinProof.start, reading));
|
|
1060
1090
|
});
|
|
1061
1091
|
});
|
|
1062
1092
|
}
|
|
@@ -1087,7 +1117,8 @@ const NOT_A_TABLE_ALIAS = new Set([
|
|
|
1087
1117
|
*/
|
|
1088
1118
|
function renamesColumns(tokens, name) {
|
|
1089
1119
|
for (let i = 0; i < tokens.length; i++) {
|
|
1090
|
-
|
|
1120
|
+
// Matched as the table's other reads are ({@link tableNameOf}): `ΟΣ` is `οσ`, as CockroachDB reads it.
|
|
1121
|
+
if (tableNameOf(tokens[i]) !== name)
|
|
1091
1122
|
continue;
|
|
1092
1123
|
for (let open = enclosing(tokens, i, "("); open >= 0; open = enclosing(tokens, open, "(")) {
|
|
1093
1124
|
if (subqueryUse(tokens, open) !== "table")
|
|
@@ -1119,15 +1150,17 @@ function aliasWithColumnList(tokens, j) {
|
|
|
1119
1150
|
* it fails closed.
|
|
1120
1151
|
*/
|
|
1121
1152
|
function selfJoinReadFiltered(a, found, targets, read, table, sameSelect) {
|
|
1122
|
-
|
|
1123
|
-
if (aliases.some((alias) => alias === undefined))
|
|
1124
|
-
return false;
|
|
1125
|
-
if (new Set(aliases.map((alias) => alias.toLowerCase())).size !== aliases.length)
|
|
1153
|
+
if (!distinctAliases(a, sameSelect))
|
|
1126
1154
|
return false;
|
|
1127
1155
|
const mine = canonicalAlias(a, read);
|
|
1128
|
-
return found.some((o) => o.filters.some((f) => f.scope === read.scope && f.qualifier === mine && targets.includes(o.target) &&
|
|
1156
|
+
return found.some((o) => o.filters.some((f) => f.scope === read.scope && f.qualifier === mine && canName(a.tokens, mine, f.on, read) && targets.includes(o.target) &&
|
|
1129
1157
|
(!o.target.discriminator || hasDiscriminator(a, read, o.target))));
|
|
1130
1158
|
}
|
|
1159
|
+
/** Whether every read in `reads` is canonical, with a plain alias no other of them shares, in any case. */
|
|
1160
|
+
function distinctAliases(a, reads) {
|
|
1161
|
+
const aliases = reads.map((r) => canonicalAlias(a, r));
|
|
1162
|
+
return aliases.every((alias) => alias !== undefined) && new Set(aliases.map((alias) => alias.toLowerCase())).size === aliases.length;
|
|
1163
|
+
}
|
|
1131
1164
|
/** The plain alias of a read written `table alias` or `table AS alias`, at its `SELECT`'s own level; else undefined. */
|
|
1132
1165
|
function canonicalAlias(a, read) {
|
|
1133
1166
|
const { tokens } = a;
|
|
@@ -1150,25 +1183,32 @@ function readFiltered(a, found, targets, read, table) {
|
|
|
1150
1183
|
return true;
|
|
1151
1184
|
}
|
|
1152
1185
|
}
|
|
1153
|
-
return targets.some((target) => target.table
|
|
1186
|
+
return targets.some((target) => lowerEachLetter(target.table) === table && !target.discriminator && filteredFromOutside(a, found, read, target, 0));
|
|
1154
1187
|
}
|
|
1155
1188
|
/**
|
|
1156
|
-
* Whether a predicate that filters `read`'s `SELECT` through `filter`
|
|
1157
|
-
* Unqualified, it does: the read's table has the column, so the name
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1162
|
-
* another table `col` filters is left to the join.
|
|
1189
|
+
* Whether a predicate on the read's own table that filters `read`'s `SELECT` through `filter`
|
|
1190
|
+
* filters `read` itself. Unqualified, it does: the read's table has the column, so the name
|
|
1191
|
+
* resolves to it or the SQL is ambiguous. Qualified, its qualifier must name that read
|
|
1192
|
+
* ({@link readNames}); a qualifier that names no read the check sees, such as a
|
|
1193
|
+
* table function's alias, counts for nothing. A predicate on another table's column counts only
|
|
1194
|
+
* through a join {@link inScope} can follow (#400).
|
|
1163
1195
|
*/
|
|
1164
1196
|
function filtersRead(a, filter, target, read, table) {
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1197
|
+
if (lowerEachLetter(target.table) !== table)
|
|
1198
|
+
return false;
|
|
1199
|
+
return canName(a.tokens, filter.qualifier, filter.on, read);
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Whether a column written with qualifier `q`, in the `ON` at `on` if it sits in one, can name
|
|
1203
|
+
* `read`: qualified, the qualifier must name it. In an `ON`, qualified or not, it names only one of
|
|
1204
|
+
* its join's operands ({@link seenByOn}): a qualifier for a read joined after it, or outside its
|
|
1205
|
+
* parentheses, names some other read, such as an outer query's by correlation. Anywhere else it
|
|
1206
|
+
* names any read of its `SELECT`.
|
|
1207
|
+
*/
|
|
1208
|
+
function canName(tokens, q, on, read) {
|
|
1209
|
+
if (q !== undefined && !read.names.has(q))
|
|
1210
|
+
return false;
|
|
1211
|
+
return on === undefined || seenByOn(tokens, on, read.at);
|
|
1172
1212
|
}
|
|
1173
1213
|
/**
|
|
1174
1214
|
* Whether `read`, inside a derived table or CTE, is filtered from outside on `target`'s column:
|
|
@@ -1194,7 +1234,7 @@ function filteredFromOutside(a, found, read, target, depth) {
|
|
|
1194
1234
|
const refs = sourceRefs(a, open);
|
|
1195
1235
|
filtered = refs !== undefined && refs.length > 0 && passesThrough(a, open, read.names, target.column) &&
|
|
1196
1236
|
refs.every((ref) => found.some((o) => o.target === target &&
|
|
1197
|
-
o.filters.some((f) => f.
|
|
1237
|
+
o.filters.some((f) => f.scope === ref.scope && canName(a.tokens, f.qualifier, f.on, ref))) ||
|
|
1198
1238
|
filteredFromOutside(a, found, ref, target, depth + 1));
|
|
1199
1239
|
}
|
|
1200
1240
|
memo.set(key, filtered);
|
|
@@ -1325,7 +1365,7 @@ function passesThrough(a, open, names, column) {
|
|
|
1325
1365
|
return false; // an expression, a call or an aggregate
|
|
1326
1366
|
const name = expr[expr.length - 1];
|
|
1327
1367
|
// An item that yields the name in any case is a candidate; it must name the column the way the engine reads it.
|
|
1328
|
-
if ((alias ?? name)
|
|
1368
|
+
if (tableNameOf(alias ?? name) !== lowerEachLetter(column))
|
|
1329
1369
|
continue;
|
|
1330
1370
|
if (!namesColumn(alias ?? name, column, reading) || !namesColumn(name, column, reading))
|
|
1331
1371
|
return false;
|
|
@@ -1390,11 +1430,468 @@ function hasDiscriminator(a, read, target) {
|
|
|
1390
1430
|
if (view.text.slice(open, close + 1).trim() !== "" || source.slice(close + 1, next).trim() !== "")
|
|
1391
1431
|
continue;
|
|
1392
1432
|
const filters = filteredScopes(a, qualifiedStart(tokens, i), i + 1, qualifier, i);
|
|
1393
|
-
if (filters.some((f) => f.
|
|
1433
|
+
if (filters.some((f) => f.scope === read.scope && canName(tokens, qualifier, f.on, read)))
|
|
1394
1434
|
return true;
|
|
1395
1435
|
}
|
|
1396
1436
|
return false;
|
|
1397
1437
|
}
|
|
1438
|
+
const graphs = new WeakMap();
|
|
1439
|
+
/**
|
|
1440
|
+
* {@link PolicyGraph} for `policy`, its table names lower-cased letter by letter ({@link tableNameOf})
|
|
1441
|
+
* and its column names read the way `reading` compares a written name ({@link columnKey}).
|
|
1442
|
+
*/
|
|
1443
|
+
function policyGraph(policy, reading) {
|
|
1444
|
+
const byReading = graphs.get(policy) ?? new Map();
|
|
1445
|
+
graphs.set(policy, byReading);
|
|
1446
|
+
let graph = byReading.get(reading);
|
|
1447
|
+
if (graph !== undefined)
|
|
1448
|
+
return graph;
|
|
1449
|
+
const carriers = new Map();
|
|
1450
|
+
const columns = new Map();
|
|
1451
|
+
const spellings = new Map();
|
|
1452
|
+
const tableKey = (table) => {
|
|
1453
|
+
const key = lowerEachLetter(table);
|
|
1454
|
+
spellings.set(key, (spellings.get(key) ?? new Set()).add(table));
|
|
1455
|
+
return key;
|
|
1456
|
+
};
|
|
1457
|
+
const name = (columnId) => [tableKey(tableOfColumn(columnId)), columnKey(extractColumnName(columnId), reading)];
|
|
1458
|
+
const know = (columnId) => {
|
|
1459
|
+
const [table, column] = name(columnId);
|
|
1460
|
+
columns.set(table, (columns.get(table) ?? new Set()).add(column));
|
|
1461
|
+
};
|
|
1462
|
+
for (const root of policy.roots) {
|
|
1463
|
+
for (const carrier of idCarriers(policy, root.id)) {
|
|
1464
|
+
const table = tableKey(carrier.table);
|
|
1465
|
+
const column = columnKey(carrier.column, reading);
|
|
1466
|
+
const byColumn = carriers.get(table) ?? new Map();
|
|
1467
|
+
byColumn.set(column, (byColumn.get(column) ?? new Set()).add(carrier.placeholder));
|
|
1468
|
+
carriers.set(table, byColumn);
|
|
1469
|
+
columns.set(table, (columns.get(table) ?? new Set()).add(column));
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
const hops = [];
|
|
1473
|
+
const hop = (fromId, toId) => {
|
|
1474
|
+
know(fromId);
|
|
1475
|
+
know(toId);
|
|
1476
|
+
const [from, fromColumn] = name(fromId);
|
|
1477
|
+
const [to, toColumn] = name(toId);
|
|
1478
|
+
hops.push({ from, fromColumn, to, toColumn });
|
|
1479
|
+
};
|
|
1480
|
+
const idColumn = (rootId) => policy.roots.find((r) => r.id === rootId)?.tenantIdColumn;
|
|
1481
|
+
for (const edge of policy.hierarchy) {
|
|
1482
|
+
const to = idColumn(edge.parent);
|
|
1483
|
+
if (to !== undefined)
|
|
1484
|
+
hop(edge.foreignKey, to);
|
|
1485
|
+
}
|
|
1486
|
+
for (const root of policy.roots) {
|
|
1487
|
+
const to = root.parent ? idColumn(root.parent.root) : undefined;
|
|
1488
|
+
if (root.parent && to !== undefined)
|
|
1489
|
+
hop(root.parent.foreignKey, to);
|
|
1490
|
+
}
|
|
1491
|
+
for (const st of policy.scopedTables) {
|
|
1492
|
+
for (const path of st.scopeThrough) {
|
|
1493
|
+
if ("column" in path) {
|
|
1494
|
+
const to = idColumn(path.root);
|
|
1495
|
+
if (to !== undefined)
|
|
1496
|
+
hop(path.column, to);
|
|
1497
|
+
}
|
|
1498
|
+
else {
|
|
1499
|
+
for (const step of path.join)
|
|
1500
|
+
hop(step.from, step.to);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
graph = { carriers, hops, columns, spellings, placeholders: new Set(policy.roots.map((r) => placeholderForTenantRoot(r))) };
|
|
1505
|
+
byReading.set(reading, graph);
|
|
1506
|
+
return graph;
|
|
1507
|
+
}
|
|
1508
|
+
const proofs = new WeakMap();
|
|
1509
|
+
function proofFor(a, graph) {
|
|
1510
|
+
const byGraph = proofs.get(a) ?? new WeakMap();
|
|
1511
|
+
proofs.set(a, byGraph);
|
|
1512
|
+
let proof = byGraph.get(graph);
|
|
1513
|
+
if (proof !== undefined)
|
|
1514
|
+
return proof;
|
|
1515
|
+
// Every CTE's name, whatever its body (a data-modifying one opens no query): a read of that name may be the CTE.
|
|
1516
|
+
const ctes = new Set(a.tokens.flatMap((t, open) => (t.text === "(" ? [cteNameAt(a.tokens, open)] : [])).filter((n) => n !== undefined));
|
|
1517
|
+
const reading = a.view.reading.profile.id;
|
|
1518
|
+
const readsAsPolicyTable = (at, table) => [...(graph.spellings.get(table) ?? [])].some((spelling) => namesTable(a.tokens[at], spelling, reading));
|
|
1519
|
+
const nodes = [...graph.columns.keys()]
|
|
1520
|
+
.filter((table) => !ctes.has(table))
|
|
1521
|
+
.flatMap((table) => readsOf(a, table).filter((read) => isTableReference(a.tokens, read.at) && readsAsPolicyTable(read.at, table)).map((read) => ({ ...read, table })));
|
|
1522
|
+
proof = { a, graph, nodes, equalities: equalities(a), bound: new Map(), links: new Map() };
|
|
1523
|
+
byGraph.set(graph, proof);
|
|
1524
|
+
return proof;
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* The name of the CTE whose body opens at `open`, with or without a column list (`x AS (`,
|
|
1528
|
+
* `x (a, b) AS (`, `x AS MATERIALIZED (`), or undefined when it opens none.
|
|
1529
|
+
*/
|
|
1530
|
+
function cteNameAt(tokens, open) {
|
|
1531
|
+
let k = open - 1;
|
|
1532
|
+
if (tokens[k]?.kw === "materialized")
|
|
1533
|
+
k -= tokens[k - 1]?.kw === "not" ? 2 : 1;
|
|
1534
|
+
if (tokens[k]?.kw !== "as")
|
|
1535
|
+
return undefined;
|
|
1536
|
+
k--;
|
|
1537
|
+
if (tokens[k]?.text === ")") {
|
|
1538
|
+
const list = tokens[k - 1]?.text === "(" ? k - 1 : enclosing(tokens, k - 1, "(");
|
|
1539
|
+
if (list < 0 || tokens[list].depth !== tokens[k].depth)
|
|
1540
|
+
return undefined;
|
|
1541
|
+
k = list - 1;
|
|
1542
|
+
}
|
|
1543
|
+
const before = tokens[k - 1];
|
|
1544
|
+
if (!isWord(tokens[k]) || !(before?.kw === "with" || before?.kw === "recursive" || before?.text === ","))
|
|
1545
|
+
return undefined;
|
|
1546
|
+
return tableNameOf(tokens[k]);
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Whether the name at `i` is a table read in a `FROM` clause: written right after a `SELECT`'s own
|
|
1550
|
+
* `FROM`, after `JOIN` or MySQL's `STRAIGHT_JOIN`, after the `(` of a parenthesized join group in
|
|
1551
|
+
* one of those places, or after a comma of that `FROM` list (joins included), after any schema
|
|
1552
|
+
* qualifier, and not a function's name. The alias of a derived table, of a table function or of
|
|
1553
|
+
* another table is not, so the policy's columns are never trusted on a source that only borrows a
|
|
1554
|
+
* table's name (#400).
|
|
1555
|
+
*/
|
|
1556
|
+
function isTableReference(tokens, i) {
|
|
1557
|
+
if (tokens[i + 1]?.text === "(")
|
|
1558
|
+
return false;
|
|
1559
|
+
return startsSource(tokens, qualifiedStart(tokens, i));
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* The column written at `i`, read the way the view's reading compares it with the policy's
|
|
1563
|
+
* ({@link engineName}, {@link columnKey}); undefined when the engines of that reading read it apart,
|
|
1564
|
+
* so it ties nothing. `"AGENCY_ID"`, read the Postgres way, is another column than `agency_id`.
|
|
1565
|
+
*/
|
|
1566
|
+
function writtenColumn(a, i) {
|
|
1567
|
+
return engineName(a.tokens[i], a.view.reading.profile.id);
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* The `col = col` terms ANDed into a `WHERE`, `ON` or `HAVING` clause, with their `SELECT` and the
|
|
1571
|
+
* `ON` they sit in, if any. In a `SELECT` that may keep unmatched rows ({@link mayKeepUnmatchedRows}),
|
|
1572
|
+
* an `ON` equality holds only on the rows its join matched, so it links nothing: there only `WHERE`
|
|
1573
|
+
* and `HAVING` count, as {@link filteredScopes} reads them (#400).
|
|
1574
|
+
*/
|
|
1575
|
+
function equalities(a) {
|
|
1576
|
+
const { tokens } = a;
|
|
1577
|
+
const found = [];
|
|
1578
|
+
tokens.forEach((t, i) => {
|
|
1579
|
+
if (t.text !== "=" || !tokens[i - 1] || !isIdentifier(tokens[i - 1]) || !tokens[i + 1] || !isIdentifier(tokens[i + 1]))
|
|
1580
|
+
return;
|
|
1581
|
+
let end = i + 1;
|
|
1582
|
+
while (tokens[end + 1]?.text === "." && tokens[end + 2] !== undefined && isIdentifier(tokens[end + 2]))
|
|
1583
|
+
end += 2;
|
|
1584
|
+
const leftColumn = writtenColumn(a, i - 1);
|
|
1585
|
+
const rightColumn = writtenColumn(a, end);
|
|
1586
|
+
if (leftColumn === undefined || rightColumn === undefined)
|
|
1587
|
+
return;
|
|
1588
|
+
const left = { q: qualifierOf(tokens, i - 1), column: leftColumn };
|
|
1589
|
+
const right = { q: qualifierOf(tokens, end), column: rightColumn };
|
|
1590
|
+
// In `HAVING`, each side must be a `GROUP BY` key ({@link onGroupKey}).
|
|
1591
|
+
for (const f of filteredScopes(a, qualifiedStart(tokens, i - 1), end, undefined, [i - 1, end]))
|
|
1592
|
+
found.push({ left, right, scope: f.scope, on: f.on });
|
|
1593
|
+
});
|
|
1594
|
+
return found;
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Whether a `SELECT` may keep rows a join's `ON` never matched: `LEFT`, `RIGHT`, `FULL` or `OUTER`
|
|
1598
|
+
* appears in it as a word, whatever hint follows (`RIGHT STRAIGHT JOIN`, `OUTER APPLY`). No `ON`
|
|
1599
|
+
* there filters, seeds, links or hops: only `WHERE` and `HAVING` do (#400).
|
|
1600
|
+
*/
|
|
1601
|
+
function mayKeepUnmatchedRows(a, scope) {
|
|
1602
|
+
const { tokens, scopes } = a;
|
|
1603
|
+
return tokens.some((t, i) => scopes.of[i] === scope && ["left", "right", "full", "outer"].includes(t.kw) && tokens[i + 1]?.text !== "(");
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* The read a column refers to in `scope`: the one its qualifier names, or, unqualified, the one
|
|
1607
|
+
* whose table the policy says has the column, among the reads it can name ({@link canName}: in an
|
|
1608
|
+
* `ON`, that join's operands). Another source with the same column would make the SQL ambiguous,
|
|
1609
|
+
* so it can't name a different one. Undefined when no single read fits.
|
|
1610
|
+
*/
|
|
1611
|
+
function resolve(p, scope, ref, on) {
|
|
1612
|
+
const hits = p.nodes.filter((n) => n.scope === scope && canName(p.a.tokens, ref.q, on, n) && (ref.q !== undefined || p.graph.columns.get(n.table)?.has(ref.column)));
|
|
1613
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* The placeholders whose IDs each column of a read holds in `scope`: a column compared with a
|
|
1617
|
+
* placeholder (`= P`, `IN (P)`, `= ANY(P)`), ANDed into a filter clause, holds its root's IDs; so does
|
|
1618
|
+
* a column equal to one that does, through `col = col` conjuncts; and so does `col IN (SELECT x …)`
|
|
1619
|
+
* when the subquery's `x` does in its own `SELECT`.
|
|
1620
|
+
*/
|
|
1621
|
+
function boundIn(p, scope) {
|
|
1622
|
+
const cached = p.bound.get(scope);
|
|
1623
|
+
if (cached !== undefined)
|
|
1624
|
+
return cached;
|
|
1625
|
+
const { a } = p;
|
|
1626
|
+
const { tokens, scopes } = a;
|
|
1627
|
+
const parent = new Map();
|
|
1628
|
+
const find = (x) => {
|
|
1629
|
+
let r = x;
|
|
1630
|
+
while (parent.has(r) && parent.get(r) !== r)
|
|
1631
|
+
r = parent.get(r);
|
|
1632
|
+
parent.set(x, r);
|
|
1633
|
+
return r;
|
|
1634
|
+
};
|
|
1635
|
+
const term = (node, column) => `${node.at}.${column}`;
|
|
1636
|
+
for (const eq of p.equalities) {
|
|
1637
|
+
if (eq.scope !== scope)
|
|
1638
|
+
continue;
|
|
1639
|
+
const l = resolve(p, scope, eq.left, eq.on);
|
|
1640
|
+
const r = resolve(p, scope, eq.right, eq.on);
|
|
1641
|
+
if (l && r)
|
|
1642
|
+
parent.set(find(term(l, eq.left.column)), find(term(r, eq.right.column)));
|
|
1643
|
+
}
|
|
1644
|
+
const seeds = new Map();
|
|
1645
|
+
const seed = (key, placeholders) => {
|
|
1646
|
+
const root = find(key);
|
|
1647
|
+
seeds.set(root, new Set([...(seeds.get(root) ?? []), ...placeholders]));
|
|
1648
|
+
};
|
|
1649
|
+
tokens.forEach((t, k) => {
|
|
1650
|
+
// A placeholder counts only outside a quoted name: `":tenant_agency_ids"` is a column.
|
|
1651
|
+
if (t.quoted === true || !p.graph.placeholders.has(t.text))
|
|
1652
|
+
return;
|
|
1653
|
+
const predicate = predicateAt(tokens, k, undefined, a.view.reading.profile.id);
|
|
1654
|
+
const column = predicate === undefined ? undefined : writtenColumn(a, predicate.column);
|
|
1655
|
+
if (predicate === undefined || column === undefined)
|
|
1656
|
+
return;
|
|
1657
|
+
const ref = { q: qualifierOf(tokens, predicate.column), column };
|
|
1658
|
+
const filter = filteredScopes(a, predicate.start, predicate.end, ref.q, predicate.column).find((f) => f.scope === scope);
|
|
1659
|
+
if (filter === undefined)
|
|
1660
|
+
return;
|
|
1661
|
+
const node = resolve(p, scope, ref, filter.on);
|
|
1662
|
+
if (node)
|
|
1663
|
+
seed(term(node, ref.column), [t.text]);
|
|
1664
|
+
});
|
|
1665
|
+
for (const link of inLinks(p, scope)) {
|
|
1666
|
+
const node = resolve(p, scope, link.ref, link.on);
|
|
1667
|
+
if (link.inner && node)
|
|
1668
|
+
seed(term(node, link.ref.column), boundIn(p, link.innerScope)(link.inner, link.item.column));
|
|
1669
|
+
}
|
|
1670
|
+
const lookup = (node, column) => seeds.get(find(term(node, column))) ?? new Set();
|
|
1671
|
+
p.bound.set(scope, lookup);
|
|
1672
|
+
return lookup;
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Each `col IN (SELECT x …)` ANDed into a filter clause of `scope`, over a subquery with no set
|
|
1676
|
+
* operation that selects one plain column: `col` as written, the subquery's `SELECT`, the column it
|
|
1677
|
+
* selects, and the read that column resolves to there, if any.
|
|
1678
|
+
*/
|
|
1679
|
+
function inLinks(p, scope) {
|
|
1680
|
+
const cached = p.links.get(scope);
|
|
1681
|
+
if (cached !== undefined)
|
|
1682
|
+
return cached;
|
|
1683
|
+
const { tokens, scopes } = p.a;
|
|
1684
|
+
const links = [];
|
|
1685
|
+
p.links.set(scope, links);
|
|
1686
|
+
for (const [open, close] of scopes.close) {
|
|
1687
|
+
const column = open - 2;
|
|
1688
|
+
if (scopes.of[column] !== scope || scopes.branched.has(open) || subqueryUse(tokens, open) !== "in")
|
|
1689
|
+
continue;
|
|
1690
|
+
const written = writtenColumn(p.a, column);
|
|
1691
|
+
if (written === undefined)
|
|
1692
|
+
continue;
|
|
1693
|
+
const ref = { q: qualifierOf(tokens, column), column: written };
|
|
1694
|
+
const filter = filteredScopes(p.a, qualifiedStart(tokens, column), close, ref.q, column).find((f) => f.scope === scope);
|
|
1695
|
+
if (filter === undefined)
|
|
1696
|
+
continue;
|
|
1697
|
+
const item = selectedColumn(p.a, open);
|
|
1698
|
+
if (item === undefined)
|
|
1699
|
+
continue;
|
|
1700
|
+
const innerScope = scopeKey(open, 0);
|
|
1701
|
+
links.push({ ref, innerScope, item, inner: resolve(p, innerScope, item), on: filter.on });
|
|
1702
|
+
}
|
|
1703
|
+
return links;
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* The reads that `node`'s `hop` links it to in its `SELECT`: a conjunct `node.fromColumn = y.toColumn`
|
|
1707
|
+
* (either way round), or `node.fromColumn IN (SELECT y.toColumn …)`, where `y` reads the hop's table.
|
|
1708
|
+
*/
|
|
1709
|
+
function hopTargets(p, node, hop) {
|
|
1710
|
+
const { tokens, scopes } = p.a;
|
|
1711
|
+
const targets = [];
|
|
1712
|
+
for (const eq of p.equalities) {
|
|
1713
|
+
if (eq.scope !== node.scope)
|
|
1714
|
+
continue;
|
|
1715
|
+
for (const [mine, theirs] of [[eq.left, eq.right], [eq.right, eq.left]]) {
|
|
1716
|
+
if (mine.column !== hop.fromColumn || theirs.column !== hop.toColumn || resolve(p, node.scope, mine, eq.on)?.at !== node.at)
|
|
1717
|
+
continue;
|
|
1718
|
+
const y = resolve(p, node.scope, theirs, eq.on);
|
|
1719
|
+
if (y?.table === hop.to)
|
|
1720
|
+
targets.push(y);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
for (const link of inLinks(p, node.scope)) {
|
|
1724
|
+
if (link.ref.column !== hop.fromColumn || resolve(p, node.scope, link.ref, link.on)?.at !== node.at)
|
|
1725
|
+
continue;
|
|
1726
|
+
if (link.item.column === hop.toColumn && link.inner?.table === hop.to)
|
|
1727
|
+
targets.push(link.inner);
|
|
1728
|
+
}
|
|
1729
|
+
return targets;
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* The proof for the join path `join` of the scoped `table`. The steps run in the order they chain from
|
|
1733
|
+
* `table` (each one leaving the table the one before reached), whatever order the policy lists them in;
|
|
1734
|
+
* when they don't chain that way (a fork or a gap), in the listed order.
|
|
1735
|
+
*/
|
|
1736
|
+
function joinPathProof(policy, table, join, reading) {
|
|
1737
|
+
const hop = (fromId, toId) => ({
|
|
1738
|
+
from: lowerEachLetter(tableOfColumn(fromId)),
|
|
1739
|
+
fromColumn: columnKey(extractColumnName(fromId), reading),
|
|
1740
|
+
to: lowerEachLetter(tableOfColumn(toId)),
|
|
1741
|
+
toColumn: columnKey(extractColumnName(toId), reading),
|
|
1742
|
+
});
|
|
1743
|
+
const idColumn = (rootId) => policy.roots.find((r) => r.id === rootId)?.tenantIdColumn;
|
|
1744
|
+
const roots = new Map();
|
|
1745
|
+
for (const root of policy.roots) {
|
|
1746
|
+
const parents = [];
|
|
1747
|
+
for (const edge of policy.hierarchy) {
|
|
1748
|
+
const to = edge.child === root.id ? idColumn(edge.parent) : undefined;
|
|
1749
|
+
if (to !== undefined)
|
|
1750
|
+
parents.push(hop(edge.foreignKey, to));
|
|
1751
|
+
}
|
|
1752
|
+
const to = root.parent ? idColumn(root.parent.root) : undefined;
|
|
1753
|
+
if (root.parent && to !== undefined)
|
|
1754
|
+
parents.push(hop(root.parent.foreignKey, to));
|
|
1755
|
+
roots.set(lowerEachLetter(tableOfColumn(root.tenantIdColumn)), {
|
|
1756
|
+
idColumn: columnKey(extractColumnName(root.tenantIdColumn), reading),
|
|
1757
|
+
placeholder: placeholderFor(policy, root.id),
|
|
1758
|
+
parents,
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
const listed = join.map((step) => hop(step.from, step.to));
|
|
1762
|
+
const left = [...listed];
|
|
1763
|
+
const chained = [];
|
|
1764
|
+
let at = lowerEachLetter(table);
|
|
1765
|
+
while (left.length > 0) {
|
|
1766
|
+
const next = left.filter((h) => h.from === at);
|
|
1767
|
+
if (next.length !== 1)
|
|
1768
|
+
break;
|
|
1769
|
+
chained.push(next[0]);
|
|
1770
|
+
left.splice(left.indexOf(next[0]), 1);
|
|
1771
|
+
at = next[0].to;
|
|
1772
|
+
}
|
|
1773
|
+
return { steps: left.length === 0 ? chained : listed, roots };
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Whether `node`, read of a table scoped by a join path, is within scope through that path alone
|
|
1777
|
+
* (#400): step `k` of `path` from `node` (a declared key, on exactly its columns) to a read of the
|
|
1778
|
+
* next table, and so on in order, then the path's root, through its ID or the hierarchy above it
|
|
1779
|
+
* ({@link rootInScope}). On the last step, the step's own column carries the root's IDs, so a filter
|
|
1780
|
+
* on it counts (`appointments.client_id IN (:tenant_client_ids)`). A table along the way counts only
|
|
1781
|
+
* through the path's own steps, never through its own scope column or another path's key.
|
|
1782
|
+
*/
|
|
1783
|
+
function pathInScope(p, node, path, allowed, k = 0, visited = new Set([node.at])) {
|
|
1784
|
+
if (k === path.steps.length)
|
|
1785
|
+
return rootInScope(p, node, path, allowed, visited);
|
|
1786
|
+
const step = path.steps[k];
|
|
1787
|
+
if (node.table !== step.from)
|
|
1788
|
+
return false;
|
|
1789
|
+
if (k === path.steps.length - 1) {
|
|
1790
|
+
const holds = p.graph.carriers.get(node.table)?.get(step.fromColumn);
|
|
1791
|
+
for (const placeholder of boundIn(p, node.scope)(node, step.fromColumn)) {
|
|
1792
|
+
if (holds?.has(placeholder) && allowed.has(placeholder))
|
|
1793
|
+
return true;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
for (const next of hopTargets(p, node, step)) {
|
|
1797
|
+
if (visited.has(next.at))
|
|
1798
|
+
continue;
|
|
1799
|
+
visited.add(next.at);
|
|
1800
|
+
if (pathInScope(p, next, path, allowed, k + 1, visited))
|
|
1801
|
+
return true;
|
|
1802
|
+
}
|
|
1803
|
+
return false;
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Whether `node`, a read of a root table a join path reaches, is within scope: its ID column holds
|
|
1807
|
+
* its own placeholder, a key to a parent root holds that parent's placeholder, or such a key links it
|
|
1808
|
+
* to a parent root's read that is, in turn, within scope. Only the root's ID and the hierarchy count.
|
|
1809
|
+
*/
|
|
1810
|
+
function rootInScope(p, node, path, allowed, visited) {
|
|
1811
|
+
const link = path.roots.get(node.table);
|
|
1812
|
+
if (link === undefined)
|
|
1813
|
+
return false;
|
|
1814
|
+
const bound = boundIn(p, node.scope);
|
|
1815
|
+
if (allowed.has(link.placeholder) && bound(node, link.idColumn).has(link.placeholder))
|
|
1816
|
+
return true;
|
|
1817
|
+
for (const up of link.parents) {
|
|
1818
|
+
const parent = path.roots.get(up.to);
|
|
1819
|
+
if (parent !== undefined && allowed.has(parent.placeholder) && bound(node, up.fromColumn).has(parent.placeholder))
|
|
1820
|
+
return true;
|
|
1821
|
+
for (const next of hopTargets(p, node, up)) {
|
|
1822
|
+
if (visited.has(next.at))
|
|
1823
|
+
continue;
|
|
1824
|
+
visited.add(next.at);
|
|
1825
|
+
if (rootInScope(p, next, path, allowed, visited))
|
|
1826
|
+
return true;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Whether `node` reads only rows within the scope of the roots whose placeholders are `allowed`
|
|
1833
|
+
* (#400): a column of its table that holds an allowed root's IDs (by the policy) is bound to that
|
|
1834
|
+
* root's placeholder in its `SELECT` (see {@link boundIn}), or a foreign key the policy declares links
|
|
1835
|
+
* it, on exactly the declared columns, to a read that is itself within that scope. A join on any
|
|
1836
|
+
* other pair of columns links nothing. With `start`, `node` counts only through that column of its
|
|
1837
|
+
* table: a table scoped by a column is proven through that column, never another path's key on it.
|
|
1838
|
+
*/
|
|
1839
|
+
function inScope(p, node, allowed, visited = new Set([node.at]), start) {
|
|
1840
|
+
const bound = boundIn(p, node.scope);
|
|
1841
|
+
for (const [column, holds] of p.graph.carriers.get(node.table) ?? []) {
|
|
1842
|
+
if (start !== undefined && column !== start)
|
|
1843
|
+
continue;
|
|
1844
|
+
for (const placeholder of bound(node, column))
|
|
1845
|
+
if (holds.has(placeholder) && allowed.has(placeholder))
|
|
1846
|
+
return true;
|
|
1847
|
+
}
|
|
1848
|
+
for (const hop of p.graph.hops) {
|
|
1849
|
+
if (hop.from !== node.table || (start !== undefined && hop.fromColumn !== start))
|
|
1850
|
+
continue;
|
|
1851
|
+
for (const next of hopTargets(p, node, hop)) {
|
|
1852
|
+
if (visited.has(next.at))
|
|
1853
|
+
continue;
|
|
1854
|
+
visited.add(next.at);
|
|
1855
|
+
if (inScope(p, next, allowed, visited))
|
|
1856
|
+
return true;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
return false;
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* The column the query at `open` selects, when its select list is one plain column (`col` or
|
|
1863
|
+
* `q.col`, under any alias), else undefined.
|
|
1864
|
+
*/
|
|
1865
|
+
function selectedColumn(a, open) {
|
|
1866
|
+
const { tokens, scopes, view } = a;
|
|
1867
|
+
const scope = scopeKey(open, 0);
|
|
1868
|
+
const depth = tokens[open].depth + 1;
|
|
1869
|
+
if (tokens[open + 1]?.kw !== "select")
|
|
1870
|
+
return undefined;
|
|
1871
|
+
let first = open + 2;
|
|
1872
|
+
if (tokens[first]?.kw === "all" || tokens[first]?.kw === "distinct")
|
|
1873
|
+
first++;
|
|
1874
|
+
let from = first;
|
|
1875
|
+
while (from < tokens.length && !(scopes.of[from] === scope && tokens[from].depth === depth && tokens[from].kw === "from"))
|
|
1876
|
+
from++;
|
|
1877
|
+
const item = tokens.slice(first, from);
|
|
1878
|
+
if (item.length === 0 || from >= tokens.length || item.some((t) => t.text === ","))
|
|
1879
|
+
return undefined;
|
|
1880
|
+
const last = item[item.length - 1];
|
|
1881
|
+
for (let at = item[0].offset; at < last.offset + last.text.length; at++) {
|
|
1882
|
+
if (view.text[at] !== view.source[at] && view.text[at] !== QUOTE_MARK)
|
|
1883
|
+
return undefined;
|
|
1884
|
+
}
|
|
1885
|
+
const n = item[1]?.text === "." ? 3 : 1;
|
|
1886
|
+
const rest = item.slice(n);
|
|
1887
|
+
const aliased = rest.length === 0 || (rest.length === 1 && isWord(rest[0])) || (rest.length === 2 && rest[0].kw === "as" && isWord(rest[1]));
|
|
1888
|
+
if (!aliased || !isIdentifier(item[n - 1]) || (n === 3 && !isWord(item[0])))
|
|
1889
|
+
return undefined;
|
|
1890
|
+
const column = writtenColumn(a, first + n - 1);
|
|
1891
|
+
if (column === undefined)
|
|
1892
|
+
return undefined;
|
|
1893
|
+
return { q: n === 3 ? item[0].name : undefined, column };
|
|
1894
|
+
}
|
|
1398
1895
|
/** An identifier token: a word that isn't one of the predicate forms' keywords. */
|
|
1399
1896
|
function isIdentifier(token) {
|
|
1400
1897
|
return token.quoted === true || (/^[A-Za-z_\u0080-\uffff]/.test(token.text) && !PREDICATE_WORDS.has(token.kw));
|
|
@@ -1416,14 +1913,14 @@ function qualifiedStart(tokens, end) {
|
|
|
1416
1913
|
/**
|
|
1417
1914
|
* The span `[start, end]` and column index of a tenant predicate around the placeholder at `k`:
|
|
1418
1915
|
* `col = P`, `P = col`, `col IN (P)` or `col = ANY (P)`, where `col` (the last part of a possibly
|
|
1419
|
-
* qualified name) is `column`, as {@link namesColumn} compares them under `reading
|
|
1420
|
-
* the column belongs to is its qualifier's to say.
|
|
1916
|
+
* qualified name) is `column`, as {@link namesColumn} compares them under `reading`, or any column
|
|
1917
|
+
* when none is given. Which read the column belongs to is its qualifier's to say.
|
|
1421
1918
|
*/
|
|
1422
1919
|
function predicateAt(tokens, k, column, reading) {
|
|
1423
1920
|
const at = (i) => tokens[i];
|
|
1424
1921
|
const matches = (i) => {
|
|
1425
1922
|
const t = at(i);
|
|
1426
|
-
return t !== undefined && isIdentifier(t) && namesColumn(t, column, reading);
|
|
1923
|
+
return t !== undefined && isIdentifier(t) && (column === undefined || namesColumn(t, column, reading));
|
|
1427
1924
|
};
|
|
1428
1925
|
// col = P
|
|
1429
1926
|
if (at(k - 1)?.text === "=" && matches(k - 2))
|
|
@@ -1448,25 +1945,58 @@ function predicateAt(tokens, k, column, reading) {
|
|
|
1448
1945
|
return undefined;
|
|
1449
1946
|
}
|
|
1450
1947
|
/**
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1456
|
-
*
|
|
1457
|
-
*
|
|
1948
|
+
* The name every engine that shares the `reading` reads for the written name `t`, to compare with a
|
|
1949
|
+
* column's spelling ({@link namesColumn}) or with another written name ({@link sameName}); undefined
|
|
1950
|
+
* when those engines read it apart, so it names no column. A name read as another column may be
|
|
1951
|
+
* another source's, so it ties nothing to the policy's.
|
|
1952
|
+
* - Read the Postgres way (`postgres`, `cockroachdb`): a quoted name exactly; an unquoted one with
|
|
1953
|
+
* its ASCII letters lower-cased, as Postgres folds it. CockroachDB lower-cases every letter, so an
|
|
1954
|
+
* unquoted name with an upper-case non-ASCII letter (`Égency_id`) is read apart.
|
|
1955
|
+
* - Read the SQL Server way, as written: the database's collation may compare names
|
|
1458
1956
|
* case-sensitively, and the check can't see it, so `agencyid` is another column than `agencyId`.
|
|
1459
|
-
* - Read the MySQL way (MySQL and MariaDB),
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
* A custom id or none is read every way, so
|
|
1957
|
+
* - Read the MySQL way (MySQL and MariaDB) and otherwise (SQLite, the generic reading), with only
|
|
1958
|
+
* ASCII letters lower-cased, quoted or not: SQLite folds only those, and MariaDB keeps apart
|
|
1959
|
+
* letters Unicode lower-casing merges (U+212A KELVIN SIGN and `k`).
|
|
1960
|
+
* A custom id or none is read every way, so a name has to match under each.
|
|
1961
|
+
*/
|
|
1962
|
+
function engineName(t, reading) {
|
|
1963
|
+
if (t.name.startsWith(READ_APART))
|
|
1964
|
+
return undefined;
|
|
1965
|
+
return reading === "sqlserver" || reading === "postgres" ? t.name : asciiLower(t.name);
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* The start of the name a token gets when the engines that share its reading read it apart (an
|
|
1969
|
+
* unquoted name with an upper-case non-ASCII letter, read the Postgres way), followed by its
|
|
1970
|
+
* offset: no other name equals it, so an alias, a qualifier or a column spelled that way names
|
|
1971
|
+
* nothing ({@link engineName}).
|
|
1463
1972
|
*/
|
|
1973
|
+
const READ_APART = "\u0000";
|
|
1974
|
+
/** Whether the written column `t` is the policy's `column`, whose spelling the policy writes as the schema does ({@link engineName}). */
|
|
1464
1975
|
function namesColumn(t, column, reading) {
|
|
1465
|
-
|
|
1466
|
-
|
|
1976
|
+
const name = engineName(t, reading);
|
|
1977
|
+
return name !== undefined && name === columnKey(column, reading);
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Whether the table read written `t` is the policy's `table`, spelled as the schema spells it, for
|
|
1981
|
+
* every engine that shares `reading`, so the join proof may count it (#400). Read as a column is
|
|
1982
|
+
* ({@link namesColumn}), except the MySQL way, where it is compared as written: MySQL and MariaDB
|
|
1983
|
+
* keep a table name's case on some servers (`lower_case_table_names = 0`). The table gate finds
|
|
1984
|
+
* reads more loosely ({@link tableNameOf}), which only finds more of them; the join proof counts
|
|
1985
|
+
* reads, so `"CLIENTS"`, which Postgres reads as another table, isn't `clients` there.
|
|
1986
|
+
*/
|
|
1987
|
+
function namesTable(t, table, reading) {
|
|
1467
1988
|
if (reading === "mysql")
|
|
1468
|
-
return t.
|
|
1469
|
-
return
|
|
1989
|
+
return !t.name.startsWith(READ_APART) && t.name === table;
|
|
1990
|
+
return namesColumn(t, table, reading);
|
|
1991
|
+
}
|
|
1992
|
+
/** A column's spelling, as the policy writes it from the schema, read the way `reading` compares a written name ({@link engineName}). */
|
|
1993
|
+
function columnKey(column, reading) {
|
|
1994
|
+
return reading === "postgres" || reading === "sqlserver" ? column : asciiLower(column);
|
|
1995
|
+
}
|
|
1996
|
+
/** Whether the written names `t` and `u` are the same name for every engine that shares the `reading` ({@link engineName}). */
|
|
1997
|
+
function sameName(t, u, reading) {
|
|
1998
|
+
const name = engineName(t, reading);
|
|
1999
|
+
return name !== undefined && name === engineName(u, reading);
|
|
1470
2000
|
}
|
|
1471
2001
|
/** `name` with its ASCII letters lower-cased, as SQLite folds a name. */
|
|
1472
2002
|
function asciiLower(name) {
|
|
@@ -1539,6 +2069,9 @@ function isOuterJoinOn(tokens, on) {
|
|
|
1539
2069
|
continue;
|
|
1540
2070
|
if (t.kw === "on" || t.kw === "using")
|
|
1541
2071
|
return true;
|
|
2072
|
+
// MySQL has no outer `STRAIGHT_JOIN`, so an `ON` that closes one is an inner join's.
|
|
2073
|
+
if (t.kw === "straight_join")
|
|
2074
|
+
return false;
|
|
1542
2075
|
if (t.kw !== "join")
|
|
1543
2076
|
continue;
|
|
1544
2077
|
for (let k = i - 1; k >= 0; k--) {
|
|
@@ -1617,14 +2150,14 @@ function takesNoOn(tokens, join) {
|
|
|
1617
2150
|
return false;
|
|
1618
2151
|
}
|
|
1619
2152
|
/**
|
|
1620
|
-
* The `SELECT`
|
|
1621
|
-
*
|
|
1622
|
-
* `
|
|
1623
|
-
*
|
|
1624
|
-
* at that level and no `NOT` in front of it.
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
2153
|
+
* The `SELECT` whose reads the term `tokens[start..end]` filters (#399), with `qualifier`, its
|
|
2154
|
+
* column's, which ties it to a read there: none, or one. The term must be ANDed into a `WHERE`,
|
|
2155
|
+
* `ON` (of an inner join, in a `SELECT` that keeps no unmatched rows) or `HAVING` clause: at its own level and
|
|
2156
|
+
* at every enclosing parenthesized level up to that clause, it sits between `AND`s (or the clause's
|
|
2157
|
+
* start and end), with no `OR`, `XOR` (or MySQL's `||`) at that level and no `NOT` in front of it.
|
|
2158
|
+
* A level opened by a function call or a comparison doesn't count. In `HAVING`, each of the term's
|
|
2159
|
+
* columns at `termColumn` must be a `GROUP BY` key ({@link onGroupKey}). A `SELECT` used as
|
|
2160
|
+
* `col IN (…)` limits the query around it only through the join {@link inScope} follows (#400).
|
|
1628
2161
|
*/
|
|
1629
2162
|
function filteredScopes(a, start, end, qualifier, termColumn) {
|
|
1630
2163
|
const { tokens, scopes } = a;
|
|
@@ -1634,15 +2167,13 @@ function filteredScopes(a, start, end, qualifier, termColumn) {
|
|
|
1634
2167
|
let lo = start;
|
|
1635
2168
|
let hi = end;
|
|
1636
2169
|
let depth = tokens[start].depth;
|
|
1637
|
-
let q = qualifier;
|
|
1638
|
-
let ownSelect = true;
|
|
1639
|
-
let column = termColumn;
|
|
1640
2170
|
for (;;) {
|
|
1641
2171
|
// What is directly around the term, at its level.
|
|
1642
2172
|
const before = tokens[lo - 1];
|
|
1643
2173
|
const after = tokens[hi + 1];
|
|
1644
2174
|
const leftOk = before !== undefined && (conjunctAnd(tokens, lo - 1) || before.text === "(" || FILTER_CLAUSE.has(before.kw));
|
|
1645
|
-
|
|
2175
|
+
// A `,` ends a join's `ON` when another source follows in the `FROM` list; elsewhere the opener check below refuses it.
|
|
2176
|
+
const rightOk = after === undefined || after.kw === "and" || after.text === ")" || after.text === ";" || after.text === "," || endsClause(hi + 1);
|
|
1646
2177
|
if (!leftOk || !rightOk)
|
|
1647
2178
|
return filters;
|
|
1648
2179
|
// Scan the rest of this level on both sides for a disjunction, a NOT, or the clause keyword.
|
|
@@ -1674,30 +2205,20 @@ function filteredScopes(a, start, end, qualifier, termColumn) {
|
|
|
1674
2205
|
}
|
|
1675
2206
|
if (t.depth > depth)
|
|
1676
2207
|
continue;
|
|
1677
|
-
if (t.text === ";" || endsFilterScan(tokens, j, a.view.reading.profile.id))
|
|
2208
|
+
if (t.text === ";" || t.text === "," || endsFilterScan(tokens, j, a.view.reading.profile.id))
|
|
1678
2209
|
break;
|
|
1679
2210
|
if (disjunction(t))
|
|
1680
2211
|
return filters;
|
|
1681
2212
|
}
|
|
1682
2213
|
if (clause >= 0) {
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
if (tokens[clause].kw === "
|
|
2214
|
+
// An outer join keeps rows its ON doesn't match, and in a SELECT with one, an inner join's ON
|
|
2215
|
+
// filters only the rows that join builds; there only WHERE and HAVING filter every row (#400).
|
|
2216
|
+
if (tokens[clause].kw === "on" && (isOuterJoinOn(tokens, clause) || mayKeepUnmatchedRows(a, scopes.of[clause])))
|
|
1686
2217
|
return filters;
|
|
1687
|
-
|
|
1688
|
-
filters.push({ scope, qualifier: q, ownSelect, ...(tokens[clause].kw === "on" ? { on: clause } : {}) });
|
|
1689
|
-
// `col IN (<this SELECT>)` limits the query around it to the rows this SELECT returns.
|
|
1690
|
-
const query = scopeOpen(scope);
|
|
1691
|
-
const queryClose = scopes.close.get(query);
|
|
1692
|
-
if (query < 0 || queryClose === undefined || scopes.branched.has(query) || subqueryUse(tokens, query) !== "in")
|
|
2218
|
+
if (tokens[clause].kw === "having" && !(typeof termColumn === "number" ? [termColumn] : termColumn).every((c) => onGroupKey(a, clause, c)))
|
|
1693
2219
|
return filters;
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
depth = tokens[query].depth;
|
|
1697
|
-
q = qualifierOf(tokens, query - 2);
|
|
1698
|
-
column = query - 2;
|
|
1699
|
-
ownSelect = false;
|
|
1700
|
-
continue;
|
|
2220
|
+
filters.push(tokens[clause].kw === "on" ? { scope: scopes.of[clause], qualifier, on: clause } : { scope: scopes.of[clause], qualifier });
|
|
2221
|
+
return filters;
|
|
1701
2222
|
}
|
|
1702
2223
|
if (open < 0 || tokens[open].text !== "(" || close >= tokens.length || tokens[close].text !== ")")
|
|
1703
2224
|
return filters;
|
|
@@ -1771,7 +2292,7 @@ function endsFilterScan(tokens, j, engine) {
|
|
|
1771
2292
|
/**
|
|
1772
2293
|
* Whether a predicate on the column at `column`, ANDed into the `HAVING` at `having`, filters rows:
|
|
1773
2294
|
* only when that column is a plain key of its `SELECT`'s `GROUP BY` (`agency_id`, or `o.agency_id`
|
|
1774
|
-
* written the same way). On any other column, a group holds every row it covers, and SQLite and
|
|
2295
|
+
* written the same way, the name compared by {@link sameName}). On any other column, a group holds every row it covers, and SQLite and
|
|
1775
2296
|
* MariaDB take the column's value from one of them. Read the MySQL way, an unqualified column that a
|
|
1776
2297
|
* select-list item is aliased to never counts: MySQL and MariaDB resolve such a `HAVING` name to the
|
|
1777
2298
|
* alias in some forms and to the `GROUP BY` column in others, so the check refuses the name either way.
|
|
@@ -1822,7 +2343,7 @@ function onGroupKey(a, having, column) {
|
|
|
1822
2343
|
}
|
|
1823
2344
|
return itemRanges(groupBy + 1, having).some(([s, e]) => {
|
|
1824
2345
|
const last = tokens[e];
|
|
1825
|
-
if (!isIdentifier(last) || last
|
|
2346
|
+
if (!isIdentifier(last) || !sameName(last, tokens[column], a.view.reading.profile.id))
|
|
1826
2347
|
return false;
|
|
1827
2348
|
const plain = e === s || (e === s + 2 && tokens[s + 1]?.text === ".");
|
|
1828
2349
|
return plain && qualifierOf(tokens, e) === qualifier;
|