@malloy-publisher/server 0.0.202 → 0.0.204
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/app/api-doc.yaml +25 -3
- package/dist/app/assets/{EnvironmentPage-CNQYDaxR.js → EnvironmentPage-CX06cjOF.js} +1 -1
- package/dist/app/assets/HomePage-CNFt_eUU.js +1 -0
- package/dist/app/assets/{MainPage-B0kNpkxT.js → MainPage-nUJ9YatG.js} +1 -1
- package/dist/app/assets/{PackagePage-yAh0TrOV.js → MaterializationsPage-B5goxVXW.js} +1 -1
- package/dist/app/assets/{ModelPage-DcVElc9L.js → ModelPage-Ba7Xh4lL.js} +1 -1
- package/dist/app/assets/PackagePage-BaEVdEAG.js +1 -0
- package/dist/app/assets/{RouteError-DknUbx_s.js → RouteError-BShQjZio.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CCqc8otA.js → WorkbookPage-CBn6ZjJW.js} +1 -1
- package/dist/app/assets/{core-B3A61KGJ.es-iOUZ6RJL.js → core-DECXYL4E.es-OaRfXwuQ.js} +1 -1
- package/dist/app/assets/{index-W0bOLKGl.js → index-BLfPC1gy.js} +2 -2
- package/dist/app/assets/index-DqiJ0bWp.js +455 -0
- package/dist/app/assets/index-Dy3YhAZQ.js +1812 -0
- package/dist/app/assets/index.umd-DAN9K8yC.js +2469 -0
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +392 -67
- package/dist/server.mjs +418 -153
- package/package.json +11 -11
- package/src/ducklake_version.spec.ts +43 -0
- package/src/ducklake_version.ts +26 -0
- package/src/errors.ts +18 -1
- 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/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 +838 -0
- package/src/service/connection.ts +1 -1
- package/src/service/environment.ts +4 -4
- package/src/service/environment_store.ts +14 -2
- 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 +305 -155
- 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/dist/app/assets/HomePage-DBFTIoD8.js +0 -1
- package/dist/app/assets/index-F_o127LC.js +0 -454
- package/dist/app/assets/index-QeX_e740.js +0 -1803
- package/dist/app/assets/index.umd-CEDRw4TK.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,152 @@ 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
|
+
* Effective authorize expressions for whatever a query runs against:
|
|
237
|
+
* - a declared model source → its own list (file-level ++ source-level);
|
|
238
|
+
* - anything else (an ad-hoc inline `duckdb.sql(...)` source, or a source
|
|
239
|
+
* we couldn't name) → the model-wide file-level `##(authorize)` gates.
|
|
240
|
+
* The second case is what stops a file-level gate from being bypassed by
|
|
241
|
+
* querying the warehouse through raw inline SQL.
|
|
242
|
+
*/
|
|
243
|
+
private effectiveAuthorizeFor(sourceName: string | undefined): string[] {
|
|
244
|
+
if (sourceName && this.sources?.some((s) => s.name === sourceName)) {
|
|
245
|
+
return this.getAuthorize(sourceName);
|
|
246
|
+
}
|
|
247
|
+
return this.fileLevelAuthorize;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Runtime authorize gate. Throws `AccessDeniedError` (403) unless at least
|
|
252
|
+
* one in-scope authorize expression evaluates true for the supplied givens.
|
|
253
|
+
* No in-scope expressions = unrestricted.
|
|
254
|
+
*
|
|
255
|
+
* Fail closed: any failure to evaluate the probe — a missing given value, a
|
|
256
|
+
* transient probe error, a missing/non-true result — denies. (Expression
|
|
257
|
+
* well-formedness was already validated at model load; see authorize.ts.)
|
|
258
|
+
* The 403 message names only the source, never the expression, so gate logic
|
|
259
|
+
* is not leaked to the caller.
|
|
260
|
+
*/
|
|
261
|
+
public async assertAuthorized(
|
|
262
|
+
sourceName: string | undefined,
|
|
263
|
+
givens: Record<string, GivenValue>,
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
const exprs = this.effectiveAuthorizeFor(sourceName);
|
|
266
|
+
if (exprs.length === 0) return; // unrestricted
|
|
267
|
+
const label = sourceName ?? "(query)";
|
|
268
|
+
const deny = () => {
|
|
269
|
+
throw new AccessDeniedError(`Access denied for source "${label}".`);
|
|
270
|
+
};
|
|
271
|
+
if (!this.modelMaterializer) deny();
|
|
272
|
+
let passed = false;
|
|
273
|
+
try {
|
|
274
|
+
passed = await evaluateAuthorize(
|
|
275
|
+
this.modelMaterializer!,
|
|
276
|
+
exprs,
|
|
277
|
+
givens,
|
|
278
|
+
);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
// Fail closed — e.g. a referenced given had no supplied value.
|
|
281
|
+
logger.debug("Authorize probe failed; denying", {
|
|
282
|
+
sourceName: label,
|
|
283
|
+
modelPath: this.modelPath,
|
|
284
|
+
error: err instanceof Error ? err.message : String(err),
|
|
285
|
+
});
|
|
286
|
+
deny();
|
|
287
|
+
}
|
|
288
|
+
if (!passed) deny();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Resolve the source a compiled query reads, from its prepared query's
|
|
293
|
+
* `structRef`. This is authoritative — it survives named-query indirection
|
|
294
|
+
* and bare `run: <query>` forms that surface-syntax extraction misses — so
|
|
295
|
+
* the authorize gate can't be dodged by how a request names the query.
|
|
296
|
+
* Returns undefined if the source can't be determined.
|
|
297
|
+
*/
|
|
298
|
+
private async resolveAuthorizeSourceFromRunnable(runnable: {
|
|
299
|
+
getPreparedQuery(): Promise<unknown>;
|
|
300
|
+
}): Promise<string | undefined> {
|
|
301
|
+
try {
|
|
302
|
+
const prepared = (await runnable.getPreparedQuery()) as {
|
|
303
|
+
_query?: { structRef?: unknown };
|
|
304
|
+
};
|
|
305
|
+
const structRef = prepared._query?.structRef;
|
|
306
|
+
if (typeof structRef === "string") return structRef;
|
|
307
|
+
if (structRef && typeof structRef === "object") {
|
|
308
|
+
const s = structRef as { as?: string; name?: string };
|
|
309
|
+
return s.as || s.name;
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
// Can't resolve — caller simply has no name to gate on here.
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
194
317
|
/**
|
|
195
318
|
* Best-effort extraction of a source name from an ad-hoc Malloy query string.
|
|
196
319
|
* Matches patterns like `run: source_name -> ...` or `source_name -> ...`.
|
|
197
320
|
*/
|
|
198
321
|
private extractSourceName(query?: string): string | undefined {
|
|
199
322
|
if (!query) return undefined;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
323
|
+
// Match a bare `\w+` identifier or a backtick-quoted Malloy identifier
|
|
324
|
+
// (e.g. `gated-source`, which needs quoting for the hyphen). Quoted names
|
|
325
|
+
// must be recognized here too, or the early schema-oracle gate would miss
|
|
326
|
+
// a gated source with a quoted name and let a denied caller probe its
|
|
327
|
+
// columns via a pre-compilation field error. The quoted capture returns
|
|
328
|
+
// the inner name (no backticks), matching how sources are keyed.
|
|
329
|
+
const runMatch = query.match(/run\s*:\s*(?:`([^`]+)`|(\w+))\s*->/);
|
|
330
|
+
const arrowMatch = query.match(/^\s*(?:`([^`]+)`|(\w+))\s*->/m);
|
|
331
|
+
return (
|
|
332
|
+
runMatch?.[1] ?? runMatch?.[2] ?? arrowMatch?.[1] ?? arrowMatch?.[2]
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolve the run target of an ad-hoc query to the model-defined source
|
|
338
|
+
* whose filters apply, following source-derivation declarations so that a
|
|
339
|
+
* filter-protected source carries its filter requirements when read under a
|
|
340
|
+
* derived name. The declared filter belongs to the source, not to the name
|
|
341
|
+
* it is read under. Returns undefined when the run target does not derive
|
|
342
|
+
* from a protected source.
|
|
343
|
+
*/
|
|
344
|
+
private resolveFilterSource(query?: string): string | undefined {
|
|
345
|
+
const target = this.extractSourceName(query);
|
|
346
|
+
if (!target || !query) return undefined;
|
|
347
|
+
|
|
348
|
+
// Map each ad-hoc source name to the base it derives from
|
|
349
|
+
// (`source: NAME is BASE ...` → NAME → BASE).
|
|
350
|
+
const aliasOf = new Map<string, string>();
|
|
351
|
+
const declRe = /source\s*:\s*(\w+)\s+is\s+(\w+)/g;
|
|
352
|
+
let match: RegExpExecArray | null;
|
|
353
|
+
while ((match = declRe.exec(query)) !== null) {
|
|
354
|
+
aliasOf.set(match[1], match[2]);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Walk the derivation chain until we hit a protected source or run out.
|
|
358
|
+
let current: string | undefined = target;
|
|
359
|
+
const seen = new Set<string>();
|
|
360
|
+
while (current && !seen.has(current)) {
|
|
361
|
+
if (this.filterMap.has(current)) return current;
|
|
362
|
+
seen.add(current);
|
|
363
|
+
current = aliasOf.get(current);
|
|
364
|
+
}
|
|
365
|
+
return undefined;
|
|
203
366
|
}
|
|
204
367
|
|
|
205
368
|
/**
|
|
@@ -255,10 +418,16 @@ export class Model {
|
|
|
255
418
|
malloyGivens.length > 0
|
|
256
419
|
? (malloyGivens.map(malloyGivenToApi) as ApiGiven[])
|
|
257
420
|
: undefined;
|
|
258
|
-
const sourceResult = Model.getSources(
|
|
421
|
+
const sourceResult = Model.getSources(modelDef, givens);
|
|
259
422
|
sources = sourceResult.sources;
|
|
260
423
|
filterMap = sourceResult.filterMap;
|
|
261
|
-
queries = Model.getQueries(
|
|
424
|
+
queries = Model.getQueries(modelDef);
|
|
425
|
+
|
|
426
|
+
// Translation-time validation of #(authorize) annotations (shared
|
|
427
|
+
// with the package-load worker so both compile paths validate
|
|
428
|
+
// identically). Compiling the probe surfaces unknown givens and
|
|
429
|
+
// source-field references at model-load instead of first request.
|
|
430
|
+
await validateAuthorizeProbes(modelMaterializer, sources ?? []);
|
|
262
431
|
|
|
263
432
|
// Collect sourceInfos from imported models first
|
|
264
433
|
// This follows the same pattern as notebook imports handling
|
|
@@ -557,6 +726,24 @@ export class Model {
|
|
|
557
726
|
if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
|
|
558
727
|
throw new BadRequestError("Model has no queryable entities.");
|
|
559
728
|
|
|
729
|
+
// Early fast-path authorize gate (before loadQuery). Resolve the source
|
|
730
|
+
// from surface syntax; gate if it names one. This runs BEFORE compilation
|
|
731
|
+
// so the gate can't be used as a schema oracle — without it, a denied
|
|
732
|
+
// caller probing `run: gated -> { group_by: maybe_field }` would get a
|
|
733
|
+
// Malloy "field not found" vs a 403 and learn the gated source's columns.
|
|
734
|
+
// It does NOT replace the authoritative compiled-source gate below (which
|
|
735
|
+
// always runs and catches named-query / multi-statement forms surface
|
|
736
|
+
// syntax can't resolve); it only fails fast for the common case.
|
|
737
|
+
const earlySource =
|
|
738
|
+
sourceName ||
|
|
739
|
+
(queryName
|
|
740
|
+
? this.queries?.find((q) => q.name === queryName)?.sourceName
|
|
741
|
+
: undefined) ||
|
|
742
|
+
this.extractSourceName(query);
|
|
743
|
+
if (earlySource) {
|
|
744
|
+
await this.assertAuthorized(earlySource, givens ?? {});
|
|
745
|
+
}
|
|
746
|
+
|
|
560
747
|
// Wrap loadQuery calls in try-catch to handle query parsing errors
|
|
561
748
|
try {
|
|
562
749
|
let queryString: string;
|
|
@@ -579,9 +766,19 @@ export class Model {
|
|
|
579
766
|
);
|
|
580
767
|
}
|
|
581
768
|
|
|
582
|
-
//
|
|
769
|
+
// Distinguishes free-form query text from the named `source->view`
|
|
770
|
+
// form. Both are driven by untrusted caller input and compiled in
|
|
771
|
+
// restricted mode below; this flag only controls how the protected
|
|
772
|
+
// source is resolved for filter injection.
|
|
773
|
+
const isAdHocQuery = !sourceName && !queryName && !!query;
|
|
774
|
+
|
|
775
|
+
// Inject source filter predicates unless bypassed. For ad-hoc queries
|
|
776
|
+
// resolve the run target through any alias/extend/chain so a protected
|
|
777
|
+
// source can't be read unfiltered under a different name.
|
|
583
778
|
if (!bypassFilters) {
|
|
584
|
-
const effectiveSource =
|
|
779
|
+
const effectiveSource = isAdHocQuery
|
|
780
|
+
? this.resolveFilterSource(query)
|
|
781
|
+
: sourceName;
|
|
585
782
|
if (effectiveSource) {
|
|
586
783
|
const filters = this.getFilters(effectiveSource);
|
|
587
784
|
if (filters.length > 0) {
|
|
@@ -597,7 +794,14 @@ export class Model {
|
|
|
597
794
|
}
|
|
598
795
|
}
|
|
599
796
|
|
|
600
|
-
|
|
797
|
+
// Restricted mode keeps untrusted query text inside the model's curated
|
|
798
|
+
// surface — it rejects `import`, raw `connection.table(...)` /
|
|
799
|
+
// `connection.sql(...)`, raw-SQL functions, and `##!` flags. The
|
|
800
|
+
// model's own definitions are unaffected. Both the ad-hoc `query` text
|
|
801
|
+
// and the `run: source->view` string built from the caller-supplied
|
|
802
|
+
// `sourceName`/`queryName` pair are untrusted, so both compile here;
|
|
803
|
+
// only author-curated notebook cells use the unrestricted `loadQuery`.
|
|
804
|
+
runnable = this.modelMaterializer.loadRestrictedQuery(queryString);
|
|
601
805
|
} catch (error) {
|
|
602
806
|
// Re-throw BadRequestError as-is
|
|
603
807
|
if (error instanceof BadRequestError) {
|
|
@@ -626,6 +830,31 @@ export class Model {
|
|
|
626
830
|
throw new BadRequestError(`Invalid query: ${errorMessage}`);
|
|
627
831
|
}
|
|
628
832
|
|
|
833
|
+
// Authoritative authorize gate: resolve the gated source from the
|
|
834
|
+
// COMPILED query — the source Malloy actually runs (the LAST `run:`
|
|
835
|
+
// statement) — not from surface syntax. Surface-syntax resolution alone
|
|
836
|
+
// is both bypassable (first-statement regex vs. last-statement execution:
|
|
837
|
+
// `run: ungated\nrun: gated` would gate `ungated` while running `gated`)
|
|
838
|
+
// and over-restrictive, so this compiled check always runs and is the
|
|
839
|
+
// source of truth; it handles named-query / blank-source / multi-statement
|
|
840
|
+
// forms uniformly. Skip only the redundant re-probe when it's the same
|
|
841
|
+
// source the early gate already cleared. Outside the loadQuery try so
|
|
842
|
+
// AccessDeniedError stays a 403; independent of bypassFilters.
|
|
843
|
+
const compiledSource =
|
|
844
|
+
await this.resolveAuthorizeSourceFromRunnable(runnable);
|
|
845
|
+
// Run unless it's the redundant re-probe of the exact named source the
|
|
846
|
+
// early gate already cleared. When compiledSource is unknown/unresolved,
|
|
847
|
+
// this still runs and assertAuthorized applies the model-wide file-level
|
|
848
|
+
// gate via effectiveAuthorizeFor. Note: on this path an ad-hoc inline
|
|
849
|
+
// `duckdb.sql(...)` query is rejected by restricted mode (the raw-SQL
|
|
850
|
+
// ban from loadRestrictedQuery above) before it can run, so the
|
|
851
|
+
// raw-warehouse bypass is closed by restricted mode — not by this gate.
|
|
852
|
+
// This fallback's job is to apply the file-level gate to permitted ad-hoc
|
|
853
|
+
// forms (declared-source references) whose source can't be named.
|
|
854
|
+
if (!(compiledSource && compiledSource === earlySource)) {
|
|
855
|
+
await this.assertAuthorized(compiledSource, givens ?? {});
|
|
856
|
+
}
|
|
857
|
+
|
|
629
858
|
const maxRows = getMaxQueryRows();
|
|
630
859
|
const maxBytes = getMaxResponseBytes();
|
|
631
860
|
const rowLimit = resolveModelQueryRowLimit(
|
|
@@ -727,6 +956,32 @@ export class Model {
|
|
|
727
956
|
} as ApiCompiledModel;
|
|
728
957
|
}
|
|
729
958
|
|
|
959
|
+
/**
|
|
960
|
+
* Serialize a notebook cell's `newSources` to the wire shape (an array
|
|
961
|
+
* of JSON strings), embedding the model-level `givens` on every
|
|
962
|
+
* SourceInfo so consumers iterating `newSources` can render `given:`
|
|
963
|
+
* inputs without a second getModel round-trip. Matches `Source.givens`
|
|
964
|
+
* in the API spec ("Identical to CompiledModel.givens") and how
|
|
965
|
+
* `getSources` already copies the full list onto each CompiledModel
|
|
966
|
+
* source. When the model declares no givens, the SourceInfo is emitted
|
|
967
|
+
* untouched (no empty `givens` key).
|
|
968
|
+
*
|
|
969
|
+
* Shared by `getNotebookModel` (the notebook GET endpoint) and
|
|
970
|
+
* `executeNotebookCell` (the cell-run endpoint) so both surface givens
|
|
971
|
+
* identically.
|
|
972
|
+
*/
|
|
973
|
+
private serializeNewSources(
|
|
974
|
+
newSources: Malloy.SourceInfo[] | undefined,
|
|
975
|
+
): string[] | undefined {
|
|
976
|
+
return newSources?.map((source) =>
|
|
977
|
+
JSON.stringify(
|
|
978
|
+
this.givens && this.givens.length > 0
|
|
979
|
+
? { ...source, givens: this.givens }
|
|
980
|
+
: source,
|
|
981
|
+
),
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
730
985
|
private async getNotebookModel(): Promise<ApiRawNotebook> {
|
|
731
986
|
// Return raw cell contents without executing them
|
|
732
987
|
const notebookCells: ApiNotebookCell[] = (
|
|
@@ -735,33 +990,16 @@ export class Model {
|
|
|
735
990
|
return {
|
|
736
991
|
type: cell.type,
|
|
737
992
|
text: cell.text,
|
|
738
|
-
newSources: cell.newSources
|
|
739
|
-
JSON.stringify(source),
|
|
740
|
-
),
|
|
993
|
+
newSources: this.serializeNewSources(cell.newSources),
|
|
741
994
|
queryInfo: cell.queryInfo
|
|
742
995
|
? JSON.stringify(cell.queryInfo)
|
|
743
996
|
: undefined,
|
|
744
997
|
} as ApiNotebookCell;
|
|
745
998
|
});
|
|
746
999
|
|
|
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
|
-
}
|
|
1000
|
+
const allAnnotations = this.modelDef
|
|
1001
|
+
? new Annotations(modelAnnotations(this.modelDef)).texts()
|
|
1002
|
+
: [];
|
|
765
1003
|
|
|
766
1004
|
return {
|
|
767
1005
|
type: "notebook",
|
|
@@ -814,6 +1052,20 @@ export class Model {
|
|
|
814
1052
|
};
|
|
815
1053
|
}
|
|
816
1054
|
|
|
1055
|
+
// Authorize gate — only cells that actually run a query touch data, so
|
|
1056
|
+
// gate exactly those (a source-def / import cell has no runnable and
|
|
1057
|
+
// accesses nothing). Resolve the source from the COMPILED cell query
|
|
1058
|
+
// (authoritative — survives `run: <named query>` cells the text regex
|
|
1059
|
+
// misses); assertAuthorized applies the source's gate, or the model-wide
|
|
1060
|
+
// file-level gate for an unknown/inline source. Before the execution try
|
|
1061
|
+
// below so AccessDeniedError stays a 403; independent of bypassFilters.
|
|
1062
|
+
if (cell.runnable) {
|
|
1063
|
+
const authorizeSource = await this.resolveAuthorizeSourceFromRunnable(
|
|
1064
|
+
cell.runnable,
|
|
1065
|
+
);
|
|
1066
|
+
await this.assertAuthorized(authorizeSource, givens ?? {});
|
|
1067
|
+
}
|
|
1068
|
+
|
|
817
1069
|
// For code cells, execute the runnable if available
|
|
818
1070
|
let queryName: string | undefined = undefined;
|
|
819
1071
|
let queryResult: string | undefined = undefined;
|
|
@@ -910,7 +1162,7 @@ export class Model {
|
|
|
910
1162
|
text: cell.text,
|
|
911
1163
|
queryName: queryName,
|
|
912
1164
|
result: queryResult,
|
|
913
|
-
newSources: cell.newSources
|
|
1165
|
+
newSources: this.serializeNewSources(cell.newSources),
|
|
914
1166
|
};
|
|
915
1167
|
}
|
|
916
1168
|
|
|
@@ -976,132 +1228,30 @@ export class Model {
|
|
|
976
1228
|
return malloyConfig;
|
|
977
1229
|
}
|
|
978
1230
|
|
|
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
|
-
}));
|
|
1231
|
+
private static getQueries(modelDef: ModelDef): ApiQuery[] {
|
|
1232
|
+
// Shared with the package-load worker — see service/source_extraction.ts.
|
|
1233
|
+
return extractQueriesFromModelDef(modelDef) as ApiQuery[];
|
|
1001
1234
|
}
|
|
1002
1235
|
|
|
1003
1236
|
private static getSources(
|
|
1004
|
-
modelPath: string,
|
|
1005
1237
|
modelDef: ModelDef,
|
|
1006
1238
|
givens?: ApiGiven[],
|
|
1007
1239
|
): {
|
|
1008
1240
|
sources: ApiSource[];
|
|
1009
1241
|
filterMap: Map<string, FilterDefinition[]>;
|
|
1010
1242
|
} {
|
|
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 };
|
|
1243
|
+
// Shared with the package-load worker — see service/source_extraction.ts.
|
|
1244
|
+
// The service path logs filter parse failures; the worker stays silent.
|
|
1245
|
+
const { sources, filterMap } = extractSourcesFromModelDef(
|
|
1246
|
+
modelDef,
|
|
1247
|
+
givens,
|
|
1248
|
+
(sourceName, err) =>
|
|
1249
|
+
logger.warn(
|
|
1250
|
+
`Failed to parse filter annotations on source "${sourceName}"`,
|
|
1251
|
+
{ error: err },
|
|
1252
|
+
),
|
|
1253
|
+
);
|
|
1254
|
+
return { sources: sources as unknown as ApiSource[], filterMap };
|
|
1105
1255
|
}
|
|
1106
1256
|
|
|
1107
1257
|
static async getModelMaterializer(
|