@@ -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.
4747import { 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 - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * ( \. [ A - Z a - z _ ] [ A - Z a - z 0 - 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