@malloy-publisher/server 0.0.203 → 0.0.205
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/build.ts +10 -1
- package/dist/app/api-doc.yaml +146 -0
- package/dist/app/assets/{EnvironmentPage-BVQ7glKP.js → EnvironmentPage-CAge6UHD.js} +1 -1
- package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
- package/dist/app/assets/{MainPage-bYOWcgDP.js → MainPage-CeTxxGex.js} +2 -2
- package/dist/app/assets/MaterializationsPage-CpDHB70t.js +1 -0
- package/dist/app/assets/ModelPage-D9sSMb75.js +1 -0
- package/dist/app/assets/PackagePage-LRqQWrFY.js +1 -0
- package/dist/app/assets/{RouteError-_J-EBz7W.js → RouteError-xT6kuCNw.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Bjs9Nm-_.js → WorkbookPage-DsIh9svZ.js} +1 -1
- package/dist/app/assets/{core-BPLlx5VM.es-C2ARtwWI.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
- package/dist/app/assets/{index-CqUWJELr.js → index-BdOZDcce.js} +2 -2
- package/dist/app/assets/index-DHHAcY5o.js +1812 -0
- package/dist/app/assets/index-RX3QOTde.js +455 -0
- package/dist/app/assets/index.umd-D2WH3D-f.js +2469 -0
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +392 -67
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +982 -346
- package/package.json +15 -14
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/ducklake_version.spec.ts +43 -0
- package/src/ducklake_version.ts +26 -0
- package/src/errors.spec.ts +21 -0
- package/src/errors.ts +18 -1
- package/src/mcp/error_messages.spec.ts +35 -0
- package/src/mcp/error_messages.ts +14 -1
- package/src/mcp/handler_utils.ts +12 -0
- package/src/package_load/package_load_pool.ts +0 -5
- package/src/package_load/package_load_worker.ts +41 -99
- package/src/package_load/protocol.ts +1 -7
- package/src/runtime/publisher.js +318 -0
- package/src/server.ts +479 -2
- package/src/service/annotations.spec.ts +118 -0
- package/src/service/annotations.ts +91 -0
- package/src/service/authorize.spec.ts +132 -0
- package/src/service/authorize.ts +241 -0
- package/src/service/authorize_integration.spec.ts +932 -0
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/connection.ts +1 -1
- package/src/service/environment.ts +67 -9
- package/src/service/environment_store.ts +142 -11
- package/src/service/filter.spec.ts +14 -3
- package/src/service/filter.ts +5 -1
- package/src/service/filter_bypass.spec.ts +418 -0
- package/src/service/given.ts +37 -12
- package/src/service/givens_integration.spec.ts +34 -7
- package/src/service/materialization_service.ts +25 -20
- package/src/service/materialized_table_gc.spec.ts +6 -5
- package/src/service/materialized_table_gc.ts +2 -50
- package/src/service/model.spec.ts +203 -8
- package/src/service/model.ts +349 -155
- package/src/service/package.ts +17 -6
- package/src/service/package_worker_path.spec.ts +113 -0
- package/src/service/quoting.ts +0 -20
- package/src/service/restricted_mode.spec.ts +299 -0
- package/src/service/source_extraction.ts +226 -0
- package/src/storage/StorageManager.ts +73 -0
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/tests/fixtures/authorize-compile/model.malloy +9 -0
- package/tests/fixtures/authorize-compile/publisher.json +4 -0
- package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
- package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/data.csv +3 -0
- package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
- package/tests/fixtures/html-pages-test/public/data.json +1 -0
- package/tests/fixtures/html-pages-test/public/index.html +9 -0
- package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
- package/tests/fixtures/html-pages-test/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/report.malloy +1 -0
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
- package/tests/unit/duckdb/attached_databases.test.ts +111 -0
- package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
- package/tests/unit/duckdb/repositories.test.ts +208 -0
- package/dist/app/assets/HomePage-D9drXoZX.js +0 -1
- package/dist/app/assets/ModelPage-DT0gjNy1.js +0 -1
- package/dist/app/assets/PackagePage-N1ZBNJul.js +0 -1
- package/dist/app/assets/index-BeNwIeYQ.js +0 -454
- package/dist/app/assets/index-Dx7qi2LO.js +0 -1803
- package/dist/app/assets/index.umd-BXm2lnUO.js +0 -1145
package/src/service/model.ts
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
Annotations,
|
|
3
3
|
API,
|
|
4
4
|
Connection,
|
|
5
5
|
FixedConnectionMap,
|
|
6
6
|
GivenValue,
|
|
7
|
-
isSourceDef,
|
|
8
7
|
MalloyConfig,
|
|
9
8
|
MalloyError,
|
|
10
9
|
ModelDef,
|
|
11
10
|
modelDefToModelInfo,
|
|
12
11
|
ModelMaterializer,
|
|
13
|
-
NamedModelObject,
|
|
14
12
|
NamedQueryDef,
|
|
15
13
|
QueryData,
|
|
16
14
|
QueryMaterializer,
|
|
17
15
|
Runtime,
|
|
18
|
-
StructDef,
|
|
19
|
-
TurtleDef,
|
|
20
16
|
} from "@malloydata/malloy";
|
|
21
17
|
import * as Malloy from "@malloydata/malloy-interfaces";
|
|
22
18
|
import {
|
|
@@ -42,6 +38,7 @@ import {
|
|
|
42
38
|
import { MODEL_FILE_SUFFIX, NOTEBOOK_FILE_SUFFIX } from "../constants";
|
|
43
39
|
import { HackyDataStylesAccumulator } from "../data_styles";
|
|
44
40
|
import {
|
|
41
|
+
AccessDeniedError,
|
|
45
42
|
BadRequestError,
|
|
46
43
|
ModelCompilationError,
|
|
47
44
|
ModelNotFoundError,
|
|
@@ -54,11 +51,20 @@ import {
|
|
|
54
51
|
buildFilterClause,
|
|
55
52
|
FilterValidationError,
|
|
56
53
|
injectFilterRefinement,
|
|
57
|
-
parseFilters,
|
|
58
54
|
type FilterDefinition,
|
|
59
55
|
type FilterParams,
|
|
60
56
|
} from "./filter";
|
|
57
|
+
import {
|
|
58
|
+
collectAuthorizeExprs,
|
|
59
|
+
evaluateAuthorize,
|
|
60
|
+
validateAuthorizeProbes,
|
|
61
|
+
} from "./authorize";
|
|
62
|
+
import { modelAnnotations } from "./annotations";
|
|
61
63
|
import { malloyGivenToApi, type MalloyGiven } from "./given";
|
|
64
|
+
import {
|
|
65
|
+
extractQueriesFromModelDef,
|
|
66
|
+
extractSourcesFromModelDef,
|
|
67
|
+
} from "./source_extraction";
|
|
62
68
|
import {
|
|
63
69
|
assertWithinModelResponseLimits,
|
|
64
70
|
resolveModelQueryRowLimit,
|
|
@@ -68,9 +74,7 @@ type ApiCompiledModel = components["schemas"]["CompiledModel"];
|
|
|
68
74
|
type ApiNotebookCell = components["schemas"]["NotebookCell"];
|
|
69
75
|
type ApiRawNotebook = components["schemas"]["RawNotebook"];
|
|
70
76
|
type ApiSource = components["schemas"]["Source"];
|
|
71
|
-
type ApiFilter = components["schemas"]["Filter"];
|
|
72
77
|
type ApiGiven = components["schemas"]["Given"];
|
|
73
|
-
type ApiView = components["schemas"]["View"];
|
|
74
78
|
type ApiQuery = components["schemas"]["Query"];
|
|
75
79
|
export type ApiConnection = components["schemas"]["Connection"];
|
|
76
80
|
export type SnowflakeConnection = components["schemas"]["SnowflakeConnection"];
|
|
@@ -116,6 +120,9 @@ export class Model {
|
|
|
116
120
|
* `Model.givens` already collapses inheritance; we just stash the list
|
|
117
121
|
* for surfacing on the compiled-model response. */
|
|
118
122
|
private givens: ApiGiven[] | undefined;
|
|
123
|
+
/** Model-wide `##(authorize)` expressions; apply to every query in the
|
|
124
|
+
* model, including ad-hoc inline sources not declared in the model. */
|
|
125
|
+
private fileLevelAuthorize: string[] = [];
|
|
119
126
|
private meter = metrics.getMeter("publisher");
|
|
120
127
|
private queryExecutionHistogram = this.meter.createHistogram(
|
|
121
128
|
"malloy_model_query_duration",
|
|
@@ -163,6 +170,25 @@ export class Model {
|
|
|
163
170
|
this.compilationError = compilationError;
|
|
164
171
|
this.filterMap = filterMap ?? new Map();
|
|
165
172
|
this.givens = givens;
|
|
173
|
+
// Model-wide ##(authorize) gates, derived from the file-level annotations
|
|
174
|
+
// on the modelDef (which survives the worker boundary). These apply to
|
|
175
|
+
// any query that resolves to a model source (or to no nameable source),
|
|
176
|
+
// model-wide. (Raw-SQL access to the warehouse is closed separately by
|
|
177
|
+
// restricted mode, which rejects inline `duckdb.sql(...)` on the caller
|
|
178
|
+
// query path before any gate runs — see getQueryResults.) A
|
|
179
|
+
// successfully-loaded model has already had these validated; guard the
|
|
180
|
+
// parse defensively so the constructor never throws.
|
|
181
|
+
try {
|
|
182
|
+
this.fileLevelAuthorize = this.modelDef
|
|
183
|
+
? collectAuthorizeExprs(
|
|
184
|
+
(modelAnnotations(this.modelDef).notes ?? []).map(
|
|
185
|
+
(note) => note.text,
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
: [];
|
|
189
|
+
} catch {
|
|
190
|
+
this.fileLevelAuthorize = [];
|
|
191
|
+
}
|
|
166
192
|
this.modelInfo =
|
|
167
193
|
modelInfo ??
|
|
168
194
|
(this.modelDef ? modelDefToModelInfo(this.modelDef) : undefined);
|
|
@@ -191,15 +217,196 @@ export class Model {
|
|
|
191
217
|
return this.filterMap.get(sourceName) ?? [];
|
|
192
218
|
}
|
|
193
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Effective authorize expressions gating a source: file-level
|
|
222
|
+
* `##(authorize)` followed by the source's own `#(authorize)`, evaluated as
|
|
223
|
+
* one OR disjunction at request time. Empty array means unrestricted. Reads
|
|
224
|
+
* the per-source list surfaced on `sources` (which rides the worker
|
|
225
|
+
* serialization boundary), so it works for both freshly-created and
|
|
226
|
+
* deserialized models.
|
|
227
|
+
*/
|
|
228
|
+
public getAuthorize(sourceName: string): string[] {
|
|
229
|
+
return (
|
|
230
|
+
this.sources?.find((source) => source.name === sourceName)
|
|
231
|
+
?.authorize ?? []
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Whether the model declares any `#(authorize)` / `##(authorize)` gate at all
|
|
237
|
+
* (file-level or on any source). Lets callers cheaply skip authorize work for
|
|
238
|
+
* ungated models without compiling a probe.
|
|
239
|
+
*/
|
|
240
|
+
public hasAuthorize(): boolean {
|
|
241
|
+
return (
|
|
242
|
+
this.fileLevelAuthorize.length > 0 ||
|
|
243
|
+
(this.sources?.some((s) => (s.authorize?.length ?? 0) > 0) ?? false)
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Effective authorize expressions for whatever a query runs against:
|
|
249
|
+
* - a declared model source → its own list (file-level ++ source-level);
|
|
250
|
+
* - anything else (an ad-hoc inline `duckdb.sql(...)` source, or a source
|
|
251
|
+
* we couldn't name) → the model-wide file-level `##(authorize)` gates.
|
|
252
|
+
* The second case is what stops a file-level gate from being bypassed by
|
|
253
|
+
* querying the warehouse through raw inline SQL.
|
|
254
|
+
*/
|
|
255
|
+
private effectiveAuthorizeFor(sourceName: string | undefined): string[] {
|
|
256
|
+
if (sourceName && this.sources?.some((s) => s.name === sourceName)) {
|
|
257
|
+
return this.getAuthorize(sourceName);
|
|
258
|
+
}
|
|
259
|
+
return this.fileLevelAuthorize;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Runtime authorize gate. Throws `AccessDeniedError` (403) unless at least
|
|
264
|
+
* one in-scope authorize expression evaluates true for the supplied givens.
|
|
265
|
+
* No in-scope expressions = unrestricted.
|
|
266
|
+
*
|
|
267
|
+
* Fail closed: any failure to evaluate the probe — a missing given value, a
|
|
268
|
+
* transient probe error, a missing/non-true result — denies. (Expression
|
|
269
|
+
* well-formedness was already validated at model load; see authorize.ts.)
|
|
270
|
+
* The 403 message names only the source, never the expression, so gate logic
|
|
271
|
+
* is not leaked to the caller.
|
|
272
|
+
*/
|
|
273
|
+
public async assertAuthorized(
|
|
274
|
+
sourceName: string | undefined,
|
|
275
|
+
givens: Record<string, GivenValue>,
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
const exprs = this.effectiveAuthorizeFor(sourceName);
|
|
278
|
+
if (exprs.length === 0) return; // unrestricted
|
|
279
|
+
const label = sourceName ?? "(query)";
|
|
280
|
+
const deny = () => {
|
|
281
|
+
throw new AccessDeniedError(`Access denied for source "${label}".`);
|
|
282
|
+
};
|
|
283
|
+
if (!this.modelMaterializer) deny();
|
|
284
|
+
let passed = false;
|
|
285
|
+
try {
|
|
286
|
+
passed = await evaluateAuthorize(
|
|
287
|
+
this.modelMaterializer!,
|
|
288
|
+
exprs,
|
|
289
|
+
givens,
|
|
290
|
+
);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
// Fail closed — e.g. a referenced given had no supplied value.
|
|
293
|
+
logger.debug("Authorize probe failed; denying", {
|
|
294
|
+
sourceName: label,
|
|
295
|
+
modelPath: this.modelPath,
|
|
296
|
+
error: err instanceof Error ? err.message : String(err),
|
|
297
|
+
});
|
|
298
|
+
deny();
|
|
299
|
+
}
|
|
300
|
+
if (!passed) deny();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Gate ad-hoc compile/query text by the named source it targets. Resolves the
|
|
305
|
+
* source from surface syntax (`extractSourceName`) and applies the gate. An
|
|
306
|
+
* unnamed/inline source resolves to `undefined`, so only the model-wide
|
|
307
|
+
* file-level gate applies — the same top-level-only boundary as the query
|
|
308
|
+
* path's early gate. Used by the `/compile` path, which has no runnable to
|
|
309
|
+
* resolve before it decides whether to compile at all.
|
|
310
|
+
*/
|
|
311
|
+
public async assertAuthorizedForText(
|
|
312
|
+
text: string,
|
|
313
|
+
givens: Record<string, GivenValue>,
|
|
314
|
+
): Promise<void> {
|
|
315
|
+
await this.assertAuthorized(this.extractSourceName(text), givens);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Gate a compiled query by the source it actually reads, resolved from the
|
|
320
|
+
* prepared query's `structRef` (authoritative — survives named-query and
|
|
321
|
+
* multi-statement indirection that surface syntax misses, e.g. the executed
|
|
322
|
+
* `run:` statement isn't the first one). Used as the `/compile` backstop once
|
|
323
|
+
* a runnable exists.
|
|
324
|
+
*/
|
|
325
|
+
public async assertAuthorizedForRunnable(
|
|
326
|
+
runnable: { getPreparedQuery(): Promise<unknown> },
|
|
327
|
+
givens: Record<string, GivenValue>,
|
|
328
|
+
): Promise<void> {
|
|
329
|
+
await this.assertAuthorized(
|
|
330
|
+
await this.resolveAuthorizeSourceFromRunnable(runnable),
|
|
331
|
+
givens,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Resolve the source a compiled query reads, from its prepared query's
|
|
337
|
+
* `structRef`. This is authoritative — it survives named-query indirection
|
|
338
|
+
* and bare `run: <query>` forms that surface-syntax extraction misses — so
|
|
339
|
+
* the authorize gate can't be dodged by how a request names the query.
|
|
340
|
+
* Returns undefined if the source can't be determined.
|
|
341
|
+
*/
|
|
342
|
+
private async resolveAuthorizeSourceFromRunnable(runnable: {
|
|
343
|
+
getPreparedQuery(): Promise<unknown>;
|
|
344
|
+
}): Promise<string | undefined> {
|
|
345
|
+
try {
|
|
346
|
+
const prepared = (await runnable.getPreparedQuery()) as {
|
|
347
|
+
_query?: { structRef?: unknown };
|
|
348
|
+
};
|
|
349
|
+
const structRef = prepared._query?.structRef;
|
|
350
|
+
if (typeof structRef === "string") return structRef;
|
|
351
|
+
if (structRef && typeof structRef === "object") {
|
|
352
|
+
const s = structRef as { as?: string; name?: string };
|
|
353
|
+
return s.as || s.name;
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
// Can't resolve — caller simply has no name to gate on here.
|
|
357
|
+
}
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
194
361
|
/**
|
|
195
362
|
* Best-effort extraction of a source name from an ad-hoc Malloy query string.
|
|
196
363
|
* Matches patterns like `run: source_name -> ...` or `source_name -> ...`.
|
|
197
364
|
*/
|
|
198
365
|
private extractSourceName(query?: string): string | undefined {
|
|
199
366
|
if (!query) return undefined;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
367
|
+
// Match a bare `\w+` identifier or a backtick-quoted Malloy identifier
|
|
368
|
+
// (e.g. `gated-source`, which needs quoting for the hyphen). Quoted names
|
|
369
|
+
// must be recognized here too, or the early schema-oracle gate would miss
|
|
370
|
+
// a gated source with a quoted name and let a denied caller probe its
|
|
371
|
+
// columns via a pre-compilation field error. The quoted capture returns
|
|
372
|
+
// the inner name (no backticks), matching how sources are keyed.
|
|
373
|
+
const runMatch = query.match(/run\s*:\s*(?:`([^`]+)`|(\w+))\s*->/);
|
|
374
|
+
const arrowMatch = query.match(/^\s*(?:`([^`]+)`|(\w+))\s*->/m);
|
|
375
|
+
return (
|
|
376
|
+
runMatch?.[1] ?? runMatch?.[2] ?? arrowMatch?.[1] ?? arrowMatch?.[2]
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Resolve the run target of an ad-hoc query to the model-defined source
|
|
382
|
+
* whose filters apply, following source-derivation declarations so that a
|
|
383
|
+
* filter-protected source carries its filter requirements when read under a
|
|
384
|
+
* derived name. The declared filter belongs to the source, not to the name
|
|
385
|
+
* it is read under. Returns undefined when the run target does not derive
|
|
386
|
+
* from a protected source.
|
|
387
|
+
*/
|
|
388
|
+
private resolveFilterSource(query?: string): string | undefined {
|
|
389
|
+
const target = this.extractSourceName(query);
|
|
390
|
+
if (!target || !query) return undefined;
|
|
391
|
+
|
|
392
|
+
// Map each ad-hoc source name to the base it derives from
|
|
393
|
+
// (`source: NAME is BASE ...` → NAME → BASE).
|
|
394
|
+
const aliasOf = new Map<string, string>();
|
|
395
|
+
const declRe = /source\s*:\s*(\w+)\s+is\s+(\w+)/g;
|
|
396
|
+
let match: RegExpExecArray | null;
|
|
397
|
+
while ((match = declRe.exec(query)) !== null) {
|
|
398
|
+
aliasOf.set(match[1], match[2]);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Walk the derivation chain until we hit a protected source or run out.
|
|
402
|
+
let current: string | undefined = target;
|
|
403
|
+
const seen = new Set<string>();
|
|
404
|
+
while (current && !seen.has(current)) {
|
|
405
|
+
if (this.filterMap.has(current)) return current;
|
|
406
|
+
seen.add(current);
|
|
407
|
+
current = aliasOf.get(current);
|
|
408
|
+
}
|
|
409
|
+
return undefined;
|
|
203
410
|
}
|
|
204
411
|
|
|
205
412
|
/**
|
|
@@ -255,10 +462,16 @@ export class Model {
|
|
|
255
462
|
malloyGivens.length > 0
|
|
256
463
|
? (malloyGivens.map(malloyGivenToApi) as ApiGiven[])
|
|
257
464
|
: undefined;
|
|
258
|
-
const sourceResult = Model.getSources(
|
|
465
|
+
const sourceResult = Model.getSources(modelDef, givens);
|
|
259
466
|
sources = sourceResult.sources;
|
|
260
467
|
filterMap = sourceResult.filterMap;
|
|
261
|
-
queries = Model.getQueries(
|
|
468
|
+
queries = Model.getQueries(modelDef);
|
|
469
|
+
|
|
470
|
+
// Translation-time validation of #(authorize) annotations (shared
|
|
471
|
+
// with the package-load worker so both compile paths validate
|
|
472
|
+
// identically). Compiling the probe surfaces unknown givens and
|
|
473
|
+
// source-field references at model-load instead of first request.
|
|
474
|
+
await validateAuthorizeProbes(modelMaterializer, sources ?? []);
|
|
262
475
|
|
|
263
476
|
// Collect sourceInfos from imported models first
|
|
264
477
|
// This follows the same pattern as notebook imports handling
|
|
@@ -557,6 +770,24 @@ export class Model {
|
|
|
557
770
|
if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
|
|
558
771
|
throw new BadRequestError("Model has no queryable entities.");
|
|
559
772
|
|
|
773
|
+
// Early fast-path authorize gate (before loadQuery). Resolve the source
|
|
774
|
+
// from surface syntax; gate if it names one. This runs BEFORE compilation
|
|
775
|
+
// so the gate can't be used as a schema oracle — without it, a denied
|
|
776
|
+
// caller probing `run: gated -> { group_by: maybe_field }` would get a
|
|
777
|
+
// Malloy "field not found" vs a 403 and learn the gated source's columns.
|
|
778
|
+
// It does NOT replace the authoritative compiled-source gate below (which
|
|
779
|
+
// always runs and catches named-query / multi-statement forms surface
|
|
780
|
+
// syntax can't resolve); it only fails fast for the common case.
|
|
781
|
+
const earlySource =
|
|
782
|
+
sourceName ||
|
|
783
|
+
(queryName
|
|
784
|
+
? this.queries?.find((q) => q.name === queryName)?.sourceName
|
|
785
|
+
: undefined) ||
|
|
786
|
+
this.extractSourceName(query);
|
|
787
|
+
if (earlySource) {
|
|
788
|
+
await this.assertAuthorized(earlySource, givens ?? {});
|
|
789
|
+
}
|
|
790
|
+
|
|
560
791
|
// Wrap loadQuery calls in try-catch to handle query parsing errors
|
|
561
792
|
try {
|
|
562
793
|
let queryString: string;
|
|
@@ -579,9 +810,19 @@ export class Model {
|
|
|
579
810
|
);
|
|
580
811
|
}
|
|
581
812
|
|
|
582
|
-
//
|
|
813
|
+
// Distinguishes free-form query text from the named `source->view`
|
|
814
|
+
// form. Both are driven by untrusted caller input and compiled in
|
|
815
|
+
// restricted mode below; this flag only controls how the protected
|
|
816
|
+
// source is resolved for filter injection.
|
|
817
|
+
const isAdHocQuery = !sourceName && !queryName && !!query;
|
|
818
|
+
|
|
819
|
+
// Inject source filter predicates unless bypassed. For ad-hoc queries
|
|
820
|
+
// resolve the run target through any alias/extend/chain so a protected
|
|
821
|
+
// source can't be read unfiltered under a different name.
|
|
583
822
|
if (!bypassFilters) {
|
|
584
|
-
const effectiveSource =
|
|
823
|
+
const effectiveSource = isAdHocQuery
|
|
824
|
+
? this.resolveFilterSource(query)
|
|
825
|
+
: sourceName;
|
|
585
826
|
if (effectiveSource) {
|
|
586
827
|
const filters = this.getFilters(effectiveSource);
|
|
587
828
|
if (filters.length > 0) {
|
|
@@ -597,7 +838,14 @@ export class Model {
|
|
|
597
838
|
}
|
|
598
839
|
}
|
|
599
840
|
|
|
600
|
-
|
|
841
|
+
// Restricted mode keeps untrusted query text inside the model's curated
|
|
842
|
+
// surface — it rejects `import`, raw `connection.table(...)` /
|
|
843
|
+
// `connection.sql(...)`, raw-SQL functions, and `##!` flags. The
|
|
844
|
+
// model's own definitions are unaffected. Both the ad-hoc `query` text
|
|
845
|
+
// and the `run: source->view` string built from the caller-supplied
|
|
846
|
+
// `sourceName`/`queryName` pair are untrusted, so both compile here;
|
|
847
|
+
// only author-curated notebook cells use the unrestricted `loadQuery`.
|
|
848
|
+
runnable = this.modelMaterializer.loadRestrictedQuery(queryString);
|
|
601
849
|
} catch (error) {
|
|
602
850
|
// Re-throw BadRequestError as-is
|
|
603
851
|
if (error instanceof BadRequestError) {
|
|
@@ -626,6 +874,31 @@ export class Model {
|
|
|
626
874
|
throw new BadRequestError(`Invalid query: ${errorMessage}`);
|
|
627
875
|
}
|
|
628
876
|
|
|
877
|
+
// Authoritative authorize gate: resolve the gated source from the
|
|
878
|
+
// COMPILED query — the source Malloy actually runs (the LAST `run:`
|
|
879
|
+
// statement) — not from surface syntax. Surface-syntax resolution alone
|
|
880
|
+
// is both bypassable (first-statement regex vs. last-statement execution:
|
|
881
|
+
// `run: ungated\nrun: gated` would gate `ungated` while running `gated`)
|
|
882
|
+
// and over-restrictive, so this compiled check always runs and is the
|
|
883
|
+
// source of truth; it handles named-query / blank-source / multi-statement
|
|
884
|
+
// forms uniformly. Skip only the redundant re-probe when it's the same
|
|
885
|
+
// source the early gate already cleared. Outside the loadQuery try so
|
|
886
|
+
// AccessDeniedError stays a 403; independent of bypassFilters.
|
|
887
|
+
const compiledSource =
|
|
888
|
+
await this.resolveAuthorizeSourceFromRunnable(runnable);
|
|
889
|
+
// Run unless it's the redundant re-probe of the exact named source the
|
|
890
|
+
// early gate already cleared. When compiledSource is unknown/unresolved,
|
|
891
|
+
// this still runs and assertAuthorized applies the model-wide file-level
|
|
892
|
+
// gate via effectiveAuthorizeFor. Note: on this path an ad-hoc inline
|
|
893
|
+
// `duckdb.sql(...)` query is rejected by restricted mode (the raw-SQL
|
|
894
|
+
// ban from loadRestrictedQuery above) before it can run, so the
|
|
895
|
+
// raw-warehouse bypass is closed by restricted mode — not by this gate.
|
|
896
|
+
// This fallback's job is to apply the file-level gate to permitted ad-hoc
|
|
897
|
+
// forms (declared-source references) whose source can't be named.
|
|
898
|
+
if (!(compiledSource && compiledSource === earlySource)) {
|
|
899
|
+
await this.assertAuthorized(compiledSource, givens ?? {});
|
|
900
|
+
}
|
|
901
|
+
|
|
629
902
|
const maxRows = getMaxQueryRows();
|
|
630
903
|
const maxBytes = getMaxResponseBytes();
|
|
631
904
|
const rowLimit = resolveModelQueryRowLimit(
|
|
@@ -727,6 +1000,32 @@ export class Model {
|
|
|
727
1000
|
} as ApiCompiledModel;
|
|
728
1001
|
}
|
|
729
1002
|
|
|
1003
|
+
/**
|
|
1004
|
+
* Serialize a notebook cell's `newSources` to the wire shape (an array
|
|
1005
|
+
* of JSON strings), embedding the model-level `givens` on every
|
|
1006
|
+
* SourceInfo so consumers iterating `newSources` can render `given:`
|
|
1007
|
+
* inputs without a second getModel round-trip. Matches `Source.givens`
|
|
1008
|
+
* in the API spec ("Identical to CompiledModel.givens") and how
|
|
1009
|
+
* `getSources` already copies the full list onto each CompiledModel
|
|
1010
|
+
* source. When the model declares no givens, the SourceInfo is emitted
|
|
1011
|
+
* untouched (no empty `givens` key).
|
|
1012
|
+
*
|
|
1013
|
+
* Shared by `getNotebookModel` (the notebook GET endpoint) and
|
|
1014
|
+
* `executeNotebookCell` (the cell-run endpoint) so both surface givens
|
|
1015
|
+
* identically.
|
|
1016
|
+
*/
|
|
1017
|
+
private serializeNewSources(
|
|
1018
|
+
newSources: Malloy.SourceInfo[] | undefined,
|
|
1019
|
+
): string[] | undefined {
|
|
1020
|
+
return newSources?.map((source) =>
|
|
1021
|
+
JSON.stringify(
|
|
1022
|
+
this.givens && this.givens.length > 0
|
|
1023
|
+
? { ...source, givens: this.givens }
|
|
1024
|
+
: source,
|
|
1025
|
+
),
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
730
1029
|
private async getNotebookModel(): Promise<ApiRawNotebook> {
|
|
731
1030
|
// Return raw cell contents without executing them
|
|
732
1031
|
const notebookCells: ApiNotebookCell[] = (
|
|
@@ -735,33 +1034,16 @@ export class Model {
|
|
|
735
1034
|
return {
|
|
736
1035
|
type: cell.type,
|
|
737
1036
|
text: cell.text,
|
|
738
|
-
newSources: cell.newSources
|
|
739
|
-
JSON.stringify(source),
|
|
740
|
-
),
|
|
1037
|
+
newSources: this.serializeNewSources(cell.newSources),
|
|
741
1038
|
queryInfo: cell.queryInfo
|
|
742
1039
|
? JSON.stringify(cell.queryInfo)
|
|
743
1040
|
: undefined,
|
|
744
1041
|
} as ApiNotebookCell;
|
|
745
1042
|
});
|
|
746
1043
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
// Traverse the inherits chain to collect all annotations
|
|
751
|
-
// Type as Annotation to handle the inherits chain properly
|
|
752
|
-
let currentAnnotation: Annotation | undefined =
|
|
753
|
-
this.modelDef.annotation;
|
|
754
|
-
|
|
755
|
-
while (currentAnnotation) {
|
|
756
|
-
if (currentAnnotation.notes) {
|
|
757
|
-
allAnnotations.push(
|
|
758
|
-
...currentAnnotation.notes.map((note) => note.text),
|
|
759
|
-
);
|
|
760
|
-
}
|
|
761
|
-
// Navigate to the inherited annotation if it exists
|
|
762
|
-
currentAnnotation = currentAnnotation.inherits;
|
|
763
|
-
}
|
|
764
|
-
}
|
|
1044
|
+
const allAnnotations = this.modelDef
|
|
1045
|
+
? new Annotations(modelAnnotations(this.modelDef)).texts()
|
|
1046
|
+
: [];
|
|
765
1047
|
|
|
766
1048
|
return {
|
|
767
1049
|
type: "notebook",
|
|
@@ -814,6 +1096,20 @@ export class Model {
|
|
|
814
1096
|
};
|
|
815
1097
|
}
|
|
816
1098
|
|
|
1099
|
+
// Authorize gate — only cells that actually run a query touch data, so
|
|
1100
|
+
// gate exactly those (a source-def / import cell has no runnable and
|
|
1101
|
+
// accesses nothing). Resolve the source from the COMPILED cell query
|
|
1102
|
+
// (authoritative — survives `run: <named query>` cells the text regex
|
|
1103
|
+
// misses); assertAuthorized applies the source's gate, or the model-wide
|
|
1104
|
+
// file-level gate for an unknown/inline source. Before the execution try
|
|
1105
|
+
// below so AccessDeniedError stays a 403; independent of bypassFilters.
|
|
1106
|
+
if (cell.runnable) {
|
|
1107
|
+
const authorizeSource = await this.resolveAuthorizeSourceFromRunnable(
|
|
1108
|
+
cell.runnable,
|
|
1109
|
+
);
|
|
1110
|
+
await this.assertAuthorized(authorizeSource, givens ?? {});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
817
1113
|
// For code cells, execute the runnable if available
|
|
818
1114
|
let queryName: string | undefined = undefined;
|
|
819
1115
|
let queryResult: string | undefined = undefined;
|
|
@@ -910,7 +1206,7 @@ export class Model {
|
|
|
910
1206
|
text: cell.text,
|
|
911
1207
|
queryName: queryName,
|
|
912
1208
|
result: queryResult,
|
|
913
|
-
newSources: cell.newSources
|
|
1209
|
+
newSources: this.serializeNewSources(cell.newSources),
|
|
914
1210
|
};
|
|
915
1211
|
}
|
|
916
1212
|
|
|
@@ -976,132 +1272,30 @@ export class Model {
|
|
|
976
1272
|
return malloyConfig;
|
|
977
1273
|
}
|
|
978
1274
|
|
|
979
|
-
private static getQueries(
|
|
980
|
-
|
|
981
|
-
modelDef
|
|
982
|
-
): ApiQuery[] {
|
|
983
|
-
const isNamedQuery = (
|
|
984
|
-
object: NamedModelObject,
|
|
985
|
-
): object is NamedQueryDef => object.type === "query";
|
|
986
|
-
return Object.values(modelDef.contents)
|
|
987
|
-
.filter(isNamedQuery)
|
|
988
|
-
.map((queryObj: NamedQueryDef) => ({
|
|
989
|
-
name: queryObj.as || queryObj.name,
|
|
990
|
-
// What to do when the source is not a string?
|
|
991
|
-
sourceName:
|
|
992
|
-
typeof queryObj.structRef === "string"
|
|
993
|
-
? queryObj.structRef
|
|
994
|
-
: undefined,
|
|
995
|
-
annotations: queryObj?.annotation?.blockNotes
|
|
996
|
-
?.filter((note: { at: { url: string } }) =>
|
|
997
|
-
note.at.url.includes(modelPath),
|
|
998
|
-
)
|
|
999
|
-
.map((note: { text: string }) => note.text),
|
|
1000
|
-
}));
|
|
1275
|
+
private static getQueries(modelDef: ModelDef): ApiQuery[] {
|
|
1276
|
+
// Shared with the package-load worker — see service/source_extraction.ts.
|
|
1277
|
+
return extractQueriesFromModelDef(modelDef) as ApiQuery[];
|
|
1001
1278
|
}
|
|
1002
1279
|
|
|
1003
1280
|
private static getSources(
|
|
1004
|
-
modelPath: string,
|
|
1005
1281
|
modelDef: ModelDef,
|
|
1006
1282
|
givens?: ApiGiven[],
|
|
1007
1283
|
): {
|
|
1008
1284
|
sources: ApiSource[];
|
|
1009
1285
|
filterMap: Map<string, FilterDefinition[]>;
|
|
1010
1286
|
} {
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
const sources =
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
// picked up by an extending source (`manufacturer_recalls is
|
|
1024
|
-
// recalls extend {}`). The Malloy compiler stores the base
|
|
1025
|
-
// source's annotations in `annotation.inherits`.
|
|
1026
|
-
//
|
|
1027
|
-
// The chain goes child → parent, so we collect child-first.
|
|
1028
|
-
// parseFilters uses "last wins" dedup, so we reverse to put
|
|
1029
|
-
// parent annotations first and child annotations last (winning).
|
|
1030
|
-
const collectedAnnotations: string[][] = [];
|
|
1031
|
-
let curAnnotation: Annotation | undefined = (sourceObj as StructDef)
|
|
1032
|
-
.annotation;
|
|
1033
|
-
while (curAnnotation) {
|
|
1034
|
-
if (curAnnotation.blockNotes) {
|
|
1035
|
-
collectedAnnotations.push(
|
|
1036
|
-
curAnnotation.blockNotes.map((note) => note.text),
|
|
1037
|
-
);
|
|
1038
|
-
}
|
|
1039
|
-
curAnnotation = curAnnotation.inherits;
|
|
1040
|
-
}
|
|
1041
|
-
const allAnnotations = collectedAnnotations.reverse().flat();
|
|
1042
|
-
let filters: ApiFilter[] | undefined;
|
|
1043
|
-
if (allAnnotations.length > 0) {
|
|
1044
|
-
try {
|
|
1045
|
-
const parsed = parseFilters(allAnnotations);
|
|
1046
|
-
if (parsed.length > 0) {
|
|
1047
|
-
filterMap.set(sourceName, parsed);
|
|
1048
|
-
const structFields = (sourceObj as StructDef).fields;
|
|
1049
|
-
filters = parsed.map((f) => {
|
|
1050
|
-
const field = structFields.find(
|
|
1051
|
-
(fd) => (fd.as || fd.name) === f.dimension,
|
|
1052
|
-
);
|
|
1053
|
-
return {
|
|
1054
|
-
name: f.name,
|
|
1055
|
-
dimension: f.dimension,
|
|
1056
|
-
type: f.type,
|
|
1057
|
-
implicit: f.implicit,
|
|
1058
|
-
required: f.required,
|
|
1059
|
-
dimensionType: field?.type as string | undefined,
|
|
1060
|
-
};
|
|
1061
|
-
});
|
|
1062
|
-
}
|
|
1063
|
-
} catch (err) {
|
|
1064
|
-
logger.warn(
|
|
1065
|
-
`Failed to parse filter annotations on source "${sourceName}"`,
|
|
1066
|
-
{ error: err },
|
|
1067
|
-
);
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
const views = (sourceObj as StructDef).fields
|
|
1072
|
-
.filter((turtleObj) => turtleObj.type === "turtle")
|
|
1073
|
-
.filter((turtleObj) =>
|
|
1074
|
-
// TODO(kjnesbit): Fix non-reduce views. Filter out
|
|
1075
|
-
// non-reduce views, i.e., indexes. Need to discuss with Will.
|
|
1076
|
-
(turtleObj as TurtleDef).pipeline
|
|
1077
|
-
.map((stage) => stage.type)
|
|
1078
|
-
.every((type) => type == "reduce"),
|
|
1079
|
-
)
|
|
1080
|
-
.map(
|
|
1081
|
-
(turtleObj) =>
|
|
1082
|
-
({
|
|
1083
|
-
name: turtleObj.as || turtleObj.name,
|
|
1084
|
-
annotations: turtleObj?.annotation?.blockNotes
|
|
1085
|
-
?.filter((note) => note.at.url.includes(modelPath))
|
|
1086
|
-
.map((note) => note.text),
|
|
1087
|
-
}) as ApiView,
|
|
1088
|
-
);
|
|
1089
|
-
|
|
1090
|
-
return {
|
|
1091
|
-
name: sourceName,
|
|
1092
|
-
annotations,
|
|
1093
|
-
views,
|
|
1094
|
-
filters,
|
|
1095
|
-
// Malloy exposes givens at the model level, not per-source.
|
|
1096
|
-
// First pass: surface the full model-level list on every source
|
|
1097
|
-
// — matches how filter introspection already collapses
|
|
1098
|
-
// inheritance into the per-source list. Refine to view-scoped
|
|
1099
|
-
// filtering if a customer asks.
|
|
1100
|
-
givens,
|
|
1101
|
-
} as ApiSource;
|
|
1102
|
-
});
|
|
1103
|
-
|
|
1104
|
-
return { sources, filterMap };
|
|
1287
|
+
// Shared with the package-load worker — see service/source_extraction.ts.
|
|
1288
|
+
// The service path logs filter parse failures; the worker stays silent.
|
|
1289
|
+
const { sources, filterMap } = extractSourcesFromModelDef(
|
|
1290
|
+
modelDef,
|
|
1291
|
+
givens,
|
|
1292
|
+
(sourceName, err) =>
|
|
1293
|
+
logger.warn(
|
|
1294
|
+
`Failed to parse filter annotations on source "${sourceName}"`,
|
|
1295
|
+
{ error: err },
|
|
1296
|
+
),
|
|
1297
|
+
);
|
|
1298
|
+
return { sources: sources as unknown as ApiSource[], filterMap };
|
|
1105
1299
|
}
|
|
1106
1300
|
|
|
1107
1301
|
static async getModelMaterializer(
|