@objectstack/plugin-reports 16.1.0 → 17.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-reports",
3
- "version": "16.1.0",
3
+ "version": "17.0.0-rc.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Saved reports + scheduled email digests for ObjectStack — sys_saved_report + sys_report_schedule + IReportService.",
6
6
  "main": "dist/index.js",
@@ -14,9 +14,9 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "croner": "^10.0.1",
17
- "@objectstack/core": "16.1.0",
18
- "@objectstack/platform-objects": "16.1.0",
19
- "@objectstack/spec": "16.1.0"
17
+ "@objectstack/core": "17.0.0-rc.0",
18
+ "@objectstack/platform-objects": "17.0.0-rc.0",
19
+ "@objectstack/spec": "17.0.0-rc.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^26.1.1",
@@ -0,0 +1,220 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * #3710 — the reports side door around the user-level export axis (#3544).
5
+ *
6
+ * `GET /data/:object/export` answers 403 for a caller without the export grant.
7
+ * A report over the SAME object rendered as CSV is the same bulk copy of the
8
+ * same rows, so before this gate a refused caller could simply save a report,
9
+ * run it as CSV — or schedule one to their own inbox — and get the data anyway.
10
+ *
11
+ * The gate lives in `executeReport`, which every path funnels through (run,
12
+ * ad-hoc, and the scheduled dispatch), so these exercise all three rather than
13
+ * trusting one call site.
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
17
+ import { ReportService, type ReportEmail } from './report-service.js';
18
+
19
+ function makeFakeEngine() {
20
+ const tables: Record<string, any[]> = {};
21
+ const ensure = (n: string) => (tables[n] ??= []);
22
+ const matches = (row: any, filter: any): boolean => {
23
+ if (!filter || typeof filter !== 'object') return true;
24
+ for (const [k, v] of Object.entries(filter)) if (row[k] !== v) return false;
25
+ return true;
26
+ };
27
+ return {
28
+ _tables: tables,
29
+ async find(object: string, options?: any) {
30
+ return ensure(object)
31
+ .filter((r) => matches(r, options?.filter ?? options?.where))
32
+ .slice(0, options?.limit ?? 1000);
33
+ },
34
+ async insert(object: string, data: any) { ensure(object).push({ ...data }); return { ...data }; },
35
+ async update(object: string, idOrData: any, _opts?: any) {
36
+ const data = typeof idOrData === 'object' ? idOrData : _opts;
37
+ const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
38
+ const table = ensure(object);
39
+ const i = table.findIndex((r) => r.id === id);
40
+ if (i >= 0) table[i] = { ...table[i], ...data };
41
+ return table[i];
42
+ },
43
+ async delete(object: string, options?: any) {
44
+ const table = ensure(object);
45
+ const i = table.findIndex((r) => r.id === (options?.where?.id ?? options?.id));
46
+ if (i >= 0) table.splice(i, 1);
47
+ return {};
48
+ },
49
+ };
50
+ }
51
+
52
+ function makeFakeEmail() {
53
+ const sent: any[] = [];
54
+ const email: ReportEmail & { _sent: any[] } = {
55
+ _sent: sent,
56
+ async send(input) { sent.push(input); return { status: 'sent' }; },
57
+ };
58
+ return email;
59
+ }
60
+
61
+ const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] };
62
+ const now = new Date('2026-01-15T10:00:00Z');
63
+
64
+ describe('reports × the user-level export axis (#3710)', () => {
65
+ let engine: ReturnType<typeof makeFakeEngine>;
66
+ let email: ReturnType<typeof makeFakeEmail>;
67
+ let canExport: ReturnType<typeof vi.fn>;
68
+
69
+ const build = (opts: { canExport?: any } = {}) =>
70
+ new ReportService({
71
+ engine: engine as any,
72
+ email,
73
+ clock: { now: () => now },
74
+ maxRows: 5000,
75
+ resolveOwnerContext: async (ownerId: string) =>
76
+ ownerId ? { userId: ownerId, tenantId: 't1', positions: [], permissions: [] } : null,
77
+ ...('canExport' in opts ? { canExport: opts.canExport } : { canExport }),
78
+ });
79
+
80
+ beforeEach(() => {
81
+ engine = makeFakeEngine();
82
+ email = makeFakeEmail();
83
+ canExport = vi.fn().mockResolvedValue(true);
84
+ engine._tables['lead'] = [
85
+ { id: 'l1', name: 'Acme', status: 'open' },
86
+ { id: 'l2', name: 'Globex', status: 'won' },
87
+ ];
88
+ });
89
+
90
+ const saveReport = async (svc: ReportService, format: 'csv' | 'json' | 'html_table') =>
91
+ svc.saveReport(
92
+ { name: 'leads', object: 'lead', query: { fields: ['id', 'name'] }, format },
93
+ CTX,
94
+ );
95
+
96
+ describe('interactive run', () => {
97
+ it('denies a CSV report run when the caller may not export the object', async () => {
98
+ const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
99
+ const report = await saveReport(svc, 'csv');
100
+ await expect(svc.run(report.id, CTX)).rejects.toThrow(/EXPORT_NOT_PERMITTED/);
101
+ });
102
+
103
+ it('denies a JSON report run too — json is a bulk machine-readable copy', async () => {
104
+ const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
105
+ const report = await saveReport(svc, 'json');
106
+ await expect(svc.run(report.id, CTX)).rejects.toThrow(/EXPORT_NOT_PERMITTED/);
107
+ });
108
+
109
+ it('ALLOWS html_table — a rendered view is reading, not exporting', async () => {
110
+ // The axis must not become a second read permission: a caller holding
111
+ // allowRead may already see these rows on screen.
112
+ const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
113
+ const report = await saveReport(svc, 'html_table');
114
+ const result = await svc.run(report.id, CTX);
115
+ expect(result.rowCount).toBe(2);
116
+ });
117
+
118
+ it('allows CSV when the grant is held, and asks about the REPORT object', async () => {
119
+ const svc = build();
120
+ const report = await saveReport(svc, 'csv');
121
+ const result = await svc.run(report.id, CTX);
122
+ expect(result.rowCount).toBe(2);
123
+ expect(canExport).toHaveBeenCalledWith('lead', expect.objectContaining({ userId: 'u1' }));
124
+ });
125
+
126
+ it('refuses BEFORE reading any row', async () => {
127
+ const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
128
+ const report = await saveReport(svc, 'csv');
129
+ const findSpy = vi.spyOn(engine, 'find');
130
+ await expect(svc.run(report.id, CTX)).rejects.toThrow(/EXPORT_NOT_PERMITTED/);
131
+ // `sys_saved_report` lookups are fine; the OBJECT must never be queried.
132
+ expect(findSpy.mock.calls.some(([obj]) => obj === 'lead')).toBe(false);
133
+ });
134
+
135
+ it('a throwing canExport denies (fail closed, ADR-0049)', async () => {
136
+ const svc = build({ canExport: vi.fn().mockRejectedValue(new Error('resolution failed')) });
137
+ const report = await saveReport(svc, 'csv');
138
+ await expect(svc.run(report.id, CTX)).rejects.toThrow(/EXPORT_NOT_PERMITTED/);
139
+ });
140
+
141
+ it('no canExport wired → axis does not apply (no plugin-security deployment)', async () => {
142
+ const svc = build({ canExport: undefined });
143
+ const report = await saveReport(svc, 'csv');
144
+ const result = await svc.run(report.id, CTX);
145
+ expect(result.rowCount).toBe(2);
146
+ });
147
+ });
148
+
149
+ describe('scheduling', () => {
150
+ it('refuses to CREATE a csv schedule the author could not run', async () => {
151
+ const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
152
+ const report = await saveReport(svc, 'html_table');
153
+ await expect(
154
+ svc.scheduleReport({ reportId: report.id, recipients: ['a@b.c'], format: 'csv' }, CTX),
155
+ ).rejects.toThrow(/EXPORT_NOT_PERMITTED/);
156
+ });
157
+
158
+ it('allows an html_table schedule for the same caller', async () => {
159
+ const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
160
+ const report = await saveReport(svc, 'html_table');
161
+ const sched = await svc.scheduleReport(
162
+ { reportId: report.id, recipients: ['a@b.c'], format: 'html_table' },
163
+ CTX,
164
+ );
165
+ expect(sched.id).toBeTruthy();
166
+ });
167
+ });
168
+
169
+ describe('scheduled dispatch — the original side door', () => {
170
+ it('a csv schedule created while granted STOPS delivering once the grant is revoked', async () => {
171
+ // The reason the dispatch re-checks instead of trusting the create-time
172
+ // check: permissions change after a schedule exists.
173
+ const gate = vi.fn().mockResolvedValue(true);
174
+ const svc = build({ canExport: gate });
175
+ const report = await saveReport(svc, 'html_table');
176
+ await svc.scheduleReport(
177
+ { reportId: report.id, recipients: ['a@b.c'], format: 'csv', intervalMinutes: 1 },
178
+ CTX,
179
+ );
180
+
181
+ gate.mockResolvedValue(false); // grant revoked
182
+ // Force the schedule due (mirrors the existing dispatch suite).
183
+ engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1000).toISOString();
184
+ const out = await svc.dispatchDue();
185
+
186
+ expect(out.failed).toBe(1);
187
+ expect(email._sent).toHaveLength(0);
188
+ const sched = engine._tables['sys_report_schedule'][0];
189
+ expect(String(sched.last_error)).toMatch(/EXPORT_NOT_PERMITTED/);
190
+ });
191
+
192
+ it('an html_table schedule still delivers for a caller without the grant', async () => {
193
+ const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
194
+ const report = await saveReport(svc, 'html_table');
195
+ await svc.scheduleReport(
196
+ { reportId: report.id, recipients: ['a@b.c'], format: 'html_table', intervalMinutes: 1 },
197
+ CTX,
198
+ );
199
+ // Force the schedule due (mirrors the existing dispatch suite).
200
+ engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1000).toISOString();
201
+ const out = await svc.dispatchDue();
202
+ expect(out.fired).toBe(1);
203
+ expect(email._sent).toHaveLength(1);
204
+ });
205
+
206
+ it('a csv schedule delivers when the owner holds the grant', async () => {
207
+ const svc = build();
208
+ const report = await saveReport(svc, 'html_table');
209
+ await svc.scheduleReport(
210
+ { reportId: report.id, recipients: ['a@b.c'], format: 'csv', intervalMinutes: 1 },
211
+ CTX,
212
+ );
213
+ // Force the schedule due (mirrors the existing dispatch suite).
214
+ engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1000).toISOString();
215
+ const out = await svc.dispatchDue();
216
+ expect(out.fired).toBe(1);
217
+ expect(email._sent[0].attachments?.[0]?.contentType).toBe('text/csv');
218
+ });
219
+ });
220
+ });
@@ -197,8 +197,35 @@ export interface ReportServiceOptions {
197
197
  * closed instead of running with RLS bypassed (#2980).
198
198
  */
199
199
  resolveOwnerContext?: OwnerContextResolver;
200
+ /**
201
+ * [#3544 / #3710] The user-level export axis —
202
+ * `ISecurityService.canExport(object, context)`, wired by the reports plugin
203
+ * from `getService('security')`.
204
+ *
205
+ * A report rendered as `csv`/`json` IS a bulk machine-readable copy of the
206
+ * object, so it is the same privilege `GET /data/:object/export` gates.
207
+ * Without this the axis had a side door: a caller refused at that route could
208
+ * save a report on the same object, run it as CSV — or schedule one to their
209
+ * own inbox — and receive the identical rows.
210
+ *
211
+ * Omitted (no `plugin-security`, so no permission sets exist anywhere) → the
212
+ * axis does not apply, matching the REST export route's own fail-open.
213
+ */
214
+ canExport?: (object: string, context: unknown) => Promise<boolean>;
200
215
  }
201
216
 
217
+ /**
218
+ * [#3544 / #3710] Report formats that constitute a BULK EXPORT rather than a
219
+ * rendering.
220
+ *
221
+ * `csv`/`json` are machine-readable copies — re-importable elsewhere, and
222
+ * exactly what `GET /data/:object/export` serves. `html_table` is a PRESENTED
223
+ * view: the report equivalent of reading rows on screen, which any caller
224
+ * holding `allowRead` may already do. Gating it would restrict reading rather
225
+ * than exporting and would take the axis past what it is for.
226
+ */
227
+ const BULK_EXPORT_FORMATS: ReadonlySet<string> = new Set(['csv', 'json']);
228
+
202
229
  export class ReportService implements IReportService {
203
230
  private readonly engine: ReportEngine;
204
231
  private readonly email?: ReportEmail;
@@ -206,6 +233,7 @@ export class ReportService implements IReportService {
206
233
  private readonly logger: NonNullable<ReportServiceOptions['logger']>;
207
234
  private readonly maxRows: number;
208
235
  private readonly resolveOwnerContext?: OwnerContextResolver;
236
+ private readonly canExportFn?: (object: string, context: unknown) => Promise<boolean>;
209
237
 
210
238
  constructor(opts: ReportServiceOptions) {
211
239
  this.engine = opts.engine;
@@ -214,6 +242,48 @@ export class ReportService implements IReportService {
214
242
  this.logger = opts.logger ?? {};
215
243
  this.maxRows = Math.max(1, opts.maxRows ?? 5000);
216
244
  this.resolveOwnerContext = opts.resolveOwnerContext;
245
+ this.canExportFn = opts.canExport;
246
+ }
247
+
248
+ /**
249
+ * [#3544 / #3710] Gate a report rendering on the user-level export axis.
250
+ *
251
+ * Throws `EXPORT_NOT_PERMITTED` when the principal behind `context` may not
252
+ * take a bulk copy of `object`. A no-op for non-bulk formats (`html_table`),
253
+ * for a system context, and when no `canExport` is wired.
254
+ *
255
+ * Deliberately checked HERE — one place — rather than at each of the three
256
+ * callers (`runReport`, the ad-hoc run, and the scheduled dispatch): a gate
257
+ * per call site is how a fourth call site later ships ungated. `dispatchDue`
258
+ * routes through `executeReport` too, so the scheduled CSV is covered by the
259
+ * same line. (`scheduleReport` additionally pre-checks, so an author is
260
+ * refused when they create the schedule rather than silently at 3am — but
261
+ * that is UX, and THIS is the enforcement: a grant revoked after the schedule
262
+ * was created must still stop the delivery.)
263
+ *
264
+ * Fails CLOSED on a throw — it resolves permission sets to decide, and a
265
+ * resolution failure must never read as a grant (ADR-0049).
266
+ */
267
+ private async assertExportAllowed(
268
+ object: string,
269
+ format: string,
270
+ context: SharingExecutionContext | undefined,
271
+ ): Promise<void> {
272
+ if (!BULK_EXPORT_FORMATS.has(format)) return;
273
+ if (context?.isSystem) return;
274
+ if (!this.canExportFn) return;
275
+ let allowed: boolean;
276
+ try {
277
+ allowed = await this.canExportFn(object, context);
278
+ } catch (err) {
279
+ this.logger.warn?.('ReportService: canExport check failed — denying export', err);
280
+ allowed = false;
281
+ }
282
+ if (!allowed) {
283
+ throw new Error(
284
+ `EXPORT_NOT_PERMITTED: exporting '${object}' as ${format} is not permitted for this user`,
285
+ );
286
+ }
217
287
  }
218
288
 
219
289
  // ── Access control ─────────────────────────────────────────────
@@ -361,6 +431,9 @@ export class ReportService implements IReportService {
361
431
  context: SharingExecutionContext,
362
432
  stamp = true,
363
433
  ): Promise<ReportRunResult> {
434
+ // [#3544 / #3710] The export axis, BEFORE any row is read — a refusal must
435
+ // not be reachable after the data has already been pulled.
436
+ await this.assertExportAllowed(report.object_name, report.format, context);
364
437
  const q = report.query ?? {};
365
438
  const limit = Math.min(q.limit ?? DEFAULT_LIMIT, this.maxRows);
366
439
  const rows = await this.engine.find(report.object_name, {
@@ -416,6 +489,12 @@ export class ReportService implements IReportService {
416
489
  const report = await this.getReport(input.reportId, context);
417
490
  if (!report) throw new Error(`REPORT_NOT_FOUND: ${input.reportId}`);
418
491
 
492
+ // [#3544 / #3710] Refuse a bulk-format schedule the author could not run
493
+ // themselves, at CREATE time — otherwise the refusal only surfaces on the
494
+ // first silent 3am sweep. Advisory only: `executeReport` re-checks on every
495
+ // dispatch, which is what catches a grant revoked after this point.
496
+ await this.assertExportAllowed(report.object_name, input.format ?? 'html_table', context);
497
+
419
498
  const now = this.clock.now();
420
499
  const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;
421
500
  const cron = input.cronExpression?.trim() || null;
@@ -82,11 +82,28 @@ export class ReportsServicePlugin implements Plugin {
82
82
  ctx.logger.warn('ReportsServicePlugin: no email service — schedules will fire without delivery');
83
83
  }
84
84
 
85
+ // [#3544 / #3710] The user-level export axis. A `csv`/`json` report is a
86
+ // bulk machine-readable copy of its object — the same privilege
87
+ // `GET /data/:object/export` gates — so the reports surface must ask the
88
+ // SAME question of the SAME authority, or it is a side door around the
89
+ // axis. Resolved lazily per call rather than captured here: `security` is
90
+ // registered by plugin-security's own `kernel:ready` hook and may not
91
+ // exist yet at construction time. Absent service (no plugin-security ⇒ no
92
+ // permission sets anywhere) → the axis does not apply, matching the REST
93
+ // export route's fail-open.
94
+ const canExport = async (object: string, context: unknown): Promise<boolean> => {
95
+ let security: any;
96
+ try { security = ctx.getService<any>('security'); } catch { return true; }
97
+ if (!security || typeof security.canExport !== 'function') return true;
98
+ return await security.canExport(object, context);
99
+ };
100
+
85
101
  this.service = new ReportService({
86
102
  engine: engine as ReportEngine,
87
103
  email,
88
104
  logger: ctx.logger,
89
105
  maxRows: this.options.maxRows,
106
+ canExport,
90
107
  // Scheduled reports run under the owner's resolved RLS context, not a
91
108
  // system bypass (#2980). No owner-context resolver is wired yet — that
92
109
  // is the reports-surface consumer of ADR-0073's user-less identity