Skip to content

Commit cb466aa

Browse files
os-helpclaude
andauthored
fix(plugin-reports): forward the caller's execution envelope to the report read (#7204) (#7283)
`executeReport` rebuilt a five-field projection of the caller's `ExecutionContext` (`userId` / `tenantId` / `positions` / `permissions` / `isSystem`) before handing it to the engine read that produces the report, while the method's own comment promised "reports execute with the caller's identity". `accessible_org_ids` was not in that projection, and `buildDriverOptions` reads it by name (ADR-0105 D2 / #3623) to widen the driver's native tenant scope to the caller's membership union under the `group` tenancy posture; absent, drivers fall back to active-org equality. So the identical query returned the union interactively and collapsed to the active org inside a saved or scheduled report -- silently short rows, no error. Measured end-to-end on a real kernel + `@objectstack/driver-sql` sqlite `:memory:`: 3 rows across two member orgs came back as `['a1','a2','b1']` interactively and `['a1','a2']` in the report; the scheduled CSV digest emailed the owner the same two. The read now receives the envelope whole (the #6206 ruling / #6523), minus the `__`-prefixed keys plugin-security stamps for the operation in flight, as a fresh object so a callee's stamp cannot write back into the caller's request context -- the shape plugin-audit (#7141) and service-storage (#7145) landed. `timezone` comes with it, so a read-time formula field resolves the caller's calendar day. The projection's `positions` / `permissions` / `isSystem` defaults are kept byte-for-byte. Direction outside `group` is unchanged and pinned: `isolated`, no posture provider, and a `group` caller with an empty accessible set all still read at active-org equality. Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy Co-authored-by: Claude <noreply@anthropic.com>
1 parent f9b1cba commit cb466aa

6 files changed

Lines changed: 482 additions & 3 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/plugin-reports': patch
3+
---
4+
5+
Reports read with the caller's whole execution envelope, so a `group`-posture report no longer under-reports
6+
7+
`executeReport` rebuilt a five-field projection of the caller's `ExecutionContext`
8+
(`userId` / `tenantId` / `positions` / `permissions` / `isSystem`) before handing it to
9+
the engine read that produces the report — while the method's own comment promised
10+
"reports execute with the caller's identity".
11+
12+
**Before.** `accessible_org_ids` was not in that projection, and the engine reads it by
13+
name (`buildDriverOptions`, ADR-0105 D2 / #3623) to widen the driver's native tenant
14+
scope to the caller's whole membership set under the `group` tenancy posture. Absent, the
15+
drivers fall back to active-org equality — "fail toward isolation". So the identical query
16+
returned the membership union in an interactive list view and collapsed to the active org
17+
inside a **saved or scheduled** report: silently short rows, no error, nothing in the
18+
output saying so. Measured end-to-end on a real kernel + SQL driver: three rows across two
19+
member orgs came back as three interactively and two in the report, and a scheduled CSV
20+
digest emailed the owner the same two. `timezone` went the same way, so a read-time
21+
formula field resolved its calendar day in UTC instead of the caller's business timezone;
22+
`posture`, `org_user_ids`, `systemPermissions` and `onBehalfOf` were dropped too.
23+
24+
**After.** The read receives the caller's envelope whole (the #6206 ruling — enforcement
25+
adjudicates on the whole `resolveAuthzContext` envelope, never a per-site subset), minus
26+
the `__`-prefixed keys plugin-security stamps for the operation in flight, and as a fresh
27+
object so a callee's stamp cannot write back into the caller's request context. The same
28+
shape `plugin-audit` (#7141) and `service-storage` (#7145) landed. Direction is unchanged
29+
outside `group`: the `isolated` posture, a deployment with no posture provider, and a
30+
`group` caller with an empty accessible set all still read at active-org equality.

‎packages/plugins/plugin-reports/package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@objectstack/plugin-reports",
33
"version": "17.0.0-rc.5",
44
"license": "Apache-2.0",
5-
"description": "Saved reports + scheduled email digests for ObjectStack — sys_saved_report + sys_report_schedule + IReportService.",
5+
"description": "Saved reports + scheduled email digests for ObjectStack \u2014 sys_saved_report + sys_report_schedule + IReportService.",
66
"main": "dist/index.js",
77
"types": "dist/index.d.ts",
88
"exports": {
@@ -24,6 +24,8 @@
2424
"croner": "^10.0.1"
2525
},
2626
"devDependencies": {
27+
"@objectstack/driver-sql": "workspace:*",
28+
"@objectstack/objectql": "workspace:*",
2729
"@types/node": "^26.1.2",
2830
"typescript": "^6.0.3",
2931
"vitest": "^4.1.10"
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #7204 — a `group`-posture report returns the caller's membership union, the
5+
* same row set the caller sees interactively.
6+
*
7+
* `executeReport` used to rebuild a five-field projection of the caller's
8+
* execution envelope (`userId` / `tenantId` / `positions` / `permissions` /
9+
* `isSystem`) before handing it to the engine read that produces the report.
10+
* `accessible_org_ids` was not in that projection, and `buildDriverOptions`
11+
* reads it BY NAME (`engine.ts`, ADR-0105 D2 / #3623) to widen the driver's
12+
* native tenant scope to the caller's whole membership set under the `group`
13+
* posture. Absent, drivers "fall back to equality: fail toward isolation" — so
14+
* the report silently returned FEWER rows than the identical interactive query,
15+
* with no error and nothing in the output saying so.
16+
*
17+
* WHY THIS FILE REFUSES TO STUB THE ENGINE. The defect is invisible one layer
18+
* up: a fake engine that records the context it was handed can only assert that
19+
* a key is present, and "the key is on the object" is exactly what the previous
20+
* shape of this bug looked like from inside the service. The consumer is the
21+
* REAL `buildDriverOptions` → real `@objectstack/driver-sql` native scope, so
22+
* the assertions below land on ROW SETS: the union of the rows in both orgs, or
23+
* the equality subset. Backend is better-sqlite3 `:memory:`, the canonical
24+
* in-repo integration stack (PR #5715).
25+
*
26+
* The posture matrix is load-bearing, not decoration. `group` is the only
27+
* posture the widening applies to; `isolated`, "no provider wired" and "group
28+
* with an empty accessible set" must all still collapse to active-org equality
29+
* after the fix, or the change traded under-reporting for exposure.
30+
*/
31+
32+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
33+
import { ObjectKernel } from '@objectstack/core';
34+
import { ObjectQLPlugin } from '@objectstack/objectql';
35+
import type { IDataEngine } from '@objectstack/spec/contracts';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import { SysSavedReport, SysReportSchedule } from '@objectstack/platform-objects/audit';
38+
import { ReportService, type ReportEngine, type ReportEmail } from './report-service.js';
39+
40+
/**
41+
* A tenant-scoped business object. `organization_id` is what makes the driver's
42+
* native tenant scope engage at all (`isTenancyDisabled` / the SQL driver's
43+
* tenant column), and `day` is a READ-TIME formula field: `applyFormulaPlan`
44+
* evaluates it with `execCtx.timezone`, which the same projection also dropped.
45+
*/
46+
const account = {
47+
name: 'account',
48+
label: 'Account',
49+
fields: {
50+
organization_id: { name: 'organization_id', label: 'Org', type: 'text' },
51+
name: { name: 'name', label: 'Name', type: 'text' },
52+
day: {
53+
name: 'day',
54+
label: 'Calendar day',
55+
type: 'formula',
56+
expression: { dialect: 'cel', source: 'today()' },
57+
},
58+
},
59+
};
60+
61+
/** The caller: active org `org_a`, membership union `org_a` + `org_b`. */
62+
const GROUP_CTX = {
63+
userId: 'u1',
64+
tenantId: 'org_a',
65+
positions: [],
66+
permissions: [],
67+
posture: 'MEMBER',
68+
accessible_org_ids: ['org_a', 'org_b'],
69+
} as any;
70+
71+
const ids = (rows: any[]): string[] => rows.map((r) => String(r.id)).sort();
72+
73+
/**
74+
* The engine surface this harness drives. `getService('objectql')` is declared
75+
* as the data-plane contract `IDataEngine`; the boot-time knobs below
76+
* (`registerDriver`, `registry`, `syncSchemas`) and the posture provider
77+
* plugin-security wires in production sit outside it, so they are named here
78+
* rather than erased with `any`.
79+
*/
80+
interface TestEngine extends IDataEngine {
81+
registerDriver(driver: unknown, isDefault?: boolean): void;
82+
registry: { registerObject(def: unknown, packageId: string, namespace: string): void };
83+
syncSchemas(): Promise<unknown>;
84+
setTenancyPostureProvider(provider: () => string | undefined): void;
85+
destroy(): Promise<void>;
86+
}
87+
88+
describe('#7204 a group-posture report reads the caller\'s whole membership union', () => {
89+
let objectql: TestEngine | undefined;
90+
let svc: ReportService;
91+
let email: ReportEmail & { _sent: any[] };
92+
93+
afterEach(async () => {
94+
vi.useRealTimers();
95+
try { await objectql?.destroy(); } catch { /* noop */ }
96+
});
97+
98+
beforeEach(async () => {
99+
const kernel = new ObjectKernel({ logger: { level: 'error' } });
100+
await kernel.use(new ObjectQLPlugin());
101+
await kernel.bootstrap();
102+
objectql = kernel.getService<IDataEngine>('objectql') as TestEngine;
103+
104+
// The engine's own `init()` ran during bootstrap, before this driver
105+
// existed, so the connect the engine would have done is done here.
106+
const driver = new SqlDriver({
107+
client: 'better-sqlite3',
108+
connection: { filename: ':memory:' },
109+
useNullAsDefault: true,
110+
});
111+
await driver.connect();
112+
engine().registerDriver(driver, true);
113+
for (const def of [account, SysSavedReport, SysReportSchedule]) {
114+
engine().registry.registerObject(def, 'reports-test', 'reports-test');
115+
}
116+
await engine().syncSchemas();
117+
118+
// Two rows in the active org, one in the other org the caller belongs to.
119+
for (const row of [
120+
{ id: 'a1', organization_id: 'org_a', name: 'A1' },
121+
{ id: 'a2', organization_id: 'org_a', name: 'A2' },
122+
{ id: 'b1', organization_id: 'org_b', name: 'B1' },
123+
]) {
124+
await engine().insert('account', row, { context: { isSystem: true } });
125+
}
126+
127+
const sent: any[] = [];
128+
email = {
129+
_sent: sent,
130+
async send(input) { sent.push(input); return { status: 'sent' as const }; },
131+
};
132+
svc = new ReportService({
133+
engine: engine() as unknown as ReportEngine,
134+
email,
135+
// A scheduled run executes as the report's OWNER (#2849 / #2980) — the
136+
// resolver hands back a real RLS-bearing envelope, membership set and all.
137+
resolveOwnerContext: async (ownerId: string) =>
138+
(ownerId === 'u1' ? ({ ...GROUP_CTX } as any) : null),
139+
});
140+
});
141+
142+
/** What plugin-security's wiring reports in a deployment of this posture. */
143+
const posture = (p: string | undefined) => engine().setTenancyPostureProvider(() => p);
144+
145+
/** The booted engine — narrowed once so every call site stays typed. */
146+
const engine = (): TestEngine => objectql as TestEngine;
147+
148+
const saveAccountReport = async (format = 'csv') =>
149+
svc.saveReport(
150+
{ name: 'Accounts', object: 'account', query: {}, format } as any,
151+
GROUP_CTX,
152+
);
153+
154+
describe('group posture', () => {
155+
beforeEach(() => posture('group'));
156+
157+
it('a SAVED report returns the same rows as the identical interactive query', async () => {
158+
const interactive = await engine().find('account', {}, { context: GROUP_CTX });
159+
expect(ids(interactive), 'the interactive baseline is the union').toEqual(['a1', 'a2', 'b1']);
160+
161+
const report = await svc.run((await saveAccountReport()).id, GROUP_CTX);
162+
163+
// The card's exact claim: the saved-report path used to return FEWER rows
164+
// (['a1','a2'] — active-org equality) than the interactive query above.
165+
expect(ids(report.rows)).toEqual(ids(interactive));
166+
expect(report.rowCount).toBe(3);
167+
});
168+
169+
it('an AD-HOC report returns the union too', async () => {
170+
const report = await svc.runAdHoc(
171+
{ name: 'Ad hoc', object: 'account', query: {} } as any,
172+
GROUP_CTX,
173+
);
174+
expect(ids(report.rows)).toEqual(['a1', 'a2', 'b1']);
175+
});
176+
177+
it('a SCHEDULED run emails the owner the union, not the active org', async () => {
178+
const report = await saveAccountReport();
179+
await svc.scheduleReport(
180+
{ reportId: report.id, recipients: ['ops@example.com'], format: 'csv', intervalMinutes: 60 },
181+
GROUP_CTX,
182+
);
183+
184+
const result = await svc.dispatchDue(new Date(Date.now() + 3 * 60 * 60 * 1000));
185+
expect(result, 'the sweep must actually fire').toMatchObject({ fired: 1, failed: 0 });
186+
187+
const csv: string = email._sent[email._sent.length - 1]?.attachments?.[0]?.content ?? '';
188+
const dataRows = csv.split('\r\n').slice(1).filter(Boolean);
189+
expect(dataRows).toHaveLength(3);
190+
expect(csv).toContain('B1');
191+
});
192+
193+
it('the report also sees rows the ACTIVE org has none of', async () => {
194+
// Nothing in org_a at all: under equality the report is empty, under the
195+
// union it is the one org_b row. Isolates the widening from "the active
196+
// org happened to hold most of the rows".
197+
const report = await svc.runAdHoc(
198+
{ name: 'B only', object: 'account', query: { filter: { name: 'B1' } } } as any,
199+
GROUP_CTX,
200+
);
201+
expect(ids(report.rows)).toEqual(['b1']);
202+
});
203+
204+
it('an EMPTY accessible set still collapses to active-org equality (fail toward isolation)', async () => {
205+
const ctx = { ...GROUP_CTX, accessible_org_ids: [] };
206+
const interactive = await engine().find('account', {}, { context: ctx });
207+
const report = await svc.runAdHoc({ name: 'r', object: 'account', query: {} } as any, ctx);
208+
expect(ids(report.rows)).toEqual(['a1', 'a2']);
209+
expect(ids(report.rows)).toEqual(ids(interactive));
210+
});
211+
212+
it('forwards the business timezone, so a read-time formula field resolves the caller\'s calendar day', async () => {
213+
// 20:00Z is the day BEFORE in UTC and the day AFTER in UTC+14, so the two
214+
// timezones disagree deterministically at this instant. `today()` is a
215+
// read-time formula evaluated by `applyFormulaPlan` with `execCtx.timezone`
216+
// — dropped by the same projection, and observable on the row's VALUE.
217+
vi.useFakeTimers({ shouldAdvanceTime: true });
218+
vi.setSystemTime(new Date('2026-08-10T20:00:00Z'));
219+
const ctx = { ...GROUP_CTX, timezone: 'Pacific/Kiritimati' };
220+
221+
const interactive = await engine().find('account', { where: { id: 'a1' } }, { context: ctx });
222+
const report = await svc.runAdHoc(
223+
{ name: 'r', object: 'account', query: { filter: { id: 'a1' } } } as any,
224+
ctx,
225+
);
226+
227+
const dayOf = (v: unknown): string =>
228+
String(v instanceof Date ? v.toISOString() : v).slice(0, 10);
229+
expect(dayOf(interactive[0]?.day), 'UTC+14 is already on the 11th').toBe('2026-08-11');
230+
expect(dayOf((report.rows[0] as any)?.day)).toBe(dayOf(interactive[0]?.day));
231+
});
232+
});
233+
234+
describe('every other posture is unchanged — the widening is group-only', () => {
235+
it('isolated posture: the report stays at active-org equality', async () => {
236+
posture('isolated');
237+
const interactive = await engine().find('account', {}, { context: GROUP_CTX });
238+
const report = await svc.runAdHoc({ name: 'r', object: 'account', query: {} } as any, GROUP_CTX);
239+
expect(ids(report.rows)).toEqual(['a1', 'a2']);
240+
expect(ids(report.rows)).toEqual(ids(interactive));
241+
});
242+
243+
it('no posture provider (no enforcement layer): equality, never widened', async () => {
244+
const interactive = await engine().find('account', {}, { context: GROUP_CTX });
245+
const report = await svc.runAdHoc({ name: 'r', object: 'account', query: {} } as any, GROUP_CTX);
246+
expect(ids(report.rows)).toEqual(['a1', 'a2']);
247+
expect(ids(report.rows)).toEqual(ids(interactive));
248+
});
249+
});
250+
});

0 commit comments

Comments
 (0)