@malloy-publisher/server 0.0.235 → 0.0.237
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 +83 -27
- package/dist/app/assets/{EnvironmentPage-BsAnavYN.js → EnvironmentPage-DaP6CpgK.js} +1 -1
- package/dist/app/assets/{HomePage-CADE138j.js → HomePage-C9yNw3iu.js} +1 -1
- package/dist/app/assets/{LightMode-Cfh7KzN8.js → LightMode-Be4-G-dX.js} +1 -1
- package/dist/app/assets/{MainPage-CO3pRlnV.js → MainPage-J_rU2SgI.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-p9YjkRXZ.js → MaterializationsPage-DHgPI4MO.js} +1 -1
- package/dist/app/assets/ModelPage-BzXfjTu5.js +1 -0
- package/dist/app/assets/{PackagePage-e4kN75YR.js → PackagePage-ByoSTLJ4.js} +1 -1
- package/dist/app/assets/RouteError-CrSeKN2r.js +1 -0
- package/dist/app/assets/{ThemeEditorPage-CciagFTq.js → ThemeEditorPage-Btxnmhtk.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DNWmkCXa.js → WorkbookPage-D6JLPnWO.js} +1 -1
- package/dist/app/assets/{core-Rj_4rRnA.es-BZyvITuO.js → core-BSRgt1mG.es-Bc_D2Lq4.js} +1 -1
- package/dist/app/assets/{index-CH2AcDzc.js → index-BRijWj6K.js} +15 -15
- package/dist/app/assets/{index-DQa463gC.js → index-BX4D94xw.js} +1 -1
- package/dist/app/assets/{index-VBbcc8s6.js → index-Cvy0xtb7.js} +1 -1
- package/dist/app/assets/{index-DQpV7MyA.js → index-DMAhIJNC.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +59 -20
- package/dist/server.mjs +1342 -616
- package/package.json +8 -2
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -97
- package/dist/app/assets/ModelPage-C1OSTv-x.js +0 -1
- package/dist/app/assets/RouteError-CzbfOkng.js +0 -1
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.example.bigquery.json +0 -33
- package/publisher.config.example.duckdb.json +0 -23
- package/publisher.config.json +0 -23
- package/scripts/bake-duckdb-extensions.js +0 -110
- package/src/config.spec.ts +0 -1535
- package/src/config.theme.spec.ts +0 -178
- package/src/config.ts +0 -1142
- package/src/constants.ts +0 -105
- package/src/controller/compile.controller.ts +0 -40
- package/src/controller/connection.controller.spec.ts +0 -849
- package/src/controller/connection.controller.ts +0 -901
- package/src/controller/database.controller.ts +0 -24
- package/src/controller/materialization.controller.spec.ts +0 -293
- package/src/controller/materialization.controller.ts +0 -319
- package/src/controller/model.controller.ts +0 -164
- package/src/controller/package.controller.spec.ts +0 -273
- package/src/controller/package.controller.ts +0 -306
- package/src/controller/query.controller.ts +0 -152
- package/src/controller/theme.controller.ts +0 -83
- package/src/controller/watch-mode.controller.ts +0 -243
- package/src/data_styles.ts +0 -71
- package/src/default-publisher.config.json +0 -23
- package/src/ducklake_version.spec.ts +0 -163
- package/src/ducklake_version.ts +0 -153
- package/src/errors.spec.ts +0 -119
- package/src/errors.ts +0 -237
- package/src/filter_deprecation.spec.ts +0 -64
- package/src/filter_deprecation.ts +0 -42
- package/src/health.spec.ts +0 -123
- package/src/health.ts +0 -233
- package/src/heap_check.spec.ts +0 -144
- package/src/heap_check.ts +0 -144
- package/src/instrumentation.ts +0 -214
- package/src/json_utils.spec.ts +0 -51
- package/src/json_utils.ts +0 -33
- package/src/logger.spec.ts +0 -210
- package/src/logger.ts +0 -251
- package/src/malloy_pin_prereqs.spec.ts +0 -25
- package/src/materialization_metrics.spec.ts +0 -169
- package/src/materialization_metrics.ts +0 -346
- package/src/mcp/error_messages.spec.ts +0 -35
- package/src/mcp/error_messages.ts +0 -183
- package/src/mcp/handler_utils.spec.ts +0 -108
- package/src/mcp/handler_utils.ts +0 -251
- package/src/mcp/mcp_constants.ts +0 -6
- package/src/mcp/query_envelope.spec.ts +0 -229
- package/src/mcp/query_envelope.ts +0 -240
- package/src/mcp/server.protocol.spec.ts +0 -250
- package/src/mcp/server.ts +0 -87
- package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
- package/src/mcp/skills/build_skills_bundle.ts +0 -176
- package/src/mcp/skills/skills_bundle.json +0 -1
- package/src/mcp/skills/skills_bundle.spec.ts +0 -171
- package/src/mcp/tool_response.spec.ts +0 -108
- package/src/mcp/tool_response.ts +0 -138
- package/src/mcp/tools/compile_tool.spec.ts +0 -315
- package/src/mcp/tools/compile_tool.ts +0 -208
- package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
- package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
- package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
- package/src/mcp/tools/docs_search_tool.ts +0 -128
- package/src/mcp/tools/embedding_index.spec.ts +0 -1236
- package/src/mcp/tools/embedding_index.ts +0 -808
- package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
- package/src/mcp/tools/execute_query_tool.ts +0 -316
- package/src/mcp/tools/get_context_eval.ts +0 -275
- package/src/mcp/tools/get_context_tool.spec.ts +0 -592
- package/src/mcp/tools/get_context_tool.ts +0 -585
- package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
- package/src/mcp/tools/reload_package_tool.ts +0 -132
- package/src/mcp_config.spec.ts +0 -919
- package/src/mcp_config.ts +0 -425
- package/src/oom_guards.integration.spec.ts +0 -269
- package/src/package_load/package_load_pool.spec.ts +0 -252
- package/src/package_load/package_load_pool.ts +0 -931
- package/src/package_load/package_load_worker.ts +0 -1034
- package/src/package_load/protocol.ts +0 -364
- package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
- package/src/package_load/rpc_wait_accountant.ts +0 -76
- package/src/package_load_metrics.spec.ts +0 -114
- package/src/package_load_metrics.ts +0 -127
- package/src/path_safety.spec.ts +0 -158
- package/src/path_safety.ts +0 -146
- package/src/pg_helpers.spec.ts +0 -223
- package/src/pg_helpers.ts +0 -52
- package/src/query_cap_metrics.spec.ts +0 -89
- package/src/query_cap_metrics.ts +0 -116
- package/src/query_concurrency.spec.ts +0 -247
- package/src/query_concurrency.ts +0 -237
- package/src/query_metadata_metrics.ts +0 -49
- package/src/query_param_utils.ts +0 -29
- package/src/query_timeout.spec.ts +0 -224
- package/src/query_timeout.ts +0 -179
- package/src/runtime/publisher.js +0 -328
- package/src/server-old.ts +0 -988
- package/src/server.ts +0 -2059
- package/src/service/annotations.spec.ts +0 -118
- package/src/service/annotations.ts +0 -91
- package/src/service/authorize.spec.ts +0 -154
- package/src/service/authorize.ts +0 -498
- package/src/service/authorize_integration.spec.ts +0 -2041
- package/src/service/build_plan.spec.ts +0 -685
- package/src/service/build_plan.ts +0 -797
- package/src/service/compile_authorize.spec.ts +0 -85
- package/src/service/compile_fragment_techniques.spec.ts +0 -156
- package/src/service/connection.spec.ts +0 -2360
- package/src/service/connection.ts +0 -2187
- package/src/service/connection_config.spec.ts +0 -734
- package/src/service/connection_config.ts +0 -899
- package/src/service/connection_federation.spec.ts +0 -184
- package/src/service/connection_fingerprint.spec.ts +0 -102
- package/src/service/connection_service.spec.ts +0 -711
- package/src/service/connection_service.ts +0 -291
- package/src/service/cron_evaluator.spec.ts +0 -79
- package/src/service/cron_evaluator.ts +0 -105
- package/src/service/db_utils.spec.ts +0 -1085
- package/src/service/db_utils.ts +0 -1502
- package/src/service/duckdb_instance_isolation.spec.ts +0 -137
- package/src/service/ducklake_lazy_attach.spec.ts +0 -110
- package/src/service/embedding_provider.spec.ts +0 -329
- package/src/service/embedding_provider.ts +0 -236
- package/src/service/environment.ts +0 -2191
- package/src/service/environment_admission.spec.ts +0 -344
- package/src/service/environment_compile.spec.ts +0 -197
- package/src/service/environment_store.spec.ts +0 -2274
- package/src/service/environment_store.ts +0 -2434
- package/src/service/environment_store_anchoring.spec.ts +0 -107
- package/src/service/environment_store_clone.spec.ts +0 -350
- package/src/service/explore_visibility.spec.ts +0 -434
- package/src/service/exports_probe.spec.ts +0 -107
- package/src/service/extension_fetch_policy.spec.ts +0 -256
- package/src/service/filter.spec.ts +0 -458
- package/src/service/filter.ts +0 -341
- package/src/service/filter_bypass.spec.ts +0 -418
- package/src/service/filter_integration.spec.ts +0 -1004
- package/src/service/freshness.spec.ts +0 -183
- package/src/service/freshness.ts +0 -112
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/given.ts +0 -105
- package/src/service/givens_integration.spec.ts +0 -219
- package/src/service/manifest_loader.spec.ts +0 -189
- package/src/service/manifest_loader.ts +0 -156
- package/src/service/materialization_build_session.spec.ts +0 -435
- package/src/service/materialization_build_session.ts +0 -681
- package/src/service/materialization_config_validation.spec.ts +0 -99
- package/src/service/materialization_config_validation.ts +0 -120
- package/src/service/materialization_eligibility.spec.ts +0 -158
- package/src/service/materialization_eligibility.ts +0 -305
- package/src/service/materialization_schedule_surface.spec.ts +0 -256
- package/src/service/materialization_scheduler.spec.ts +0 -289
- package/src/service/materialization_scheduler.ts +0 -304
- package/src/service/materialization_serve_transform.spec.ts +0 -1003
- package/src/service/materialization_serve_transform.ts +0 -779
- package/src/service/materialization_service.spec.ts +0 -2373
- package/src/service/materialization_service.ts +0 -2342
- package/src/service/materialization_test_fixtures.ts +0 -249
- package/src/service/model.spec.ts +0 -1157
- package/src/service/model.ts +0 -3370
- package/src/service/model_limits.spec.ts +0 -209
- package/src/service/model_limits.ts +0 -131
- package/src/service/model_storage_serve.spec.ts +0 -193
- package/src/service/model_storage_serve_joins.spec.ts +0 -193
- package/src/service/package.spec.ts +0 -612
- package/src/service/package.ts +0 -1799
- package/src/service/package_manifest.spec.ts +0 -230
- package/src/service/package_manifest.ts +0 -241
- package/src/service/package_memory_governor.spec.ts +0 -173
- package/src/service/package_memory_governor.ts +0 -233
- package/src/service/package_quote_bound_tables.spec.ts +0 -171
- package/src/service/package_race.spec.ts +0 -208
- package/src/service/package_reload_safety.spec.ts +0 -193
- package/src/service/package_rollback.spec.ts +0 -190
- package/src/service/package_worker_path.spec.ts +0 -645
- package/src/service/path_injection.spec.ts +0 -39
- package/src/service/persist_annotation_validation.spec.ts +0 -89
- package/src/service/persist_annotation_validation.ts +0 -59
- package/src/service/persistence_policy.spec.ts +0 -374
- package/src/service/proxy.spec.ts +0 -414
- package/src/service/proxy.ts +0 -248
- package/src/service/query_boundary.spec.ts +0 -470
- package/src/service/query_metadata.spec.ts +0 -408
- package/src/service/query_metadata.ts +0 -492
- package/src/service/query_metadata_identity.spec.ts +0 -149
- package/src/service/query_text.spec.ts +0 -79
- package/src/service/query_text.ts +0 -43
- package/src/service/quoting.spec.ts +0 -135
- package/src/service/quoting.ts +0 -78
- package/src/service/resolve_environment.ts +0 -15
- package/src/service/restricted_mode.spec.ts +0 -299
- package/src/service/source_extraction.ts +0 -225
- package/src/service/theme_store.ts +0 -199
- package/src/storage/DatabaseInterface.ts +0 -198
- package/src/storage/StorageManager.ts +0 -113
- package/src/storage/duckdb/ConnectionRepository.ts +0 -143
- package/src/storage/duckdb/DuckDBConnection.ts +0 -123
- package/src/storage/duckdb/DuckDBRepository.ts +0 -208
- package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
- package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
- package/src/storage/duckdb/MaterializationRepository.ts +0 -336
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/schema.ts +0 -229
- package/src/stream_helpers.spec.ts +0 -280
- package/src/stream_helpers.ts +0 -162
- package/src/telemetry.ts +0 -20
- package/src/test_helpers/metrics_harness.ts +0 -166
- package/src/theme_key_parity.spec.ts +0 -57
- package/src/utils.ts +0 -29
- package/tests/fixtures/authorize-compile/model.malloy +0 -9
- package/tests/fixtures/authorize-compile/publisher.json +0 -4
- package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
- package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/data.csv +0 -3
- package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
- package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
- package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
- package/tests/fixtures/html-pages-test/public/data.json +0 -1
- package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
- package/tests/fixtures/html-pages-test/public/index.html +0 -9
- package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
- package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
- package/tests/fixtures/html-pages-test/public/slides.html +0 -12
- package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
- package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
- package/tests/fixtures/html-pages-test/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/report.malloy +0 -1
- package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
- package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
- package/tests/fixtures/persist-multi-level/publisher.json +0 -5
- package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/fixtures/query-givens/data/orders.csv +0 -7
- package/tests/fixtures/query-givens/model.malloy +0 -34
- package/tests/fixtures/query-givens/publisher.json +0 -5
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/harness/mcp_test_setup.ts +0 -332
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
- package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
- package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
- package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
- package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
- package/tests/unit/controller/theme.controller.spec.ts +0 -141
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
- package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
- package/tests/unit/duckdb/repositories.test.ts +0 -208
- package/tests/unit/ducklake/ducklake.test.ts +0 -952
- package/tests/unit/service/theme_store.spec.ts +0 -139
- package/tsconfig.json +0 -32
|
@@ -1,2041 +0,0 @@
|
|
|
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, compile-time validation, and the runtime gate for
|
|
11
|
-
// #(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
|
-
CREATE TABLE IF NOT EXISTS nested_cols (
|
|
29
|
-
id INTEGER,
|
|
30
|
-
rec STRUCT(a INTEGER),
|
|
31
|
-
arr INTEGER[],
|
|
32
|
-
arr_rec STRUCT(b INTEGER)[]
|
|
33
|
-
);
|
|
34
|
-
INSERT INTO nested_cols VALUES (1, {'a': 1}, [1, 2, 3], [{'b': 2}]);
|
|
35
|
-
`;
|
|
36
|
-
|
|
37
|
-
function getConnections(): Map<string, Connection> {
|
|
38
|
-
const map = new Map<string, Connection>();
|
|
39
|
-
map.set("duckdb", duckdbConnection);
|
|
40
|
-
return map;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function writeModel(filename: string, content: string): Promise<void> {
|
|
44
|
-
await fs.writeFile(path.join(TEST_PKG_DIR, filename), content, "utf-8");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function sourceNamed(model: Model, name: string) {
|
|
48
|
-
return model.getSources()?.find((s) => s.name === name);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
beforeAll(async () => {
|
|
52
|
-
await fs.mkdir(TEST_DB_DIR, { recursive: true });
|
|
53
|
-
await fs.mkdir(TEST_PKG_DIR, { recursive: true });
|
|
54
|
-
duckdbConnection = new DuckDBConnection("duckdb", TEST_DB_PATH, TEST_DB_DIR);
|
|
55
|
-
for (const stmt of SEED_SQL.trim().split(";").filter(Boolean)) {
|
|
56
|
-
await duckdbConnection.runSQL(stmt.trim() + ";");
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
afterAll(async () => {
|
|
61
|
-
try {
|
|
62
|
-
await duckdbConnection.close();
|
|
63
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
64
|
-
await fs.rm(TEST_DIR, { recursive: true, force: true });
|
|
65
|
-
} catch {
|
|
66
|
-
// ignore cleanup errors
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe("authorize annotation introspection", () => {
|
|
71
|
-
it("collects file-level then source-level expressions as one list", async () => {
|
|
72
|
-
await writeModel(
|
|
73
|
-
"disjunction.malloy",
|
|
74
|
-
`##! experimental.givens
|
|
75
|
-
|
|
76
|
-
given:
|
|
77
|
-
ROLE :: string
|
|
78
|
-
REGION :: string
|
|
79
|
-
|
|
80
|
-
##(authorize) "$ROLE = 'admin'"
|
|
81
|
-
|
|
82
|
-
#(authorize) "$REGION = 'us-west'"
|
|
83
|
-
source: regional is duckdb.table('customers')
|
|
84
|
-
`,
|
|
85
|
-
);
|
|
86
|
-
const model = await Model.create(
|
|
87
|
-
"test-pkg",
|
|
88
|
-
TEST_PKG_DIR,
|
|
89
|
-
"disjunction.malloy",
|
|
90
|
-
getConnections(),
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
// File-level first, then the source's own.
|
|
94
|
-
expect(model.getAuthorize("regional")).toEqual([
|
|
95
|
-
"$ROLE = 'admin'",
|
|
96
|
-
"$REGION = 'us-west'",
|
|
97
|
-
]);
|
|
98
|
-
expect(sourceNamed(model, "regional")?.authorize).toEqual([
|
|
99
|
-
"$ROLE = 'admin'",
|
|
100
|
-
"$REGION = 'us-west'",
|
|
101
|
-
]);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("does NOT inherit a base source's authorize through extend", async () => {
|
|
105
|
-
await writeModel(
|
|
106
|
-
"extend.malloy",
|
|
107
|
-
`##! experimental.givens
|
|
108
|
-
|
|
109
|
-
given:
|
|
110
|
-
ROLE :: string
|
|
111
|
-
|
|
112
|
-
// Locked base.
|
|
113
|
-
#(authorize) "false"
|
|
114
|
-
source: customers_raw is duckdb.table('customers')
|
|
115
|
-
|
|
116
|
-
// Extension with its own gate — must NOT pick up the base's "false".
|
|
117
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
118
|
-
source: customers_marketing is customers_raw extend {
|
|
119
|
-
measure: customer_count is count()
|
|
120
|
-
}
|
|
121
|
-
`,
|
|
122
|
-
);
|
|
123
|
-
const model = await Model.create(
|
|
124
|
-
"test-pkg",
|
|
125
|
-
TEST_PKG_DIR,
|
|
126
|
-
"extend.malloy",
|
|
127
|
-
getConnections(),
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
// Base keeps its own lock.
|
|
131
|
-
expect(model.getAuthorize("customers_raw")).toEqual(["false"]);
|
|
132
|
-
// Extension is governed ONLY by its own gate — the base "false" is gone.
|
|
133
|
-
expect(model.getAuthorize("customers_marketing")).toEqual([
|
|
134
|
-
"$ROLE = 'analyst'",
|
|
135
|
-
]);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("applies a file-level gate to a source with no own authorize", async () => {
|
|
139
|
-
await writeModel(
|
|
140
|
-
"file_only.malloy",
|
|
141
|
-
`##! experimental.givens
|
|
142
|
-
|
|
143
|
-
given:
|
|
144
|
-
ROLE :: string
|
|
145
|
-
|
|
146
|
-
##(authorize) "$ROLE = 'admin'"
|
|
147
|
-
|
|
148
|
-
source: plain is duckdb.table('customers')
|
|
149
|
-
`,
|
|
150
|
-
);
|
|
151
|
-
const model = await Model.create(
|
|
152
|
-
"test-pkg",
|
|
153
|
-
TEST_PKG_DIR,
|
|
154
|
-
"file_only.malloy",
|
|
155
|
-
getConnections(),
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
expect(model.getAuthorize("plain")).toEqual(["$ROLE = 'admin'"]);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("applies a file-level gate inherited from an imported model", async () => {
|
|
162
|
-
// Regression for the hand-rolled model-annotation fold (malloy 0.0.405+
|
|
163
|
-
// removed ModelDef.annotation): a `##(authorize)` declared in an
|
|
164
|
-
// imported file must flow into the importing file's file-level gate even
|
|
165
|
-
// when the importer declares no `##` of its own. The fold must match
|
|
166
|
-
// malloy's getModelAnnotations (skip empty-ownNotes links) or `.notes`
|
|
167
|
-
// returns [] here and the gate silently drops — fail-open.
|
|
168
|
-
await writeModel(
|
|
169
|
-
"auth_base.malloy",
|
|
170
|
-
`##! experimental.givens
|
|
171
|
-
|
|
172
|
-
given:
|
|
173
|
-
ROLE :: string
|
|
174
|
-
|
|
175
|
-
##(authorize) "$ROLE = 'admin'"
|
|
176
|
-
`,
|
|
177
|
-
);
|
|
178
|
-
await writeModel(
|
|
179
|
-
"auth_importer.malloy",
|
|
180
|
-
`import "auth_base.malloy"
|
|
181
|
-
|
|
182
|
-
source: inherited_gate is duckdb.table('customers') extend {
|
|
183
|
-
measure: c is count()
|
|
184
|
-
}
|
|
185
|
-
`,
|
|
186
|
-
);
|
|
187
|
-
const model = await Model.create(
|
|
188
|
-
"test-pkg",
|
|
189
|
-
TEST_PKG_DIR,
|
|
190
|
-
"auth_importer.malloy",
|
|
191
|
-
getConnections(),
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
expect(model.getAuthorize("inherited_gate")).toEqual(["$ROLE = 'admin'"]);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("fails model load on a malformed authorize annotation (no silent drop)", async () => {
|
|
198
|
-
await writeModel(
|
|
199
|
-
"malformed.malloy",
|
|
200
|
-
`#(authorize) notquoted
|
|
201
|
-
source: broken is duckdb.table('customers')
|
|
202
|
-
`,
|
|
203
|
-
);
|
|
204
|
-
const model = await Model.create(
|
|
205
|
-
"test-pkg",
|
|
206
|
-
TEST_PKG_DIR,
|
|
207
|
-
"malformed.malloy",
|
|
208
|
-
getConnections(),
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
// A malformed gate must surface as a compilation error, not vanish.
|
|
212
|
-
const err = model.getNotebookError();
|
|
213
|
-
expect(err).toBeDefined();
|
|
214
|
-
expect(err?.message).toMatch(/quote/i);
|
|
215
|
-
// No sources surfaced for a failed compile — the gate is not silently
|
|
216
|
-
// reported as unrestricted.
|
|
217
|
-
expect(model.getSources()).toBeUndefined();
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it("treats a source with no authorize annotations as unrestricted", async () => {
|
|
221
|
-
await writeModel(
|
|
222
|
-
"none.malloy",
|
|
223
|
-
`source: open_source is duckdb.table('customers')
|
|
224
|
-
`,
|
|
225
|
-
);
|
|
226
|
-
const model = await Model.create(
|
|
227
|
-
"test-pkg",
|
|
228
|
-
TEST_PKG_DIR,
|
|
229
|
-
"none.malloy",
|
|
230
|
-
getConnections(),
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
-
expect(model.getAuthorize("open_source")).toEqual([]);
|
|
234
|
-
expect(sourceNamed(model, "open_source")?.authorize).toBeUndefined();
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
describe("authorize annotation compile-time validation", () => {
|
|
239
|
-
it("loads a valid expression that references a value-less given", async () => {
|
|
240
|
-
// The probe is compiled, not run, so a given with no default/value does
|
|
241
|
-
// NOT cause a false failure (the original getSQL approach would have).
|
|
242
|
-
await writeModel(
|
|
243
|
-
"valid_valueless.malloy",
|
|
244
|
-
`##! experimental.givens
|
|
245
|
-
|
|
246
|
-
given:
|
|
247
|
-
ROLE :: string
|
|
248
|
-
|
|
249
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
250
|
-
source: gated is duckdb.table('customers')
|
|
251
|
-
`,
|
|
252
|
-
);
|
|
253
|
-
const model = await Model.create(
|
|
254
|
-
"test-pkg",
|
|
255
|
-
TEST_PKG_DIR,
|
|
256
|
-
"valid_valueless.malloy",
|
|
257
|
-
getConnections(),
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
expect(model.getNotebookError()).toBeUndefined();
|
|
261
|
-
expect(model.getAuthorize("gated")).toEqual(["$ROLE = 'analyst'"]);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("fails model load when an expression references an unknown given", async () => {
|
|
265
|
-
await writeModel(
|
|
266
|
-
"unknown_given.malloy",
|
|
267
|
-
`##! experimental.givens
|
|
268
|
-
|
|
269
|
-
given:
|
|
270
|
-
ROLE :: string
|
|
271
|
-
|
|
272
|
-
#(authorize) "$NOPE = 'x'"
|
|
273
|
-
source: gated is duckdb.table('customers')
|
|
274
|
-
`,
|
|
275
|
-
);
|
|
276
|
-
const model = await Model.create(
|
|
277
|
-
"test-pkg",
|
|
278
|
-
TEST_PKG_DIR,
|
|
279
|
-
"unknown_given.malloy",
|
|
280
|
-
getConnections(),
|
|
281
|
-
);
|
|
282
|
-
|
|
283
|
-
const err = model.getNotebookError();
|
|
284
|
-
expect(err).toBeDefined();
|
|
285
|
-
// Names the source and surfaces the underlying Malloy reason.
|
|
286
|
-
expect(err?.message).toContain("gated");
|
|
287
|
-
expect(err?.message).toMatch(/NOPE|not declared/i);
|
|
288
|
-
// Redaction policy (pinned): the model-load 424 is author-facing, so it
|
|
289
|
-
// KEEPS the full expression text (needed to fix a malformed annotation).
|
|
290
|
-
// Only the runtime 403 redacts to the source name. If this assertion ever
|
|
291
|
-
// flips, the redaction split was changed — make it a conscious decision.
|
|
292
|
-
expect(err?.message).toContain("$NOPE = 'x'");
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it("fails model load when an expression references a source field", async () => {
|
|
296
|
-
await writeModel(
|
|
297
|
-
"field_ref.malloy",
|
|
298
|
-
`#(authorize) "some_field = 1"
|
|
299
|
-
source: gated is duckdb.table('customers')
|
|
300
|
-
`,
|
|
301
|
-
);
|
|
302
|
-
const model = await Model.create(
|
|
303
|
-
"test-pkg",
|
|
304
|
-
TEST_PKG_DIR,
|
|
305
|
-
"field_ref.malloy",
|
|
306
|
-
getConnections(),
|
|
307
|
-
);
|
|
308
|
-
|
|
309
|
-
const err = model.getNotebookError();
|
|
310
|
-
expect(err).toBeDefined();
|
|
311
|
-
expect(err?.message).toContain("gated");
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
it("does not reject a type-mismatched comparison (not a Malloy compile error)", async () => {
|
|
315
|
-
// Documents the boundary: `$ROLE = 5` is not a compile error; such a gate
|
|
316
|
-
// simply evaluates per the warehouse at the runtime gate.
|
|
317
|
-
await writeModel(
|
|
318
|
-
"type_mismatch.malloy",
|
|
319
|
-
`##! experimental.givens
|
|
320
|
-
|
|
321
|
-
given:
|
|
322
|
-
ROLE :: string
|
|
323
|
-
|
|
324
|
-
#(authorize) "$ROLE = 5"
|
|
325
|
-
source: gated is duckdb.table('customers')
|
|
326
|
-
`,
|
|
327
|
-
);
|
|
328
|
-
const model = await Model.create(
|
|
329
|
-
"test-pkg",
|
|
330
|
-
TEST_PKG_DIR,
|
|
331
|
-
"type_mismatch.malloy",
|
|
332
|
-
getConnections(),
|
|
333
|
-
);
|
|
334
|
-
|
|
335
|
-
expect(model.getNotebookError()).toBeUndefined();
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it("fails model load when a file-level ##(authorize) references an unknown given", async () => {
|
|
339
|
-
await writeModel(
|
|
340
|
-
"file_unknown_given.malloy",
|
|
341
|
-
`##! experimental.givens
|
|
342
|
-
|
|
343
|
-
given:
|
|
344
|
-
ROLE :: string
|
|
345
|
-
|
|
346
|
-
##(authorize) "$NOPE = 'x'"
|
|
347
|
-
|
|
348
|
-
source: gated is duckdb.table('customers')
|
|
349
|
-
`,
|
|
350
|
-
);
|
|
351
|
-
const model = await Model.create(
|
|
352
|
-
"test-pkg",
|
|
353
|
-
TEST_PKG_DIR,
|
|
354
|
-
"file_unknown_given.malloy",
|
|
355
|
-
getConnections(),
|
|
356
|
-
);
|
|
357
|
-
|
|
358
|
-
const err = model.getNotebookError();
|
|
359
|
-
expect(err).toBeDefined();
|
|
360
|
-
expect(err?.message).toContain("gated");
|
|
361
|
-
expect(err?.message).toMatch(/NOPE|not declared/i);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it("validates expressions over number and list givens, not just strings", async () => {
|
|
365
|
-
await writeModel(
|
|
366
|
-
"given_types.malloy",
|
|
367
|
-
`##! experimental.givens
|
|
368
|
-
|
|
369
|
-
given:
|
|
370
|
-
AGE :: number
|
|
371
|
-
TENANT :: string
|
|
372
|
-
ALLOWED :: string[]
|
|
373
|
-
|
|
374
|
-
#(authorize) "$AGE > 18"
|
|
375
|
-
#(authorize) "$TENANT in $ALLOWED"
|
|
376
|
-
source: gated is duckdb.table('customers')
|
|
377
|
-
`,
|
|
378
|
-
);
|
|
379
|
-
const model = await Model.create(
|
|
380
|
-
"test-pkg",
|
|
381
|
-
TEST_PKG_DIR,
|
|
382
|
-
"given_types.malloy",
|
|
383
|
-
getConnections(),
|
|
384
|
-
);
|
|
385
|
-
|
|
386
|
-
expect(model.getNotebookError()).toBeUndefined();
|
|
387
|
-
expect(model.getAuthorize("gated")).toEqual([
|
|
388
|
-
"$AGE > 18",
|
|
389
|
-
"$TENANT in $ALLOWED",
|
|
390
|
-
]);
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
// Helper: run an ad-hoc query through the full getQueryResults path (which is
|
|
395
|
-
// where the gate fires). Returns the result or throws AccessDeniedError.
|
|
396
|
-
// Module-scoped (not just inside "authorize runtime gate") so the
|
|
397
|
-
// BLOCKING-1/2/3 regression describes below can reuse it too.
|
|
398
|
-
async function runGated(
|
|
399
|
-
modelFile: string,
|
|
400
|
-
query: string,
|
|
401
|
-
givens?: Record<string, GivenValue>,
|
|
402
|
-
bypassFilters?: boolean,
|
|
403
|
-
) {
|
|
404
|
-
const model = await Model.create(
|
|
405
|
-
"test-pkg",
|
|
406
|
-
TEST_PKG_DIR,
|
|
407
|
-
modelFile,
|
|
408
|
-
getConnections(),
|
|
409
|
-
);
|
|
410
|
-
return model.getQueryResults(
|
|
411
|
-
undefined,
|
|
412
|
-
undefined,
|
|
413
|
-
query,
|
|
414
|
-
undefined,
|
|
415
|
-
bypassFilters,
|
|
416
|
-
givens,
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
describe("authorize runtime gate", () => {
|
|
421
|
-
const SINGLE_GATE = `##! experimental.givens
|
|
422
|
-
|
|
423
|
-
given:
|
|
424
|
-
ROLE :: string
|
|
425
|
-
|
|
426
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
427
|
-
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
428
|
-
`;
|
|
429
|
-
|
|
430
|
-
it("allows the query when a given satisfies the gate", async () => {
|
|
431
|
-
await writeModel("rt_single.malloy", SINGLE_GATE);
|
|
432
|
-
const { result } = await runGated(
|
|
433
|
-
"rt_single.malloy",
|
|
434
|
-
"run: gated -> { aggregate: c }",
|
|
435
|
-
{ ROLE: "analyst" },
|
|
436
|
-
);
|
|
437
|
-
expect(result.data).toBeDefined();
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
it("denies (403) when no given satisfies the gate", async () => {
|
|
441
|
-
await writeModel("rt_single.malloy", SINGLE_GATE);
|
|
442
|
-
await expect(
|
|
443
|
-
runGated("rt_single.malloy", "run: gated -> { aggregate: c }", {
|
|
444
|
-
ROLE: "intern",
|
|
445
|
-
}),
|
|
446
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
it("denies when the referenced given has no value (fail closed)", async () => {
|
|
450
|
-
await writeModel("rt_single.malloy", SINGLE_GATE);
|
|
451
|
-
await expect(
|
|
452
|
-
runGated("rt_single.malloy", "run: gated -> { aggregate: c }", {}),
|
|
453
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
it("still enforces the gate when bypassFilters is true (authorize is not a filter)", async () => {
|
|
457
|
-
await writeModel("rt_single.malloy", SINGLE_GATE);
|
|
458
|
-
await expect(
|
|
459
|
-
runGated(
|
|
460
|
-
"rt_single.malloy",
|
|
461
|
-
"run: gated -> { aggregate: c }",
|
|
462
|
-
{ ROLE: "intern" },
|
|
463
|
-
true,
|
|
464
|
-
),
|
|
465
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
// A model that declares no `##` of its own and inherits its file-level gate
|
|
469
|
-
// entirely from an imported model. Exercises the model-annotation fold end
|
|
470
|
-
// to end: parse → fold → fileLevelAuthorize → runtime gate.
|
|
471
|
-
const IMPORT_BASE = `##! experimental.givens
|
|
472
|
-
|
|
473
|
-
given:
|
|
474
|
-
ROLE :: string
|
|
475
|
-
|
|
476
|
-
##(authorize) "$ROLE = 'admin'"
|
|
477
|
-
`;
|
|
478
|
-
const IMPORT_GATED = `import "rt_auth_base.malloy"
|
|
479
|
-
|
|
480
|
-
source: inherited_gate is duckdb.table('customers') extend {
|
|
481
|
-
measure: c is count()
|
|
482
|
-
}
|
|
483
|
-
`;
|
|
484
|
-
|
|
485
|
-
it("enforces a file-level gate inherited from an imported model (allow with role)", async () => {
|
|
486
|
-
await writeModel("rt_auth_base.malloy", IMPORT_BASE);
|
|
487
|
-
await writeModel("rt_import.malloy", IMPORT_GATED);
|
|
488
|
-
const { result } = await runGated(
|
|
489
|
-
"rt_import.malloy",
|
|
490
|
-
"run: inherited_gate -> { aggregate: c }",
|
|
491
|
-
{ ROLE: "admin" },
|
|
492
|
-
);
|
|
493
|
-
expect(result.data).toBeDefined();
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
it("enforces a file-level gate inherited from an imported model (deny without role — not fail-open)", async () => {
|
|
497
|
-
await writeModel("rt_auth_base.malloy", IMPORT_BASE);
|
|
498
|
-
await writeModel("rt_import.malloy", IMPORT_GATED);
|
|
499
|
-
await expect(
|
|
500
|
-
runGated(
|
|
501
|
-
"rt_import.malloy",
|
|
502
|
-
"run: inherited_gate -> { aggregate: c }",
|
|
503
|
-
{
|
|
504
|
-
ROLE: "intern",
|
|
505
|
-
},
|
|
506
|
-
),
|
|
507
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
const DISJUNCTION = `##! experimental.givens
|
|
511
|
-
|
|
512
|
-
given:
|
|
513
|
-
ROLE :: string
|
|
514
|
-
REGION :: string
|
|
515
|
-
|
|
516
|
-
##(authorize) "$ROLE = 'admin'"
|
|
517
|
-
|
|
518
|
-
#(authorize) "$REGION = 'us-west'"
|
|
519
|
-
source: regional is duckdb.table('customers') extend { measure: c is count() }
|
|
520
|
-
`;
|
|
521
|
-
|
|
522
|
-
it("grants on the file-level gate even when the OTHER disjunct's given is missing", async () => {
|
|
523
|
-
// The key OR-semantics case: admin supplies only ROLE; REGION is absent.
|
|
524
|
-
// A disjunct that can't evaluate (missing given) must not sink the whole
|
|
525
|
-
// request — the satisfied $ROLE='admin' branch still grants.
|
|
526
|
-
await writeModel("rt_disj.malloy", DISJUNCTION);
|
|
527
|
-
const { result } = await runGated(
|
|
528
|
-
"rt_disj.malloy",
|
|
529
|
-
"run: regional -> { aggregate: c }",
|
|
530
|
-
{ ROLE: "admin" },
|
|
531
|
-
);
|
|
532
|
-
expect(result.data).toBeDefined();
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
it("grants on the source-level gate even when the file-level disjunct's given is missing", async () => {
|
|
536
|
-
await writeModel("rt_disj.malloy", DISJUNCTION);
|
|
537
|
-
const { result } = await runGated(
|
|
538
|
-
"rt_disj.malloy",
|
|
539
|
-
"run: regional -> { aggregate: c }",
|
|
540
|
-
{ REGION: "us-west" },
|
|
541
|
-
);
|
|
542
|
-
expect(result.data).toBeDefined();
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
it("denies when neither disjunct is satisfied", async () => {
|
|
546
|
-
await writeModel("rt_disj.malloy", DISJUNCTION);
|
|
547
|
-
await expect(
|
|
548
|
-
runGated("rt_disj.malloy", "run: regional -> { aggregate: c }", {
|
|
549
|
-
ROLE: "nobody",
|
|
550
|
-
REGION: "nowhere",
|
|
551
|
-
}),
|
|
552
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
it("gates a named query that targets a gated source (no sourceName supplied)", async () => {
|
|
556
|
-
await writeModel(
|
|
557
|
-
"rt_namedq.malloy",
|
|
558
|
-
`##! experimental.givens
|
|
559
|
-
|
|
560
|
-
given:
|
|
561
|
-
ROLE :: string
|
|
562
|
-
|
|
563
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
564
|
-
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
565
|
-
|
|
566
|
-
query: secret is gated -> { aggregate: c }
|
|
567
|
-
`,
|
|
568
|
-
);
|
|
569
|
-
const model = await Model.create(
|
|
570
|
-
"test-pkg",
|
|
571
|
-
TEST_PKG_DIR,
|
|
572
|
-
"rt_namedq.malloy",
|
|
573
|
-
getConnections(),
|
|
574
|
-
);
|
|
575
|
-
// Named query, no sourceName — must still resolve to `gated` and gate it.
|
|
576
|
-
await expect(
|
|
577
|
-
model.getQueryResults(
|
|
578
|
-
undefined,
|
|
579
|
-
"secret",
|
|
580
|
-
undefined,
|
|
581
|
-
undefined,
|
|
582
|
-
false,
|
|
583
|
-
{
|
|
584
|
-
ROLE: "intern",
|
|
585
|
-
},
|
|
586
|
-
),
|
|
587
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
588
|
-
// And it runs when the gate passes.
|
|
589
|
-
const { result } = await model.getQueryResults(
|
|
590
|
-
undefined,
|
|
591
|
-
"secret",
|
|
592
|
-
undefined,
|
|
593
|
-
undefined,
|
|
594
|
-
false,
|
|
595
|
-
{ ROLE: "analyst" },
|
|
596
|
-
);
|
|
597
|
-
expect(result.data).toBeDefined();
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
it("gates an ad-hoc query even when a blank sourceName is supplied", async () => {
|
|
601
|
-
await writeModel("rt_single.malloy", SINGLE_GATE);
|
|
602
|
-
const model = await Model.create(
|
|
603
|
-
"test-pkg",
|
|
604
|
-
TEST_PKG_DIR,
|
|
605
|
-
"rt_single.malloy",
|
|
606
|
-
getConnections(),
|
|
607
|
-
);
|
|
608
|
-
// Blank sourceName must not skip the gate while the query-builder treats
|
|
609
|
-
// it as absent and runs the ad-hoc query.
|
|
610
|
-
await expect(
|
|
611
|
-
model.getQueryResults(
|
|
612
|
-
"",
|
|
613
|
-
undefined,
|
|
614
|
-
"run: gated -> { aggregate: c }",
|
|
615
|
-
undefined,
|
|
616
|
-
false,
|
|
617
|
-
{ ROLE: "intern" },
|
|
618
|
-
),
|
|
619
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
it("gates the source the query actually RUNS, not a decoy leading statement", async () => {
|
|
623
|
-
// Malloy runs the LAST `run:`. A multi-statement ad-hoc query that names
|
|
624
|
-
// an ungated source first and the gated source last must be gated on the
|
|
625
|
-
// gated source (the one that executes), not fooled by the leading one.
|
|
626
|
-
await writeModel(
|
|
627
|
-
"rt_multi.malloy",
|
|
628
|
-
`##! experimental.givens
|
|
629
|
-
|
|
630
|
-
given:
|
|
631
|
-
ROLE :: string
|
|
632
|
-
|
|
633
|
-
source: ungated is duckdb.table('customers') extend { measure: c is count() }
|
|
634
|
-
|
|
635
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
636
|
-
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
637
|
-
`,
|
|
638
|
-
);
|
|
639
|
-
await expect(
|
|
640
|
-
runGated(
|
|
641
|
-
"rt_multi.malloy",
|
|
642
|
-
"run: ungated -> { aggregate: c }\nrun: gated -> { aggregate: c }",
|
|
643
|
-
{ ROLE: "intern" },
|
|
644
|
-
),
|
|
645
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
it("leaves a source with no authorize annotations unrestricted", async () => {
|
|
649
|
-
await writeModel(
|
|
650
|
-
"rt_open.malloy",
|
|
651
|
-
`source: open_src is duckdb.table('customers') extend { measure: c is count() }
|
|
652
|
-
`,
|
|
653
|
-
);
|
|
654
|
-
const { result } = await runGated(
|
|
655
|
-
"rt_open.malloy",
|
|
656
|
-
"run: open_src -> { aggregate: c }",
|
|
657
|
-
);
|
|
658
|
-
expect(result.data).toBeDefined();
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
const FILE_LEVEL = `##! experimental.givens
|
|
662
|
-
|
|
663
|
-
given:
|
|
664
|
-
ROLE :: string
|
|
665
|
-
|
|
666
|
-
##(authorize) "$ROLE = 'admin'"
|
|
667
|
-
|
|
668
|
-
source: declared is duckdb.table('customers') extend { measure: c is count() }
|
|
669
|
-
`;
|
|
670
|
-
|
|
671
|
-
it("applies a file-level gate to an ad-hoc query (no gate bypass)", async () => {
|
|
672
|
-
await writeModel("rt_filelevel.malloy", FILE_LEVEL);
|
|
673
|
-
// The model-wide file-level gate must apply to any ad-hoc query against
|
|
674
|
-
// the model, denying a non-admin with a 403.
|
|
675
|
-
await expect(
|
|
676
|
-
runGated("rt_filelevel.malloy", "run: declared -> { aggregate: c }", {
|
|
677
|
-
ROLE: "user",
|
|
678
|
-
}),
|
|
679
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
680
|
-
// An admin (file-level gate satisfied) can run it.
|
|
681
|
-
const { result } = await runGated(
|
|
682
|
-
"rt_filelevel.malloy",
|
|
683
|
-
"run: declared -> { aggregate: c }",
|
|
684
|
-
{ ROLE: "admin" },
|
|
685
|
-
);
|
|
686
|
-
expect(result.data).toBeDefined();
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
it("rejects an ad-hoc inline-SQL query (restricted mode closes the raw-warehouse path)", async () => {
|
|
690
|
-
// Pre-#807 the file-level #(authorize) gate was the only thing between a
|
|
691
|
-
// caller and raw `duckdb.sql(...)`. #807's restricted mode now rejects
|
|
692
|
-
// inline raw SQL outright while resolving the compiled source — before the
|
|
693
|
-
// gate is reached — so the raw-warehouse path is closed at the compile
|
|
694
|
-
// layer regardless of givens (even for an admin who satisfies the gate).
|
|
695
|
-
await writeModel("rt_filelevel.malloy", FILE_LEVEL);
|
|
696
|
-
await expect(
|
|
697
|
-
runGated(
|
|
698
|
-
"rt_filelevel.malloy",
|
|
699
|
-
`run: duckdb.sql("SELECT 1 AS id") -> { aggregate: c is count() }`,
|
|
700
|
-
{ ROLE: "admin" },
|
|
701
|
-
),
|
|
702
|
-
).rejects.toThrow(/raw SQL is not permitted/);
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
const MIXED_SOURCE_GATE = `##! experimental.givens
|
|
706
|
-
|
|
707
|
-
given:
|
|
708
|
-
ROLE :: string
|
|
709
|
-
|
|
710
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
711
|
-
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
712
|
-
|
|
713
|
-
source: open_src is duckdb.table('customers') extend { measure: c is count() }
|
|
714
|
-
`;
|
|
715
|
-
|
|
716
|
-
it("does not over-gate: a source-level gate is not model-wide", async () => {
|
|
717
|
-
// Control: a per-source gate applies only to that source, not the whole
|
|
718
|
-
// model. An ad-hoc query against an ungated declared source in the same
|
|
719
|
-
// model runs without any given.
|
|
720
|
-
await writeModel("rt_mixed.malloy", MIXED_SOURCE_GATE);
|
|
721
|
-
const { result } = await runGated(
|
|
722
|
-
"rt_mixed.malloy",
|
|
723
|
-
"run: open_src -> { aggregate: c }",
|
|
724
|
-
);
|
|
725
|
-
expect(result.data).toBeDefined();
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
it("gates a quoted-identifier source BEFORE compilation (no schema oracle)", async () => {
|
|
729
|
-
// A gated source whose Malloy name must be quoted (here, a hyphen) must be
|
|
730
|
-
// recognized by the early gate too. Otherwise a denied caller could probe
|
|
731
|
-
// a non-existent field and learn the schema from a pre-compilation Malloy
|
|
732
|
-
// field error instead of a clean 403.
|
|
733
|
-
await writeModel(
|
|
734
|
-
"rt_quoted.malloy",
|
|
735
|
-
`##! experimental.givens
|
|
736
|
-
|
|
737
|
-
given:
|
|
738
|
-
ROLE :: string
|
|
739
|
-
|
|
740
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
741
|
-
source: \`gated-source\` is duckdb.table('customers') extend {
|
|
742
|
-
measure: c is count()
|
|
743
|
-
}
|
|
744
|
-
`,
|
|
745
|
-
);
|
|
746
|
-
// Probing a field that doesn't exist must deny (403) before compilation,
|
|
747
|
-
// not surface a Malloy "field not found" error.
|
|
748
|
-
await expect(
|
|
749
|
-
runGated(
|
|
750
|
-
"rt_quoted.malloy",
|
|
751
|
-
"run: `gated-source` -> { group_by: no_such_field }",
|
|
752
|
-
{ ROLE: "viewer" },
|
|
753
|
-
),
|
|
754
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
it("gates a notebook cell that runs a NAMED QUERY targeting a gated source", async () => {
|
|
758
|
-
// `run: secret` has no `->`, so source resolution must come from the
|
|
759
|
-
// compiled query, not a text regex — otherwise the gate is bypassed.
|
|
760
|
-
await writeModel(
|
|
761
|
-
"rt_nb.malloynb",
|
|
762
|
-
`>>>malloy
|
|
763
|
-
##! experimental.givens
|
|
764
|
-
|
|
765
|
-
given:
|
|
766
|
-
ROLE :: string
|
|
767
|
-
|
|
768
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
769
|
-
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
770
|
-
query: secret is gated -> { aggregate: c }
|
|
771
|
-
|
|
772
|
-
>>>malloy
|
|
773
|
-
run: secret
|
|
774
|
-
`,
|
|
775
|
-
);
|
|
776
|
-
const model = await Model.create(
|
|
777
|
-
"test-pkg",
|
|
778
|
-
TEST_PKG_DIR,
|
|
779
|
-
"rt_nb.malloynb",
|
|
780
|
-
getConnections(),
|
|
781
|
-
);
|
|
782
|
-
// Cell 1 is `run: secret` — must be denied without the gate-passing given.
|
|
783
|
-
await expect(
|
|
784
|
-
model.executeNotebookCell(1, undefined, false, { ROLE: "intern" }),
|
|
785
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
786
|
-
// ...and allowed when the gate passes.
|
|
787
|
-
const ok = await model.executeNotebookCell(1, undefined, false, {
|
|
788
|
-
ROLE: "analyst",
|
|
789
|
-
});
|
|
790
|
-
expect(ok.result).toBeDefined();
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
const LOCKED_BASE = `##! experimental.givens
|
|
794
|
-
|
|
795
|
-
given:
|
|
796
|
-
ROLE :: string
|
|
797
|
-
|
|
798
|
-
#(authorize) "false"
|
|
799
|
-
source: base_locked is duckdb.table('customers') extend { measure: c is count() }
|
|
800
|
-
|
|
801
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
802
|
-
source: ext_gated is base_locked extend {}
|
|
803
|
-
|
|
804
|
-
source: ext_nogate is base_locked extend {}
|
|
805
|
-
|
|
806
|
-
source: joiner is duckdb.table('customers') extend {
|
|
807
|
-
join_one: base_locked on id = base_locked.id
|
|
808
|
-
measure: c is count()
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
source: joiner_aliased is duckdb.table('customers') extend {
|
|
812
|
-
join_one: b is base_locked on id = b.id
|
|
813
|
-
measure: c is count()
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
source: plain is duckdb.table('customers') extend { measure: c is count() }
|
|
817
|
-
|
|
818
|
-
source: mixed_joiner is duckdb.table('customers') extend {
|
|
819
|
-
join_one: base_locked on id = base_locked.id
|
|
820
|
-
join_one: plain on id = plain.id
|
|
821
|
-
measure: c is count()
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
source: inline_joiner is duckdb.table('customers') extend {
|
|
825
|
-
join_one: t is duckdb.table('customers') on id = t.id
|
|
826
|
-
measure: c is count()
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
source: mid_join is duckdb.table('customers') extend {
|
|
830
|
-
join_one: base_locked on id = base_locked.id
|
|
831
|
-
measure: c is count()
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
source: top_join is duckdb.table('customers') extend {
|
|
835
|
-
join_one: mid_join on id = mid_join.id
|
|
836
|
-
measure: c is count()
|
|
837
|
-
}
|
|
838
|
-
`;
|
|
839
|
-
|
|
840
|
-
it('denies a direct query against a base locked with #(authorize) "false"', async () => {
|
|
841
|
-
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
842
|
-
await expect(
|
|
843
|
-
runGated("rt_locked.malloy", "run: base_locked -> { aggregate: c }", {
|
|
844
|
-
ROLE: "analyst",
|
|
845
|
-
}),
|
|
846
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
it("allows an extension of a locked base when the extension's own gate passes", async () => {
|
|
850
|
-
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
851
|
-
const { result } = await runGated(
|
|
852
|
-
"rt_locked.malloy",
|
|
853
|
-
"run: ext_gated -> { aggregate: c }",
|
|
854
|
-
{ ROLE: "analyst" },
|
|
855
|
-
);
|
|
856
|
-
expect(result.data).toBeDefined();
|
|
857
|
-
});
|
|
858
|
-
|
|
859
|
-
it("denies an extension that declares no own gate — it inherits the base lock (safe default)", async () => {
|
|
860
|
-
// Malloy carries the base's #(authorize) onto an extension UNLESS the
|
|
861
|
-
// extension declares its own. So a bare `is base_locked extend {}` with
|
|
862
|
-
// no own gate stays locked by the base's "false". An extension escapes
|
|
863
|
-
// the base gate only by declaring its own #(authorize) (see ext_gated).
|
|
864
|
-
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
865
|
-
await expect(
|
|
866
|
-
runGated("rt_locked.malloy", "run: ext_nogate -> { aggregate: c }", {
|
|
867
|
-
ROLE: "analyst",
|
|
868
|
-
}),
|
|
869
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
870
|
-
});
|
|
871
|
-
|
|
872
|
-
it("a query joining a locked base via an ungated source is denied", async () => {
|
|
873
|
-
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
874
|
-
await expect(
|
|
875
|
-
runGated("rt_locked.malloy", "run: joiner -> { aggregate: c }", {}),
|
|
876
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
877
|
-
});
|
|
878
|
-
|
|
879
|
-
it("denies a transitive A→B→C join where C is locked", async () => {
|
|
880
|
-
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
881
|
-
await expect(
|
|
882
|
-
runGated("rt_locked.malloy", "run: top_join -> { aggregate: c }", {}),
|
|
883
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
it("denies an aliased join (join_one: b is base_locked) — alias resolution can't dodge the gate", async () => {
|
|
887
|
-
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
888
|
-
await expect(
|
|
889
|
-
runGated(
|
|
890
|
-
"rt_locked.malloy",
|
|
891
|
-
"run: joiner_aliased -> { aggregate: c }",
|
|
892
|
-
{},
|
|
893
|
-
),
|
|
894
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
895
|
-
});
|
|
896
|
-
|
|
897
|
-
it("denies a query joining one gated and one ungated source (AND across sources)", async () => {
|
|
898
|
-
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
899
|
-
await expect(
|
|
900
|
-
runGated(
|
|
901
|
-
"rt_locked.malloy",
|
|
902
|
-
"run: mixed_joiner -> { aggregate: c }",
|
|
903
|
-
{},
|
|
904
|
-
),
|
|
905
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
it("allows a query joining only an inline duckdb.table(...) source (no annotations to gate)", async () => {
|
|
909
|
-
await writeModel("rt_locked.malloy", LOCKED_BASE);
|
|
910
|
-
const { result } = await runGated(
|
|
911
|
-
"rt_locked.malloy",
|
|
912
|
-
"run: inline_joiner -> { aggregate: c }",
|
|
913
|
-
{},
|
|
914
|
-
);
|
|
915
|
-
expect(result.data).toBeDefined();
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
const FILE_LEVEL_OVERRIDE_WITH_JOIN = `##! experimental.givens
|
|
919
|
-
|
|
920
|
-
given:
|
|
921
|
-
ROLE :: string
|
|
922
|
-
|
|
923
|
-
##(authorize) "$ROLE = 'admin'"
|
|
924
|
-
|
|
925
|
-
#(authorize) "false"
|
|
926
|
-
source: fl_locked is duckdb.table('customers') extend { measure: c is count() }
|
|
927
|
-
|
|
928
|
-
source: fl_joiner is duckdb.table('customers') extend {
|
|
929
|
-
join_one: fl_locked on id = fl_locked.id
|
|
930
|
-
measure: c is count()
|
|
931
|
-
}
|
|
932
|
-
`;
|
|
933
|
-
|
|
934
|
-
it("allows a locked join for admin givens via the file-level ##(authorize) override", async () => {
|
|
935
|
-
await writeModel("rt_fl_override.malloy", FILE_LEVEL_OVERRIDE_WITH_JOIN);
|
|
936
|
-
const { result } = await runGated(
|
|
937
|
-
"rt_fl_override.malloy",
|
|
938
|
-
"run: fl_joiner -> { aggregate: c }",
|
|
939
|
-
{ ROLE: "admin" },
|
|
940
|
-
);
|
|
941
|
-
expect(result.data).toBeDefined();
|
|
942
|
-
});
|
|
943
|
-
|
|
944
|
-
it("denies a locked join for a non-admin given despite the file-level override existing", async () => {
|
|
945
|
-
await writeModel("rt_fl_override.malloy", FILE_LEVEL_OVERRIDE_WITH_JOIN);
|
|
946
|
-
await expect(
|
|
947
|
-
runGated(
|
|
948
|
-
"rt_fl_override.malloy",
|
|
949
|
-
"run: fl_joiner -> { aggregate: c }",
|
|
950
|
-
{ ROLE: "analyst" },
|
|
951
|
-
),
|
|
952
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
953
|
-
});
|
|
954
|
-
|
|
955
|
-
it("denies a notebook cell whose run target joins a locked base", async () => {
|
|
956
|
-
await writeModel(
|
|
957
|
-
"rt_locked_join.malloynb",
|
|
958
|
-
`>>>malloy
|
|
959
|
-
${LOCKED_BASE}
|
|
960
|
-
query: secret_join is joiner -> { aggregate: c }
|
|
961
|
-
|
|
962
|
-
>>>malloy
|
|
963
|
-
run: secret_join
|
|
964
|
-
`,
|
|
965
|
-
);
|
|
966
|
-
const model = await Model.create(
|
|
967
|
-
"test-pkg",
|
|
968
|
-
TEST_PKG_DIR,
|
|
969
|
-
"rt_locked_join.malloynb",
|
|
970
|
-
getConnections(),
|
|
971
|
-
);
|
|
972
|
-
await expect(
|
|
973
|
-
model.executeNotebookCell(1, undefined, false, {}),
|
|
974
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
975
|
-
});
|
|
976
|
-
});
|
|
977
|
-
|
|
978
|
-
// BLOCKING-1: `hasAuthorize()` only inspects `this.sources` (built from
|
|
979
|
-
// entry-file `modelDef.contents`), so a gated source reached ONLY via a
|
|
980
|
-
// cross-file/deep-transitive join is invisible to it and the joined-gate
|
|
981
|
-
// walk was skipped entirely. Three files: a locked base, a mid file that
|
|
982
|
-
// imports it and joins it, and an entry file that imports the mid file and
|
|
983
|
-
// joins IT — never naming (or importing directly) the locked base, and
|
|
984
|
-
// declaring no gate of its own.
|
|
985
|
-
describe("authorize deep cross-file join enforcement (BLOCKING-1)", () => {
|
|
986
|
-
it("denies a query whose run target reaches a locked source only via a two-hop cross-file join", async () => {
|
|
987
|
-
await writeModel(
|
|
988
|
-
"c_base.malloy",
|
|
989
|
-
`#(authorize) "false"
|
|
990
|
-
source: base_locked is duckdb.table('customers') extend { measure: c is count() }
|
|
991
|
-
`,
|
|
992
|
-
);
|
|
993
|
-
await writeModel(
|
|
994
|
-
"c_mid.malloy",
|
|
995
|
-
`import "c_base.malloy"
|
|
996
|
-
|
|
997
|
-
source: c_mid is duckdb.table('customers') extend {
|
|
998
|
-
join_one: base_locked on id = base_locked.id
|
|
999
|
-
measure: c is count()
|
|
1000
|
-
}
|
|
1001
|
-
`,
|
|
1002
|
-
);
|
|
1003
|
-
await writeModel(
|
|
1004
|
-
"c_entry.malloy",
|
|
1005
|
-
`import "c_mid.malloy"
|
|
1006
|
-
|
|
1007
|
-
source: top is duckdb.table('customers') extend {
|
|
1008
|
-
join_one: c_mid on id = c_mid.id
|
|
1009
|
-
measure: c is count()
|
|
1010
|
-
}
|
|
1011
|
-
`,
|
|
1012
|
-
);
|
|
1013
|
-
await expect(
|
|
1014
|
-
runGated("c_entry.malloy", "run: top -> { aggregate: c }", {}),
|
|
1015
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1016
|
-
});
|
|
1017
|
-
});
|
|
1018
|
-
|
|
1019
|
-
// SHOULD-1: `extractSourcesFromModelDef` only validates #(authorize) syntax
|
|
1020
|
-
// for TOP-LEVEL `modelDef.contents` sources at model load — a source reached
|
|
1021
|
-
// ONLY via a join (never itself imported/named at the entry file, same shape
|
|
1022
|
-
// as the BLOCKING-1 repro) is never probed there, so a malformed gate on it
|
|
1023
|
-
// does NOT fail model load. The runtime join walk must still fail CLOSED when
|
|
1024
|
-
// it hits that malformed annotation, not silently treat it as ungated.
|
|
1025
|
-
describe("authorize fail-closed on a malformed join-only gate (SHOULD-1)", () => {
|
|
1026
|
-
it("loads successfully despite the malformed gate (never validated — it's join-only, not top-level)", async () => {
|
|
1027
|
-
await writeModel(
|
|
1028
|
-
"d_base.malloy",
|
|
1029
|
-
`#(authorize) notquoted
|
|
1030
|
-
source: base_locked is duckdb.table('customers') extend { measure: c is count() }
|
|
1031
|
-
`,
|
|
1032
|
-
);
|
|
1033
|
-
await writeModel(
|
|
1034
|
-
"d_mid.malloy",
|
|
1035
|
-
`import "d_base.malloy"
|
|
1036
|
-
|
|
1037
|
-
source: d_mid is duckdb.table('customers') extend {
|
|
1038
|
-
join_one: base_locked on id = base_locked.id
|
|
1039
|
-
measure: c is count()
|
|
1040
|
-
}
|
|
1041
|
-
`,
|
|
1042
|
-
);
|
|
1043
|
-
await writeModel(
|
|
1044
|
-
"d_entry.malloy",
|
|
1045
|
-
`import "d_mid.malloy"
|
|
1046
|
-
|
|
1047
|
-
source: top is duckdb.table('customers') extend {
|
|
1048
|
-
join_one: d_mid on id = d_mid.id
|
|
1049
|
-
measure: c is count()
|
|
1050
|
-
}
|
|
1051
|
-
`,
|
|
1052
|
-
);
|
|
1053
|
-
const model = await Model.create(
|
|
1054
|
-
"test-pkg",
|
|
1055
|
-
TEST_PKG_DIR,
|
|
1056
|
-
"d_entry.malloy",
|
|
1057
|
-
getConnections(),
|
|
1058
|
-
);
|
|
1059
|
-
// Confirms the premise: the malformed gate did NOT fail model load, and
|
|
1060
|
-
// `base_locked` is not even in the entry model's own discovery surface.
|
|
1061
|
-
expect(model.getNotebookError()).toBeUndefined();
|
|
1062
|
-
expect(model.getSources()?.map((s) => s.name)).not.toContain(
|
|
1063
|
-
"base_locked",
|
|
1064
|
-
);
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
it("denies (fail closed) rather than silently admitting through the malformed gate", async () => {
|
|
1068
|
-
await writeModel(
|
|
1069
|
-
"d_base.malloy",
|
|
1070
|
-
`#(authorize) notquoted
|
|
1071
|
-
source: base_locked is duckdb.table('customers') extend { measure: c is count() }
|
|
1072
|
-
`,
|
|
1073
|
-
);
|
|
1074
|
-
await writeModel(
|
|
1075
|
-
"d_mid.malloy",
|
|
1076
|
-
`import "d_base.malloy"
|
|
1077
|
-
|
|
1078
|
-
source: d_mid is duckdb.table('customers') extend {
|
|
1079
|
-
join_one: base_locked on id = base_locked.id
|
|
1080
|
-
measure: c is count()
|
|
1081
|
-
}
|
|
1082
|
-
`,
|
|
1083
|
-
);
|
|
1084
|
-
await writeModel(
|
|
1085
|
-
"d_entry.malloy",
|
|
1086
|
-
`import "d_mid.malloy"
|
|
1087
|
-
|
|
1088
|
-
source: top is duckdb.table('customers') extend {
|
|
1089
|
-
join_one: d_mid on id = d_mid.id
|
|
1090
|
-
measure: c is count()
|
|
1091
|
-
}
|
|
1092
|
-
`,
|
|
1093
|
-
);
|
|
1094
|
-
await expect(
|
|
1095
|
-
runGated("d_entry.malloy", "run: top -> { aggregate: c }", {}),
|
|
1096
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1097
|
-
});
|
|
1098
|
-
});
|
|
1099
|
-
|
|
1100
|
-
// BLOCKING-2: composite sources (`compose(a, b)`) resolve to ONE concrete
|
|
1101
|
-
// member branch per query (`Query.compositeResolvedSourceDef`), based on
|
|
1102
|
-
// which fields are referenced. The pre-fix code resolved only
|
|
1103
|
-
// `_query.structRef` (the composite's own merged shape) and scanned
|
|
1104
|
-
// `combo.fields`, never the composite's member branches — so a query whose
|
|
1105
|
-
// resolution picked the locked branch was never gated.
|
|
1106
|
-
describe("authorize composite source enforcement (BLOCKING-2)", () => {
|
|
1107
|
-
const COMPOSITE_MODEL = `##! experimental.composite_sources
|
|
1108
|
-
|
|
1109
|
-
source: open_src is duckdb.table('customers') extend {
|
|
1110
|
-
measure: c is count()
|
|
1111
|
-
dimension: open_flag is 1
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
#(authorize) "false"
|
|
1115
|
-
source: locked_src is duckdb.table('customers') extend {
|
|
1116
|
-
measure: c is count()
|
|
1117
|
-
dimension: locked_flag is 1
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
source: combo is compose(open_src, locked_src)
|
|
1121
|
-
`;
|
|
1122
|
-
|
|
1123
|
-
it("denies a query that resolves the composite to the locked member branch", async () => {
|
|
1124
|
-
await writeModel("c_composite.malloy", COMPOSITE_MODEL);
|
|
1125
|
-
await expect(
|
|
1126
|
-
runGated(
|
|
1127
|
-
"c_composite.malloy",
|
|
1128
|
-
"run: combo -> { aggregate: c; group_by: locked_flag }",
|
|
1129
|
-
{},
|
|
1130
|
-
),
|
|
1131
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1132
|
-
});
|
|
1133
|
-
|
|
1134
|
-
it("allows a query that resolves the composite to the open member branch", async () => {
|
|
1135
|
-
await writeModel("c_composite.malloy", COMPOSITE_MODEL);
|
|
1136
|
-
const { result } = await runGated(
|
|
1137
|
-
"c_composite.malloy",
|
|
1138
|
-
"run: combo -> { aggregate: c; group_by: open_flag }",
|
|
1139
|
-
{},
|
|
1140
|
-
);
|
|
1141
|
-
expect(result.data).toBeDefined();
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
// `Query.compositeResolvedSourceDef` is what lets assertAuthorizedForAllSources
|
|
1145
|
-
// gate the ONE concrete branch a composite run target resolved to (see
|
|
1146
|
-
// model.ts's resolveRunTargetStruct/assertAuthorizedForAllSources) instead
|
|
1147
|
-
// of every member. This confirms it's populated even when the query
|
|
1148
|
-
// references NO field that discriminates between members (just the shared
|
|
1149
|
-
// `c` measure) — Malloy's composite resolver still picks a concrete first
|
|
1150
|
-
// candidate rather than leaving the resolution unset, so the gate still
|
|
1151
|
-
// fires on whichever member happens to resolve first.
|
|
1152
|
-
it("still denies when the composite resolves with no field forcing a choice (locked member listed first)", async () => {
|
|
1153
|
-
await writeModel(
|
|
1154
|
-
"c_composite_no_disambiguator.malloy",
|
|
1155
|
-
`##! experimental.composite_sources
|
|
1156
|
-
|
|
1157
|
-
source: open_src is duckdb.table('customers') extend { measure: c is count() }
|
|
1158
|
-
|
|
1159
|
-
#(authorize) "false"
|
|
1160
|
-
source: locked_src is duckdb.table('customers') extend { measure: c is count() }
|
|
1161
|
-
|
|
1162
|
-
source: combo_locked_first is compose(locked_src, open_src)
|
|
1163
|
-
`,
|
|
1164
|
-
);
|
|
1165
|
-
await expect(
|
|
1166
|
-
runGated(
|
|
1167
|
-
"c_composite_no_disambiguator.malloy",
|
|
1168
|
-
"run: combo_locked_first -> { aggregate: c }",
|
|
1169
|
-
{},
|
|
1170
|
-
),
|
|
1171
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1172
|
-
});
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
// BLOCKING-3: codex vs. Fable disagreed on whether a QUERY-LOCAL `join_one`
|
|
1176
|
-
// declared inside a `-> { ... }` refinement (rather than on the source
|
|
1177
|
-
// itself) bypasses the gate — the join then lives on the query pipeline's
|
|
1178
|
-
// segment/extendSource, not on `open_src.fields`. Exercise codex's exact
|
|
1179
|
-
// shape and report which way it actually goes.
|
|
1180
|
-
describe("authorize query-local join_one enforcement (BLOCKING-3)", () => {
|
|
1181
|
-
it("gates a source joined ONLY inside a query's own -> { ... } refinement (not on the source definition)", async () => {
|
|
1182
|
-
await writeModel(
|
|
1183
|
-
"c_query_local_join.malloy",
|
|
1184
|
-
`source: open_src is duckdb.table('customers') extend { measure: c is count() }
|
|
1185
|
-
|
|
1186
|
-
#(authorize) "false"
|
|
1187
|
-
source: locked_src is duckdb.table('customers') extend {
|
|
1188
|
-
measure: c is count()
|
|
1189
|
-
dimension: secret is name
|
|
1190
|
-
}
|
|
1191
|
-
`,
|
|
1192
|
-
);
|
|
1193
|
-
await expect(
|
|
1194
|
-
runGated(
|
|
1195
|
-
"c_query_local_join.malloy",
|
|
1196
|
-
`run: open_src -> {
|
|
1197
|
-
join_one: locked_src on id = locked_src.id
|
|
1198
|
-
group_by: locked_src.secret
|
|
1199
|
-
}`,
|
|
1200
|
-
{},
|
|
1201
|
-
),
|
|
1202
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1203
|
-
});
|
|
1204
|
-
});
|
|
1205
|
-
|
|
1206
|
-
// The /compile path gates via Model.assertAuthorizedForText (early,
|
|
1207
|
-
// surface-syntax) and Model.assertAuthorizedForRunnable (compiled-source
|
|
1208
|
-
// backstop). These are the enforcement primitives environment.compileSource
|
|
1209
|
-
// calls; exercise them directly here.
|
|
1210
|
-
describe("authorize compile-path gate", () => {
|
|
1211
|
-
const CP_GATE = `##! experimental.givens
|
|
1212
|
-
|
|
1213
|
-
given:
|
|
1214
|
-
ROLE :: string
|
|
1215
|
-
|
|
1216
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
1217
|
-
source: gated is duckdb.table('customers') extend { measure: c is count() }
|
|
1218
|
-
|
|
1219
|
-
source: open_src is duckdb.table('customers') extend { measure: c is count() }
|
|
1220
|
-
`;
|
|
1221
|
-
const CP_FILE_LEVEL = `##! experimental.givens
|
|
1222
|
-
|
|
1223
|
-
given:
|
|
1224
|
-
ROLE :: string
|
|
1225
|
-
|
|
1226
|
-
##(authorize) "$ROLE = 'admin'"
|
|
1227
|
-
|
|
1228
|
-
source: declared is duckdb.table('customers') extend { measure: c is count() }
|
|
1229
|
-
`;
|
|
1230
|
-
const CP_JOIN = `##! experimental.givens
|
|
1231
|
-
|
|
1232
|
-
#(authorize) "false"
|
|
1233
|
-
source: cp_locked is duckdb.table('customers') extend { measure: c is count() }
|
|
1234
|
-
|
|
1235
|
-
source: cp_joiner is duckdb.table('customers') extend {
|
|
1236
|
-
join_one: cp_locked on id = cp_locked.id
|
|
1237
|
-
measure: c is count()
|
|
1238
|
-
}
|
|
1239
|
-
`;
|
|
1240
|
-
|
|
1241
|
-
async function cpModel(file: string, src: string): Promise<Model> {
|
|
1242
|
-
await writeModel(file, src);
|
|
1243
|
-
return Model.create("test-pkg", TEST_PKG_DIR, file, getConnections());
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
it("assertAuthorizedForText denies/allows a gated named source by its given", async () => {
|
|
1247
|
-
const model = await cpModel("cp_gate.malloy", CP_GATE);
|
|
1248
|
-
await expect(
|
|
1249
|
-
model.assertAuthorizedForText("run: gated -> { aggregate: c }", {}),
|
|
1250
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1251
|
-
await expect(
|
|
1252
|
-
model.assertAuthorizedForText("run: gated -> { aggregate: c }", {
|
|
1253
|
-
ROLE: "analyst",
|
|
1254
|
-
}),
|
|
1255
|
-
).resolves.toBeUndefined();
|
|
1256
|
-
});
|
|
1257
|
-
|
|
1258
|
-
it("assertAuthorizedForText leaves an ungated source unrestricted", async () => {
|
|
1259
|
-
const model = await cpModel("cp_gate.malloy", CP_GATE);
|
|
1260
|
-
await expect(
|
|
1261
|
-
model.assertAuthorizedForText("run: open_src -> { aggregate: c }", {}),
|
|
1262
|
-
).resolves.toBeUndefined();
|
|
1263
|
-
});
|
|
1264
|
-
|
|
1265
|
-
it("assertAuthorizedForText applies the model-wide file-level gate to inline/unnamed text", async () => {
|
|
1266
|
-
const model = await cpModel("cp_file.malloy", CP_FILE_LEVEL);
|
|
1267
|
-
// No named source the regex recognizes -> undefined -> file-level gate.
|
|
1268
|
-
await expect(
|
|
1269
|
-
model.assertAuthorizedForText(
|
|
1270
|
-
`run: duckdb.sql("SELECT 1 AS x") -> { aggregate: n is count() }`,
|
|
1271
|
-
{},
|
|
1272
|
-
),
|
|
1273
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1274
|
-
await expect(
|
|
1275
|
-
model.assertAuthorizedForText(
|
|
1276
|
-
`run: duckdb.sql("SELECT 1 AS x") -> { aggregate: n is count() }`,
|
|
1277
|
-
{ ROLE: "admin" },
|
|
1278
|
-
),
|
|
1279
|
-
).resolves.toBeUndefined();
|
|
1280
|
-
});
|
|
1281
|
-
|
|
1282
|
-
it("assertAuthorizedForRunnable gates the compiled-source structRef (alias backstop)", async () => {
|
|
1283
|
-
const model = await cpModel("cp_gate.malloy", CP_GATE);
|
|
1284
|
-
// Stub a runnable whose compiled query reads `gated` (e.g. via an alias
|
|
1285
|
-
// the surface-syntax gate would miss).
|
|
1286
|
-
const gatedRunnable = {
|
|
1287
|
-
getPreparedQuery: async () => ({ _query: { structRef: "gated" } }),
|
|
1288
|
-
};
|
|
1289
|
-
await expect(
|
|
1290
|
-
model.assertAuthorizedForRunnable(gatedRunnable, {}),
|
|
1291
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1292
|
-
await expect(
|
|
1293
|
-
model.assertAuthorizedForRunnable(gatedRunnable, { ROLE: "analyst" }),
|
|
1294
|
-
).resolves.toBeUndefined();
|
|
1295
|
-
// Ungated compiled source -> unrestricted.
|
|
1296
|
-
const openRunnable = {
|
|
1297
|
-
getPreparedQuery: async () => ({ _query: { structRef: "open_src" } }),
|
|
1298
|
-
};
|
|
1299
|
-
await expect(
|
|
1300
|
-
model.assertAuthorizedForRunnable(openRunnable, {}),
|
|
1301
|
-
).resolves.toBeUndefined();
|
|
1302
|
-
});
|
|
1303
|
-
|
|
1304
|
-
it("assertAuthorizedForRunnable walks joined sources on the /compile path", async () => {
|
|
1305
|
-
const model = await cpModel("cp_join.malloy", CP_JOIN);
|
|
1306
|
-
// structRef "cp_joiner" resolves to its real SourceDef (with the join
|
|
1307
|
-
// field), so the /compile backstop must gate the locked joined source
|
|
1308
|
-
// too — not just the ungated run target.
|
|
1309
|
-
const joinerRunnable = {
|
|
1310
|
-
getPreparedQuery: async () => ({
|
|
1311
|
-
_query: { structRef: "cp_joiner" },
|
|
1312
|
-
}),
|
|
1313
|
-
};
|
|
1314
|
-
await expect(
|
|
1315
|
-
model.assertAuthorizedForRunnable(joinerRunnable, {}),
|
|
1316
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1317
|
-
});
|
|
1318
|
-
});
|
|
1319
|
-
|
|
1320
|
-
// A given can be both a runtime query parameter (substituted into a view's
|
|
1321
|
-
// `where`) AND the subject of an `#(authorize)` gate on the same source. These
|
|
1322
|
-
// two layers compose: the gate fires first (before any filtering), and once it
|
|
1323
|
-
// passes the parameterized filter applies to the result. This is the layered
|
|
1324
|
-
// case the end-to-end story rests on.
|
|
1325
|
-
describe("authorize composes with given parameters", () => {
|
|
1326
|
-
// `ROLE` gates access; `REGION` is a runtime filter parameter on the view.
|
|
1327
|
-
const COMPOSED = `##! experimental.givens
|
|
1328
|
-
|
|
1329
|
-
given:
|
|
1330
|
-
ROLE :: string
|
|
1331
|
-
REGION :: string
|
|
1332
|
-
|
|
1333
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
1334
|
-
source: regional is duckdb.table('customers') extend {
|
|
1335
|
-
measure: c is count()
|
|
1336
|
-
view: in_region is {
|
|
1337
|
-
where: region = $REGION
|
|
1338
|
-
aggregate: c
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
`;
|
|
1342
|
-
|
|
1343
|
-
async function runComposed(givens: Record<string, GivenValue>) {
|
|
1344
|
-
await writeModel("compose.malloy", COMPOSED);
|
|
1345
|
-
const model = await Model.create(
|
|
1346
|
-
"test-pkg",
|
|
1347
|
-
TEST_PKG_DIR,
|
|
1348
|
-
"compose.malloy",
|
|
1349
|
-
getConnections(),
|
|
1350
|
-
);
|
|
1351
|
-
return model.getQueryResults(
|
|
1352
|
-
undefined,
|
|
1353
|
-
undefined,
|
|
1354
|
-
"run: regional -> in_region",
|
|
1355
|
-
undefined,
|
|
1356
|
-
undefined,
|
|
1357
|
-
givens,
|
|
1358
|
-
);
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
function countOf(compactResult: unknown): number {
|
|
1362
|
-
const rows = compactResult as Array<Record<string, unknown>>;
|
|
1363
|
-
return Number(rows[0]?.c);
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
it("denies before filtering when the gate fails, even with the filter given supplied", async () => {
|
|
1367
|
-
await expect(runComposed({ REGION: "us-west" })).rejects.toBeInstanceOf(
|
|
1368
|
-
AccessDeniedError,
|
|
1369
|
-
);
|
|
1370
|
-
});
|
|
1371
|
-
|
|
1372
|
-
it("applies the parameterized filter once the gate passes", async () => {
|
|
1373
|
-
// Seed: one 'us-west' customer, one 'us-east'.
|
|
1374
|
-
const west = await runComposed({ ROLE: "analyst", REGION: "us-west" });
|
|
1375
|
-
expect(countOf(west.compactResult)).toBe(1);
|
|
1376
|
-
|
|
1377
|
-
const east = await runComposed({ ROLE: "analyst", REGION: "us-east" });
|
|
1378
|
-
expect(countOf(east.compactResult)).toBe(1);
|
|
1379
|
-
|
|
1380
|
-
// A region matching no rows proves the given genuinely filters (not a
|
|
1381
|
-
// gate-only pass-through).
|
|
1382
|
-
const none = await runComposed({ ROLE: "analyst", REGION: "nowhere" });
|
|
1383
|
-
expect(countOf(none.compactResult)).toBe(0);
|
|
1384
|
-
});
|
|
1385
|
-
});
|
|
1386
|
-
|
|
1387
|
-
// BLOCKING-4: a GIVEN-based #(authorize) gate on a source reached only
|
|
1388
|
-
// through a TWO-HOP transitive import over-denied every caller regardless of
|
|
1389
|
-
// the supplied given's value. Root cause: evaluateAuthorize's probe compiled
|
|
1390
|
-
// against the ENTRY model's modelMaterializer, but `$ROLE` is declared in
|
|
1391
|
-
// g_base — two import hops away — and Malloy's given-namespace merge covers
|
|
1392
|
-
// only one hop of import, so the probe failed to compile and was swallowed
|
|
1393
|
-
// by the catch-and-deny in evaluateAuthorize, fail-closed regardless of
|
|
1394
|
-
// value. Fixed by making each probe self-contained (bindProbeGivens):
|
|
1395
|
-
// declare + bind just the givens an expression references, inferring type
|
|
1396
|
-
// from the supplied value, as a fallback when the ambient-namespace probe
|
|
1397
|
-
// fails to compile. (One-hop cases still resolve ambiently on the first
|
|
1398
|
-
// try — ambient-declares-ROLE is the common case and is left unchanged, see
|
|
1399
|
-
// `evaluateAuthorize`.) The self-contained probe fix alone isn't sufficient
|
|
1400
|
-
// end to end: the REAL query also has to accept a `ROLE` given the entry
|
|
1401
|
-
// model doesn't itself surface, which required filterGivensToModelSurface to
|
|
1402
|
-
// stop forwarding an authorize-only given to the real query (Malloy's
|
|
1403
|
-
// `resolveSuppliedGivens` rejects any given a model doesn't surface).
|
|
1404
|
-
describe("authorize given-based gate across a two-hop transitive import (BLOCKING-4)", () => {
|
|
1405
|
-
const G_BASE = `##! experimental.givens
|
|
1406
|
-
|
|
1407
|
-
given:
|
|
1408
|
-
ROLE :: string
|
|
1409
|
-
|
|
1410
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
1411
|
-
source: base_gated is duckdb.table('customers') extend { measure: c is count() }
|
|
1412
|
-
`;
|
|
1413
|
-
const G_MID = `import "g_base.malloy"
|
|
1414
|
-
|
|
1415
|
-
source: g_mid is duckdb.table('customers') extend {
|
|
1416
|
-
join_one: base_gated on id = base_gated.id
|
|
1417
|
-
measure: c is count()
|
|
1418
|
-
}
|
|
1419
|
-
`;
|
|
1420
|
-
const G_ENTRY = `import "g_mid.malloy"
|
|
1421
|
-
|
|
1422
|
-
source: g_top is duckdb.table('customers') extend {
|
|
1423
|
-
join_one: g_mid on id = g_mid.id
|
|
1424
|
-
measure: c is count()
|
|
1425
|
-
}
|
|
1426
|
-
`;
|
|
1427
|
-
|
|
1428
|
-
it("allows a two-hop transitively-imported given-based gate when the correct given is supplied", async () => {
|
|
1429
|
-
await writeModel("g_base.malloy", G_BASE);
|
|
1430
|
-
await writeModel("g_mid.malloy", G_MID);
|
|
1431
|
-
await writeModel("g_entry.malloy", G_ENTRY);
|
|
1432
|
-
const { result } = await runGated(
|
|
1433
|
-
"g_entry.malloy",
|
|
1434
|
-
"run: g_top -> { aggregate: c }",
|
|
1435
|
-
{ ROLE: "analyst" },
|
|
1436
|
-
);
|
|
1437
|
-
expect(result.data).toBeDefined();
|
|
1438
|
-
});
|
|
1439
|
-
|
|
1440
|
-
it("still denies the two-hop gate when the wrong given is supplied", async () => {
|
|
1441
|
-
await writeModel("g_base.malloy", G_BASE);
|
|
1442
|
-
await writeModel("g_mid.malloy", G_MID);
|
|
1443
|
-
await writeModel("g_entry.malloy", G_ENTRY);
|
|
1444
|
-
await expect(
|
|
1445
|
-
runGated("g_entry.malloy", "run: g_top -> { aggregate: c }", {
|
|
1446
|
-
ROLE: "intern",
|
|
1447
|
-
}),
|
|
1448
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1449
|
-
});
|
|
1450
|
-
|
|
1451
|
-
it("still denies a two-hop transitive join to a constant-`false`-gated source", async () => {
|
|
1452
|
-
// Control: the pre-existing BLOCKING-1 case (a constant gate, no given
|
|
1453
|
-
// involved) must still deny — the self-contained-probe fallback must
|
|
1454
|
-
// not accidentally turn an unconditional deny into an allow.
|
|
1455
|
-
await writeModel(
|
|
1456
|
-
"cf_base.malloy",
|
|
1457
|
-
`#(authorize) "false"
|
|
1458
|
-
source: base_locked is duckdb.table('customers') extend { measure: c is count() }
|
|
1459
|
-
`,
|
|
1460
|
-
);
|
|
1461
|
-
await writeModel(
|
|
1462
|
-
"cf_mid.malloy",
|
|
1463
|
-
`import "cf_base.malloy"
|
|
1464
|
-
|
|
1465
|
-
source: cf_mid is duckdb.table('customers') extend {
|
|
1466
|
-
join_one: base_locked on id = base_locked.id
|
|
1467
|
-
measure: c is count()
|
|
1468
|
-
}
|
|
1469
|
-
`,
|
|
1470
|
-
);
|
|
1471
|
-
await writeModel(
|
|
1472
|
-
"cf_entry.malloy",
|
|
1473
|
-
`import "cf_mid.malloy"
|
|
1474
|
-
|
|
1475
|
-
source: cf_top is duckdb.table('customers') extend {
|
|
1476
|
-
join_one: cf_mid on id = cf_mid.id
|
|
1477
|
-
measure: c is count()
|
|
1478
|
-
}
|
|
1479
|
-
`,
|
|
1480
|
-
);
|
|
1481
|
-
await expect(
|
|
1482
|
-
runGated("cf_entry.malloy", "run: cf_top -> { aggregate: c }", {}),
|
|
1483
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1484
|
-
});
|
|
1485
|
-
|
|
1486
|
-
it("still allows a ONE-hop given-based gate on a joined source (no regression)", async () => {
|
|
1487
|
-
// Control: a joined source whose gate's given is declared in a
|
|
1488
|
-
// DIRECTLY imported file (one hop) already resolved ambiently before
|
|
1489
|
-
// this fix — confirm the self-contained fallback didn't change that.
|
|
1490
|
-
await writeModel(
|
|
1491
|
-
"oh_base.malloy",
|
|
1492
|
-
`##! experimental.givens
|
|
1493
|
-
|
|
1494
|
-
given:
|
|
1495
|
-
ROLE :: string
|
|
1496
|
-
|
|
1497
|
-
#(authorize) "$ROLE = 'analyst'"
|
|
1498
|
-
source: base_gated is duckdb.table('customers') extend { measure: c is count() }
|
|
1499
|
-
`,
|
|
1500
|
-
);
|
|
1501
|
-
await writeModel(
|
|
1502
|
-
"oh_entry.malloy",
|
|
1503
|
-
`import "oh_base.malloy"
|
|
1504
|
-
|
|
1505
|
-
source: oh_top is duckdb.table('customers') extend {
|
|
1506
|
-
join_one: base_gated on id = base_gated.id
|
|
1507
|
-
measure: c is count()
|
|
1508
|
-
}
|
|
1509
|
-
`,
|
|
1510
|
-
);
|
|
1511
|
-
const { result } = await runGated(
|
|
1512
|
-
"oh_entry.malloy",
|
|
1513
|
-
"run: oh_top -> { aggregate: c }",
|
|
1514
|
-
{ ROLE: "analyst" },
|
|
1515
|
-
);
|
|
1516
|
-
expect(result.data).toBeDefined();
|
|
1517
|
-
await expect(
|
|
1518
|
-
runGated("oh_entry.malloy", "run: oh_top -> { aggregate: c }", {
|
|
1519
|
-
ROLE: "intern",
|
|
1520
|
-
}),
|
|
1521
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1522
|
-
});
|
|
1523
|
-
|
|
1524
|
-
// Name-collision isolation: the entry model declares its OWN `$LEVEL`
|
|
1525
|
-
// given (default 99), and a deep-joined source's gate ALSO references
|
|
1526
|
-
// `$LEVEL` but means its own, unrelated given. Root cause: evaluateAuthorize
|
|
1527
|
-
// tried the AMBIENT probe first — compiled and evaluated against the
|
|
1528
|
-
// ENTRY model's `modelMaterializer` — so on a name collision it happily
|
|
1529
|
-
// compiled against the entry model's OWN `$LEVEL` default and granted a
|
|
1530
|
-
// caller who supplied no `LEVEL` at all, never falling back to the
|
|
1531
|
-
// isolating self-contained probe (that fallback only fires when the
|
|
1532
|
-
// ambient probe THROWS, and here it didn't). Fixed by evaluating a
|
|
1533
|
-
// joined-source's gate self-contained FIRST, so a caller supplying no
|
|
1534
|
-
// `LEVEL` fails to build a self-contained probe for the joined gate and is
|
|
1535
|
-
// denied, regardless of what the entry model's own `$LEVEL` default is.
|
|
1536
|
-
it("denies a joined source's $LEVEL gate on a name collision with the entry model's own $LEVEL given, when no LEVEL is supplied", async () => {
|
|
1537
|
-
// coll_base's $LEVEL is TWO hops from the entry (via coll_mid), so it is
|
|
1538
|
-
// never merged into the entry's own ambient given namespace (only a
|
|
1539
|
-
// one-hop import merges) — this is what lets the entry separately
|
|
1540
|
-
// declare its OWN, unrelated $LEVEL without a "Cannot redefine"
|
|
1541
|
-
// compile error, setting up the name collision.
|
|
1542
|
-
await writeModel(
|
|
1543
|
-
"coll_base.malloy",
|
|
1544
|
-
`##! experimental.givens
|
|
1545
|
-
|
|
1546
|
-
given:
|
|
1547
|
-
LEVEL :: number
|
|
1548
|
-
|
|
1549
|
-
#(authorize) "$LEVEL > 3"
|
|
1550
|
-
source: coll_base_gated is duckdb.table('customers') extend { measure: c is count() }
|
|
1551
|
-
`,
|
|
1552
|
-
);
|
|
1553
|
-
await writeModel(
|
|
1554
|
-
"coll_mid.malloy",
|
|
1555
|
-
`import "coll_base.malloy"
|
|
1556
|
-
|
|
1557
|
-
source: coll_mid is duckdb.table('customers') extend {
|
|
1558
|
-
join_one: coll_base_gated on id = coll_base_gated.id
|
|
1559
|
-
measure: c is count()
|
|
1560
|
-
}
|
|
1561
|
-
`,
|
|
1562
|
-
);
|
|
1563
|
-
await writeModel(
|
|
1564
|
-
"coll_entry.malloy",
|
|
1565
|
-
`import "coll_mid.malloy"
|
|
1566
|
-
|
|
1567
|
-
##! experimental.givens
|
|
1568
|
-
|
|
1569
|
-
given:
|
|
1570
|
-
LEVEL :: number is 99
|
|
1571
|
-
|
|
1572
|
-
source: coll_top is duckdb.table('customers') extend {
|
|
1573
|
-
join_one: coll_mid on id = coll_mid.id
|
|
1574
|
-
measure: c is count()
|
|
1575
|
-
}
|
|
1576
|
-
`,
|
|
1577
|
-
);
|
|
1578
|
-
await expect(
|
|
1579
|
-
runGated("coll_entry.malloy", "run: coll_top -> { aggregate: c }", {}),
|
|
1580
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1581
|
-
});
|
|
1582
|
-
});
|
|
1583
|
-
|
|
1584
|
-
// BLOCKING-5: a query-source derivation (`source: laundered is locked_src ->
|
|
1585
|
-
// { ... }`) laundered a locked base's gate away — the run target resolves to
|
|
1586
|
-
// a `QuerySourceDef` whose own `.fields`/`.annotations` reflect the DERIVED
|
|
1587
|
-
// shape, not `locked_src`'s #(authorize), so the walk found nothing. Fixed
|
|
1588
|
-
// by resolveQuerySourceBases/collectQuerySourceBaseGates: a `QuerySourceDef`
|
|
1589
|
-
// keeps its base reachable via `query.structRef` (an inline SourceDef or a
|
|
1590
|
-
// string name resolving through modelDef.contents — the same StructRef
|
|
1591
|
-
// shape resolveRunTargetStruct already resolves for a run target), so the
|
|
1592
|
-
// walk now gates that base too, recursing for a chained derivation and for a
|
|
1593
|
-
// query-source reached only via a join.
|
|
1594
|
-
describe("authorize query-source derivation enforcement (BLOCKING-5)", () => {
|
|
1595
|
-
const QS_MODEL = `#(authorize) "false"
|
|
1596
|
-
source: locked_src is duckdb.table('customers') extend {
|
|
1597
|
-
measure: c is count()
|
|
1598
|
-
dimension: secret is name
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
source: laundered is locked_src -> { select: id, secret }
|
|
1602
|
-
|
|
1603
|
-
source: open_src is duckdb.table('customers') extend { measure: c is count() }
|
|
1604
|
-
|
|
1605
|
-
source: open_laundered is open_src -> { select: id }
|
|
1606
|
-
|
|
1607
|
-
source: qs_joiner is duckdb.table('customers') extend {
|
|
1608
|
-
join_one: laundered on id = laundered.id
|
|
1609
|
-
measure: c is count()
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
source: double_laundered is laundered -> { select: id, secret }
|
|
1613
|
-
`;
|
|
1614
|
-
|
|
1615
|
-
it("denies a query against a query-source derived from a locked base", async () => {
|
|
1616
|
-
await writeModel("qs.malloy", QS_MODEL);
|
|
1617
|
-
await expect(
|
|
1618
|
-
runGated("qs.malloy", "run: laundered -> { select: id, secret }", {}),
|
|
1619
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1620
|
-
});
|
|
1621
|
-
|
|
1622
|
-
it("allows a query-source derived from an ungated base", async () => {
|
|
1623
|
-
await writeModel("qs.malloy", QS_MODEL);
|
|
1624
|
-
const { result } = await runGated(
|
|
1625
|
-
"qs.malloy",
|
|
1626
|
-
"run: open_laundered -> { select: id }",
|
|
1627
|
-
{},
|
|
1628
|
-
);
|
|
1629
|
-
expect(result.data).toBeDefined();
|
|
1630
|
-
});
|
|
1631
|
-
|
|
1632
|
-
it("denies a query joining a query-source that derives from a locked base", async () => {
|
|
1633
|
-
await writeModel("qs.malloy", QS_MODEL);
|
|
1634
|
-
await expect(
|
|
1635
|
-
runGated("qs.malloy", "run: qs_joiner -> { aggregate: c }", {}),
|
|
1636
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1637
|
-
});
|
|
1638
|
-
|
|
1639
|
-
it("denies a CHAINED query-source (derived from a derivation of a locked base)", async () => {
|
|
1640
|
-
await writeModel("qs.malloy", QS_MODEL);
|
|
1641
|
-
await expect(
|
|
1642
|
-
runGated(
|
|
1643
|
-
"qs.malloy",
|
|
1644
|
-
"run: double_laundered -> { select: id, secret }",
|
|
1645
|
-
{},
|
|
1646
|
-
),
|
|
1647
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1648
|
-
});
|
|
1649
|
-
});
|
|
1650
|
-
|
|
1651
|
-
// BLOCKING-6: a query-source's OWN inner pipeline segment can declare a
|
|
1652
|
-
// `join_one` INSIDE the derivation (`-> { extend: { join_one: locked ... }
|
|
1653
|
-
// ... }`). That join lives on the pipeline segment's `extendSource`, not on
|
|
1654
|
-
// `struct.fields` and not reachable via `query.structRef` (the base chain
|
|
1655
|
-
// resolveQuerySourceBases/collectQuerySourceBaseGates already walk) — so it
|
|
1656
|
-
// was never gated, laundering a locked source's #(authorize) through the
|
|
1657
|
-
// query-source's own derivation. The identical join declared inline in a
|
|
1658
|
-
// RUN query (rather than inside a `source: x is y -> {...}` derivation) was
|
|
1659
|
-
// already correctly denied via assertAuthorizedForAllSources's own
|
|
1660
|
-
// `extendSources` handling.
|
|
1661
|
-
describe("authorize query-source's own inner-pipeline join enforcement (BLOCKING-6)", () => {
|
|
1662
|
-
const QS_INNER_JOIN_MODEL = `#(authorize) "false"
|
|
1663
|
-
source: locked9 is duckdb.table('customers') extend { dimension: secret is name }
|
|
1664
|
-
|
|
1665
|
-
source: open9 is duckdb.table('customers') extend { measure: c is count() }
|
|
1666
|
-
|
|
1667
|
-
source: sneaky is open9 -> {
|
|
1668
|
-
extend: { join_one: locked9 on id = locked9.id }
|
|
1669
|
-
select: id, leak is locked9.secret
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
|
-
source: qs_joiner is duckdb.table('customers') extend {
|
|
1673
|
-
join_one: sneaky on id = sneaky.id
|
|
1674
|
-
measure: c is count()
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
source: open_inner_join is open9 -> {
|
|
1678
|
-
extend: { join_one: open9_b is duckdb.table('customers') on id = open9_b.id }
|
|
1679
|
-
select: id
|
|
1680
|
-
}
|
|
1681
|
-
`;
|
|
1682
|
-
|
|
1683
|
-
it("denies a direct query against a query-source whose own inner pipeline joins a locked source", async () => {
|
|
1684
|
-
await writeModel("qs_inner.malloy", QS_INNER_JOIN_MODEL);
|
|
1685
|
-
await expect(
|
|
1686
|
-
runGated("qs_inner.malloy", "run: sneaky -> { select: id, leak }", {}),
|
|
1687
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1688
|
-
});
|
|
1689
|
-
|
|
1690
|
-
it("denies a query joining a query-source whose own inner pipeline joins a locked source", async () => {
|
|
1691
|
-
await writeModel("qs_inner.malloy", QS_INNER_JOIN_MODEL);
|
|
1692
|
-
await expect(
|
|
1693
|
-
runGated(
|
|
1694
|
-
"qs_inner.malloy",
|
|
1695
|
-
"run: qs_joiner -> { group_by: sneaky.leak }",
|
|
1696
|
-
{},
|
|
1697
|
-
),
|
|
1698
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1699
|
-
});
|
|
1700
|
-
|
|
1701
|
-
it("allows a query-source whose own inner pipeline joins an UNGATED source", async () => {
|
|
1702
|
-
await writeModel("qs_inner.malloy", QS_INNER_JOIN_MODEL);
|
|
1703
|
-
const { result } = await runGated(
|
|
1704
|
-
"qs_inner.malloy",
|
|
1705
|
-
"run: open_inner_join -> { select: id }",
|
|
1706
|
-
{},
|
|
1707
|
-
);
|
|
1708
|
-
expect(result.data).toBeDefined();
|
|
1709
|
-
});
|
|
1710
|
-
});
|
|
1711
|
-
|
|
1712
|
-
// SHOULD-2: filterGivensToModelSurface dropped EVERY given name absent from
|
|
1713
|
-
// the entry model's own surface, not just an authorize-only given. That
|
|
1714
|
-
// over-drops a genuine `where:` filter given declared two-plus import hops
|
|
1715
|
-
// away (never on the entry surface, per Malloy's one-hop given-namespace
|
|
1716
|
-
// merge — see BLOCKING-4): the caller's value is silently discarded and the
|
|
1717
|
-
// query falls back to the given's declared default, returning MORE rows
|
|
1718
|
-
// than the caller intended. It also silently swallows a typo'd given name
|
|
1719
|
-
// instead of raising Malloy's "unknown given" error. Fix: only drop a name
|
|
1720
|
-
// that is BOTH referenced by an authorize gate reachable in this model AND
|
|
1721
|
-
// absent from the surface -- a name no gate references is left alone so it
|
|
1722
|
-
// either reaches the real query (honoring the caller's value) or fails
|
|
1723
|
-
// closed with a compile/runtime error, never a silent default.
|
|
1724
|
-
describe("filterGivensToModelSurface no longer over-drops a non-gate given (SHOULD-2)", () => {
|
|
1725
|
-
const HB_BASE = `##! experimental.givens
|
|
1726
|
-
|
|
1727
|
-
given:
|
|
1728
|
-
HIDE :: string is 'none'
|
|
1729
|
-
|
|
1730
|
-
source: hb_base is duckdb.table('customers') extend {
|
|
1731
|
-
where: region != $HIDE
|
|
1732
|
-
measure: c is count()
|
|
1733
|
-
}
|
|
1734
|
-
`;
|
|
1735
|
-
const HB_MID = `import "hb_base.malloy"
|
|
1736
|
-
|
|
1737
|
-
source: hb_mid is duckdb.table('customers') extend {
|
|
1738
|
-
join_one: hb_base on id = hb_base.id
|
|
1739
|
-
measure: c is count()
|
|
1740
|
-
}
|
|
1741
|
-
`;
|
|
1742
|
-
const HB_ENTRY = `import "hb_mid.malloy"
|
|
1743
|
-
|
|
1744
|
-
source: hb_top is duckdb.table('customers') extend {
|
|
1745
|
-
join_one: hb_mid on id = hb_mid.id
|
|
1746
|
-
measure: c is count()
|
|
1747
|
-
}
|
|
1748
|
-
`;
|
|
1749
|
-
|
|
1750
|
-
it("does not silently over-return when a two-hop where-given is supplied", async () => {
|
|
1751
|
-
await writeModel("hb_base.malloy", HB_BASE);
|
|
1752
|
-
await writeModel("hb_mid.malloy", HB_MID);
|
|
1753
|
-
await writeModel("hb_entry.malloy", HB_ENTRY);
|
|
1754
|
-
// Seed has one 'us-west' row and one 'us-east' row. The caller intends
|
|
1755
|
-
// to exclude 'us-west'. Silently falling back to the default ('none',
|
|
1756
|
-
// which excludes nothing) would return BOTH rows via hb_base.c.
|
|
1757
|
-
let overExposed = false;
|
|
1758
|
-
try {
|
|
1759
|
-
const { compactResult } = await runGated(
|
|
1760
|
-
"hb_entry.malloy",
|
|
1761
|
-
"run: hb_top -> { aggregate: c is hb_mid.hb_base.c }",
|
|
1762
|
-
{ HIDE: "us-west" },
|
|
1763
|
-
);
|
|
1764
|
-
const rows = compactResult as unknown as Array<
|
|
1765
|
-
Record<string, unknown>
|
|
1766
|
-
>;
|
|
1767
|
-
overExposed = Number(rows[0]?.c) === 2;
|
|
1768
|
-
} catch {
|
|
1769
|
-
// Erroring instead of over-returning is an acceptable outcome too
|
|
1770
|
-
// (fail closed on a name the entry model can't resolve).
|
|
1771
|
-
}
|
|
1772
|
-
expect(overExposed).toBe(false);
|
|
1773
|
-
});
|
|
1774
|
-
|
|
1775
|
-
it("does not silently swallow a typo'd given name", async () => {
|
|
1776
|
-
await writeModel("hb_base.malloy", HB_BASE);
|
|
1777
|
-
await writeModel("hb_mid.malloy", HB_MID);
|
|
1778
|
-
await writeModel("hb_entry.malloy", HB_ENTRY);
|
|
1779
|
-
await expect(
|
|
1780
|
-
runGated(
|
|
1781
|
-
"hb_entry.malloy",
|
|
1782
|
-
"run: hb_top -> { aggregate: c is hb_mid.hb_base.c }",
|
|
1783
|
-
{ HIDEE: "us-west" }, // typo for HIDE
|
|
1784
|
-
),
|
|
1785
|
-
).rejects.toBeTruthy();
|
|
1786
|
-
});
|
|
1787
|
-
});
|
|
1788
|
-
|
|
1789
|
-
// BLOCKING-7: two call sites in assertAuthorizedForAllSources hand-composed
|
|
1790
|
-
// gateExprsForOwnAnnotations + collectJoinedAuthorizeGates for a struct
|
|
1791
|
-
// WITHOUT also calling collectQuerySourceBaseGates on it, unlike every other
|
|
1792
|
-
// call site in this file — so specifically when that struct is ITSELF a
|
|
1793
|
-
// query-source derived from a locked base, the derivation laundered the
|
|
1794
|
-
// locked base's gate away:
|
|
1795
|
-
// (1) a composite's RESOLVED branch (compositeResolvedSourceDef);
|
|
1796
|
-
// (2) a run target's own query-local join (extendSources).
|
|
1797
|
-
// Fixed by routing every reachable-gate call site through one unified
|
|
1798
|
-
// collectAllReachableGates walk (own annotations ++ joined sources ++
|
|
1799
|
-
// query-source bases ++ a query-source's own inner-pipeline joins, all
|
|
1800
|
-
// recursed through itself) instead of a hand-picked subset per call site.
|
|
1801
|
-
describe("BLOCKING-7 / unified walk", () => {
|
|
1802
|
-
const UNIFIED_MODEL = `##! experimental.composite_sources
|
|
1803
|
-
source: open_src is duckdb.table('customers') extend {
|
|
1804
|
-
measure: c is count()
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
#(authorize) "false"
|
|
1808
|
-
source: locked is duckdb.table('customers') extend {
|
|
1809
|
-
measure: c is count()
|
|
1810
|
-
dimension: locked_region is region
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
source: laundered is locked -> { group_by: id, locked_region }
|
|
1814
|
-
|
|
1815
|
-
source: combo is compose(open_src, laundered)
|
|
1816
|
-
|
|
1817
|
-
source: open_laundered is open_src -> { group_by: id }
|
|
1818
|
-
`;
|
|
1819
|
-
|
|
1820
|
-
it("denies a composite query resolving to a query-source branch derived from a locked base", async () => {
|
|
1821
|
-
await writeModel("c_unified.malloy", UNIFIED_MODEL);
|
|
1822
|
-
await expect(
|
|
1823
|
-
runGated(
|
|
1824
|
-
"c_unified.malloy",
|
|
1825
|
-
"run: combo -> { group_by: locked_region }",
|
|
1826
|
-
{},
|
|
1827
|
-
),
|
|
1828
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1829
|
-
});
|
|
1830
|
-
|
|
1831
|
-
it("denies a run target's own query-local join to a query-source derived from a locked base", async () => {
|
|
1832
|
-
await writeModel("c_unified.malloy", UNIFIED_MODEL);
|
|
1833
|
-
await expect(
|
|
1834
|
-
runGated(
|
|
1835
|
-
"c_unified.malloy",
|
|
1836
|
-
`run: open_src -> {
|
|
1837
|
-
extend: { join_one: laundered on id = laundered.id }
|
|
1838
|
-
group_by: leak is laundered.locked_region
|
|
1839
|
-
}`,
|
|
1840
|
-
{},
|
|
1841
|
-
),
|
|
1842
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1843
|
-
});
|
|
1844
|
-
|
|
1845
|
-
it("allows a composite query resolving to the ungated open branch", async () => {
|
|
1846
|
-
await writeModel("c_unified.malloy", UNIFIED_MODEL);
|
|
1847
|
-
const { result } = await runGated(
|
|
1848
|
-
"c_unified.malloy",
|
|
1849
|
-
"run: combo -> { aggregate: c }",
|
|
1850
|
-
{},
|
|
1851
|
-
);
|
|
1852
|
-
expect(result.data).toBeDefined();
|
|
1853
|
-
});
|
|
1854
|
-
|
|
1855
|
-
it("allows a run target's own query-local join to an UNGATED query-source", async () => {
|
|
1856
|
-
await writeModel("c_unified.malloy", UNIFIED_MODEL);
|
|
1857
|
-
const { result } = await runGated(
|
|
1858
|
-
"c_unified.malloy",
|
|
1859
|
-
`run: open_src -> {
|
|
1860
|
-
extend: { join_one: open_laundered on id = open_laundered.id }
|
|
1861
|
-
group_by: ok is open_laundered.id
|
|
1862
|
-
}`,
|
|
1863
|
-
{},
|
|
1864
|
-
);
|
|
1865
|
-
expect(result.data).toBeDefined();
|
|
1866
|
-
});
|
|
1867
|
-
});
|
|
1868
|
-
|
|
1869
|
-
// query_source's OWN base is a composite (not the run target itself): the
|
|
1870
|
-
// query_source branch of collectAllReachableGates resolved the base via
|
|
1871
|
-
// query.structRef (the raw composite) and walked query.pipeline, but never
|
|
1872
|
-
// walked the query-source's own compositeResolvedSourceDef — the RESOLVED
|
|
1873
|
-
// branch Malloy picked for that query-source's derivation. A query-source
|
|
1874
|
-
// derived from a locked composite member laundered that member's gate away.
|
|
1875
|
-
describe("query-source over composite: resolved-branch gate", () => {
|
|
1876
|
-
const QS_OVER_COMPOSITE_MODEL = `##! experimental.composite_sources
|
|
1877
|
-
source: open_src is duckdb.table('customers') extend {
|
|
1878
|
-
measure: c is count()
|
|
1879
|
-
dimension: region_d is region
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
#(authorize) "false"
|
|
1883
|
-
source: locked is duckdb.table('customers') extend {
|
|
1884
|
-
measure: c is count()
|
|
1885
|
-
dimension: locked_region is region
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
source: laundered is locked -> { group_by: id, locked_region }
|
|
1889
|
-
|
|
1890
|
-
source: inner_combo is compose(open_src, laundered)
|
|
1891
|
-
|
|
1892
|
-
source: qs_over_combo is inner_combo -> { group_by: id, locked_region }
|
|
1893
|
-
|
|
1894
|
-
source: open_qs_over_combo is inner_combo -> { group_by: id, region_d }
|
|
1895
|
-
|
|
1896
|
-
source: outer_qs is qs_over_combo -> { group_by: locked_region }
|
|
1897
|
-
`;
|
|
1898
|
-
|
|
1899
|
-
it("denies a query-source whose base composite resolves to a locked member", async () => {
|
|
1900
|
-
await writeModel("qsc_unified.malloy", QS_OVER_COMPOSITE_MODEL);
|
|
1901
|
-
await expect(
|
|
1902
|
-
runGated(
|
|
1903
|
-
"qsc_unified.malloy",
|
|
1904
|
-
"run: qs_over_combo -> { group_by: locked_region }",
|
|
1905
|
-
{},
|
|
1906
|
-
),
|
|
1907
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1908
|
-
});
|
|
1909
|
-
|
|
1910
|
-
it("allows its open-branch twin: query-source whose base composite resolves to the ungated member", async () => {
|
|
1911
|
-
await writeModel("qsc_unified.malloy", QS_OVER_COMPOSITE_MODEL);
|
|
1912
|
-
const { result } = await runGated(
|
|
1913
|
-
"qsc_unified.malloy",
|
|
1914
|
-
"run: open_qs_over_combo -> { group_by: region_d }",
|
|
1915
|
-
{},
|
|
1916
|
-
);
|
|
1917
|
-
expect(result.data).toBeDefined();
|
|
1918
|
-
});
|
|
1919
|
-
|
|
1920
|
-
it("denies at nesting depth 2: a query-source over a query-source over a composite whose resolved branch hits a locked base", async () => {
|
|
1921
|
-
await writeModel("qsc_unified.malloy", QS_OVER_COMPOSITE_MODEL);
|
|
1922
|
-
await expect(
|
|
1923
|
-
runGated(
|
|
1924
|
-
"qsc_unified.malloy",
|
|
1925
|
-
"run: outer_qs -> { group_by: locked_region }",
|
|
1926
|
-
{},
|
|
1927
|
-
),
|
|
1928
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
1929
|
-
});
|
|
1930
|
-
});
|
|
1931
|
-
|
|
1932
|
-
// MUST-FIX 1 (review regression): Malloy's RecordDef/RepeatedRecordDef/
|
|
1933
|
-
// BasicArrayDef (STRUCT/ARRAY/JSON-shaped nested columns) extend JoinBase,
|
|
1934
|
-
// so `isJoined()` is true for them even though they are ordinary columns,
|
|
1935
|
-
// not joins to another authorize-gated source. `isSourceDef()` correctly
|
|
1936
|
-
// excludes them ('record'/'array' aren't source kinds), which used to trip
|
|
1937
|
-
// the "isJoined but not isSourceDef" drift invariant and deny a query
|
|
1938
|
-
// against any table with a nested column — even with zero authorize
|
|
1939
|
-
// annotations anywhere in the model.
|
|
1940
|
-
describe("authorize tolerates record/array-typed columns (MUST-FIX 1)", () => {
|
|
1941
|
-
it("allows a query against a table with STRUCT/ARRAY/nested-STRUCT-ARRAY columns and no gates", async () => {
|
|
1942
|
-
await writeModel(
|
|
1943
|
-
"rt_nested.malloy",
|
|
1944
|
-
`source: nested_src is duckdb.table('nested_cols') extend {
|
|
1945
|
-
measure: c is count()
|
|
1946
|
-
}
|
|
1947
|
-
`,
|
|
1948
|
-
);
|
|
1949
|
-
const { result } = await runGated(
|
|
1950
|
-
"rt_nested.malloy",
|
|
1951
|
-
"run: nested_src -> { aggregate: c }",
|
|
1952
|
-
{},
|
|
1953
|
-
);
|
|
1954
|
-
expect(result.data).toBeDefined();
|
|
1955
|
-
});
|
|
1956
|
-
});
|
|
1957
|
-
|
|
1958
|
-
// MUST-FIX 2 (review regression): evaluateSelfContainedFirst treated
|
|
1959
|
-
// `decls.length === 0` as "unsatisfiable, deny" unconditionally — but that
|
|
1960
|
-
// conflates "the expression references givens the caller can't supply"
|
|
1961
|
-
// (correct deny) with "the expression references NO givens at all" (e.g. a
|
|
1962
|
-
// constant/public gate like `#(authorize) "true"` — there's nothing ambient
|
|
1963
|
-
// to isolate from, so there's nothing wrong with running it with no decls).
|
|
1964
|
-
describe("authorize allows a givens-free joined gate (MUST-FIX 2)", () => {
|
|
1965
|
-
it('allows a same-file `#(authorize) "true"` source joined by an ungated top', async () => {
|
|
1966
|
-
await writeModel(
|
|
1967
|
-
"rt_pub.malloy",
|
|
1968
|
-
`#(authorize) "true"
|
|
1969
|
-
source: pub_gated is duckdb.table('customers') extend { measure: c is count() }
|
|
1970
|
-
|
|
1971
|
-
source: pub_joiner is duckdb.table('customers') extend {
|
|
1972
|
-
join_one: pub_gated on id = pub_gated.id
|
|
1973
|
-
measure: c is count()
|
|
1974
|
-
}
|
|
1975
|
-
`,
|
|
1976
|
-
);
|
|
1977
|
-
const { result } = await runGated(
|
|
1978
|
-
"rt_pub.malloy",
|
|
1979
|
-
"run: pub_joiner -> { aggregate: c }",
|
|
1980
|
-
{},
|
|
1981
|
-
);
|
|
1982
|
-
expect(result.data).toBeDefined();
|
|
1983
|
-
});
|
|
1984
|
-
});
|
|
1985
|
-
|
|
1986
|
-
// MUST-FIX 3 (review regression): when a joined gate references TWO givens
|
|
1987
|
-
// and the caller supplies only ONE of them, the self-contained probe throws
|
|
1988
|
-
// (the other given is unbound) and the old code fell back to the AMBIENT
|
|
1989
|
-
// probe — which compiles against the ENTRY model's own given namespace. If
|
|
1990
|
-
// the entry model happens to declare its OWN given of the SAME NAME as the
|
|
1991
|
-
// unsupplied one (a two-hop-away collision, same setup as the existing
|
|
1992
|
-
// BLOCKING-4 $LEVEL-collision test), the entry's default silently decided
|
|
1993
|
-
// the outcome — reopening exactly the name-collision hole the
|
|
1994
|
-
// self-contained-first fix exists to close. Fix: only fall back to ambient
|
|
1995
|
-
// when EVERY referenced given was covered by the self-contained decls.
|
|
1996
|
-
describe("authorize multi-given joined gate stays fail-closed on partial given supply (MUST-FIX 3)", () => {
|
|
1997
|
-
it("denies when only one of two referenced givens is supplied and the entry model has a colliding default for the other", async () => {
|
|
1998
|
-
await writeModel(
|
|
1999
|
-
"mg_base.malloy",
|
|
2000
|
-
`##! experimental.givens
|
|
2001
|
-
|
|
2002
|
-
given:
|
|
2003
|
-
LEVEL :: number
|
|
2004
|
-
REGION :: string
|
|
2005
|
-
|
|
2006
|
-
#(authorize) "$LEVEL > 3 and $REGION = 'us-west'"
|
|
2007
|
-
source: mg_base_gated is duckdb.table('customers') extend { measure: c is count() }
|
|
2008
|
-
`,
|
|
2009
|
-
);
|
|
2010
|
-
await writeModel(
|
|
2011
|
-
"mg_mid.malloy",
|
|
2012
|
-
`import "mg_base.malloy"
|
|
2013
|
-
|
|
2014
|
-
source: mg_mid is duckdb.table('customers') extend {
|
|
2015
|
-
join_one: mg_base_gated on id = mg_base_gated.id
|
|
2016
|
-
measure: c is count()
|
|
2017
|
-
}
|
|
2018
|
-
`,
|
|
2019
|
-
);
|
|
2020
|
-
await writeModel(
|
|
2021
|
-
"mg_entry.malloy",
|
|
2022
|
-
`import "mg_mid.malloy"
|
|
2023
|
-
|
|
2024
|
-
##! experimental.givens
|
|
2025
|
-
|
|
2026
|
-
given:
|
|
2027
|
-
LEVEL :: number is 99
|
|
2028
|
-
|
|
2029
|
-
source: mg_top is duckdb.table('customers') extend {
|
|
2030
|
-
join_one: mg_mid on id = mg_mid.id
|
|
2031
|
-
measure: c is count()
|
|
2032
|
-
}
|
|
2033
|
-
`,
|
|
2034
|
-
);
|
|
2035
|
-
await expect(
|
|
2036
|
-
runGated("mg_entry.malloy", "run: mg_top -> { aggregate: c }", {
|
|
2037
|
-
REGION: "us-west",
|
|
2038
|
-
}),
|
|
2039
|
-
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
2040
|
-
});
|
|
2041
|
-
});
|