Skip to content

Commit 40c4979

Browse files
committed
fix(service-analytics)!: one field-level read gate at the analytics door, before either strategy (#20917)
The analytics admission step now resolves every member a query names -- dimensions, measures, time dimensions, where members, order keys, joined members, and a compiled dataset's own and its requested measures' filters -- to the field it reads, and judges each against the caller's readable fields before a strategy is selected. A member the caller may not read answers PERMISSION_DENIED / 403 in the engine's words. The native-SQL strategy held no field permissions and served such members; it now inherits the verdict by construction, as does the SQL echo and any strategy added later. The permission rule stays the security service's: the plugin bridges the new getReadableFields hook to its getReadableFields reader, and the analytics layer contributes only the member-to-field resolution. A host read scope is policy and is not judged, as the engine's own field guard does not judge it. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77f7370 commit 40c4979

3 files changed

Lines changed: 439 additions & 1 deletion

File tree

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

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ import {
4545
// object-level `readAdmissionDeniedError` above, and the reason a fail-closed
4646
// row-scope denial can no longer be re-judged by its wording.
4747
import { readScopeUnresolvedError } from './read-scope-refusal.js';
48+
// [#20917] …and the FIELD-level half, asked at the same door right after it:
49+
// every member a query names, judged against the caller's readable fields.
50+
import {
51+
assertNamedFieldsReadable,
52+
type NamedField,
53+
type FieldReadRole,
54+
type ReadableFieldsProvider,
55+
} from './field-read-admission.js';
4856
// [#15768] The measure result-type rule — which aggregates return a value of
4957
// the aggregated field's own type, and which are numeric whatever they read.
5058
// Owned in its own module so the enumerated verdict per `AggregationFunction`
@@ -382,6 +390,112 @@ function resolveMemberSource(
382390
return { key: member, source: BARE_IDENTIFIER.test(member) ? member : null };
383391
}
384392

393+
/** [#20917] A dotted identifier path: relationship hops, then one column — `NativeSQLStrategy`'s own test. */
394+
const IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/;
395+
396+
/**
397+
* [#20917] The fields a member's column `sql` reads, each on the object that
398+
* declares it.
399+
*
400+
* - A bare identifier is a column of the base object.
401+
* - A dotted identifier path is a relationship path: every segment but the
402+
* last is a relationship field on the object before it, the last is the
403+
* column. Each hop's object is the cube's join at that path, keyed as the
404+
* strategies key it (the path with its dots as `__`) and falling back to the
405+
* alias itself — the object both strategies read there. The relationship
406+
* fields are named too: a hidden relationship field discloses which record
407+
* each row points to, and the engine judges a path's first segment on the
408+
* local object for the same reason.
409+
* - Anything else is an EXPRESSION the cube's author wrote (`CASE WHEN …`,
410+
* `SUM(…)`, `*`). It names no field this gate can attribute, so it adds
411+
* nothing — the author's declaration of a derived value, the way a formula
412+
* field is.
413+
*/
414+
function fieldsOfColumnSql(cube: Cube, baseObject: string, sql: string, role: FieldReadRole): NamedField[] {
415+
const path = sql.trim();
416+
if (BARE_IDENTIFIER.test(path)) return [{ object: baseObject, field: path, role }];
417+
if (!IDENTIFIER_PATH.test(path)) return [];
418+
const segments = path.split('.');
419+
const joins = cube.joins as Record<string, { name?: unknown } | undefined> | undefined;
420+
const out: NamedField[] = [];
421+
let object = baseObject;
422+
let alias = '';
423+
for (const segment of segments.slice(0, -1)) {
424+
out.push({ object, field: segment, role });
425+
alias = alias ? `${alias}__${segment}` : segment;
426+
const joined = joins?.[alias]?.name;
427+
object = typeof joined === 'string' && joined !== '' ? joined : alias;
428+
}
429+
out.push({ object, field: segments[segments.length - 1], role });
430+
return out;
431+
}
432+
433+
/**
434+
* [#20917] Every field a query reads, for the field-level read gate — the
435+
* members the caller named and the compiled dataset's own filters, each
436+
* resolved the way the strategies resolve it ({@link declaredMemberEntry}),
437+
* base-object fields first.
438+
*
439+
* | position | resolved as | role |
440+
* |:--|:--|:--|
441+
* | `dimensions` | dimension | aggregate |
442+
* | `timeDimensions` | any | aggregate when bucketed, else predicate (a window) |
443+
* | `measures` | measure (the field it aggregates) | aggregate |
444+
* | `where` leaves | any | predicate |
445+
* | a requested measure's own filter | any | predicate |
446+
* | the dataset's own filter | any | predicate |
447+
* | `order` keys | any | predicate |
448+
*
449+
* An undeclared member is read as the column the strategies read for it: the
450+
* member itself, a dotted one as a relationship path. A measure has no such
451+
* fallback. The `where` members are read through the SAME lowering the
452+
* strategies compile with (`normalizeAnalyticsFilterTree` +
453+
* `collectFilterLeaves`), so the field the gate judges is the column that
454+
* reaches the statement; a filter that lowering refuses is refused by the
455+
* strategy, and names nothing here. A host read scope is deliberately absent:
456+
* it is policy, and the engine's own field guard never judges policy
457+
* predicates — they may name fields the caller cannot read.
458+
*
459+
* A cube whose `sql` is not a bare object name names no attributable field.
460+
*/
461+
function namedQueryFields(
462+
query: AnalyticsQuery,
463+
cube: Cube,
464+
datasetScope: DatasetScope | undefined,
465+
): NamedField[] {
466+
const baseObject = typeof cube.sql === 'string' ? cube.sql.trim() : '';
467+
if (!baseObject || !BARE_IDENTIFIER.test(baseObject)) return [];
468+
const out: NamedField[] = [];
469+
const name = (member: string, kind: 'dimension' | 'measure' | 'any', role: FieldReadRole) => {
470+
if (typeof member !== 'string' || member === '') return;
471+
const entry = declaredMemberEntry(cube, member, kind);
472+
const sql = entry ? entry.sql : kind === 'measure' ? undefined : member;
473+
if (typeof sql === 'string') out.push(...fieldsOfColumnSql(cube, baseObject, sql, role));
474+
};
475+
const filterMembers = (where: unknown): string[] => {
476+
if (!where || typeof where !== 'object') return [];
477+
try {
478+
return collectFilterLeaves(normalizeAnalyticsFilterTree({ where }, NO_DATETIME_COLUMNS)).map((leaf) => leaf.member);
479+
} catch {
480+
return [];
481+
}
482+
};
483+
484+
for (const member of query.dimensions ?? []) name(member, 'dimension', 'aggregate');
485+
for (const td of query.timeDimensions ?? []) name(td.dimension, 'any', td.granularity ? 'aggregate' : 'predicate');
486+
for (const member of query.measures ?? []) name(member, 'measure', 'aggregate');
487+
for (const member of filterMembers((query as { where?: unknown }).where)) name(member, 'any', 'predicate');
488+
for (const measure of query.measures ?? []) {
489+
for (const member of filterMembers(datasetScope?.measureFilters?.[measure])) name(member, 'any', 'predicate');
490+
}
491+
for (const member of filterMembers(datasetScope?.filter)) name(member, 'any', 'predicate');
492+
const order = (query as { order?: unknown }).order;
493+
if (order && typeof order === 'object' && !Array.isArray(order)) {
494+
for (const key of Object.keys(order)) name(key, 'any', 'predicate');
495+
}
496+
return [...out.filter((f) => f.object === baseObject), ...out.filter((f) => f.object !== baseObject)];
497+
}
498+
385499
/**
386500
* `analytics_cube.dimensions.granularities` on the query doors: bucket every
387501
* GROUPED time dimension that names no granularity at the default its cube
@@ -582,6 +696,27 @@ export interface AnalyticsServiceConfig {
582696
* is the same deployment in which `/data` has no object-level gate either.
583697
*/
584698
admitObjectRead?: ObjectReadAdmissionProvider;
699+
/**
700+
* [#20917] The FIELD-LEVEL read admission — which fields of an object the
701+
* caller may read. Asked at the door, right after the object-level gate and
702+
* before a strategy is selected, for every object a query names a field of:
703+
* every member the query names — dimensions, measures, time dimensions,
704+
* `where` members, order keys, joined members, and the compiled dataset's own
705+
* and its requested measures' filters — is resolved to the field it reads
706+
* and refused `PERMISSION_DENIED` / 403, in the engine's words, when that
707+
* field is not readable. A host read scope ({@link getReadScope}) is not
708+
* judged: a policy predicate may name fields the caller cannot read, as the
709+
* engine's own field guard allows. See `field-read-admission.ts`.
710+
*
711+
* The plugin auto-bridges this to the `security` service's
712+
* `getReadableFields`, so the answer is the one the engine middleware
713+
* enforces with. MAY be async. `undefined` for an object is "no answer" and
714+
* judges none of its fields; a THROW refuses the query (fail-closed). When
715+
* the hook is absent entirely no field-level gate applies — the deployment
716+
* has no security service, which is the one in which `/data` has no
717+
* field-level security either.
718+
*/
719+
getReadableFields?: ReadableFieldsProvider;
585720
/**
586721
* ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
587722
* Joins outside this set are rejected by the strategy. Compiled datasets
@@ -993,6 +1128,8 @@ export class AnalyticsService implements IAnalyticsService {
9931128
private readonly readScopeProvider?: AnalyticsServiceConfig['getReadScope'];
9941129
/** Object-level read-admission provider (bound per call to the request context). */
9951130
private readonly readAdmissionProvider?: ObjectReadAdmissionProvider;
1131+
/** [#20917] Field-level read-admission provider (bound per call to the request context). */
1132+
private readonly readableFieldsProvider?: ReadableFieldsProvider;
9961133
/**
9971134
* Compiled datasets by name, as `registerDataset` registered them — feeds the
9981135
* shared scope's join allowlist (D-C) and dataset scope. `queryDataset`
@@ -1061,6 +1198,7 @@ export class AnalyticsService implements IAnalyticsService {
10611198

10621199
this.readScopeProvider = config.getReadScope;
10631200
this.readAdmissionProvider = config.admitObjectRead;
1201+
this.readableFieldsProvider = config.getReadableFields;
10641202
this.configuredAllowedRelationships = config.getAllowedRelationships;
10651203
this.relationshipResolver = config.relationshipResolver;
10661204
this.sourceFieldMeta = config.sourceFieldMeta;
@@ -1306,6 +1444,17 @@ export class AnalyticsService implements IAnalyticsService {
13061444
// so gating it covers the direct `/analytics/query` door, the `/analytics/sql`
13071445
// echo door and — through `DatasetExecutor` — every dataset door.
13081446
await this.assertReadAdmitted(this.queryObjects(query, scope), context);
1447+
// [#20917] …and the FIELD-level gate, right behind it and for the same
1448+
// reason: every member the query names is judged here, once, so every
1449+
// strategy — and the SQL echo — inherits the verdict by construction. The
1450+
// cube is read from `scope` AFTER `ensureCube`, so a measure it minted for
1451+
// this call is judged by the field it aggregates.
1452+
await this.assertFieldsReadable(
1453+
query,
1454+
query.cube ? scope.getCube(query.cube) : undefined,
1455+
query.cube ? reads.getDatasetScope(query.cube) : undefined,
1456+
context,
1457+
);
13091458
// #3602 — `context` rides along unconditionally. It is the ENGINE-side belt
13101459
// (forwarded to `engine.aggregate`, where the middleware chain applies its
13111460
// own RLS), so it must not be gated on the analytics-side belt being wired:
@@ -1452,6 +1601,34 @@ export class AnalyticsService implements IAnalyticsService {
14521601
await assertObjectsReadable(objects, provider, context, this.logger);
14531602
}
14541603

1604+
/**
1605+
* [#20917] The FIELD-LEVEL read gate, asked at this door for every field the
1606+
* query names, BEFORE a strategy is selected — `NativeSQLStrategy` compiles
1607+
* members straight into a statement no middleware sees, so the engine's field
1608+
* guard could never reach it. See `field-read-admission.ts`.
1609+
*
1610+
* A no-op when no provider is wired (no security service) or when the query
1611+
* names no field.
1612+
*/
1613+
private async assertFieldsReadable(
1614+
query: AnalyticsQuery,
1615+
cube: Cube | undefined,
1616+
datasetScope: DatasetScope | undefined,
1617+
context: ExecutionContext | undefined,
1618+
): Promise<void> {
1619+
const provider = this.readableFieldsProvider;
1620+
if (!provider || !cube) return;
1621+
const named = namedQueryFields(query, cube, datasetScope);
1622+
if (named.length === 0) return;
1623+
await assertNamedFieldsReadable(
1624+
named,
1625+
provider,
1626+
context,
1627+
(object) => this.getObjectFieldNames?.(object),
1628+
this.logger,
1629+
);
1630+
}
1631+
14551632
/**
14561633
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
14571634
* AND every joined object of the query's cube, keyed by object name. This is
@@ -1773,8 +1950,19 @@ export class AnalyticsService implements IAnalyticsService {
17731950
// pending seed either.
17741951
await this.assertReadAdmitted(this.cubeObjects(compiled.cube), context);
17751952
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" → preview over ${seedRows.length} drafted seed row(s)`);
1953+
// [#20917] …and the field-level gate, per query the executor issues,
1954+
// over the same compiled dataset: a drafted seed row carries the same
1955+
// fields the published ones do.
17761956
const previewService = {
1777-
query: async (q: AnalyticsQuery) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows!),
1957+
query: async (q: AnalyticsQuery) => {
1958+
await this.assertFieldsReadable(
1959+
q,
1960+
compiled.cube,
1961+
{ filter: compiled.filter, measureFilters: compiled.measureFilters },
1962+
context,
1963+
);
1964+
return evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows!);
1965+
},
17781966
} as IAnalyticsService;
17791967
const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
17801968
// ADR-0021 result-column enrichment runs on this path too. Every key it

0 commit comments

Comments
 (0)