Skip to content

Commit 992a592

Browse files
committed
fix(service-analytics): the field reader is always the security service's; changeset (#20917)
The plugin no longer offers its own option for the field-level reader: it always bridges AnalyticsServiceConfig.getReadableFields to the security service, and a host composing its own reader constructs AnalyticsService with it. The changeset records the narrowing, the new optional service hook, and the ADR-0087 disposition. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40c4979 commit 992a592

2 files changed

Lines changed: 61 additions & 32 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics)!: every analytics face answers the engine's field-level read refusal, whichever strategy serves the cube: a field the caller may not read is judged before either strategy runs (#20917)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or stored shape is removed or renamed. The change refuses analytics queries that read a field the caller's field-level permissions hide, which the engine already refuses on the data API and on the ObjectQL strategy, so there is nothing for `objectstack migrate meta` to rewrite. The one public-surface addition is a new optional service hook. -->
10+
11+
**BREAKING for analytics queries on a SQL deployment that read a field the caller may not read.**
12+
13+
**What changed.** `POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`
14+
and `POST /api/v1/analytics/dataset/query` now judge every field a query reads
15+
against the caller's field-level read permissions before a strategy is chosen:
16+
dimensions, measures, time dimensions, filter members, order keys, members
17+
joined through a relationship, and a dataset's own and its requested measures'
18+
filters. A member of an authored cube is judged by the field it resolves to,
19+
not by its name in the cube. A field the caller may not read answers
20+
`403 PERMISSION_DENIED`, in the words the engine uses for the same field. The
21+
native-SQL strategy, the one a SQL driver serves first, answered such queries;
22+
the ObjectQL strategy and the data API already refused them.
23+
24+
**What is not affected.** A query that reads only fields the caller may read
25+
answers as before. A system context, and a caller with no permission sets, are
26+
unaffected, as on the data API. A host read scope (row-level policy) may still
27+
name fields the caller cannot read. A deployment with no security service applies
28+
no field-level check, as on the data API. A member of an authored cube whose `sql`
29+
is an expression is not attributed to a field.
30+
31+
**New hook.** `AnalyticsServiceConfig.getReadableFields(object, context)` supplies
32+
the reader. `AnalyticsServicePlugin` wires it to the `security` service's
33+
`getReadableFields`; a host that constructs `AnalyticsService` itself passes its
34+
own, and without one no field-level check applies.
35+
36+
**If a widget stopped answering for some users,** it reads a field those users
37+
may not read. Grant that field's read permission to the users who need it, or
38+
build the widget on fields they can read.

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

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -217,15 +217,6 @@ export interface AnalyticsServicePluginOptions {
217217
objectName: string,
218218
context?: ExecutionContext,
219219
) => boolean | Promise<boolean>;
220-
/**
221-
* [#20917] The FIELD-LEVEL read admission — which fields of an object the
222-
* caller may read. The service judges every member a query names against it
223-
* before a strategy is selected (`AnalyticsServiceConfig.getReadableFields`).
224-
* When omitted, the plugin auto-bridges to a registered `'security'`
225-
* service's `getReadableFields(object, context)` — the reader computed from
226-
* the same resolution the engine middleware enforces field permissions with.
227-
*/
228-
getReadableFields?: AnalyticsServiceConfig['getReadableFields'];
229220
/**
230221
* ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
231222
* Typically wired from the dataset registry's compiled `allowedRelationships`.
@@ -769,9 +760,12 @@ export class AnalyticsServicePlugin implements Plugin {
769760
autoBridgedReadAdmission = true;
770761
}
771762

772-
// [#20917] The FIELD-LEVEL half of the same read, bridged the same way and
763+
// [#20917] The FIELD-LEVEL half of the same read
764+
// (`AnalyticsServiceConfig.getReadableFields`), bridged the same way and
773765
// for the same reasons as the two halves above: resolution at CALL time,
774-
// and the three resolutions kept apart.
766+
// and the three resolutions kept apart. There is no plugin option for it:
767+
// the reader is the security service's, and a host that composes its own
768+
// reader constructs `AnalyticsService` with it.
775769
//
776770
// ABSENT — no security service: no field-level security anywhere on
777771
// this deployment, `/data` included. The provider answers
@@ -786,27 +780,24 @@ export class AnalyticsServicePlugin implements Plugin {
786780
interface SecurityReadableFields {
787781
getReadableFields?(object: string, context?: ExecutionContext): Promise<string[] | undefined>;
788782
}
789-
let getReadableFields = this.options.getReadableFields;
790-
if (!getReadableFields) {
791-
getReadableFields = async (object, context) => {
792-
let svc: SecurityReadableFields | undefined;
793-
try {
794-
svc = ctx.getService<SecurityReadableFields>('security');
795-
} catch (e) {
796-
throw new Error(
797-
`resolving the "security" service threw (${String((e as Error)?.message ?? e)})`,
798-
);
799-
}
800-
if (!svc) return undefined;
801-
if (typeof svc.getReadableFields !== 'function') {
802-
throw new Error(
803-
'the registered "security" service exposes no getReadableFields(), so it cannot answer ' +
804-
'which fields the caller may read',
805-
);
806-
}
807-
return svc.getReadableFields(object, context);
808-
};
809-
}
783+
const getReadableFields: AnalyticsServiceConfig['getReadableFields'] = async (object, context) => {
784+
let svc: SecurityReadableFields | undefined;
785+
try {
786+
svc = ctx.getService<SecurityReadableFields>('security');
787+
} catch (e) {
788+
throw new Error(
789+
`resolving the "security" service threw (${String((e as Error)?.message ?? e)})`,
790+
);
791+
}
792+
if (!svc) return undefined;
793+
if (typeof svc.getReadableFields !== 'function') {
794+
throw new Error(
795+
'the registered "security" service exposes no getReadableFields(), so it cannot answer ' +
796+
'which fields the caller may read',
797+
);
798+
}
799+
return svc.getReadableFields(object, context);
800+
};
810801

811802
// ADR-0021 — relationship → target-object resolver. A dataset's `include`
812803
// names lookup/master_detail FIELDS on the base object; the joined TABLE is

0 commit comments

Comments
 (0)