Skip to content

Commit ae1e950

Browse files
fix(service-analytics): the field-level read gate refuses a cube member that names no field instead of standing down (#20965) (#21153)
Fixes #20965 Clause-②: no ⚠️ Security family, same disclosure discipline as the parent card: this body carries no request body, header, field spelling or returned value. Classes and positions only. ## What changes The analytics field-level read gate no longer stands down on a cube member that names no field. A member whose `sql` resolves to neither a field the gate judges nor the row wildcard `'*'` is now refused with the gate's own envelope, `PERMISSION_DENIED` / `403`. The refusal comes before any strategy runs, on both the cube read and the SQL echo, and on both strategy paths. This is triage's correction `5921856508` as ruled: the gate fails closed, and such a member is never stood down and never passed. - `analytics-service.ts`, `fieldsOfColumnSql`: `'*'` names nothing (an empty list, as before). Anything that is neither a bare identifier nor an identifier path now answers "not a column reference" instead of an empty list. The deleted arm is the one documented as "Anything else is an EXPRESSION the cube's author wrote". - `analytics-service.ts`, `namedQueryFields`: such a member, or a declared member whose `sql` is not a string, is carried to the gate as a member that names no field, on the cube's base object. An undeclared measure still names nothing, because both strategies refuse a measure the cube does not carry. - `field-read-admission.ts`: `assertNamedFieldsReadable` refuses such a member ahead of that object's field verdicts, through a new constructor in the same envelope, whatever grants the caller holds. No new error code is added. The refusal names the member and the object. It never carries the member's `sql`, because echoing it would hand the cube author's text to a caller who was refused for not being judged able to read it. - Changeset: `@objectstack/service-analytics` patch. ## Premise check (zone 2, measured at base `c6954d6d`) 1. **The stand-down's location.** It was `fieldsOfColumnSql` (its doc arm at `:421` at base; the card said about `:409`, the claim said `:416`). That is the field-level read gate's resolver. `resolveMemberSource` is not part of that gate. It belongs to the two source-field EXISTENCE gates, `assertDimensionFields` and `assertWhereFields` (`INVALID_FIELD` / `400`, "does the object have this column"). Its `source: null` on an expression means "no column to check for existence", and it is left as it is. Those gates run first, in `ensureCube`, and stand down on an expression. The refusal then comes from the field gate in `callCtx`. 2. **How a stored cube reaches the gate.** There is no metadata read path into the analytics registry. `CubeRegistry` has two writers: configuration cubes (`AnalyticsServiceConfig.cubes`, fed by `AnalyticsServicePlugin({ cubes })`, which the CLI's `analyticsCubes` capability arg builds) and compiled datasets. `register` never parses. So a cube configured before the parse refusal, or never put through it, reaches the gate as written. The fixture is built that way: the test file's authored cube, unparsed. A new pin asserts that `CubeSchema` refuses exactly its two expression members, plus one member with no `sql` string, and nothing else. A plugin-level pin passes the same cube through `AnalyticsServicePlugin({ cubes })` with the security bridge. 3. **"The engine's 403 shape".** It is reused from `field-read-admission.ts`: `PERMISSION_DENIED`, pinned against the standard catalog, with status `403` and the `object` property. No new code. 4. **Consumers of `resolveMemberSource`** at base, by `git grep`: `assertDimensionFields` (one site) and `assertWhereFields` (two sites), plus doc mentions in `where-source-field-gate.test.ts`. None consumes the field gate's verdict. The field gate's collector `namedQueryFields` has two consumers. `queryObjects` (the object-admission and read-scope set) is unchanged, because a member that names no field adds only the base object, which is already in the set. `assertFieldsReadable` now refuses. ## Measured before and after (unit probe through `AnalyticsService.query`, not committed) At base, with a reader wired and answering, these positions were all served, and the native-SQL statement carried the member's `sql` as written: an expression member as a grouped dimension, an aggregated measure, a filter member and an order key. A member the query named itself that is not a column reference was served the same way, on a configured cube and on an inferred one. After the change, all of these are refused `PERMISSION_DENIED` / `403` on both strategy paths, with nothing executed. With no reader, the behaviour is unchanged (see Acceptance notes). ## Pins - **Deleted**: the expression half of `field-read-admission-gate.test.ts` › "stands down where no field can be named: an authored expression member, and an object the reader has no answer for". It held the stand-down. The other half, an object the reader has no answer for, is kept, retitled "stands down for an object the reader has no answer for". - **Added** (`field-read-admission-gate.test.ts`, block "a member that names no field is refused, never stood down"): - the fixture is written around the parse (`CubeSchema` refuses exactly the members the gate refuses); - refusal, for each strategy path and on both doors (cube read and SQL echo): an aggregated expression measure, a grouped expression dimension, a filter member, an order key, a declared member with no `sql` string, and a member the query names itself that is not a column reference. Each asserts `code`, `status`, `object` and `member`, that nothing executed, and that the message carries neither of the author's expression texts; - no grant makes it judgeable: a reader answering every field still refuses it; - it is refused ahead of a hidden field on the same object; - every field member is judged as before, on the same cube; - `'*'` still counts (the control): a count beside a field member is served, and a count alone is served with the reader never asked; - plugin bridge: a configured cube's expression member is refused through the security service's reader. - The existing field-gate table (14 refusal cases × 2 strategies × 2 doors) and the rest of the file are unchanged and green. ## Ablations (each from committed head `f6f10470`, through `scripts/ablation-replace.mjs`: anchor hit once, blob changed, restored blob equal to HEAD, `git diff HEAD` empty) The test file imports the service by relative path (`../analytics-service.js`), so the ablated source is what runs and no `dist/` is involved. | leg | mutation | predicted | measured | |---|---|---|---| | A1 | the stand-down put back: `fieldsOfColumnSql` answers an empty list for a non-column `sql` | 15 red / 55 green | 15 failed / 55 passed. Every refusal row except the no-`sql` one, no-grant, ahead-of, and the plugin pin. Failure shape: `expected null to match object { code: 'PERMISSION_DENIED', …(3) }`, and `promise resolved … instead of rejecting` | | A2 | a declared member with a non-string `sql` names nothing | 2 red | 2 failed / 68 passed. The no-`sql` row, where the strategy then crashed with a `TypeError` | | A3 | the gate's refusal statement removed (`field-read-admission.ts`) | 17 red | 17 failed / 53 passed | | A4 | the `'*'` arm removed, so the wildcard reads as an expression | the control turns red, along with every count under an answering reader | 47 failed / 23 passed, both `'*'` control cases among them | Restored state: 70 / 70 green. ## Tests (final head `f6f10470`) - `pnpm --filter @objectstack/service-analytics test`: 156 files, 3560 passed, 21 skipped. At base, with only the source change, it was 2 failed (the stand-down pin, one row per strategy) and 3536 passed. - `pnpm --filter @objectstack/service-analytics typecheck`: exit 0, and `--listFiles` includes the edited test file. - Route level, over the real security plugin, engine and SQL driver: `@objectstack/rest` `analytics-field-permission-gate`, `analytics-masked-field-gate`, `analytics-relationship-path-admission` and `analytics-hop-object-reference` gave 4 files, 60 passed, against the rebuilt `service-analytics` dist. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 62 commands. All 62 were run, and `--ran` reconciled them as "62 derived, 61 run, 1 NOT-MEASURED, 0 UNRUN". 61 exited 0. The other one, `pnpm check:dual-build-cjs-loads`, exited 3: PREREQUISITE NOT MET, because it needs every package's `dist/` and this worktree built only the analytics closure. It is NOT MEASURED and declared to CI. - Lint, a declared narrowing: `eslint --no-inline-config --format json` over the 3 changed lintable files gave 3 files, 0 errors and 0 warnings at `f6f10470`. - The population comes from `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block; the fourth changed file is the `.md` changeset. - The count comes from the JSON output. - Invariance: the config never enables type-aware linting (every `parserOptions` is `ecmaVersion` and `sourceType` only, and no typed rules are on), so this diff cannot move a verdict on an untouched file. The whole-repo `pnpm lint` is CI's. ## Acceptance notes - **File surface, declared.** `field-read-admission.ts` is outside the claim's named file, in the same package and directory. It is the module that holds the gate's refusal envelope and the per-object tiers, which zone 2 item 3 pointed to. The refusal has to sit inside those tiers (next note), so it lands there. None of the three files held by the seat-1 PR is touched. - **The tier the refusal respects.** The refusal fires where the gate judges the member's object, that is, where a reader gives a field answer for it. With no security service (the plugin's reader answers "no answer") or for an object the reader cannot resolve, no member of that object is judged, field or not. That is the gate's existing tier. Refusing there would put a permission refusal on a deployment that has no permissions. Raised to the seat as an open question in the report; no pin holds either reading for that tier. - **Caller-named members.** The rule covers every member the gate sees, so a member the query names itself that is not a column reference is now refused too, wherever the reader answers. At base it reached the native-SQL statement as written. The remaining no-reader case of that shape is reported to the seat, not fixed here. - **Left as they are:** the existence gates' stand-down (above); and a cube whose own base `sql` is not a bare object name, which names no attributable field (a cube-level shape this card does not cover). - **Spec doc.** `analytics.zod.ts`'s note that the runtime's expression branches "are left as they are here" stays true of that PR. This PR deletes the gate's half. The raw-SQL emit half lives in `strategies/native-sql-strategy.ts`, which is held elsewhere, and it is not touched here. - **Landing.** A security-family change, left draft for the seat's review. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent df1feae commit ae1e950

4 files changed

Lines changed: 271 additions & 23 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): the analytics field-level read gate refuses a cube member whose `sql` names no field, instead of letting the query run (#20965)
6+
7+
Clause-②: no
8+
9+
**What changed.** Where the analytics field-level read gate judges a cube's
10+
object (a security service is registered and gives a field answer for that
11+
object), a query that names a cube member whose `sql` is neither a column
12+
reference (a field of the cube's object, or a relationship path ending in one)
13+
nor `'*'` is now refused `403 PERMISSION_DENIED` on
14+
`POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql`, before either
15+
strategy runs. Whatever
16+
the caller may read, the member is refused. That covers an expression member
17+
of a cube that reached the service without the spec's parse (the cube
18+
registry never parses: `analyticsCubes` and `AnalyticsServicePlugin({ cubes })`
19+
arrive as written), a declared member with no `sql` string, and a member the
20+
query names itself that is not a column reference. The gate used to stand down
21+
on such a member, because it names no field, and the native-SQL strategy then
22+
compiled it into its statement as written: a read of fields no permission
23+
verdict was reached for. The refusal names the member and the object, and
24+
never the member's `sql`.
25+
26+
**What is not affected.** A member that is a column reference is judged by the
27+
field it resolves to, as before. A `count` over `'*'` names no field and is
28+
served. A deployment with no security service, and an object the security
29+
service gives no field answer for, apply no field-level check, as before. The
30+
spec's parse already refuses an expression member, so a cube that parses is
31+
unaffected.
32+
33+
**If a widget stopped answering,** its cube carries an expression member from
34+
before the parse refused one. Re-author the member as a column reference, or
35+
declare the derived value on an ADR-0021 dataset: a conditional count or sum
36+
is a dataset measure with its own `filter`, and a ratio of measures is
37+
`derived: { op: 'ratio', of: [...] }`.

‎packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts‎

Lines changed: 122 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@
1515
* The route-level half, over the real `SecurityPlugin`, `ObjectQL` and
1616
* `SqlDriver`, compares each refusal with the engine's own answer:
1717
* `packages/rest/src/analytics-field-permission-gate.test.ts`.
18+
*
19+
* [#20965] A member that resolves to neither a field nor `'*'` is refused, in
20+
* the same envelope, and never stood down — see the block of that name below.
21+
* Its fixture, `AUTHORED`'s two expression members, is written around the
22+
* parse: `CubeSchema` refuses both, and the registry never parses, which is
23+
* how a cube configured before that refusal (or never put through it) still
24+
* reaches the gate.
1825
*/
1926

2027
import { describe, it, expect, vi } from 'vitest';
2128
import { DatasetSchema } from '@objectstack/spec/ui';
22-
import type { Cube } from '@objectstack/spec/data';
29+
import { CubeSchema, type Cube } from '@objectstack/spec/data';
2330
import type { ExecutionContext } from '@objectstack/spec/kernel';
2431
import { AnalyticsService } from '../analytics-service.js';
2532
import { AnalyticsServicePlugin } from '../plugin.js';
@@ -62,6 +69,16 @@ const AUTHORED: Cube = {
6269
joins: { owner: { name: OWNER }, hidden_link: { name: OWNER } },
6370
} as Cube;
6471

72+
/** [#20965] A declared member with no `sql` string at all — refused at parse, unparsed here. */
73+
const NO_SQL: Cube = {
74+
name: 'fr_no_sql',
75+
title: 'No sql',
76+
sql: LEDGER,
77+
public: true,
78+
measures: { count: { type: 'count', sql: '*', label: 'Count' } },
79+
dimensions: { bare: { type: 'string', label: 'Bare' } },
80+
} as unknown as Cube;
81+
6582
/** A registered dataset whose OWN filter, and one of whose measures' filter, name a hidden field. */
6683
const SCOPED_DATASET = DatasetSchema.parse({
6784
name: 'fr_scoped',
@@ -105,7 +122,7 @@ function makeService(opts: {
105122
}) {
106123
const executed: string[] = [];
107124
const service = new AnalyticsService({
108-
cubes: [AUTHORED],
125+
cubes: [AUTHORED, NO_SQL],
109126
datasets: [SCOPED_DATASET, MEASURE_FILTER_DATASET],
110127
queryCapabilities: opts.capabilities,
111128
getReadableFields: opts.getReadableFields,
@@ -201,11 +218,7 @@ describe('[#20917] analytics — the field-level read gate at the door', () => {
201218
expect(grouped).toBe(aggregateSentence(LEDGER, ['hidden_text']));
202219
});
203220

204-
it('stands down where no field can be named: an authored expression member, and an object the reader has no answer for', async () => {
205-
const { service, executed } = makeService({ capabilities: nativeSqlOnly, getReadableFields: readable });
206-
await service.query({ cube: 'fr_authored', measures: ['expression_total'], dimensions: ['expression_flag'] } as never, CALLER);
207-
expect(executed).toHaveLength(1);
208-
221+
it('stands down for an object the reader has no answer for', async () => {
209222
const unanswered = makeService({ capabilities, getReadableFields: (object) => (object === OWNER ? undefined : READABLE[object]) });
210223
await unanswered.service.query({ cube: 'fr_authored', measures: ['count'], dimensions: ['alias_owner_code'] } as never, CALLER);
211224
expect(unanswered.executed.length).toBeGreaterThan(0);
@@ -276,6 +289,94 @@ describe('[#20917] analytics — the field-level read gate at the door', () => {
276289
});
277290
});
278291

292+
// ── [#20965] A member that names no field ─────────────────────────────────────
293+
294+
/** The cube author's expression text — which no refusal may hand back to the caller. */
295+
const AUTHORED_EXPRESSIONS = [
296+
(AUTHORED.measures as Record<string, { sql: string }>).expression_total.sql,
297+
(AUTHORED.dimensions as Record<string, { sql: string }>).expression_flag.sql,
298+
];
299+
300+
/** A member the query names itself, spelled as no column is. */
301+
const NAMED_EXPRESSION = 'hidden_number * 2';
302+
303+
interface ExpressionCase {
304+
label: string;
305+
query: Record<string, unknown>;
306+
member: string;
307+
}
308+
309+
const EXPRESSION_REFUSED: readonly ExpressionCase[] = [
310+
{ label: 'an aggregated expression measure', query: { cube: 'fr_authored', measures: ['expression_total'] }, member: 'expression_total' },
311+
{ label: 'a grouped expression dimension', query: { cube: 'fr_authored', measures: ['count'], dimensions: ['expression_flag'] }, member: 'expression_flag' },
312+
{ label: 'a filtered expression member', query: { cube: 'fr_authored', measures: ['count'], where: { expression_flag: 1 } }, member: 'expression_flag' },
313+
{ label: 'an expression member as an order key', query: { cube: 'fr_authored', measures: ['count'], dimensions: ['title'], order: { expression_flag: 'asc' } }, member: 'expression_flag' },
314+
{ label: 'a declared member with no sql string', query: { cube: 'fr_no_sql', measures: ['count'], dimensions: ['bare'] }, member: 'bare' },
315+
{ label: 'a member the query names itself that is not a column reference', query: { cube: 'fr_authored', measures: ['count'], dimensions: [NAMED_EXPRESSION] }, member: NAMED_EXPRESSION },
316+
];
317+
318+
describe('[#20965] the field-level read gate — a member that names no field is refused, never stood down', () => {
319+
it('the fixture is written around the parse: CubeSchema refuses exactly the members the gate now refuses', () => {
320+
for (const [cube, paths] of [
321+
[AUTHORED, ['dimensions.expression_flag.sql', 'measures.expression_total.sql']],
322+
[NO_SQL, ['dimensions.bare.sql']],
323+
] as const) {
324+
const parsed = CubeSchema.safeParse(cube);
325+
expect(parsed.success).toBe(false);
326+
expect(parsed.error?.issues.map((issue) => issue.path.join('.')).sort()).toEqual(paths);
327+
}
328+
});
329+
330+
describe.each(STRATEGY_PATHS)('$label', ({ capabilities }) => {
331+
it.each(EXPRESSION_REFUSED)('$label: refused PERMISSION_DENIED / 403 on both doors, before any strategy ran, without the author\'s text', async ({ query, member }) => {
332+
const { service, executed } = makeService({ capabilities, getReadableFields: readable });
333+
for (const run of [() => service.query(query as never, CALLER), () => service.generateSql(query as never, CALLER)]) {
334+
const refusal = await run().then(() => null, (e: unknown) => e as Record<string, unknown>);
335+
expect(refusal).toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: LEDGER, member });
336+
for (const text of AUTHORED_EXPRESSIONS) expect(String(refusal?.message)).not.toContain(text);
337+
}
338+
expect(executed).toEqual([]);
339+
});
340+
341+
it('no grant makes it judgeable: a reader answering every field of the object still refuses it', async () => {
342+
const { service, executed } = makeService({ capabilities, getReadableFields: (object) => FIELDS[object] });
343+
await expect(
344+
service.query({ cube: 'fr_authored', measures: ['expression_total'] } as never, CALLER),
345+
).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: LEDGER, member: 'expression_total' });
346+
expect(executed).toEqual([]);
347+
});
348+
349+
it('it is refused ahead of a hidden field on the same object', async () => {
350+
const { service } = makeService({ capabilities, getReadableFields: readable });
351+
await expect(
352+
service.query({ cube: 'fr_authored', measures: ['expression_total'], dimensions: ['alias_code'] } as never, CALLER),
353+
).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: LEDGER, member: 'expression_total' });
354+
});
355+
356+
it('every field member is judged as before: a query naming none of the cube\'s expression members gets the field verdicts', async () => {
357+
const { service, executed } = makeService({ capabilities, getReadableFields: readable });
358+
await expect(
359+
service.query({ cube: 'fr_authored', measures: ['alias_total'] } as never, CALLER),
360+
).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: LEDGER, fields: ['hidden_number'] });
361+
expect(executed).toEqual([]);
362+
await service.query({ cube: 'fr_authored', measures: ['count'], dimensions: ['title', 'alias_owner_region'] } as never, CALLER);
363+
expect(executed.length).toBeGreaterThan(0);
364+
});
365+
366+
it('\'*\' still counts (the control): a count names no field and is served, beside a field member and alone', async () => {
367+
const beside = makeService({ capabilities, getReadableFields: vi.fn(readable) });
368+
await beside.service.query({ cube: 'fr_authored', measures: ['count'], dimensions: ['title'] } as never, CALLER);
369+
expect(beside.executed.length).toBeGreaterThan(0);
370+
371+
const getReadableFields = vi.fn(readable);
372+
const alone = makeService({ capabilities, getReadableFields });
373+
await alone.service.query({ cube: 'fr_authored', measures: ['count'] } as never, CALLER);
374+
expect(alone.executed.length).toBeGreaterThan(0);
375+
expect(getReadableFields).not.toHaveBeenCalled();
376+
});
377+
});
378+
});
379+
279380
// ── The plugin's bridge to the `security` service ─────────────────────────────
280381

281382
function fakeEngine() {
@@ -298,7 +399,7 @@ function fakeEngine() {
298399
};
299400
}
300401

301-
async function bootPlugin(security?: () => unknown) {
402+
async function bootPlugin(security?: () => unknown, cubes?: Cube[]) {
302403
const { engine, reads } = fakeEngine();
303404
const registered: Record<string, unknown> = {};
304405
const error = vi.fn();
@@ -312,7 +413,7 @@ async function bootPlugin(security?: () => unknown) {
312413
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
313414
logger: { info() {}, warn() {}, error, debug() {} },
314415
};
315-
await new AnalyticsServicePlugin({ queryCapabilities: nativeSqlOnly }).init(ctx as never);
416+
await new AnalyticsServicePlugin({ queryCapabilities: nativeSqlOnly, ...(cubes ? { cubes } : {}) }).init(ctx as never);
316417
return { service: registered.analytics as AnalyticsService, reads, error };
317418
}
318419

@@ -344,4 +445,16 @@ describe('[#20917] analytics plugin — the field-level half of the "security" b
344445
await service.query(groupedHidden as never, CALLER);
345446
expect(reads).toHaveLength(1);
346447
});
448+
449+
it('[#20965] refuses a configured cube\'s expression member through the security service\'s reader — `cubes` reach the registry unparsed', async () => {
450+
const getReadableFields = vi.fn(async (object: string) => READABLE[object]);
451+
const { service, reads } = await bootPlugin(() => ({ ...objectAndRowsOpen, getReadableFields }), [AUTHORED]);
452+
await expect(
453+
service.query({ cube: 'fr_authored', measures: ['expression_total'] } as never, CALLER),
454+
).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: LEDGER, member: 'expression_total' });
455+
expect(reads).toEqual([]);
456+
expect(getReadableFields).toHaveBeenCalledWith(LEDGER, CALLER);
457+
await service.query({ cube: 'fr_authored', measures: ['count'], dimensions: ['title'] } as never, CALLER);
458+
expect(reads).toHaveLength(1);
459+
});
347460
});

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

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
assertNamedFieldsReadable,
5252
type QueryableFieldsProvider,
5353
type NamedField,
54+
type NamedRead,
5455
type FieldReadRole,
5556
type ReadableFieldsProvider,
5657
} from './field-read-admission.js';
@@ -418,21 +419,26 @@ const IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/;
418419
* hidden relationship field discloses which record each row points to, and
419420
* the engine judges a path's first segment on the local object for the same
420421
* reason.
421-
* - Anything else is an EXPRESSION the cube's author wrote (`CASE WHEN …`,
422-
* `SUM(…)`, `*`). It names no field this gate can attribute, so it adds
423-
* nothing — the author's declaration of a derived value, the way a formula
424-
* field is.
422+
* - `'*'` reads no field value — the row wildcard a `count` uses — so it
423+
* names nothing (`[]`).
424+
* - [#20965] Anything else is not a column reference: `null`. It names no
425+
* field this gate can judge, so the caller refuses the member
426+
* (`NamedExpression`, `field-read-admission.ts`) — ⛔ never an empty list,
427+
* which would pass it. The parse refuses such a `sql` (#20943), but the
428+
* registry never parses, so a cube built before that, or never put through
429+
* it, still reaches here.
425430
*/
426431
function fieldsOfColumnSql(
427432
cube: Cube,
428433
baseObject: string,
429434
sql: string,
430435
role: FieldReadRole,
431436
referenceOf: HopReference | undefined,
432-
): NamedField[] {
437+
): NamedField[] | null {
433438
const path = sql.trim();
439+
if (path === '*') return [];
434440
if (BARE_IDENTIFIER.test(path)) return [{ object: baseObject, field: path, role }];
435-
if (!IDENTIFIER_PATH.test(path)) return [];
441+
if (!IDENTIFIER_PATH.test(path)) return null;
436442
const segments = path.split('.');
437443
const hops = resolvePathHops(cube, baseObject, segments.slice(0, -1), referenceOf);
438444
const out: NamedField[] = hops.map((hop) => ({ object: hop.from, field: hop.field, role }));
@@ -466,6 +472,13 @@ function fieldsOfColumnSql(
466472
* it is policy, and the engine's own field guard never judges policy
467473
* predicates — they may name fields the caller cannot read.
468474
*
475+
* [#20965] A member that resolves to neither a field nor `'*'` — a declared
476+
* member whose `sql` is not a column reference (or is not a string), an
477+
* undeclared one whose own spelling is not — is read as a `NamedExpression`
478+
* on the base object, which the field gate refuses. `NativeSQLStrategy` emits
479+
* such a `sql` into its statement as written, so passing it would let the
480+
* statement read fields no verdict was reached for.
481+
*
469482
* A cube whose `sql` is not a bare object name names no attributable field.
470483
*
471484
* [#20986] `referenceOf` answers a relationship field's declared target: the
@@ -476,15 +489,20 @@ function namedQueryFields(
476489
cube: Cube,
477490
datasetScope: DatasetScope | undefined,
478491
referenceOf: HopReference | undefined,
479-
): NamedField[] {
492+
): NamedRead[] {
480493
const baseObject = typeof cube.sql === 'string' ? cube.sql.trim() : '';
481494
if (!baseObject || !BARE_IDENTIFIER.test(baseObject)) return [];
482-
const out: NamedField[] = [];
495+
const out: NamedRead[] = [];
483496
const name = (member: string, kind: 'dimension' | 'measure' | 'any', role: FieldReadRole) => {
484497
if (typeof member !== 'string' || member === '') return;
485498
const entry = declaredMemberEntry(cube, member, kind);
486-
const sql = entry ? entry.sql : kind === 'measure' ? undefined : member;
487-
if (typeof sql === 'string') out.push(...fieldsOfColumnSql(cube, baseObject, sql, role, referenceOf));
499+
// An undeclared measure reads nothing: both strategies refuse a measure the
500+
// cube does not carry.
501+
if (!entry && kind === 'measure') return;
502+
const sql = entry ? entry.sql : member;
503+
const fields = typeof sql === 'string' ? fieldsOfColumnSql(cube, baseObject, sql, role, referenceOf) : null;
504+
if (fields) out.push(...fields);
505+
else out.push({ object: baseObject, member, expression: true });
488506
};
489507
const filterMembers = (where: unknown): string[] => {
490508
if (!where || typeof where !== 'object') return [];
@@ -1779,6 +1797,10 @@ export class AnalyticsService implements IAnalyticsService {
17791797
* members straight into a statement no middleware sees, so the engine's field
17801798
* guard could never reach it. See `field-read-admission.ts`.
17811799
*
1800+
* [#20965] A member that resolves to neither a field nor `'*'` is refused
1801+
* here too, `PERMISSION_DENIED` / 403, ahead of the field verdicts on its
1802+
* object ({@link namedQueryFields}).
1803+
*
17821804
* A no-op when no provider is wired (no security service) or when the query
17831805
* names no field.
17841806
*/

0 commit comments

Comments
 (0)