Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/20986-analytics-hop-object-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@objectstack/service-analytics": minor
---

fix(service-analytics)!: a relationship-path hop the cube declares no join for reads the object its lookup field declares, so an inferred cube's dotted path through a lookup named differently from its target is answered

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) a change of which OBJECT the analytics doors read at a relationship-path hop the cube declares no join for: the object the lookup field declares as its `reference`, where it used to be an object named after the field. No authorable key, spelling, export or stored shape moves: `@objectstack/service-analytics` exports nothing new and nothing less, `CubeSchema`, `DatasetSchema` and the analytics query body keep parsing every value they parsed, and no stored row is read or rewritten. A cube that declares its join keeps it. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers which object a hop reads (not `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->

**BREAKING**: this widens what the analytics query doors answer for a dotted relationship path the cube declares no join for — an inferred cube's dotted member (`owner.region`), or an authored member whose `sql` walks a relationship its `joins` does not list — and narrows it in one case, named below. It holds on `POST /api/v1/analytics/query` and on its dry run `POST /api/v1/analytics/sql`, on both strategies and every SQL driver. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.

**What an author sees now.** Each hop of the path reads the object its lookup field declares as its target, the way a join the cube declares already did. With a lookup `owner` that references a person object:

- the caller may read the person object: `dimensions: ['owner.region']` is answered with the person rows' regions on both strategies, and `where: { 'owner.region': 'NA' }` is answered on the native-SQL strategy with what the nested form `{ owner: { region: 'NA' } }` answers. The engine-aggregate strategy keeps refusing a filter on a related value with its own `400 INVALID_FIELD`, as it does through a declared join;
- the caller may not read the person object: `403 PERMISSION_DENIED` naming the person object, before any statement runs;
- the field-level gate judges `region` on the person object, and the caller's row scope on the person object is applied where the related value is read (the join on the native-SQL strategy, the related read on the engine-aggregate one).

A lookup to the cube's own object (a self-reference such as `parent`) is read the same way. A lookup named after its target answers exactly as before.

**Why.** An inferred cube declares no join, so a hop fell back to an object named after the lookup field. For a lookup named differently from its target that is no object: a caller who may read both objects was refused `403` "reading "owner" is not permitted", and a caller the object check passes reached a statement over a table named `owner` (`500`).

**The narrowing.** A lookup whose name is ALSO the name of another object — a field `account` referencing `crm_account` while an object `account` exists — used to be read from that other object: joined by the ids of the records the field points to, admitted and scoped as that other object. It now reads its declared target. So that path answers from the target's rows, and a caller who may not read the target is refused `403 PERMISSION_DENIED` naming it, where the query used to be answered.

**Unchanged.** A cube that declares a join for the path keeps reading the join's object. A host that wires no `relationshipResolver` (`AnalyticsServicePlugin` always wires it, from the data engine's object schema), or a relationship field it cannot answer for, keeps reading the object named after the field. A dataset's `include` compiles to declared joins, so a path it declares is unchanged.
258 changes: 258 additions & 0 deletions packages/rest/src/analytics-hop-object-reference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20986] On an inferred cube, a dotted path through a lookup whose NAME
* differs from its target object reads the TARGET object — the object the
* lookup field declares as its `reference` — on both strategies and on both
* faces, the cube read (`AnalyticsService.query`, what
* `POST /api/v1/analytics/query` relays) and the SQL echo
* (`AnalyticsService.generateSql`, what `POST /api/v1/analytics/sql` relays).
*
* An inferred cube declares no join, so each hop's object used to fall back to
* the ALIAS, the lookup's own name. For a lookup named after its target that
* is the target; for `owner` → a person object it is no object at all. The
* door admitted the name as an object, so a member who may read both objects
* was refused `403` "reading "owner" is not permitted", and a caller the
* object check passes reached a statement over a table named `owner`.
*
* The reference for every answer is the same question asked through a
* DECLARED join — an authored cube whose `joins` keys the lookup and names the
* target — by the same caller, on the same strategy, in the same test: a
* declared join was always resolved to its target. Each reference is also
* checked absolutely, so an equality between two wrong answers cannot pass.
* On the native strategy the dotted filter also answers what the engine's
* nested form `{ owner: { region: … } }` answers, the form the dotted
* spelling maps onto. A lookup named after its target is the control.
*
* The composition is the shipped one, with the REAL security layer:
* `SecurityPlugin` over a real `ObjectQL` on a real `SqlDriver` (SQLite), and
* `AnalyticsServicePlugin` over the same engine as its `'data'` service, whose
* `relationshipResolver` reads each lookup's declared `reference` off the
* engine's object schema. Two compositions, one per strategy: `native` (the
* plugin's own capabilities) and `objectql` (narrowed to the engine-aggregate
* path).
*/

import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { PermissionSetSchema } from '@objectstack/spec/security';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { SecurityPlugin } from '@objectstack/plugin-security';
import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics';

const LEDGER = 'rest_an_hop_ledger';
/** The target of lookup `owner`: readable by the member. */
const PERSON = 'rest_an_hop_person';
/** The target of lookup `keeper`: the member holds no read grant on it. */
const VAULT = 'rest_an_hop_vault';
/** A lookup named after its target, readable — the control. */
const OPEN = 'rest_an_hop_open';
/** A lookup named after its target, without a read grant — the control's refusal. */
const SHUT = 'rest_an_hop_shut';

const SYS_CTX = { isSystem: true, userId: 'usr_system' };

const MEMBER_SET = PermissionSetSchema.parse({
name: 'member_default',
label: 'Member',
objects: {
'*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
[VAULT]: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false },
[SHUT]: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false },
},
});

const MEMBER_CTX = { userId: 'usr_member', positions: [], permissions: [MEMBER_SET.name], posture: 'MEMBER' };

const count = { type: 'count', sql: '*', label: 'Count' };

/** The declared-join references: the lookups keyed in `joins`, each naming its target. */
const VIA_PERSON = {
name: 'rest_an_hop_via_person',
title: 'Via person',
sql: LEDGER,
measures: { count },
dimensions: { owner_region: { type: 'string', sql: 'owner.region', label: 'Region' } },
joins: { owner: { name: PERSON } },
};
const VIA_VAULT = {
name: 'rest_an_hop_via_vault',
title: 'Via vault',
sql: LEDGER,
measures: { count },
dimensions: { keeper_code: { type: 'string', sql: 'keeper.code', label: 'Code' } },
joins: { keeper: { name: VAULT } },
};

const PERSONS = [{ id: 'p1', region: 'NA' }, { id: 'p2', region: 'EU' }];
const VAULTS = [{ id: 'v1', code: 'c1' }];
const OPENS = [{ id: 'o1', region: 'NA' }, { id: 'o2', region: 'EU' }];
const SHUTS = [{ id: 's1', code: 'c1' }];
const LEDGER_ROWS = [
{ id: 'd1', title: 't1', owner: 'p1', keeper: 'v1', [OPEN]: 'o1', [SHUT]: 's1' },
{ id: 'd2', title: 't2', owner: 'p2', keeper: 'v1', [OPEN]: 'o2', [SHUT]: 's1' },
{ id: 'd3', title: 't3', owner: 'p1', [OPEN]: 'o1' },
];

const quiet: any = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };

interface Harness {
engine: ObjectQL;
service: AnalyticsService;
/** Every object the security service was asked to admit, since the last `clear()`. */
admitted: { objects: () => string[]; clear: () => void };
}

async function boot(strategy: 'native' | 'objectql'): Promise<Harness> {
const engine = new ObjectQL({ logger: quiet } as any);
engine.registerDriver(
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any),
true,
);
await engine.init();
engine.registerApp({
id: 'com.objectstack.qa.analytics-hop-object-reference-20986',
name: 'Analytics hop object reference',
version: '1.0.0',
type: 'plugin',
scope: 'system',
objects: [
{ name: PERSON, label: 'Person', sharingModel: 'public_read_write', fields: { region: { name: 'region', type: 'text' } } },
{ name: VAULT, label: 'Vault', sharingModel: 'public_read_write', fields: { code: { name: 'code', type: 'text' } } },
{ name: OPEN, label: 'Open', sharingModel: 'public_read_write', fields: { region: { name: 'region', type: 'text' } } },
{ name: SHUT, label: 'Shut', sharingModel: 'public_read_write', fields: { code: { name: 'code', type: 'text' } } },
{
name: LEDGER,
label: 'Ledger',
sharingModel: 'public_read_write',
fields: {
title: { name: 'title', type: 'text' },
// Named differently from their targets.
owner: { name: 'owner', type: 'lookup', reference: PERSON },
keeper: { name: 'keeper', type: 'lookup', reference: VAULT },
// Named after their targets: the control.
[OPEN]: { name: OPEN, type: 'lookup', reference: OPEN },
[SHUT]: { name: SHUT, type: 'lookup', reference: SHUT },
},
},
],
} as never);
await engine.syncSchemas();

const services: Record<string, unknown> = {
manifest: { register: vi.fn() },
objectql: engine,
data: engine,
metadata: {
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
list: async () => [MEMBER_SET],
},
};
const ctx: any = {
logger: quiet,
hook: () => {},
registerService: (name: string, svc: unknown) => { services[name] = svc; },
replaceService: (name: string, svc: unknown) => { services[name] = svc; },
getService: (name: string) => {
if (!(name in services)) throw new Error(`service not registered: ${name}`);
return services[name];
},
};
const security = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
await security.init(ctx);
await security.start(ctx);
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);

await engine.insert(PERSON, PERSONS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
await engine.insert(VAULT, VAULTS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
await engine.insert(OPEN, OPENS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
await engine.insert(SHUT, SHUTS.map((r) => ({ ...r })), { context: SYS_CTX } as never);
await engine.insert(LEDGER, LEDGER_ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never);

await new AnalyticsServicePlugin({
cubes: [VIA_PERSON, VIA_VAULT] as never,
...(strategy === 'objectql'
? { queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) }
: {}),
}).init(ctx);

const spy = vi.spyOn(services.security as { canReadObject: (object: string, context?: unknown) => Promise<boolean> }, 'canReadObject');
return {
engine,
service: services.analytics as AnalyticsService,
admitted: { objects: () => spy.mock.calls.map((call) => call[0] as string), clear: () => spy.mockClear() },
};
}

type Thrown = { code?: string; status?: number; statusCode?: number; object?: string } | null;

/**
* What a face answered — its rows with the column names dropped (a path member
* and the declared member that reads the same column are named differently),
* or its refusal's envelope and the object it names.
*/
const answerOf = (run: () => Promise<{ rows?: ReadonlyArray<Record<string, unknown>>; sql?: unknown }>) =>
run().then(
(r) => ({ answered: r.rows ? [...r.rows].map((row) => JSON.stringify(Object.values(row))).sort() : typeof r.sql }),
(e: Thrown) => ({ refused: { code: e?.code, status: e?.status ?? e?.statusCode, object: e?.object } }),
);

for (const strategy of ['native', 'objectql'] as const) {
describe(`[#20986] a dotted path through a lookup named differently from its target reads the target — ${strategy} composition`, () => {
let h: Harness;

beforeAll(async () => {
h = await boot(strategy);
}, 60_000);

afterAll(async () => {
try { await h?.engine.destroy(); } catch { /* noop */ }
});

it('readable target, a dimension: the rows a declared join answers, and only the target is admitted', async () => {
const reference = await answerOf(() => h.service.query({ cube: VIA_PERSON.name, measures: ['count'], dimensions: ['owner_region'] } as never, MEMBER_CTX as never));
expect(reference).toEqual({ answered: [JSON.stringify(['EU', 1]), JSON.stringify(['NA', 2])] });
h.admitted.clear();
const answer = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: ['owner.region'] } as never, MEMBER_CTX as never));
expect(answer).toEqual(reference);
expect([...new Set(h.admitted.objects())].sort()).toEqual([LEDGER, PERSON].sort());
expect(await answerOf(() => h.service.generateSql({ cube: LEDGER, measures: ['count'], dimensions: ['owner.region'] } as never, MEMBER_CTX as never))).toEqual({ answered: 'string' });
});

it('readable target, a filter member: what a declared join answers in the same position', async () => {
const reference = await answerOf(() => h.service.query({ cube: VIA_PERSON.name, measures: ['count'], where: { owner_region: 'NA' } } as never, MEMBER_CTX as never));
const answer = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], where: { 'owner.region': 'NA' } } as never, MEMBER_CTX as never));
expect(answer).toEqual(reference);
if (strategy === 'native') {
// Served: the two rows whose owner is in region NA — what the engine's
// nested form answers for the same condition.
expect(answer).toEqual({ answered: [JSON.stringify([2])] });
const nested = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], where: { owner: { region: 'NA' } } } as never, MEMBER_CTX as never));
expect(answer).toEqual(nested);
} else {
// The engine-aggregate strategy filters on no related value, through a
// declared join or not: its own capability refusal, never the door's.
expect(answer).toEqual({ refused: { code: 'INVALID_FIELD', status: 400, object: undefined } });
}
});

it.each([
['a dimension', { cube: LEDGER, measures: ['count'], dimensions: ['keeper.code'] }, { cube: VIA_VAULT.name, measures: ['count'], dimensions: ['keeper_code'] }],
['a filter member', { cube: LEDGER, measures: ['count'], where: { 'keeper.code': 'c1' } }, { cube: VIA_VAULT.name, measures: ['count'], where: { keeper_code: 'c1' } }],
])('unreadable target, %s: refused 403 naming the target, as through a declared join, on both faces', async (_label, path, declared) => {
const reference = await answerOf(() => h.service.query(declared as never, MEMBER_CTX as never));
expect(reference).toEqual({ refused: { code: 'PERMISSION_DENIED', status: 403, object: VAULT } });
expect(await answerOf(() => h.service.query(path as never, MEMBER_CTX as never)), 'the cube read').toEqual(reference);
expect(await answerOf(() => h.service.generateSql(path as never, MEMBER_CTX as never)), 'the SQL echo').toEqual(reference);
});

it('the control: a lookup named after its target is answered when readable and refused naming it when not', async () => {
expect(await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: [`${OPEN}.region`] } as never, MEMBER_CTX as never))).toEqual({
answered: [JSON.stringify(['EU', 1]), JSON.stringify(['NA', 2])],
});
expect(await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: [`${SHUT}.code`] } as never, MEMBER_CTX as never))).toEqual({
refused: { code: 'PERMISSION_DENIED', status: 403, object: SHUT },
});
});
});
}
Loading
Loading