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
11 changes: 11 additions & 0 deletions .changeset/20381-adhoc-cube-request-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/service-analytics": patch
---

`AnalyticsService.query()` and `generateSql()` no longer write the service-wide cube registry before the object-level read admission has admitted the request, and never write a caller-named measure into a registered cube (#20381).

Clause-②: no

- **What changes**: both ad-hoc doors — `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`) — resolved the query's cube and recorded what `ensureCube` minted straight into the shared registry, ahead of the admission check. A request refused `PERMISSION_DENIED` still left the cube it inferred for the refused object in the registry, and a suffix measure a caller named on a registered cube (`<field>_sum`, `<field>_count_distinct`, …) was appended to that cube for every later reader, whether the request was refused or admitted. Both doors now run in the same request-local scope `queryDataset` runs in: what `ensureCube` mints stays with the call, and the admission, read scope and strategy all read it from there.
- **What does not change**: every request is served as before, with the same admission, read scope, refusals, codes and statuses, and a caller-named suffix measure is still served to the caller who named it. An ADMITTED ad-hoc query over an object with no configured cube still registers the cube it inferred, as before — now only after the admission has admitted the request, and never over a cube registered under the same name in the meantime. Configured cubes and datasets registered at construction (`AnalyticsServiceConfig.cubes` / `datasets`) are untouched.
- **What `getMeta()` lists, the one observable difference**: `getMeta()` and `GET /api/v1/analytics/meta` no longer list a cube inferred for a refused request, and no longer list a suffix measure some caller named on a registered cube — a registered cube is listed as it was registered. A cube inferred for an admitted request is still listed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// END-TO-END gate: an ad-hoc query on `POST /analytics/query` or
// `POST /analytics/sql` changes nothing another member sees unless the
// object-level admission admitted it — and even then, a measure the caller
// named on top of a configured cube stays that caller's (#20381).
//
// ## The defect
//
// Both ad-hoc doors resolved the query's cube, and minted what was missing,
// straight into the analytics service's process-wide registry — BEFORE the
// object-level read admission ran. A request refused `403 PERMISSION_DENIED`
// still left the cube it inferred for the refused object in every member's
// `GET /analytics/meta`, and a suffix measure a caller named on a configured
// cube was appended to that cube for every member, admitted or refused. The
// doors now run in a request scope of their own (the one the dataset door got
// for #20356); an inferred cube is published only once the request has been
// admitted, and an appended measure never is.
//
// ## How it is observed
//
// Two separate sign-ups, A and B, holding the same grant (read on
// `admission_open` only), plus the administrator. Member A — or the admin —
// asks; member B observes. B's observation is the whole of what B can see of
// the analytics registry through the two doors B uses — the `meta` listing
// and B's query of the configured cube — taken immediately before and after
// each leg and compared for EQUALITY, so a partial rewrite cannot pass.
//
// ## The legs, on each door
//
// - REFUSED, inferred: A's ad-hoc query over the object A may not read →
// `403 PERMISSION_DENIED`, and B's view is unchanged.
// - REFUSED, appended: A's query of the configured cube over that object,
// naming a suffix measure the cube does not declare → `403`, and B's view is
// unchanged.
// - ADMITTED, appended: the same suffix measure on the configured cube over
// the object A may read → `200`, served WITH A's measure, and B's view is
// unchanged. The negative control a fix that simply refused would lose.
//
// ## Controls
//
// - A configured cube still serves: every B observation is a `200` count of
// B's own rows.
// - An admitted scalar metric over an object still works on a second request
// — the documented "CubeRegistry source 3" path, which stays: the inferred
// cube is registered once the first request is admitted.
// - That published cube widens nothing: after the administrator's admitted
// ad-hoc query over the walled object, B's own query of that object is still
// refused on both doors, and every cube B saw before is listed unchanged.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
import { defineStack } from '@objectstack/spec';
import type { Cube } from '@objectstack/spec/data';
import {
AdmissionOpen,
AdmissionWalled,
admissionFixtureSecurity,
} from './fixtures/analytics-admission-fixture.js';

const A_OPEN_ROWS = 3;
/** Deliberately different from A's count, so the two members' numbers cannot be confused. */
const B_OPEN_ROWS = 2;
const WALLED_ROWS = 4;

/** The configured cube B reads, over the object every member may read. */
const OPEN_SUMMARY: Cube = {
name: 'open_summary',
title: 'Open summary',
sql: 'admission_open',
measures: {
authored_total: { name: 'authored_total', label: 'Authored total', type: 'count', sql: '*' },
},
dimensions: {
region: { name: 'region', label: 'Region', type: 'string', sql: 'region' },
},
};

/** A configured cube over the object no member may read. */
const WALLED_SUMMARY: Cube = {
name: 'walled_summary',
title: 'Walled summary',
sql: 'admission_walled',
measures: {
walled_total: { name: 'walled_total', label: 'Walled total', type: 'count', sql: '*' },
},
dimensions: {},
};

const adhocStack = defineStack({
manifest: {
id: 'com.dogfood.analytics-adhoc-isolation',
// The fixture objects' own prefix — they are reused, not renamed.
namespace: 'admission',
version: '0.0.0',
type: 'app',
name: 'Analytics Ad-hoc Query Isolation Fixture',
description: 'The admission fixture objects, with configured cubes over each on the analytics plugin.',
},
objects: [AdmissionOpen, AdmissionWalled],
});

/** A suffix measure no configured cube declares — `ensureCube` appends it. */
const APPENDED = 'region_count_distinct';

const DRIVERS = ['sqlite-wasm', 'memory'] as const;
const DOORS = ['/analytics/query', '/analytics/sql'] as const;
type Door = (typeof DOORS)[number];

/**
* One boot per driver AND door: the registry is process-wide, so a leg on one
* door would otherwise leave behind — on a regressed build — exactly the entry
* the same leg on the other door is meant to catch, and read green.
*/
const CASES = DRIVERS.flatMap((driver) => DOORS.map((door) => ({ driver, door })));

interface Boot {
stack: VerifyStack;
adminToken: string;
tokenA: string;
tokenB: string;
}

interface Observation {
meta: { status: number; body: unknown };
authored: { status: number; body: unknown };
}

const boots = new Map<string, Boot>();

async function read(res: Response): Promise<{ status: number; body: unknown }> {
return { status: res.status, body: await res.json() };
}

/** Everything member B can see of the analytics registry. */
async function observeAsB(stack: VerifyStack, tokenB: string): Promise<Observation> {
return {
meta: await read(await stack.apiAs(tokenB, 'GET', '/analytics/meta')),
authored: await read(
await stack.apiAs(tokenB, 'POST', '/analytics/query', {
cube: 'open_summary',
measures: ['authored_total'],
}),
),
};
}

/** The listed cubes, whichever envelope the door uses. */
function cubesOf(meta: Observation['meta']): unknown[] {
const payload = (meta.body as { data?: unknown })?.data ?? meta.body;
return Array.isArray(payload) ? payload : [];
}

/** The single count a one-measure answer carries, whichever envelope the door uses. */
function countOf(body: unknown, measure: string): number {
const payload = (body as { data?: unknown })?.data ?? body;
const rows = (payload as { rows?: Array<Record<string, unknown>> })?.rows ?? [];
return rows.reduce((sum, row) => sum + Number(row[measure] ?? 0), 0);
}

/** The ADR-0112 refusal both ad-hoc doors answer for an object the caller may not read. */
async function expectRefused(res: Response): Promise<void> {
expect(res.status).toBe(403);
const body = (await res.json()) as { error?: { code?: string; httpStatus?: number } };
expect(body.error?.code).toBe('PERMISSION_DENIED');
expect(body.error?.httpStatus).toBe(403);
}

async function bootFor(driver: (typeof DRIVERS)[number], door: Door): Promise<Boot> {
const stack = await bootStack(adhocStack as never, {
security: admissionFixtureSecurity(),
databaseDriver: driver,
analytics: new AnalyticsServicePlugin({ cubes: [OPEN_SUMMARY, WALLED_SUMMARY] }),
});
const adminToken = await stack.signIn();
const slug = door.replace(/\W+/g, '-');
const tokenA = await stack.signUp(`adhoc-a-${driver}${slug}@verify.test`);
const tokenB = await stack.signUp(`adhoc-b-${driver}${slug}@verify.test`);

// Each member authors their own rows over HTTP, so `created_by` is the real
// caller and the owner policy makes each member's count their own number.
for (const [token, rows, who] of [
[tokenA, A_OPEN_ROWS, 'a'],
[tokenB, B_OPEN_ROWS, 'b'],
] as const) {
for (let i = 0; i < rows; i++) {
const r = await stack.apiAs(token, 'POST', '/data/admission_open', { name: `${who}-open-${i}`, region: 'west' });
expect(r.status).toBeLessThan(300);
}
}
for (let i = 0; i < WALLED_ROWS; i++) {
const w = await stack.apiAs(adminToken, 'POST', '/data/admission_walled', { name: `walled-${i}`, region: 'west' });
expect(w.status).toBeLessThan(300);
}
return { stack, adminToken, tokenA, tokenB };
}

describe.each(CASES)(
'dogfood: an ad-hoc analytics query changes nothing another member sees before it is admitted [driver=$driver, door=$door]',
({ driver, door }) => {
const key = `${driver} ${door}`;

beforeAll(async () => {
boots.set(key, await bootFor(driver, door));
}, 120_000);

afterAll(async () => {
await boots.get(key)?.stack.stop();
boots.delete(key);
});

it('baseline: B sees the configured cubes, and the open one serves B\'s own rows', async () => {
const { stack, tokenB } = boots.get(key)!;
const baseline = await observeAsB(stack, tokenB);
expect(baseline.meta.status).toBe(200);
expect(cubesOf(baseline.meta).map((c) => (c as { name: string }).name).sort()).toEqual([
'open_summary',
'walled_summary',
]);
expect(baseline.authored.status).toBe(200);
expect(countOf(baseline.authored.body, 'authored_total')).toBe(B_OPEN_ROWS);
});

it('REFUSED: A\'s ad-hoc query over the object A may not read answers 403 PERMISSION_DENIED, and B\'s view is unchanged', async () => {
const { stack, tokenA, tokenB } = boots.get(key)!;
const before = await observeAsB(stack, tokenB);

await expectRefused(await stack.apiAs(tokenA, 'POST', door, { cube: 'admission_walled', measures: ['count'] }));

expect(await observeAsB(stack, tokenB)).toEqual(before);
});

it('REFUSED: A\'s suffix measure on the configured cube over that object answers 403, and B\'s view is unchanged', async () => {
const { stack, tokenA, tokenB } = boots.get(key)!;
const before = await observeAsB(stack, tokenB);

await expectRefused(
await stack.apiAs(tokenA, 'POST', door, { cube: 'walled_summary', measures: ['walled_total', APPENDED] }),
);

expect(await observeAsB(stack, tokenB)).toEqual(before);
});

it('ADMITTED: A\'s suffix measure on the configured cube over the open object is served, and B\'s view is unchanged', async () => {
const { stack, tokenA, tokenB } = boots.get(key)!;
const before = await observeAsB(stack, tokenB);

const res = await stack.apiAs(tokenA, 'POST', door, { cube: 'open_summary', measures: ['authored_total', APPENDED] });
expect(res.status).toBe(200);
const body = await res.json();
// Served WITH A's own measure — in the rows on the query door, in the
// statement on the dry-run door.
expect(JSON.stringify(body)).toContain(door === '/analytics/query' ? APPENDED : 'region');
if (door === '/analytics/query') expect(countOf(body, 'authored_total')).toBe(A_OPEN_ROWS);

expect(await observeAsB(stack, tokenB)).toEqual(before);
});

it('CONTROL: an admitted scalar metric over an object still works on a second request', async () => {
const { stack, tokenA, tokenB } = boots.get(key)!;
const before = await observeAsB(stack, tokenB);

for (let i = 0; i < 2; i++) {
const res = await stack.apiAs(tokenA, 'POST', door, { cube: 'admission_open', measures: ['count'] });
expect(res.status).toBe(200);
const body = await res.json();
if (door === '/analytics/query') expect(countOf(body, 'count')).toBe(A_OPEN_ROWS);
else expect(JSON.stringify(body)).toContain('admission_open');
}

const after = await observeAsB(stack, tokenB);
expect(after.authored).toEqual(before.authored);
expect(cubesOf(after.meta)).toEqual(expect.arrayContaining(cubesOf(before.meta)));
});

it('CONTROL: the administrator\'s admitted ad-hoc query over the walled object widens nothing for B', async () => {
const { stack, adminToken, tokenB } = boots.get(key)!;
const before = await observeAsB(stack, tokenB);

const res = await stack.apiAs(adminToken, 'POST', door, { cube: 'admission_walled', measures: ['count'] });
expect(res.status).toBe(200);

// B still may not read the object, whatever the registry now holds under its name.
await expectRefused(await stack.apiAs(tokenB, 'POST', door, { cube: 'admission_walled', measures: ['count'] }));
const after = await observeAsB(stack, tokenB);
expect(after.authored).toEqual(before.authored);
expect(cubesOf(after.meta)).toEqual(expect.arrayContaining(cubesOf(before.meta)));
});
},
);
Loading
Loading