@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
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
2
|
+
import { Connection, GivenValue } from "@malloydata/malloy";
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
4
|
+
import fs from "fs/promises";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { AccessDeniedError } from "../errors";
|
|
8
|
+
import { Model } from "./model";
|
|
9
|
+
|
|
10
|
+
// Introspection (PR1), compile-time validation (PR2), and the runtime gate
|
|
11
|
+
// (PR3) for #(authorize) / ##(authorize).
|
|
12
|
+
|
|
13
|
+
const TEST_DIR = path.join(os.tmpdir(), "authorize-integration-tests");
|
|
14
|
+
const TEST_DB_DIR = path.join(TEST_DIR, "db");
|
|
15
|
+
const TEST_DB_PATH = path.join(TEST_DB_DIR, "test.duckdb");
|
|
16
|
+
const TEST_PKG_DIR = path.join(TEST_DIR, "pkg");
|
|
17
|
+
|
|
18
|
+
let duckdbConnection: DuckDBConnection;
|
|
19
|
+
|
|
20
|
+
const SEED_SQL = `
|
|
21
|
+
CREATE TABLE IF NOT EXISTS customers (
|
|
22
|
+
id INTEGER,
|
|
23
|
+
name VARCHAR,
|
|
24
|
+
region VARCHAR
|
|
25
|
+
);
|
|
26
|
+
INSERT INTO customers VALUES (1, 'a', 'us-west'), (2, 'b', 'us-east');
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
function getConnections(): Map<string, Connection> {
|
|
30
|
+
const map = new Map<string, Connection>();
|
|
31
|
+
map.set("duckdb", duckdbConnection);
|
|
32
|
+
return map;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function writeModel(filename: string, content: string): Promise<void> {
|
|
36
|
+
await fs.writeFile(path.join(TEST_PKG_DIR, filename), content, "utf-8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sourceNamed(model: Model, name: string) {
|
|
40
|
+
return model.getSources()?.find((s) => s.name === name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
await fs.mkdir(TEST_DB_DIR, { recursive: true });
|
|
45
|
+
await fs.mkdir(TEST_PKG_DIR, { recursive: true });
|
|
46
|
+
duckdbConnection = new DuckDBConnection("duckdb", TEST_DB_PATH, TEST_DB_DIR);
|
|
47
|
+
for (const stmt of SEED_SQL.trim().split(";").filter(Boolean)) {
|
|
48
|
+
await duckdbConnection.runSQL(stmt.trim() + ";");
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
try {
|
|
54
|
+
await duckdbConnection.close();
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
56
|
+
await fs.rm(TEST_DIR, { recursive: true, force: true });
|
|
57
|
+
} catch {
|
|
58
|
+
// ignore cleanup errors
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("authorize annotation introspection", () => {
|
|
63
|
+
it("collects file-level then source-level expressions as one list", async () => {
|
|
64
|
+
await writeModel(
|
|
65
|
+
"disjunction.malloy",
|
|
66
|
+
`##! experimental.givens
|
|
67
|
+
|
|
68
|
+
given:
|
|
69
|
+
ROLE :: string
|
|
70
|
+
REGION :: string
|
|
71
|
+
|
|
72
|
+
##(authorize) "$ROLE = 'admin'"
|
|
73
|
+
|
|
74
|
+
#(authorize) "$REGION = 'us-west'"
|
|
75
|
+
source: regional is duckdb.table('customers')
|
|
76
|
+
`,
|
|
77
|
+
);
|
|
78
|
+
const model = await Model.create(
|
|
79
|
+
"test-pkg",
|
|
80
|
+
TEST_PKG_DIR,
|
|
81
|
+
"disjunction.malloy",
|
|
82
|
+
getConnections(),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// File-level first, then the source's own.
|
|
86
|
+
expect(model.getAuthorize("regional")).toEqual([
|
|
87
|
+
"$ROLE = 'admin'",
|
|
88
|
+
"$REGION = 'us-west'",
|
|
89
|
+
]);
|
|
90
|
+
expect(sourceNamed(model, "regional")?.authorize).toEqual([
|
|
91
|
+
"$ROLE = 'admin'",
|
|
92
|
+
"$REGION = 'us-west'",
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does NOT inherit a base source's authorize through extend", async () => {
|
|
97
|
+
await writeModel(
|
|
98
|
+
"extend.malloy",
|
|
99
|
+
`##! experimental.givens
|
|
100
|
+
|
|
101
|
+
given:
|
|
102
|
+
ROLE :: string
|
|
103
|
+
|
|
104
|
+
// Locked base.
|
|
105
|
+
#(authorize) "false"
|
|
106
|
+
source: customers_raw is duckdb.table('customers')
|
|
107
|
+
|
|
108
|
+
// Extension with its own gate — must NOT pick up the base's "false".
|
|
109
|
+
#(authorize) "$ROLE = 'analyst'"
|
|
110
|
+
source: customers_marketing is customers_raw extend {
|
|
111
|
+
measure: customer_count is count()
|
|
112
|
+
}
|
|
113
|
+
`,
|
|
114
|
+
);
|
|
115
|
+
const model = await Model.create(
|
|
116
|
+
"test-pkg",
|
|
117
|
+
TEST_PKG_DIR,
|
|
118
|
+
"extend.malloy",
|
|
119
|
+
getConnections(),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Base keeps its own lock.
|
|
123
|
+
expect(model.getAuthorize("customers_raw")).toEqual(["false"]);
|
|
124
|
+
// Extension is governed ONLY by its own gate — the base "false" is gone.
|
|
125
|
+
expect(model.getAuthorize("customers_marketing")).toEqual([
|
|
126
|
+
"$ROLE = 'analyst'",
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("applies a file-level gate to a source with no own authorize", async () => {
|
|
131
|
+
await writeModel(
|
|
132
|
+
"file_only.malloy",
|
|
133
|
+
`##! experimental.givens
|
|
134
|
+
|
|
135
|
+
given:
|
|
136
|
+
ROLE :: string
|
|
137
|
+
|
|
138
|
+
##(authorize) "$ROLE = 'admin'"
|
|
139
|
+
|
|
140
|
+
source: plain is duckdb.table('customers')
|
|
141
|
+
`,
|
|
142
|
+
);
|
|
143
|
+
const model = await Model.create(
|
|
144
|
+
"test-pkg",
|
|
145
|
+
TEST_PKG_DIR,
|
|
146
|
+
"file_only.malloy",
|
|
147
|
+
getConnections(),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(model.getAuthorize("plain")).toEqual(["$ROLE = 'admin'"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("applies a file-level gate inherited from an imported model", async () => {
|
|
154
|
+
// Regression for the hand-rolled model-annotation fold (malloy 0.0.405+
|
|
155
|
+
// removed ModelDef.annotation): a `##(authorize)` declared in an
|
|
156
|
+
// imported file must flow into the importing file's file-level gate even
|
|
157
|
+
// when the importer declares no `##` of its own. The fold must match
|
|
158
|
+
// malloy's getModelAnnotations (skip empty-ownNotes links) or `.notes`
|
|
159
|
+
// returns [] here and the gate silently drops — fail-open.
|
|
160
|
+
await writeModel(
|
|
161
|
+
"auth_base.malloy",
|
|
162
|
+
`##! experimental.givens
|
|
163
|
+
|
|
164
|
+
given:
|
|
165
|
+
ROLE :: string
|
|
166
|
+
|
|
167
|
+
##(authorize) "$ROLE = 'admin'"
|
|
168
|
+
`,
|
|
169
|
+
);
|
|
170
|
+
await writeModel(
|
|
171
|
+
"auth_importer.malloy",
|
|
172
|
+
`import "auth_base.malloy"
|
|
173
|
+
|
|
174
|
+
source: inherited_gate is duckdb.table('customers') extend {
|
|
175
|
+
measure: c is count()
|
|
176
|
+
}
|
|
177
|
+
`,
|
|
178
|
+
);
|
|
179
|
+
const model = await Model.create(
|
|
180
|
+
"test-pkg",
|
|
181
|
+
TEST_PKG_DIR,
|
|
182
|
+
"auth_importer.malloy",
|
|
183
|
+
getConnections(),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
expect(model.getAuthorize("inherited_gate")).toEqual(["$ROLE = 'admin'"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("fails model load on a malformed authorize annotation (no silent drop)", async () => {
|
|
190
|
+
await writeModel(
|
|
191
|
+
"malformed.malloy",
|
|
192
|
+
`#(authorize) notquoted
|
|
193
|
+
source: broken is duckdb.table('customers')
|
|
194
|
+
`,
|
|
195
|
+
);
|
|
196
|
+
const model = await Model.create(
|
|
197
|
+
"test-pkg",
|
|
198
|
+
TEST_PKG_DIR,
|
|
199
|
+
"malformed.malloy",
|
|
200
|
+
getConnections(),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// A malformed gate must surface as a compilation error, not vanish.
|
|
204
|
+
const err = model.getNotebookError();
|
|
205
|
+
expect(err).toBeDefined();
|
|
206
|
+
expect(err?.message).toMatch(/quote/i);
|
|
207
|
+
// No sources surfaced for a failed compile — the gate is not silently
|
|
208
|
+
// reported as unrestricted.
|
|
209
|
+
expect(model.getSources()).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("treats a source with no authorize annotations as unrestricted", async () => {
|
|
213
|
+
await writeModel(
|
|
214
|
+
"none.malloy",
|
|
215
|
+
`source: open_source is duckdb.table('customers')
|
|
216
|
+
`,
|
|
217
|
+
);
|
|
218
|
+
const model = await Model.create(
|
|
219
|
+
"test-pkg",
|
|
220
|
+
TEST_PKG_DIR,
|
|
221
|
+
"none.malloy",
|
|
222
|
+
getConnections(),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(model.getAuthorize("open_source")).toEqual([]);
|
|
226
|
+
expect(sourceNamed(model, "open_source")?.authorize).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("authorize annotation compile-time validation", () => {
|
|
231
|
+
it("loads a valid expression that references a value-less given", async () => {
|
|
232
|
+
// The probe is compiled, not run, so a given with no default/value does
|
|
233
|
+
// NOT cause a false failure (the original getSQL approach would have).
|
|
234
|
+
await writeModel(
|
|
235
|
+
"valid_valueless.malloy",
|
|
236
|
+
`##! experimental.givens
|
|
237
|
+
|
|
238
|
+
given:
|
|
239
|
+
ROLE :: string
|
|
240
|
+
|
|
241
|
+
#(authorize) "$ROLE = 'analyst'"
|
|
242
|
+
source: gated is duckdb.table('customers')
|
|
243
|
+
`,
|
|
244
|
+
);
|
|
245
|
+
const model = await Model.create(
|
|
246
|
+
"test-pkg",
|
|
247
|
+
TEST_PKG_DIR,
|
|
248
|
+
"valid_valueless.malloy",
|
|
249
|
+
getConnections(),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(model.getNotebookError()).toBeUndefined();
|
|
253
|
+
expect(model.getAuthorize("gated")).toEqual(["$ROLE = 'analyst'"]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("fails model load when an expression references an unknown given", async () => {
|
|
257
|
+
await writeModel(
|
|
258
|
+
"unknown_given.malloy",
|
|
259
|
+
`##! experimental.givens
|
|
260
|
+
|
|
261
|
+
given:
|
|
262
|
+
ROLE :: string
|
|
263
|
+
|
|
264
|
+
#(authorize) "$NOPE = 'x'"
|
|
265
|
+
source: gated is duckdb.table('customers')
|
|
266
|
+
`,
|
|
267
|
+
);
|
|
268
|
+
const model = await Model.create(
|
|
269
|
+
"test-pkg",
|
|
270
|
+
TEST_PKG_DIR,
|
|
271
|
+
"unknown_given.malloy",
|
|
272
|
+
getConnections(),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const err = model.getNotebookError();
|
|
276
|
+
expect(err).toBeDefined();
|
|
277
|
+
// Names the source and surfaces the underlying Malloy reason.
|
|
278
|
+
expect(err?.message).toContain("gated");
|
|
279
|
+
expect(err?.message).toMatch(/NOPE|not declared/i);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("fails model load when an expression references a source field", async () => {
|
|
283
|
+
await writeModel(
|
|
284
|
+
"field_ref.malloy",
|
|
285
|
+
`#(authorize) "some_field = 1"
|
|
286
|
+
source: gated is duckdb.table('customers')
|
|
287
|
+
`,
|
|
288
|
+
);
|
|
289
|
+
const model = await Model.create(
|
|
290
|
+
"test-pkg",
|
|
291
|
+
TEST_PKG_DIR,
|
|
292
|
+
"field_ref.malloy",
|
|
293
|
+
getConnections(),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const err = model.getNotebookError();
|
|
297
|
+
expect(err).toBeDefined();
|
|
298
|
+
expect(err?.message).toContain("gated");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("does not reject a type-mismatched comparison (not a Malloy compile error)", async () => {
|
|
302
|
+
// Documents the boundary: `$ROLE = 5` is not a compile error; such a gate
|
|
303
|
+
// simply evaluates per the warehouse at the runtime gate.
|
|
304
|
+
await writeModel(
|
|
305
|
+
"type_mismatch.malloy",
|
|
306
|
+
`##! experimental.givens
|
|
307
|
+
|
|
308
|
+
given:
|
|
309
|
+
ROLE :: string
|
|
310
|
+
|
|
311
|
+
#(authorize) "$ROLE = 5"
|
|
312
|
+
source: gated is duckdb.table('customers')
|
|
313
|
+
`,
|
|
314
|
+
);
|
|
315
|
+
const model = await Model.create(
|
|
316
|
+
"test-pkg",
|
|
317
|
+
TEST_PKG_DIR,
|
|
318
|
+
"type_mismatch.malloy",
|
|
319
|
+
getConnections(),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(model.getNotebookError()).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("fails model load when a file-level ##(authorize) references an unknown given", async () => {
|
|
326
|
+
await writeModel(
|
|
327
|
+
"file_unknown_given.malloy",
|
|
328
|
+
`##! experimental.givens
|
|
329
|
+
|
|
330
|
+
given:
|
|
331
|
+
ROLE :: string
|
|
332
|
+
|
|
333
|
+
##(authorize) "$NOPE = 'x'"
|
|
334
|
+
|
|
335
|
+
source: gated is duckdb.table('customers')
|
|
336
|
+
`,
|
|
337
|
+
);
|
|
338
|
+
const model = await Model.create(
|
|
339
|
+
"test-pkg",
|
|
340
|
+
TEST_PKG_DIR,
|
|
341
|
+
"file_unknown_given.malloy",
|
|
342
|
+
getConnections(),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const err = model.getNotebookError();
|
|
346
|
+
expect(err).toBeDefined();
|
|
347
|
+
expect(err?.message).toContain("gated");
|
|
348
|
+
expect(err?.message).toMatch(/NOPE|not declared/i);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("validates expressions over number and list givens, not just strings", async () => {
|
|
352
|
+
await writeModel(
|
|
353
|
+
"given_types.malloy",
|
|
354
|
+
`##! experimental.givens
|
|
355
|
+
|
|
356
|
+
given:
|
|
357
|
+
AGE :: number
|
|
358
|
+
TENANT :: string
|
|
359
|
+
ALLOWED :: string[]
|
|
360
|
+
|
|
361
|
+
#(authorize) "$AGE > 18"
|
|
362
|
+
#(authorize) "$TENANT in $ALLOWED"
|
|
363
|
+
source: gated is duckdb.table('customers')
|
|
364
|
+
`,
|
|
365
|
+
);
|
|
366
|
+
const model = await Model.create(
|
|
367
|
+
"test-pkg",
|
|
368
|
+
TEST_PKG_DIR,
|
|
369
|
+
"given_types.malloy",
|
|
370
|
+
getConnections(),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
expect(model.getNotebookError()).toBeUndefined();
|
|
374
|
+
expect(model.getAuthorize("gated")).toEqual([
|
|
375
|
+
"$AGE > 18",
|
|
376
|
+
"$TENANT in $ALLOWED",
|
|
377
|
+
]);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("authorize runtime gate", () => {
|
|
382
|
+
// Helper: run an ad-hoc query through the full getQueryResults path (which
|
|
383
|
+
// is where the gate fires). Returns the result or throws AccessDeniedError.
|
|
384
|
+
async function runGated(
|
|
385
|
+
modelFile: string,
|
|
386
|
+
query: string,
|
|
387
|
+
givens?: Record<string, GivenValue>,
|
|
388
|
+
bypassFilters?: boolean,
|
|
389
|
+
) {
|
|
390
|
+
const model = await Model.create(
|
|
391
|
+
"test-pkg",
|
|
392
|
+
TEST_PKG_DIR,
|
|
393
|
+
modelFile,
|
|
394
|
+
getConnections(),
|
|
395
|
+
);
|
|
396
|
+
return model.getQueryResults(
|
|
397
|
+
undefined,
|
|
398
|
+
undefined,
|
|
399
|
+
query,
|
|
400
|
+
undefined,
|
|
401
|
+
bypassFilters,
|
|
402
|
+
givens,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const SINGLE_GATE = `##! experimental.givens
|
|
407
|
+
|
|
408
|
+
given:
|
|
409
|
+
ROLE :: string
|
|
410
|
+
|
|
411
|
+
#(authorize) "$ROLE = 'analyst'"
|
|
412
|
+
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
413
|
+
`;
|
|
414
|
+
|
|
415
|
+
it("allows the query when a given satisfies the gate", async () => {
|
|
416
|
+
await writeModel("rt_single.malloy", SINGLE_GATE);
|
|
417
|
+
const { result } = await runGated(
|
|
418
|
+
"rt_single.malloy",
|
|
419
|
+
"run: gated -> { aggregate: c }",
|
|
420
|
+
{ ROLE: "analyst" },
|
|
421
|
+
);
|
|
422
|
+
expect(result.data).toBeDefined();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("denies (403) when no given satisfies the gate", async () => {
|
|
426
|
+
await writeModel("rt_single.malloy", SINGLE_GATE);
|
|
427
|
+
await expect(
|
|
428
|
+
runGated("rt_single.malloy", "run: gated -> { aggregate: c }", {
|
|
429
|
+
ROLE: "intern",
|
|
430
|
+
}),
|
|
431
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("denies when the referenced given has no value (fail closed)", async () => {
|
|
435
|
+
await writeModel("rt_single.malloy", SINGLE_GATE);
|
|
436
|
+
await expect(
|
|
437
|
+
runGated("rt_single.malloy", "run: gated -> { aggregate: c }", {}),
|
|
438
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("still enforces the gate when bypassFilters is true (authorize is not a filter)", async () => {
|
|
442
|
+
await writeModel("rt_single.malloy", SINGLE_GATE);
|
|
443
|
+
await expect(
|
|
444
|
+
runGated(
|
|
445
|
+
"rt_single.malloy",
|
|
446
|
+
"run: gated -> { aggregate: c }",
|
|
447
|
+
{ ROLE: "intern" },
|
|
448
|
+
true,
|
|
449
|
+
),
|
|
450
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// A model that declares no `##` of its own and inherits its file-level gate
|
|
454
|
+
// entirely from an imported model. Exercises the model-annotation fold end
|
|
455
|
+
// to end: parse → fold → fileLevelAuthorize → runtime gate.
|
|
456
|
+
const IMPORT_BASE = `##! experimental.givens
|
|
457
|
+
|
|
458
|
+
given:
|
|
459
|
+
ROLE :: string
|
|
460
|
+
|
|
461
|
+
##(authorize) "$ROLE = 'admin'"
|
|
462
|
+
`;
|
|
463
|
+
const IMPORT_GATED = `import "rt_auth_base.malloy"
|
|
464
|
+
|
|
465
|
+
source: inherited_gate is duckdb.table('customers') extend {
|
|
466
|
+
measure: c is count()
|
|
467
|
+
}
|
|
468
|
+
`;
|
|
469
|
+
|
|
470
|
+
it("enforces a file-level gate inherited from an imported model (allow with role)", async () => {
|
|
471
|
+
await writeModel("rt_auth_base.malloy", IMPORT_BASE);
|
|
472
|
+
await writeModel("rt_import.malloy", IMPORT_GATED);
|
|
473
|
+
const { result } = await runGated(
|
|
474
|
+
"rt_import.malloy",
|
|
475
|
+
"run: inherited_gate -> { aggregate: c }",
|
|
476
|
+
{ ROLE: "admin" },
|
|
477
|
+
);
|
|
478
|
+
expect(result.data).toBeDefined();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("enforces a file-level gate inherited from an imported model (deny without role — not fail-open)", async () => {
|
|
482
|
+
await writeModel("rt_auth_base.malloy", IMPORT_BASE);
|
|
483
|
+
await writeModel("rt_import.malloy", IMPORT_GATED);
|
|
484
|
+
await expect(
|
|
485
|
+
runGated(
|
|
486
|
+
"rt_import.malloy",
|
|
487
|
+
"run: inherited_gate -> { aggregate: c }",
|
|
488
|
+
{
|
|
489
|
+
ROLE: "intern",
|
|
490
|
+
},
|
|
491
|
+
),
|
|
492
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const DISJUNCTION = `##! experimental.givens
|
|
496
|
+
|
|
497
|
+
given:
|
|
498
|
+
ROLE :: string
|
|
499
|
+
REGION :: string
|
|
500
|
+
|
|
501
|
+
##(authorize) "$ROLE = 'admin'"
|
|
502
|
+
|
|
503
|
+
#(authorize) "$REGION = 'us-west'"
|
|
504
|
+
source: regional is duckdb.table('customers') extend { measure: c is count() }
|
|
505
|
+
`;
|
|
506
|
+
|
|
507
|
+
it("grants on the file-level gate even when the OTHER disjunct's given is missing", async () => {
|
|
508
|
+
// The key OR-semantics case: admin supplies only ROLE; REGION is absent.
|
|
509
|
+
// A disjunct that can't evaluate (missing given) must not sink the whole
|
|
510
|
+
// request — the satisfied $ROLE='admin' branch still grants.
|
|
511
|
+
await writeModel("rt_disj.malloy", DISJUNCTION);
|
|
512
|
+
const { result } = await runGated(
|
|
513
|
+
"rt_disj.malloy",
|
|
514
|
+
"run: regional -> { aggregate: c }",
|
|
515
|
+
{ ROLE: "admin" },
|
|
516
|
+
);
|
|
517
|
+
expect(result.data).toBeDefined();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("grants on the source-level gate even when the file-level disjunct's given is missing", async () => {
|
|
521
|
+
await writeModel("rt_disj.malloy", DISJUNCTION);
|
|
522
|
+
const { result } = await runGated(
|
|
523
|
+
"rt_disj.malloy",
|
|
524
|
+
"run: regional -> { aggregate: c }",
|
|
525
|
+
{ REGION: "us-west" },
|
|
526
|
+
);
|
|
527
|
+
expect(result.data).toBeDefined();
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("denies when neither disjunct is satisfied", async () => {
|
|
531
|
+
await writeModel("rt_disj.malloy", DISJUNCTION);
|
|
532
|
+
await expect(
|
|
533
|
+
runGated("rt_disj.malloy", "run: regional -> { aggregate: c }", {
|
|
534
|
+
ROLE: "nobody",
|
|
535
|
+
REGION: "nowhere",
|
|
536
|
+
}),
|
|
537
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("gates a named query that targets a gated source (no sourceName supplied)", async () => {
|
|
541
|
+
await writeModel(
|
|
542
|
+
"rt_namedq.malloy",
|
|
543
|
+
`##! experimental.givens
|
|
544
|
+
|
|
545
|
+
given:
|
|
546
|
+
ROLE :: string
|
|
547
|
+
|
|
548
|
+
#(authorize) "$ROLE = 'analyst'"
|
|
549
|
+
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
550
|
+
|
|
551
|
+
query: secret is gated -> { aggregate: c }
|
|
552
|
+
`,
|
|
553
|
+
);
|
|
554
|
+
const model = await Model.create(
|
|
555
|
+
"test-pkg",
|
|
556
|
+
TEST_PKG_DIR,
|
|
557
|
+
"rt_namedq.malloy",
|
|
558
|
+
getConnections(),
|
|
559
|
+
);
|
|
560
|
+
// Named query, no sourceName — must still resolve to `gated` and gate it.
|
|
561
|
+
await expect(
|
|
562
|
+
model.getQueryResults(
|
|
563
|
+
undefined,
|
|
564
|
+
"secret",
|
|
565
|
+
undefined,
|
|
566
|
+
undefined,
|
|
567
|
+
false,
|
|
568
|
+
{
|
|
569
|
+
ROLE: "intern",
|
|
570
|
+
},
|
|
571
|
+
),
|
|
572
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
573
|
+
// And it runs when the gate passes.
|
|
574
|
+
const { result } = await model.getQueryResults(
|
|
575
|
+
undefined,
|
|
576
|
+
"secret",
|
|
577
|
+
undefined,
|
|
578
|
+
undefined,
|
|
579
|
+
false,
|
|
580
|
+
{ ROLE: "analyst" },
|
|
581
|
+
);
|
|
582
|
+
expect(result.data).toBeDefined();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("gates an ad-hoc query even when a blank sourceName is supplied", async () => {
|
|
586
|
+
await writeModel("rt_single.malloy", SINGLE_GATE);
|
|
587
|
+
const model = await Model.create(
|
|
588
|
+
"test-pkg",
|
|
589
|
+
TEST_PKG_DIR,
|
|
590
|
+
"rt_single.malloy",
|
|
591
|
+
getConnections(),
|
|
592
|
+
);
|
|
593
|
+
// Blank sourceName must not skip the gate while the query-builder treats
|
|
594
|
+
// it as absent and runs the ad-hoc query.
|
|
595
|
+
await expect(
|
|
596
|
+
model.getQueryResults(
|
|
597
|
+
"",
|
|
598
|
+
undefined,
|
|
599
|
+
"run: gated -> { aggregate: c }",
|
|
600
|
+
undefined,
|
|
601
|
+
false,
|
|
602
|
+
{ ROLE: "intern" },
|
|
603
|
+
),
|
|
604
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("gates the source the query actually RUNS, not a decoy leading statement", async () => {
|
|
608
|
+
// Malloy runs the LAST `run:`. A multi-statement ad-hoc query that names
|
|
609
|
+
// an ungated source first and the gated source last must be gated on the
|
|
610
|
+
// gated source (the one that executes), not fooled by the leading one.
|
|
611
|
+
await writeModel(
|
|
612
|
+
"rt_multi.malloy",
|
|
613
|
+
`##! experimental.givens
|
|
614
|
+
|
|
615
|
+
given:
|
|
616
|
+
ROLE :: string
|
|
617
|
+
|
|
618
|
+
source: ungated is duckdb.table('customers') extend { measure: c is count() }
|
|
619
|
+
|
|
620
|
+
#(authorize) "$ROLE = 'analyst'"
|
|
621
|
+
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
622
|
+
`,
|
|
623
|
+
);
|
|
624
|
+
await expect(
|
|
625
|
+
runGated(
|
|
626
|
+
"rt_multi.malloy",
|
|
627
|
+
"run: ungated -> { aggregate: c }\nrun: gated -> { aggregate: c }",
|
|
628
|
+
{ ROLE: "intern" },
|
|
629
|
+
),
|
|
630
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("leaves a source with no authorize annotations unrestricted", async () => {
|
|
634
|
+
await writeModel(
|
|
635
|
+
"rt_open.malloy",
|
|
636
|
+
`source: open_src is duckdb.table('customers') extend { measure: c is count() }
|
|
637
|
+
`,
|
|
638
|
+
);
|
|
639
|
+
const { result } = await runGated(
|
|
640
|
+
"rt_open.malloy",
|
|
641
|
+
"run: open_src -> { aggregate: c }",
|
|
642
|
+
);
|
|
643
|
+
expect(result.data).toBeDefined();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const FILE_LEVEL = `##! experimental.givens
|
|
647
|
+
|
|
648
|
+
given:
|
|
649
|
+
ROLE :: string
|
|
650
|
+
|
|
651
|
+
##(authorize) "$ROLE = 'admin'"
|
|
652
|
+
|
|
653
|
+
source: declared is duckdb.table('customers') extend { measure: c is count() }
|
|
654
|
+
`;
|
|
655
|
+
|
|
656
|
+
it("applies a file-level gate to an ad-hoc query (no gate bypass)", async () => {
|
|
657
|
+
await writeModel("rt_filelevel.malloy", FILE_LEVEL);
|
|
658
|
+
// The model-wide file-level gate must apply to any ad-hoc query against
|
|
659
|
+
// the model, denying a non-admin with a 403.
|
|
660
|
+
await expect(
|
|
661
|
+
runGated("rt_filelevel.malloy", "run: declared -> { aggregate: c }", {
|
|
662
|
+
ROLE: "user",
|
|
663
|
+
}),
|
|
664
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
665
|
+
// An admin (file-level gate satisfied) can run it.
|
|
666
|
+
const { result } = await runGated(
|
|
667
|
+
"rt_filelevel.malloy",
|
|
668
|
+
"run: declared -> { aggregate: c }",
|
|
669
|
+
{ ROLE: "admin" },
|
|
670
|
+
);
|
|
671
|
+
expect(result.data).toBeDefined();
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("rejects an ad-hoc inline-SQL query (restricted mode closes the raw-warehouse path)", async () => {
|
|
675
|
+
// Pre-#807 the file-level #(authorize) gate was the only thing between a
|
|
676
|
+
// caller and raw `duckdb.sql(...)`. #807's restricted mode now rejects
|
|
677
|
+
// inline raw SQL outright while resolving the compiled source — before the
|
|
678
|
+
// gate is reached — so the raw-warehouse path is closed at the compile
|
|
679
|
+
// layer regardless of givens (even for an admin who satisfies the gate).
|
|
680
|
+
await writeModel("rt_filelevel.malloy", FILE_LEVEL);
|
|
681
|
+
await expect(
|
|
682
|
+
runGated(
|
|
683
|
+
"rt_filelevel.malloy",
|
|
684
|
+
`run: duckdb.sql("SELECT 1 AS id") -> { aggregate: c is count() }`,
|
|
685
|
+
{ ROLE: "admin" },
|
|
686
|
+
),
|
|
687
|
+
).rejects.toThrow(/raw SQL is not permitted/);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const MIXED_SOURCE_GATE = `##! experimental.givens
|
|
691
|
+
|
|
692
|
+
given:
|
|
693
|
+
ROLE :: string
|
|
694
|
+
|
|
695
|
+
#(authorize) "$ROLE = 'analyst'"
|
|
696
|
+
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
697
|
+
|
|
698
|
+
source: open_src is duckdb.table('customers') extend { measure: c is count() }
|
|
699
|
+
`;
|
|
700
|
+
|
|
701
|
+
it("does not over-gate: a source-level gate is not model-wide", async () => {
|
|
702
|
+
// Control: a per-source gate applies only to that source, not the whole
|
|
703
|
+
// model. An ad-hoc query against an ungated declared source in the same
|
|
704
|
+
// model runs without any given.
|
|
705
|
+
await writeModel("rt_mixed.malloy", MIXED_SOURCE_GATE);
|
|
706
|
+
const { result } = await runGated(
|
|
707
|
+
"rt_mixed.malloy",
|
|
708
|
+
"run: open_src -> { aggregate: c }",
|
|
709
|
+
);
|
|
710
|
+
expect(result.data).toBeDefined();
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("gates a quoted-identifier source BEFORE compilation (no schema oracle)", async () => {
|
|
714
|
+
// A gated source whose Malloy name must be quoted (here, a hyphen) must be
|
|
715
|
+
// recognized by the early gate too. Otherwise a denied caller could probe
|
|
716
|
+
// a non-existent field and learn the schema from a pre-compilation Malloy
|
|
717
|
+
// field error instead of a clean 403.
|
|
718
|
+
await writeModel(
|
|
719
|
+
"rt_quoted.malloy",
|
|
720
|
+
`##! experimental.givens
|
|
721
|
+
|
|
722
|
+
given:
|
|
723
|
+
ROLE :: string
|
|
724
|
+
|
|
725
|
+
#(authorize) "$ROLE = 'analyst'"
|
|
726
|
+
source: \`gated-source\` is duckdb.table('customers') extend {
|
|
727
|
+
measure: c is count()
|
|
728
|
+
}
|
|
729
|
+
`,
|
|
730
|
+
);
|
|
731
|
+
// Probing a field that doesn't exist must deny (403) before compilation,
|
|
732
|
+
// not surface a Malloy "field not found" error.
|
|
733
|
+
await expect(
|
|
734
|
+
runGated(
|
|
735
|
+
"rt_quoted.malloy",
|
|
736
|
+
"run: `gated-source` -> { group_by: no_such_field }",
|
|
737
|
+
{ ROLE: "viewer" },
|
|
738
|
+
),
|
|
739
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("gates a notebook cell that runs a NAMED QUERY targeting a gated source", async () => {
|
|
743
|
+
// `run: secret` has no `->`, so source resolution must come from the
|
|
744
|
+
// compiled query, not a text regex — otherwise the gate is bypassed.
|
|
745
|
+
await writeModel(
|
|
746
|
+
"rt_nb.malloynb",
|
|
747
|
+
`>>>malloy
|
|
748
|
+
##! experimental.givens
|
|
749
|
+
|
|
750
|
+
given:
|
|
751
|
+
ROLE :: string
|
|
752
|
+
|
|
753
|
+
#(authorize) "$ROLE = 'analyst'"
|
|
754
|
+
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
755
|
+
query: secret is gated -> { aggregate: c }
|
|
756
|
+
|
|
757
|
+
>>>malloy
|
|
758
|
+
run: secret
|
|
759
|
+
`,
|
|
760
|
+
);
|
|
761
|
+
const model = await Model.create(
|
|
762
|
+
"test-pkg",
|
|
763
|
+
TEST_PKG_DIR,
|
|
764
|
+
"rt_nb.malloynb",
|
|
765
|
+
getConnections(),
|
|
766
|
+
);
|
|
767
|
+
// Cell 1 is `run: secret` — must be denied without the gate-passing given.
|
|
768
|
+
await expect(
|
|
769
|
+
model.executeNotebookCell(1, undefined, false, { ROLE: "intern" }),
|
|
770
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
771
|
+
// ...and allowed when the gate passes.
|
|
772
|
+
const ok = await model.executeNotebookCell(1, undefined, false, {
|
|
773
|
+
ROLE: "analyst",
|
|
774
|
+
});
|
|
775
|
+
expect(ok.result).toBeDefined();
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const LOCKED_BASE = `##! experimental.givens
|
|
779
|
+
|
|
780
|
+
given:
|
|
781
|
+
ROLE :: string
|
|
782
|
+
|
|
783
|
+
#(authorize) "false"
|
|
784
|
+
source: base_locked is duckdb.table('customers') extend { measure: c is count() }
|
|
785
|
+
|
|
786
|
+
#(authorize) "$ROLE = 'analyst'"
|
|
787
|
+
source: ext_gated is base_locked extend {}
|
|
788
|
+
|
|
789
|
+
source: ext_nogate is base_locked extend {}
|
|
790
|
+
|
|
791
|
+
source: joiner is duckdb.table('customers') extend {
|
|
792
|
+
join_one: base_locked on id = base_locked.id
|
|
793
|
+
measure: c is count()
|
|
794
|
+
}
|
|
795
|
+
`;
|
|
796
|
+
|
|
797
|
+
it('denies a direct query against a base locked with #(authorize) "false"', async () => {
|
|
798
|
+
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
799
|
+
await expect(
|
|
800
|
+
runGated("rt_locked.malloy", "run: base_locked -> { aggregate: c }", {
|
|
801
|
+
ROLE: "analyst",
|
|
802
|
+
}),
|
|
803
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it("allows an extension of a locked base when the extension's own gate passes", async () => {
|
|
807
|
+
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
808
|
+
const { result } = await runGated(
|
|
809
|
+
"rt_locked.malloy",
|
|
810
|
+
"run: ext_gated -> { aggregate: c }",
|
|
811
|
+
{ ROLE: "analyst" },
|
|
812
|
+
);
|
|
813
|
+
expect(result.data).toBeDefined();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it("denies an extension that declares no own gate — it inherits the base lock (safe default)", async () => {
|
|
817
|
+
// Malloy carries the base's #(authorize) onto an extension UNLESS the
|
|
818
|
+
// extension declares its own. So a bare `is base_locked extend {}` with
|
|
819
|
+
// no own gate stays locked by the base's "false". An extension escapes
|
|
820
|
+
// the base gate only by declaring its own #(authorize) (see ext_gated).
|
|
821
|
+
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
822
|
+
await expect(
|
|
823
|
+
runGated("rt_locked.malloy", "run: ext_nogate -> { aggregate: c }", {
|
|
824
|
+
ROLE: "analyst",
|
|
825
|
+
}),
|
|
826
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it("[documented limitation] a query joining a locked base via an ungated source is allowed (top-level-source only)", async () => {
|
|
830
|
+
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
831
|
+
const { result } = await runGated(
|
|
832
|
+
"rt_locked.malloy",
|
|
833
|
+
"run: joiner -> { aggregate: c }",
|
|
834
|
+
{},
|
|
835
|
+
);
|
|
836
|
+
expect(result.data).toBeDefined();
|
|
837
|
+
});
|
|
838
|
+
});
|