@objectstack/plugin-reports 15.0.0 → 15.1.1

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/plugin-reports@15.0.0 build /home/runner/work/framework/framework/packages/plugins/plugin-reports
2
+ > @objectstack/plugin-reports@15.1.1 build /home/runner/work/framework/framework/packages/plugins/plugin-reports
3
3
  > tsup --config ../../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- ESM dist/index.mjs 19.42 KB
14
- ESM dist/index.mjs.map 39.48 KB
15
- ESM ⚡️ Build success in 99ms
16
- CJS dist/index.js 20.55 KB
17
- CJS dist/index.js.map 39.47 KB
18
- CJS ⚡️ Build success in 99ms
13
+ ESM dist/index.mjs 22.17 KB
14
+ ESM dist/index.mjs.map 46.00 KB
15
+ ESM ⚡️ Build success in 168ms
16
+ CJS dist/index.js 23.30 KB
17
+ CJS dist/index.js.map 46.00 KB
18
+ CJS ⚡️ Build success in 178ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 19210ms
21
- DTS dist/index.d.mts 5.60 KB
22
- DTS dist/index.d.ts 5.60 KB
20
+ DTS ⚡️ Build success in 19712ms
21
+ DTS dist/index.d.mts 7.17 KB
22
+ DTS dist/index.d.ts 7.17 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,92 @@
1
1
  # @objectstack/plugin-reports
2
2
 
3
+ ## 15.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - @objectstack/spec@15.1.1
8
+ - @objectstack/core@15.1.1
9
+ - @objectstack/platform-objects@15.1.1
10
+
11
+ ## 15.1.0
12
+
13
+ ### Patch Changes
14
+
15
+ - f531a26: fix(security): close three execution-surface authz holes surfaced by the #2849 class sweep (#2980, #2981, #2982)
16
+
17
+ Three independent, confirmed-exploitable defects where an execution surface
18
+ ignored the caller's identity or fell open on a missing one. Each is fixed at
19
+ its own enforcement point; none change behaviour for correctly-scoped callers.
20
+
21
+ - **#2980 — reports IDOR + scheduled-report RLS bypass.** `ReportService`
22
+ discarded the caller's context and read/wrote `sys_saved_report` with a system
23
+ context, so any authenticated user could read, delete, or overwrite any saved
24
+ report by id (cross-owner / cross-tenant), and `listReports` enumerated all
25
+ owners. `getReport`/`deleteReport`/`saveReport`/`listReports` are now
26
+ owner-scoped (system read of the protection-locked metadata object, but
27
+ authorization enforced by owner match); create/overwrite can no longer spoof
28
+ ownership. Scheduled dispatch no longer runs `isSystem` (which emailed the
29
+ target object's entire table past the owner's RLS): it resolves the owner to a
30
+ real RLS-bearing context via a new `resolveOwnerContext` seam and **fails
31
+ closed** (skips + marks the schedule failed) when the owner can't be resolved,
32
+ rather than running elevated. Wiring that resolver is the reports-surface
33
+ consumer of ADR-0073's user-less identity resolution.
34
+
35
+ - **#2981 — knowledge/RAG retrieval fall-open.** `applyPermissionFilter` returned
36
+ every hit when the context was missing _or_ system. A missing identity is no
37
+ longer treated as a grant: object-backed hits fail closed (dropped, keeping
38
+ ACL-less file/http hits), and only an **explicit** system context passes
39
+ through. Closes the agent path where an omitted `ToolExecutionContext.actor`
40
+ yielded unfiltered semantic search over the whole corpus.
41
+
42
+ - **#2982 — bulk-write OWD gap.** `update({multi:true})` / `deleteMany` had no
43
+ single id to `canEdit`-gate, so owner scoping was skipped on private (and
44
+ public_read) objects. A new `SharingService.buildWriteFilter` (the edit-set
45
+ analogue of `buildReadFilter`) is AND-ed into the write AST for multi writes,
46
+ constraining them to rows the caller may edit — including the on-behalf-of
47
+ delegator intersection.
48
+
49
+ Tracked as the motivating evidence of ADR-0096 (execution-surface identity
50
+ admission); the mechanism that would prevent the class structurally is separate.
51
+
52
+ - Updated dependencies [f531a26]
53
+ - Updated dependencies [f531a26]
54
+ - Updated dependencies [f531a26]
55
+ - Updated dependencies [f531a26]
56
+ - Updated dependencies [f531a26]
57
+ - Updated dependencies [f531a26]
58
+ - Updated dependencies [3fe9df1]
59
+ - Updated dependencies [f531a26]
60
+ - Updated dependencies [f531a26]
61
+ - Updated dependencies [f531a26]
62
+ - Updated dependencies [f531a26]
63
+ - Updated dependencies [f531a26]
64
+ - Updated dependencies [f531a26]
65
+ - Updated dependencies [f531a26]
66
+ - Updated dependencies [f531a26]
67
+ - Updated dependencies [f531a26]
68
+ - Updated dependencies [f531a26]
69
+ - Updated dependencies [f531a26]
70
+ - Updated dependencies [f531a26]
71
+ - Updated dependencies [f531a26]
72
+ - Updated dependencies [f531a26]
73
+ - Updated dependencies [f531a26]
74
+ - Updated dependencies [4109153]
75
+ - Updated dependencies [f531a26]
76
+ - Updated dependencies [f531a26]
77
+ - Updated dependencies [f531a26]
78
+ - Updated dependencies [f531a26]
79
+ - Updated dependencies [f531a26]
80
+ - Updated dependencies [f531a26]
81
+ - Updated dependencies [f531a26]
82
+ - Updated dependencies [627f225]
83
+ - Updated dependencies [f531a26]
84
+ - Updated dependencies [f531a26]
85
+ - Updated dependencies [f531a26]
86
+ - @objectstack/spec@15.1.0
87
+ - @objectstack/platform-objects@15.1.0
88
+ - @objectstack/core@15.1.0
89
+
3
90
  ## 15.0.0
4
91
 
5
92
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { SysReportSchedule, SysSavedReport } from '@objectstack/platform-objects/audit';
2
- import { IReportService, SaveReportInput, SharingExecutionContext, SavedReport, ReportRunResult, ScheduleReportInput, ReportSchedule, ReportFormat } from '@objectstack/spec/contracts';
2
+ import { IReportService, SharingExecutionContext, SaveReportInput, SavedReport, ReportRunResult, ScheduleReportInput, ReportSchedule, ReportFormat } from '@objectstack/spec/contracts';
3
3
  export { IReportService, ReportFormat, ReportQuery, ReportRunResult, ReportSchedule, SaveReportInput, SavedReport, ScheduleReportInput } from '@objectstack/spec/contracts';
4
4
  import { Plugin, PluginContext } from '@objectstack/core';
5
5
 
@@ -40,6 +40,17 @@ interface ReportClock {
40
40
  now(): Date;
41
41
  }
42
42
  declare function renderReport(rows: any[], format: ReportFormat, fields?: string[]): string;
43
+ /**
44
+ * Resolves a saved report's owner (`owner_id`) into a real, RLS-bearing
45
+ * `ExecutionContext` so a **scheduled** report executes under the owner's
46
+ * authority — the same rows the owner would see interactively — instead of
47
+ * bypassing RLS with a system context. Returns `null` when the owner cannot
48
+ * be resolved (unknown/disabled user), in which case the scheduler fails the
49
+ * run closed rather than running elevated (#2849 / #2980). Supplying this
50
+ * resolver is the reports-surface consumer of ADR-0073's user-less identity
51
+ * resolution.
52
+ */
53
+ type OwnerContextResolver = (ownerId: string) => Promise<SharingExecutionContext | null>;
43
54
  interface ReportServiceOptions {
44
55
  engine: ReportEngine;
45
56
  email?: ReportEmail;
@@ -51,6 +62,12 @@ interface ReportServiceOptions {
51
62
  };
52
63
  /** Cap rows per report to protect both DB and email size. */
53
64
  maxRows?: number;
65
+ /**
66
+ * Resolves a report owner into an RLS-bearing context for scheduled runs
67
+ * (see {@link OwnerContextResolver}). When omitted, scheduled reports fail
68
+ * closed instead of running with RLS bypassed (#2980).
69
+ */
70
+ resolveOwnerContext?: OwnerContextResolver;
54
71
  }
55
72
  declare class ReportService implements IReportService {
56
73
  private readonly engine;
@@ -58,14 +75,27 @@ declare class ReportService implements IReportService {
58
75
  private readonly clock;
59
76
  private readonly logger;
60
77
  private readonly maxRows;
78
+ private readonly resolveOwnerContext?;
61
79
  constructor(opts: ReportServiceOptions);
80
+ /**
81
+ * Authorization for a saved-report row. `sys_saved_report` is a
82
+ * protection-locked system object, so its rows are *read* with
83
+ * `SYSTEM_CTX`; the caller's right to see/mutate a specific report is
84
+ * enforced HERE, by owner match, not by the metadata read's own RLS —
85
+ * otherwise any authenticated caller could read/delete/overwrite any
86
+ * report by id (#2980). An explicit elevated context (`isSystem`) — the
87
+ * scheduler / server tooling — sees everything.
88
+ */
89
+ private canAccessReport;
90
+ /** Raw metadata read of a saved report by id (no authz — callers gate). */
91
+ private loadReportRow;
62
92
  saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise<SavedReport>;
63
93
  listReports(filter: {
64
94
  object?: string;
65
95
  ownerId?: string;
66
- } | undefined, _context: SharingExecutionContext): Promise<SavedReport[]>;
67
- getReport(reportId: string, _context: SharingExecutionContext): Promise<SavedReport | null>;
68
- deleteReport(reportId: string, _context: SharingExecutionContext): Promise<void>;
96
+ } | undefined, context: SharingExecutionContext): Promise<SavedReport[]>;
97
+ getReport(reportId: string, context: SharingExecutionContext): Promise<SavedReport | null>;
98
+ deleteReport(reportId: string, context: SharingExecutionContext): Promise<void>;
69
99
  run(reportId: string, context: SharingExecutionContext): Promise<ReportRunResult>;
70
100
  runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise<ReportRunResult>;
71
101
  private executeReport;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { SysReportSchedule, SysSavedReport } from '@objectstack/platform-objects/audit';
2
- import { IReportService, SaveReportInput, SharingExecutionContext, SavedReport, ReportRunResult, ScheduleReportInput, ReportSchedule, ReportFormat } from '@objectstack/spec/contracts';
2
+ import { IReportService, SharingExecutionContext, SaveReportInput, SavedReport, ReportRunResult, ScheduleReportInput, ReportSchedule, ReportFormat } from '@objectstack/spec/contracts';
3
3
  export { IReportService, ReportFormat, ReportQuery, ReportRunResult, ReportSchedule, SaveReportInput, SavedReport, ScheduleReportInput } from '@objectstack/spec/contracts';
4
4
  import { Plugin, PluginContext } from '@objectstack/core';
5
5
 
@@ -40,6 +40,17 @@ interface ReportClock {
40
40
  now(): Date;
41
41
  }
42
42
  declare function renderReport(rows: any[], format: ReportFormat, fields?: string[]): string;
43
+ /**
44
+ * Resolves a saved report's owner (`owner_id`) into a real, RLS-bearing
45
+ * `ExecutionContext` so a **scheduled** report executes under the owner's
46
+ * authority — the same rows the owner would see interactively — instead of
47
+ * bypassing RLS with a system context. Returns `null` when the owner cannot
48
+ * be resolved (unknown/disabled user), in which case the scheduler fails the
49
+ * run closed rather than running elevated (#2849 / #2980). Supplying this
50
+ * resolver is the reports-surface consumer of ADR-0073's user-less identity
51
+ * resolution.
52
+ */
53
+ type OwnerContextResolver = (ownerId: string) => Promise<SharingExecutionContext | null>;
43
54
  interface ReportServiceOptions {
44
55
  engine: ReportEngine;
45
56
  email?: ReportEmail;
@@ -51,6 +62,12 @@ interface ReportServiceOptions {
51
62
  };
52
63
  /** Cap rows per report to protect both DB and email size. */
53
64
  maxRows?: number;
65
+ /**
66
+ * Resolves a report owner into an RLS-bearing context for scheduled runs
67
+ * (see {@link OwnerContextResolver}). When omitted, scheduled reports fail
68
+ * closed instead of running with RLS bypassed (#2980).
69
+ */
70
+ resolveOwnerContext?: OwnerContextResolver;
54
71
  }
55
72
  declare class ReportService implements IReportService {
56
73
  private readonly engine;
@@ -58,14 +75,27 @@ declare class ReportService implements IReportService {
58
75
  private readonly clock;
59
76
  private readonly logger;
60
77
  private readonly maxRows;
78
+ private readonly resolveOwnerContext?;
61
79
  constructor(opts: ReportServiceOptions);
80
+ /**
81
+ * Authorization for a saved-report row. `sys_saved_report` is a
82
+ * protection-locked system object, so its rows are *read* with
83
+ * `SYSTEM_CTX`; the caller's right to see/mutate a specific report is
84
+ * enforced HERE, by owner match, not by the metadata read's own RLS —
85
+ * otherwise any authenticated caller could read/delete/overwrite any
86
+ * report by id (#2980). An explicit elevated context (`isSystem`) — the
87
+ * scheduler / server tooling — sees everything.
88
+ */
89
+ private canAccessReport;
90
+ /** Raw metadata read of a saved report by id (no authz — callers gate). */
91
+ private loadReportRow;
62
92
  saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise<SavedReport>;
63
93
  listReports(filter: {
64
94
  object?: string;
65
95
  ownerId?: string;
66
- } | undefined, _context: SharingExecutionContext): Promise<SavedReport[]>;
67
- getReport(reportId: string, _context: SharingExecutionContext): Promise<SavedReport | null>;
68
- deleteReport(reportId: string, _context: SharingExecutionContext): Promise<void>;
96
+ } | undefined, context: SharingExecutionContext): Promise<SavedReport[]>;
97
+ getReport(reportId: string, context: SharingExecutionContext): Promise<SavedReport | null>;
98
+ deleteReport(reportId: string, context: SharingExecutionContext): Promise<void>;
69
99
  run(reportId: string, context: SharingExecutionContext): Promise<ReportRunResult>;
70
100
  runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise<ReportRunResult>;
71
101
  private executeReport;
package/dist/index.js CHANGED
@@ -154,6 +154,32 @@ var ReportService = class {
154
154
  this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
155
155
  this.logger = opts.logger ?? {};
156
156
  this.maxRows = Math.max(1, opts.maxRows ?? 5e3);
157
+ this.resolveOwnerContext = opts.resolveOwnerContext;
158
+ }
159
+ // ── Access control ─────────────────────────────────────────────
160
+ /**
161
+ * Authorization for a saved-report row. `sys_saved_report` is a
162
+ * protection-locked system object, so its rows are *read* with
163
+ * `SYSTEM_CTX`; the caller's right to see/mutate a specific report is
164
+ * enforced HERE, by owner match, not by the metadata read's own RLS —
165
+ * otherwise any authenticated caller could read/delete/overwrite any
166
+ * report by id (#2980). An explicit elevated context (`isSystem`) — the
167
+ * scheduler / server tooling — sees everything.
168
+ */
169
+ canAccessReport(row, context) {
170
+ if (!row) return false;
171
+ if (context?.isSystem) return true;
172
+ const userId = context?.userId;
173
+ return !!userId && row.owner_id === userId;
174
+ }
175
+ /** Raw metadata read of a saved report by id (no authz — callers gate). */
176
+ async loadReportRow(reportId) {
177
+ const rows = await this.engine.find("sys_saved_report", {
178
+ filter: { id: reportId },
179
+ limit: 1,
180
+ context: SYSTEM_CTX
181
+ });
182
+ return Array.isArray(rows) && rows[0] ? rows[0] : null;
157
183
  }
158
184
  // ── Report CRUD ────────────────────────────────────────────────
159
185
  async saveReport(input, context) {
@@ -161,24 +187,25 @@ var ReportService = class {
161
187
  if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
162
188
  if (!input.query) throw new Error("VALIDATION_FAILED: query is required");
163
189
  const now = this.clock.now().toISOString();
190
+ const ownerId = context.isSystem ? input.ownerId ?? context.userId ?? null : context.userId ?? null;
164
191
  const payload = {
165
192
  name: input.name,
166
193
  description: input.description ?? null,
167
194
  object_name: input.object,
168
195
  query_json: JSON.stringify(input.query ?? {}),
169
196
  format: input.format ?? DEFAULT_FORMAT,
170
- owner_id: input.ownerId ?? context.userId ?? null,
197
+ owner_id: ownerId,
171
198
  updated_at: now
172
199
  };
173
200
  if (input.id) {
174
- const existing = await this.engine.find("sys_saved_report", {
175
- filter: { id: input.id },
176
- limit: 1,
177
- context: SYSTEM_CTX
178
- });
179
- if (Array.isArray(existing) && existing[0]) {
201
+ const existing = await this.loadReportRow(input.id);
202
+ if (existing) {
203
+ if (!this.canAccessReport(existing, context)) {
204
+ throw new Error(`REPORT_NOT_FOUND: ${input.id}`);
205
+ }
206
+ if (!context.isSystem) payload.owner_id = existing.owner_id ?? payload.owner_id;
180
207
  await this.engine.update("sys_saved_report", { id: input.id, ...payload }, { context: SYSTEM_CTX });
181
- return rowFromSaved({ ...existing[0], ...payload, id: input.id });
208
+ return rowFromSaved({ ...existing, ...payload, id: input.id });
182
209
  }
183
210
  }
184
211
  const id = input.id ?? uid("rpt");
@@ -186,10 +213,16 @@ var ReportService = class {
186
213
  await this.engine.insert("sys_saved_report", row, { context: SYSTEM_CTX });
187
214
  return rowFromSaved(row);
188
215
  }
189
- async listReports(filter, _context) {
216
+ async listReports(filter, context) {
190
217
  const f = {};
191
218
  if (filter?.object) f.object_name = filter.object;
192
- if (filter?.ownerId) f.owner_id = filter.ownerId;
219
+ if (context?.isSystem) {
220
+ if (filter?.ownerId) f.owner_id = filter.ownerId;
221
+ } else {
222
+ if (!context?.userId) return [];
223
+ if (filter?.ownerId && filter.ownerId !== context.userId) return [];
224
+ f.owner_id = context.userId;
225
+ }
193
226
  const rows = await this.engine.find("sys_saved_report", {
194
227
  filter: f,
195
228
  limit: 500,
@@ -198,16 +231,18 @@ var ReportService = class {
198
231
  });
199
232
  return Array.isArray(rows) ? rows.map(rowFromSaved) : [];
200
233
  }
201
- async getReport(reportId, _context) {
202
- const rows = await this.engine.find("sys_saved_report", {
203
- filter: { id: reportId },
204
- limit: 1,
205
- context: SYSTEM_CTX
206
- });
207
- return Array.isArray(rows) && rows[0] ? rowFromSaved(rows[0]) : null;
234
+ async getReport(reportId, context) {
235
+ const row = await this.loadReportRow(reportId);
236
+ if (!this.canAccessReport(row, context)) return null;
237
+ return rowFromSaved(row);
208
238
  }
209
- async deleteReport(reportId, _context) {
239
+ async deleteReport(reportId, context) {
210
240
  if (!reportId) throw new Error("VALIDATION_FAILED: reportId is required");
241
+ const row = await this.loadReportRow(reportId);
242
+ if (!row) return;
243
+ if (!this.canAccessReport(row, context)) {
244
+ throw new Error(`REPORT_NOT_FOUND: ${reportId}`);
245
+ }
211
246
  const schedules = await this.engine.find("sys_report_schedule", {
212
247
  filter: { report_id: reportId },
213
248
  limit: 500,
@@ -353,8 +388,8 @@ var ReportService = class {
353
388
  let fired = 0, failed = 0, skipped = 0;
354
389
  for (const schedule of list) {
355
390
  try {
356
- const report = await this.getReport(schedule.report_id, { isSystem: true });
357
- if (!report) {
391
+ const row = await this.loadReportRow(schedule.report_id);
392
+ if (!row) {
358
393
  skipped++;
359
394
  await this.markSchedule(schedule.id, {
360
395
  last_status: "skipped",
@@ -362,8 +397,22 @@ var ReportService = class {
362
397
  });
363
398
  continue;
364
399
  }
400
+ const report = rowFromSaved(row);
401
+ const ownerId = report.owner_id;
402
+ const runContext = ownerId && this.resolveOwnerContext ? await this.resolveOwnerContext(ownerId).catch((err) => {
403
+ this.logger.warn?.("ReportService.dispatchDue: owner context resolution failed", err);
404
+ return null;
405
+ }) : null;
406
+ if (!runContext) {
407
+ failed++;
408
+ await this.markSchedule(schedule.id, {
409
+ last_status: "failed",
410
+ last_error: ownerId ? `owner '${ownerId}' context unavailable \u2014 refusing to run scheduled report with RLS bypassed (#2849/#2980)` : "report has no owner \u2014 refusing to run scheduled report with RLS bypassed (#2849/#2980)"
411
+ });
412
+ continue;
413
+ }
365
414
  const fmt = schedule.format ?? "html_table";
366
- const result = await this.executeReport({ ...report, format: fmt }, { isSystem: true }, false);
415
+ const result = await this.executeReport({ ...report, format: fmt }, runContext, false);
367
416
  const recipients = schedule.recipients.split(",").map((s) => s.trim()).filter(Boolean);
368
417
  const subject = renderSubject(schedule.subject_template, {
369
418
  name: schedule.name ?? report.name,
@@ -510,7 +559,14 @@ var ReportsServicePlugin = class {
510
559
  engine,
511
560
  email,
512
561
  logger: ctx.logger,
513
- maxRows: this.options.maxRows
562
+ maxRows: this.options.maxRows,
563
+ // Scheduled reports run under the owner's resolved RLS context, not a
564
+ // system bypass (#2980). No owner-context resolver is wired yet — that
565
+ // is the reports-surface consumer of ADR-0073's user-less identity
566
+ // resolution (M2) — so until it lands, scheduled runs FAIL CLOSED
567
+ // (skipped + marked failed) rather than exfiltrate. Interactive runs
568
+ // (run/runAdHoc) are unaffected: they carry the caller's context.
569
+ resolveOwnerContext: void 0
514
570
  });
515
571
  ctx.registerService("reports", this.service);
516
572
  if (this.options.disableDispatcher) {