Skip to content

Commit aea35ad

Browse files
committed
fix(service-analytics): state that the dataset door judges a saved dataset's field too, and pin the saved branch
The `/analytics/dataset/query` route's `body.datasetName` branch loads a saved dataset from metadata and calls the same `queryDataset`, and the build probe does too, so the door judges a saved dataset's own `field` text exactly as an inline one's. Behaviour is unchanged; the docblock, the door test's header and the changeset said a registered dataset never reaches this door, which holds only for one registered through the configuration door and queried by cube name. - analytics-service.ts: correct the docblock (every branch that supplies the dataset; author-time refusal is the dataset schema retirement's job) and rename the private method to assertDatasetFieldsJudgeable. - changeset: the BREAKING note covers saved datasets with a non-column field; no shipped dataset carries one. - rest: pin the saved branch through the real route and driver — a saved dataset with a non-column field is refused 403 with nothing executed, and a saved plain-column dataset is still answered. Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f04690 commit aea35ad

4 files changed

Lines changed: 96 additions & 40 deletions

File tree

‎.changeset/21177-analytics-inline-dataset-field-admission.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
'@objectstack/service-analytics': minor
33
---
44

5-
fix(service-analytics)!: an inline dataset's own caller-supplied `field` text that is not a column reference is refused at the analytics dataset door
5+
fix(service-analytics)!: a dataset's own `field` text that is not a column reference is refused at the analytics dataset door, inline or saved
66

77
Clause-②: no (narrowing)
88

9-
<!-- adr-0087: not-required (no-migration-prescription) an inline (caller-POSTed) dataset's own dimension or measure `field` text that is not a column reference (a field, a relationship path ending in one, or `*`) is refused at the analytics dataset door, before the dataset is compiled and before any strategy runs, for every caller and whether or not a security service is wired, through the field-read gate's existing judge and envelope (`PERMISSION_DENIED` / 403). No authorable key, spelling, export or stored shape moves, and no stored row is read differently by any metadata consumer; the published surface gains and loses nothing. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a caller-supplied dataset `field` (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). A caller names a column instead of writing an expression, which is ADR-0021's author surface already ("zero raw expressions"), so there is no FROM → TO mapping to carry. -->
9+
<!-- adr-0087: not-required (no-migration-prescription) a dataset's own dimension or measure `field` text that is not a column reference (a field, a relationship path ending in one, or `*`) is refused at the analytics dataset door — whichever branch supplied the dataset, an inline `body.dataset` or a saved `body.datasetName` — before the dataset is compiled and before any strategy runs, for every caller and whether or not a security service is wired, through the field-read gate's existing judge and envelope (`PERMISSION_DENIED` / 403). No authorable key, spelling, export or stored shape moves, and no stored row is read differently by any metadata consumer; the published surface gains and loses nothing. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a dataset `field` (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). A dataset names a column instead of writing an expression, which is ADR-0021's author surface already ("zero raw expressions"), so there is no FROM → TO mapping to carry. -->
1010

11-
**BREAKING**: this narrows what the analytics dataset door accepts. An inline dataset whose dimension or measure `field` is not a column reference is now refused with `403 PERMISSION_DENIED` instead of being evaluated. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
11+
**BREAKING**: this narrows what the analytics dataset door accepts. A dataset whose dimension or measure `field` is not a column reference is now refused with `403 PERMISSION_DENIED` instead of being evaluated — an inline dataset and a saved dataset queried by name alike, since both reach the same door. No shipped dataset carries a non-column `field`. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
1212

13-
**What changes.** The service compiles an inline dataset into a cube whose members read as declared, so a dimension or measure whose `field` was a raw expression resolved to a declared cube member and was left to the field-level read gate, which stands down with no security service and on an object its reader answers `undefined` for; in those tiers the expression reached the native statement as written. The dataset's own `field` text is now judged at the dataset door, before compile and before any strategy runs, through the field-read gate's existing judge (`PERMISSION_DENIED` / 403, naming the member and never the expression text), for every caller, admin included, and with or without a security service. There is no new error code and no new admission module.
13+
**What changes.** The service compiles a dataset into a cube whose members read as declared, so a dimension or measure whose `field` was a raw expression resolved to a declared cube member and was left to the field-level read gate, which stands down with no security service and on an object its reader answers `undefined` for; in those tiers the expression reached the native statement as written. The dataset's own `field` text is now judged at the dataset door, before compile and before any strategy runs, through the field-read gate's existing judge (`PERMISSION_DENIED` / 403, naming the member and never the expression text), for every caller, admin included, and with or without a security service. There is no new error code and no new admission module.
1414

15-
**What stays answerable.** Every inline dataset whose fields are columns or relationship paths is unchanged. A registered dataset's own field text is author text, queried by cube name and left to the existing gates. The dataset's own filter, the selection's runtime filter and cube-query members are lowered into the compiled query and already judged on the query path, so they are unchanged.
15+
**What stays answerable.** Every dataset whose fields are columns or relationship paths is unchanged, inline or saved. A saved dataset whose `field` is an expression is refused the same way as an inline one; refusing such a `field` when it is authored belongs to the dataset schema's own retirement of expression fields, not to this door. The dataset's own filter, the selection's runtime filter and cube-query members are lowered into the compiled query and already judged on the query path, so they are unchanged.

‎packages/rest/src/analytics-16019-driver-declared-fault.test.ts‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
* THAT — the door refuses the expression `PERMISSION_DENIED` / 403, the one
2727
* judge #21156 reaches (no new error code) — beside a positive control that a
2828
* legitimate dataset on declared fields is still served 200 by the real driver.
29+
* It pins the route's SAVED branch (`body.datasetName`) the same way: that branch
30+
* loads the dataset from metadata and calls the same `queryDataset`, so a saved
31+
* dataset whose `field` is not a column reference is refused too, and a saved
32+
* plain-column dataset is still served.
2933
*
3034
* The second block pins the ordering the ruling's execution notes name. A
3135
* DECLARED fault is withheld even when its text is one the heuristic does not
@@ -75,11 +79,12 @@ function mockServer() {
7579
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
7680
};
7781
}
78-
function mockProtocol() {
82+
/** `savedDatasets` is what the route's `body.datasetName` branch loads from metadata. */
83+
function mockProtocol(savedDatasets: unknown[] = []) {
7984
return {
8085
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
8186
getMetaTypes: vi.fn().mockResolvedValue([]),
82-
getMetaItems: vi.fn().mockResolvedValue([]),
87+
getMetaItems: vi.fn().mockResolvedValue(savedDatasets),
8388
};
8489
}
8590
function mockRes() {
@@ -90,9 +95,9 @@ function mockRes() {
9095
return res;
9196
}
9297

93-
function buildRoute(analyticsProvider?: any) {
98+
function buildRoute(analyticsProvider?: any, savedDatasets: unknown[] = []) {
9499
const rest = new RestServer(
95-
mockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any,
100+
mockServer() as any, mockProtocol(savedDatasets) as any, { api: { requireAuth: false } } as any,
96101
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
97102
undefined, undefined, undefined, undefined,
98103
analyticsProvider,
@@ -241,6 +246,38 @@ describe('[#16019] a driver fault on the raw-SQL path reaches the caller by decl
241246
expect(res.body.rows).toEqual([{ industry: 'tech', account_count: 1 }]);
242247
expect(warned.filter((m) => m.includes('[sql-driver] DATABASE_ERROR'))).toHaveLength(0);
243248
});
249+
250+
// [#21177] The route's SAVED branch: `body.datasetName` loads the dataset from
251+
// metadata and calls the same `queryDataset`, so the door judges a saved
252+
// dataset's own `field` text exactly as it judges an inline one. The expression
253+
// here is one SQLite can run, so without the door it would be served (200).
254+
it('[#21177] a SAVED dataset (body.datasetName) whose dimension field is not a column reference is refused 403 PERMISSION_DENIED — nothing executed', async () => {
255+
const saved = {
256+
...dataset,
257+
name: 'account_metrics_saved_expr',
258+
dimensions: [{ name: 'lowered_name', field: 'lower(name)', type: 'string' }],
259+
};
260+
const execute = vi.spyOn(driver, 'execute');
261+
const route = buildRoute(async () => realAnalytics(driver), [saved]);
262+
const res = await post(route, { datasetName: saved.name, selection: { measures: ['account_count'], dimensions: ['lowered_name'] } });
263+
264+
expect(res.statusCode).toBe(403);
265+
expect(res.body.code).toBe('PERMISSION_DENIED');
266+
expect(execute).not.toHaveBeenCalled();
267+
const body = JSON.stringify(res.body);
268+
expect(body).toContain('lowered_name');
269+
expect(body).not.toMatch(/lower\(name\)/i);
270+
});
271+
272+
it('[#21177] CONTROL: a SAVED plain-column dataset (body.datasetName) → 200 with rows', async () => {
273+
const execute = vi.spyOn(driver, 'execute');
274+
const route = buildRoute(async () => realAnalytics(driver), [dataset]);
275+
const res = await post(route, { datasetName: dataset.name, selection: { measures: ['account_count'], dimensions: ['industry'] } });
276+
277+
expect(res.statusCode).toBe(200);
278+
expect(res.body.rows).toEqual([{ industry: 'tech', account_count: 1 }]);
279+
expect(execute).toHaveBeenCalled();
280+
});
244281
});
245282

246283
describe('[#16019] at the door: a declaration wins over the heuristic, and the heuristic stays as the fallback', () => {

‎packages/services/service-analytics/src/__tests__/inline-dataset-field-admission-door.test.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* [#21177] The INLINE dataset's own `field` text, judged at the analytics door.
4+
* [#21177] A dataset's own `field` text, judged at the analytics dataset door.
55
*
66
* A caller POSTs an inline dataset to `/analytics/dataset/query`. Its
77
* dimension/measure `field` text is caller content at query time, and the service
@@ -16,11 +16,16 @@
1616
* reaches, no new error code — for EVERY caller (admin included) and whether or
1717
* not a security provider is wired, before any strategy runs.
1818
*
19-
* The controls stay served: a plain-column inline dataset, and a REGISTERED
20-
* dataset queried by cube name (author text left to the existing gates, as #21156
21-
* leaves it). The `/analytics/query` door's own caller members are #21156's and
22-
* are pinned in `caller-member-column-reference-gate.test.ts` /
23-
* `field-read-admission-gate.test.ts`.
19+
* The door judges every dataset `queryDataset` is handed — the route's SAVED
20+
* branch (`body.datasetName`) included, since it calls the same method; that
21+
* branch is pinned end to end in `packages/rest`'s
22+
* `analytics-16019-driver-declared-fault.test.ts`.
23+
*
24+
* The controls stay served: a plain-column inline dataset, and a dataset
25+
* registered through the configuration door and queried by cube name (it runs
26+
* through `query()`, where #21156 leaves its members to the existing gates). The
27+
* `/analytics/query` door's own caller members are #21156's and are pinned in
28+
* `caller-member-column-reference-gate.test.ts` / `field-read-admission-gate.test.ts`.
2429
*/
2530

2631
import { describe, it, expect } from 'vitest';
@@ -126,7 +131,7 @@ describe('[#21177] inline-dataset `field` admission — the dataset door', () =>
126131
expect(executed.length).toBeGreaterThan(0);
127132
});
128133

129-
it('still answers a REGISTERED dataset queried by cube name (author text, left to the existing gates)', async () => {
134+
it('still answers a dataset registered through the configuration door and queried by cube name', async () => {
130135
const registered = datasetWith({ name: 'id_registered' });
131136
const { service, executed } = makeService({ capabilities, getReadableFields: (o) => READABLE[o], datasets: [registered] });
132137
await service.query({ cube: 'id_registered', measures: ['total'], dimensions: ['status'] } as never, MEMBER);

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2001,41 +2001,55 @@ export class AnalyticsService implements IAnalyticsService {
20012001
}
20022002

20032003
/**
2004-
* [#21177] An INLINE dataset's own dimension/measure `field` text is CALLER
2005-
* content at query time, and main does not judge it: {@link answerDataset}
2006-
* compiles the dataset into a cube whose members read as DECLARED, so a
2007-
* dimension/measure whose `field` is a raw expression resolves to a declared
2008-
* cube member whose `sql` is that expression — and #21156's
2004+
* [#21177] The dimension/measure `field` text of a dataset handed to
2005+
* {@link queryDataset} is judged here, and main did not judge it:
2006+
* {@link answerDataset} compiles the dataset into a cube whose members read as
2007+
* DECLARED, so a dimension/measure whose `field` is a raw expression resolves to
2008+
* a declared cube member whose `sql` is that expression — and #21156's
20092009
* {@link assertCallerMembersResolvable} leaves a DECLARED expression member to
20102010
* the field-level gate (#20965), which stands down with no security service and
20112011
* on an object its reader answers `undefined` for. In those tiers the
20122012
* expression reached `NativeSQLStrategy`'s statement as written.
20132013
*
2014-
* So the dataset's own `field` text is judged here, on the one judge: a `field`
2015-
* that is not a column reference is caller content the admission cannot
2014+
* ## Every branch that supplies the dataset
2015+
*
2016+
* The service cannot tell where the dataset came from, so the judgement does not
2017+
* depend on it. The `/analytics/dataset/query` route hands this door an INLINE
2018+
* dataset (`body.dataset`, the caller's own text) and a SAVED one alike — it
2019+
* loads `body.datasetName` from metadata and calls the same
2020+
* {@link queryDataset} — and the build probe calls it with a saved dataset too.
2021+
* One uniform refusal here is the safer reading: a saved dataset whose `field` is
2022+
* an expression is refused exactly as an inline one is. No shipped dataset
2023+
* carries a non-column `field`. Refusing such a `field` when it is AUTHORED is
2024+
* the job of the dataset schema's own retirement of expression fields, not of
2025+
* this door.
2026+
*
2027+
* ## The refusal
2028+
*
2029+
* A `field` that is not a column reference names no field the admission can
20162030
* attribute, so it is refused through main's {@link assertCallerMembersJudgeable}
20172031
* / {@link fieldReadUnjudgeableError} — `PERMISSION_DENIED` / 403, the SAME
20182032
* refusal #21156 reaches, no new error code — for EVERY caller (admin included)
20192033
* and whether or not a security provider is wired, BEFORE the dataset is
2020-
* compiled, so no caller expression reaches a strategy (the draft-preview branch
2021-
* included). It names the dimension/measure the caller spelled, never the
2022-
* `field` expression behind it.
2034+
* compiled, so no such expression reaches a strategy (the draft-preview branch
2035+
* included). It names the dimension/measure, never the `field` expression
2036+
* behind it.
20232037
*
20242038
* ## Boundary
20252039
*
2026-
* Only the dataset's OWN `field` text is caller content here. The dataset's
2027-
* `filter`, the selection's `runtimeFilter` and the query's members are lowered
2028-
* into the compiled query's `where` / member list by `DatasetExecutor` and are
2029-
* already judged by #21156 on the query path (`callCtx` →
2040+
* Only the dataset's OWN `field` text is judged here. The dataset's `filter`,
2041+
* the selection's `runtimeFilter` and the query's members are lowered into the
2042+
* compiled query's `where` / member list by `DatasetExecutor` and are already
2043+
* judged by #21156 on the query path (`callCtx` →
20302044
* {@link assertCallerMembersResolvable}), so this gate does not re-judge them. A
2031-
* REGISTERED dataset's own field text is author text, queried by cube name
2032-
* through {@link query} and never through {@link answerDataset}; it stays with
2033-
* the field gate / the parse (#20943), exactly as #21156 leaves it.
2045+
* dataset registered through the configuration door ({@link registerDataset})
2046+
* and queried by cube name runs through {@link query}, not here; its members
2047+
* stay with the field gate / the parse (#20943), exactly as #21156 leaves them.
20342048
*
20352049
* A derived measure references other measures BY NAME (the spec enforces that),
20362050
* so it carries no `field` to judge.
20372051
*/
2038-
private assertInlineDatasetFieldsJudgeable(dataset: Dataset, context: ExecutionContext | undefined): void {
2052+
private assertDatasetFieldsJudgeable(dataset: Dataset, context: ExecutionContext | undefined): void {
20392053
const object = typeof dataset.object === 'string' ? dataset.object : '';
20402054
const caller: NamedRead[] = [];
20412055
for (const d of dataset.dimensions ?? []) {
@@ -2369,12 +2383,12 @@ export class AnalyticsService implements IAnalyticsService {
23692383
context?: ExecutionContext,
23702384
options?: { previewDrafts?: boolean },
23712385
): Promise<AnalyticsResult> {
2372-
// [#21177] The inline dataset's own dimension/measure `field` text is caller
2373-
// content at query time — refuse any that is not a column reference here,
2374-
// ahead of compile and the draft-preview branch, so no caller expression ever
2375-
// reaches a strategy (`PERMISSION_DENIED` / 403, the one judge, every tier).
2376-
// See {@link assertInlineDatasetFieldsJudgeable}.
2377-
this.assertInlineDatasetFieldsJudgeable(dataset, context);
2386+
// [#21177] The dataset's own dimension/measure `field` text — inline or saved,
2387+
// whichever branch supplied it — is judged here: a `field` that is not a
2388+
// column reference is refused ahead of compile and the draft-preview branch,
2389+
// so no such expression reaches a strategy (`PERMISSION_DENIED` / 403, the one
2390+
// judge, every tier). See {@link assertDatasetFieldsJudgeable}.
2391+
this.assertDatasetFieldsJudgeable(dataset, context);
23782392
const compiled = this.compile(dataset);
23792393
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(',') || '—'})`);
23802394

0 commit comments

Comments
 (0)