@objectstack/plugin-reports 15.0.0 → 15.1.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 +79 -0
- package/dist/index.d.mts +34 -4
- package/dist/index.d.ts +34 -4
- package/dist/index.js +78 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +78 -22
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/report-service.test.ts +99 -4
- package/src/report-service.ts +118 -17
- package/src/reports-plugin.ts +7 -0
package/src/report-service.ts
CHANGED
|
@@ -170,6 +170,20 @@ function renderSubject(template: string | undefined, vars: Record<string, string
|
|
|
170
170
|
|
|
171
171
|
// ─── Service ──────────────────────────────────────────────────────
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Resolves a saved report's owner (`owner_id`) into a real, RLS-bearing
|
|
175
|
+
* `ExecutionContext` so a **scheduled** report executes under the owner's
|
|
176
|
+
* authority — the same rows the owner would see interactively — instead of
|
|
177
|
+
* bypassing RLS with a system context. Returns `null` when the owner cannot
|
|
178
|
+
* be resolved (unknown/disabled user), in which case the scheduler fails the
|
|
179
|
+
* run closed rather than running elevated (#2849 / #2980). Supplying this
|
|
180
|
+
* resolver is the reports-surface consumer of ADR-0073's user-less identity
|
|
181
|
+
* resolution.
|
|
182
|
+
*/
|
|
183
|
+
export type OwnerContextResolver = (
|
|
184
|
+
ownerId: string,
|
|
185
|
+
) => Promise<SharingExecutionContext | null>;
|
|
186
|
+
|
|
173
187
|
export interface ReportServiceOptions {
|
|
174
188
|
engine: ReportEngine;
|
|
175
189
|
email?: ReportEmail;
|
|
@@ -177,6 +191,12 @@ export interface ReportServiceOptions {
|
|
|
177
191
|
logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void };
|
|
178
192
|
/** Cap rows per report to protect both DB and email size. */
|
|
179
193
|
maxRows?: number;
|
|
194
|
+
/**
|
|
195
|
+
* Resolves a report owner into an RLS-bearing context for scheduled runs
|
|
196
|
+
* (see {@link OwnerContextResolver}). When omitted, scheduled reports fail
|
|
197
|
+
* closed instead of running with RLS bypassed (#2980).
|
|
198
|
+
*/
|
|
199
|
+
resolveOwnerContext?: OwnerContextResolver;
|
|
180
200
|
}
|
|
181
201
|
|
|
182
202
|
export class ReportService implements IReportService {
|
|
@@ -185,6 +205,7 @@ export class ReportService implements IReportService {
|
|
|
185
205
|
private readonly clock: ReportClock;
|
|
186
206
|
private readonly logger: NonNullable<ReportServiceOptions['logger']>;
|
|
187
207
|
private readonly maxRows: number;
|
|
208
|
+
private readonly resolveOwnerContext?: OwnerContextResolver;
|
|
188
209
|
|
|
189
210
|
constructor(opts: ReportServiceOptions) {
|
|
190
211
|
this.engine = opts.engine;
|
|
@@ -192,6 +213,33 @@ export class ReportService implements IReportService {
|
|
|
192
213
|
this.clock = opts.clock ?? { now: () => new Date() };
|
|
193
214
|
this.logger = opts.logger ?? {};
|
|
194
215
|
this.maxRows = Math.max(1, opts.maxRows ?? 5000);
|
|
216
|
+
this.resolveOwnerContext = opts.resolveOwnerContext;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Access control ─────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Authorization for a saved-report row. `sys_saved_report` is a
|
|
223
|
+
* protection-locked system object, so its rows are *read* with
|
|
224
|
+
* `SYSTEM_CTX`; the caller's right to see/mutate a specific report is
|
|
225
|
+
* enforced HERE, by owner match, not by the metadata read's own RLS —
|
|
226
|
+
* otherwise any authenticated caller could read/delete/overwrite any
|
|
227
|
+
* report by id (#2980). An explicit elevated context (`isSystem`) — the
|
|
228
|
+
* scheduler / server tooling — sees everything.
|
|
229
|
+
*/
|
|
230
|
+
private canAccessReport(row: { owner_id?: unknown } | null | undefined, context: SharingExecutionContext | undefined): boolean {
|
|
231
|
+
if (!row) return false;
|
|
232
|
+
if (context?.isSystem) return true;
|
|
233
|
+
const userId = context?.userId;
|
|
234
|
+
return !!userId && row.owner_id === userId;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Raw metadata read of a saved report by id (no authz — callers gate). */
|
|
238
|
+
private async loadReportRow(reportId: string): Promise<any | null> {
|
|
239
|
+
const rows = await this.engine.find('sys_saved_report', {
|
|
240
|
+
filter: { id: reportId }, limit: 1, context: SYSTEM_CTX,
|
|
241
|
+
});
|
|
242
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
195
243
|
}
|
|
196
244
|
|
|
197
245
|
// ── Report CRUD ────────────────────────────────────────────────
|
|
@@ -202,23 +250,33 @@ export class ReportService implements IReportService {
|
|
|
202
250
|
if (!input.query) throw new Error('VALIDATION_FAILED: query is required');
|
|
203
251
|
|
|
204
252
|
const now = this.clock.now().toISOString();
|
|
253
|
+
// A non-system caller always owns what they create — a caller-supplied
|
|
254
|
+
// ownerId cannot assign the report to someone else (#2980). Only an
|
|
255
|
+
// explicit elevated context (server tooling / import) may set it.
|
|
256
|
+
const ownerId = context.isSystem ? (input.ownerId ?? context.userId ?? null) : (context.userId ?? null);
|
|
205
257
|
const payload: any = {
|
|
206
258
|
name: input.name,
|
|
207
259
|
description: input.description ?? null,
|
|
208
260
|
object_name: input.object,
|
|
209
261
|
query_json: JSON.stringify(input.query ?? {}),
|
|
210
262
|
format: input.format ?? DEFAULT_FORMAT,
|
|
211
|
-
owner_id:
|
|
263
|
+
owner_id: ownerId,
|
|
212
264
|
updated_at: now,
|
|
213
265
|
};
|
|
214
266
|
|
|
215
267
|
if (input.id) {
|
|
216
|
-
const existing = await this.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
268
|
+
const existing = await this.loadReportRow(input.id);
|
|
269
|
+
if (existing) {
|
|
270
|
+
// An update to an existing report is a mutation — a caller may only
|
|
271
|
+
// overwrite a report they own (#2980). Not-found for others so the
|
|
272
|
+
// response doesn't leak that the id exists.
|
|
273
|
+
if (!this.canAccessReport(existing, context)) {
|
|
274
|
+
throw new Error(`REPORT_NOT_FOUND: ${input.id}`);
|
|
275
|
+
}
|
|
276
|
+
// Never let a non-system caller reassign ownership away from the row.
|
|
277
|
+
if (!context.isSystem) payload.owner_id = existing.owner_id ?? payload.owner_id;
|
|
220
278
|
await this.engine.update('sys_saved_report', { id: input.id, ...payload }, { context: SYSTEM_CTX });
|
|
221
|
-
return rowFromSaved({ ...existing
|
|
279
|
+
return rowFromSaved({ ...existing, ...payload, id: input.id });
|
|
222
280
|
}
|
|
223
281
|
}
|
|
224
282
|
|
|
@@ -230,26 +288,43 @@ export class ReportService implements IReportService {
|
|
|
230
288
|
|
|
231
289
|
async listReports(
|
|
232
290
|
filter: { object?: string; ownerId?: string } | undefined,
|
|
233
|
-
|
|
291
|
+
context: SharingExecutionContext,
|
|
234
292
|
): Promise<SavedReport[]> {
|
|
235
293
|
const f: any = {};
|
|
236
294
|
if (filter?.object) f.object_name = filter.object;
|
|
237
|
-
|
|
295
|
+
// Owner scoping (#2980): a non-system caller sees ONLY their own reports —
|
|
296
|
+
// a caller-supplied ownerId can never widen past their own id. A caller
|
|
297
|
+
// with no identity sees nothing (fail closed). System/tooling sees all,
|
|
298
|
+
// honouring an explicit ownerId narrow.
|
|
299
|
+
if (context?.isSystem) {
|
|
300
|
+
if (filter?.ownerId) f.owner_id = filter.ownerId;
|
|
301
|
+
} else {
|
|
302
|
+
if (!context?.userId) return [];
|
|
303
|
+
if (filter?.ownerId && filter.ownerId !== context.userId) return [];
|
|
304
|
+
f.owner_id = context.userId;
|
|
305
|
+
}
|
|
238
306
|
const rows = await this.engine.find('sys_saved_report', {
|
|
239
307
|
filter: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX,
|
|
240
308
|
});
|
|
241
309
|
return Array.isArray(rows) ? rows.map(rowFromSaved) : [];
|
|
242
310
|
}
|
|
243
311
|
|
|
244
|
-
async getReport(reportId: string,
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return
|
|
312
|
+
async getReport(reportId: string, context: SharingExecutionContext): Promise<SavedReport | null> {
|
|
313
|
+
const row = await this.loadReportRow(reportId);
|
|
314
|
+
// Unauthorized reads are indistinguishable from a genuine miss (#2980).
|
|
315
|
+
if (!this.canAccessReport(row, context)) return null;
|
|
316
|
+
return rowFromSaved(row);
|
|
249
317
|
}
|
|
250
318
|
|
|
251
|
-
async deleteReport(reportId: string,
|
|
319
|
+
async deleteReport(reportId: string, context: SharingExecutionContext): Promise<void> {
|
|
252
320
|
if (!reportId) throw new Error('VALIDATION_FAILED: reportId is required');
|
|
321
|
+
const row = await this.loadReportRow(reportId);
|
|
322
|
+
if (!row) return; // idempotent — nothing to drop
|
|
323
|
+
// A caller may only delete a report they own (#2980); others get a
|
|
324
|
+
// not-found so the delete neither fires nor reveals the report's existence.
|
|
325
|
+
if (!this.canAccessReport(row, context)) {
|
|
326
|
+
throw new Error(`REPORT_NOT_FOUND: ${reportId}`);
|
|
327
|
+
}
|
|
253
328
|
// Cascade — drop attached schedules first.
|
|
254
329
|
const schedules = await this.engine.find('sys_report_schedule', {
|
|
255
330
|
filter: { report_id: reportId }, limit: 500, context: SYSTEM_CTX,
|
|
@@ -410,8 +485,8 @@ export class ReportService implements IReportService {
|
|
|
410
485
|
let fired = 0, failed = 0, skipped = 0;
|
|
411
486
|
for (const schedule of list) {
|
|
412
487
|
try {
|
|
413
|
-
const
|
|
414
|
-
if (!
|
|
488
|
+
const row = await this.loadReportRow(schedule.report_id);
|
|
489
|
+
if (!row) {
|
|
415
490
|
skipped++;
|
|
416
491
|
await this.markSchedule(schedule.id, {
|
|
417
492
|
last_status: 'skipped',
|
|
@@ -419,10 +494,36 @@ export class ReportService implements IReportService {
|
|
|
419
494
|
});
|
|
420
495
|
continue;
|
|
421
496
|
}
|
|
497
|
+
const report = rowFromSaved(row);
|
|
498
|
+
|
|
499
|
+
// Run the report under the OWNER's authority, not system (#2980).
|
|
500
|
+
// A scheduled run must not read rows the report's owner cannot see —
|
|
501
|
+
// that was a silent RLS bypass (a member's scheduled report emailed
|
|
502
|
+
// the target object's entire table). Resolve the owner to a real
|
|
503
|
+
// RLS-bearing context; if we can't (no resolver wired, or unknown/
|
|
504
|
+
// disabled owner), FAIL CLOSED rather than run elevated.
|
|
505
|
+
const ownerId = report.owner_id;
|
|
506
|
+
const runContext = ownerId && this.resolveOwnerContext
|
|
507
|
+
? await this.resolveOwnerContext(ownerId).catch((err) => {
|
|
508
|
+
this.logger.warn?.('ReportService.dispatchDue: owner context resolution failed', err);
|
|
509
|
+
return null;
|
|
510
|
+
})
|
|
511
|
+
: null;
|
|
512
|
+
if (!runContext) {
|
|
513
|
+
failed++;
|
|
514
|
+
await this.markSchedule(schedule.id, {
|
|
515
|
+
last_status: 'failed',
|
|
516
|
+
last_error: ownerId
|
|
517
|
+
? `owner '${ownerId}' context unavailable — refusing to run scheduled report with RLS bypassed (#2849/#2980)`
|
|
518
|
+
: 'report has no owner — refusing to run scheduled report with RLS bypassed (#2849/#2980)',
|
|
519
|
+
});
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
422
523
|
// Force the schedule's own format so the recipient gets what
|
|
423
524
|
// the admin configured (CSV attachment vs inline HTML table).
|
|
424
525
|
const fmt: ReportFormat = (schedule.format ?? 'html_table') as ReportFormat;
|
|
425
|
-
const result = await this.executeReport({ ...report, format: fmt },
|
|
526
|
+
const result = await this.executeReport({ ...report, format: fmt }, runContext, false);
|
|
426
527
|
|
|
427
528
|
const recipients = schedule.recipients.split(',').map(s => s.trim()).filter(Boolean);
|
|
428
529
|
const subject = renderSubject(schedule.subject_template, {
|
package/src/reports-plugin.ts
CHANGED
|
@@ -87,6 +87,13 @@ export class ReportsServicePlugin implements Plugin {
|
|
|
87
87
|
email,
|
|
88
88
|
logger: ctx.logger,
|
|
89
89
|
maxRows: this.options.maxRows,
|
|
90
|
+
// Scheduled reports run under the owner's resolved RLS context, not a
|
|
91
|
+
// system bypass (#2980). No owner-context resolver is wired yet — that
|
|
92
|
+
// is the reports-surface consumer of ADR-0073's user-less identity
|
|
93
|
+
// resolution (M2) — so until it lands, scheduled runs FAIL CLOSED
|
|
94
|
+
// (skipped + marked failed) rather than exfiltrate. Interactive runs
|
|
95
|
+
// (run/runAdHoc) are unaffected: they carry the caller's context.
|
|
96
|
+
resolveOwnerContext: undefined,
|
|
90
97
|
});
|
|
91
98
|
ctx.registerService('reports', this.service);
|
|
92
99
|
|