@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.
@@ -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: input.ownerId ?? context.userId ?? null,
263
+ owner_id: ownerId,
212
264
  updated_at: now,
213
265
  };
214
266
 
215
267
  if (input.id) {
216
- const existing = await this.engine.find('sys_saved_report', {
217
- filter: { id: input.id }, limit: 1, context: SYSTEM_CTX,
218
- });
219
- if (Array.isArray(existing) && existing[0]) {
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[0], ...payload, id: input.id });
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
- _context: SharingExecutionContext,
291
+ context: SharingExecutionContext,
234
292
  ): Promise<SavedReport[]> {
235
293
  const f: any = {};
236
294
  if (filter?.object) f.object_name = filter.object;
237
- if (filter?.ownerId) f.owner_id = filter.ownerId;
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, _context: SharingExecutionContext): Promise<SavedReport | null> {
245
- const rows = await this.engine.find('sys_saved_report', {
246
- filter: { id: reportId }, limit: 1, context: SYSTEM_CTX,
247
- });
248
- return Array.isArray(rows) && rows[0] ? rowFromSaved(rows[0]) : null;
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, _context: SharingExecutionContext): Promise<void> {
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 report = await this.getReport(schedule.report_id, { isSystem: true });
414
- if (!report) {
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 }, { isSystem: true }, false);
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, {
@@ -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