@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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +206 -0
- package/dist/index.d.mts +36 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +51 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/report-export-axis.test.ts +220 -0
- package/src/report-service.ts +79 -0
- package/src/reports-plugin.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-reports",
|
|
3
|
-
"version": "
|
|
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": "
|
|
18
|
-
"@objectstack/platform-objects": "
|
|
19
|
-
"@objectstack/spec": "
|
|
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
|
+
});
|
package/src/report-service.ts
CHANGED
|
@@ -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;
|
package/src/reports-plugin.ts
CHANGED
|
@@ -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
|