Skip to content

plugin-reports' executeReport rebuilds a 5-field subset of the execution envelope, dropping accessible_org_ids — a group-posture report silently under-reports #7204

Description

@os-help

Out-of-scope observation from implementing #7135, filed rather than fixed — that card was bounded to annotation widening plus redundant-cast deletion, and the change below is a RUNTIME change with an enforcement consequence. This is the plugin-reports twin of #7141 (the plugin-audit one), same shape, different consequence.

Fact

packages/plugins/plugin-reports/src/report-service.tsexecuteReport() does not forward the caller's execution envelope to the engine read that produces the report. It rebuilds a five-field projection of it:

    const rows = await this.engine.find(report.object_name, {
      where: q.filter,
      fields: q.fields,
      orderBy: q.orderBy,
      limit,
      // Reports execute with the caller's identity so sharing rules
      // (if installed) apply. Falls back to system bypass only when
      // the report definition was created by a system writer.
      context: {
        userId: context.userId,
        tenantId: context.tenantId,
        positions: context.positions ?? [],
        permissions: context.permissions ?? [],
        isSystem: context.isSystem ?? false,
      },
    });

That comment states the intent exactly — the report must see the rows the caller would see interactively — and the projection is what stops it. #7135 widened this method's parameter ANNOTATION to the full ExecutionContext (so context here now genuinely holds the whole envelope); the body was deliberately left alone, because changing it is not inert.

The field that matters: accessible_org_ids

packages/objectql/src/engine.ts:2181 reads it straight off the execution context to widen the driver's native tenant scope:

    if (hasTenant && opts.tenantIds === undefined && this.tenancyPostureProvider?.() === 'group') {
      const set = (execCtx as any)?.accessible_org_ids;
      if (Array.isArray(set) && set.length > 0) {
        opts.tenantIds = set.map(String);
      }
    }

accessible_org_ids is not in the projection, so opts.tenantIds is never set for a report. Per the ADR-0105 D2 comment immediately above that block, an absent set makes drivers "fall back to equality: fail toward isolation, never toward exposure".

So the direction here is under-reporting, not a leak — and that is worth stating plainly, because it makes this a correctness bug rather than a security one. Under the group tenancy posture a user's read reach is their whole membership set; the same query returns that union in an interactive list view and collapses to active-org equality inside a saved or scheduled report. The report is silently short rows, with no error and nothing in the output saying so.

posture (ADR-0095 D2), org_user_ids, systemPermissions, onBehalfOf and timezone are dropped by the same projection. timezone is read two lines up in the same engine method (hasTz), which governs date-dependent driver generation.

What I could not establish

Whether any deployment currently runs the group posture with saved reports in use — I did not trace tenancyPostureProvider back to a real configuration, so the blast radius is unmeasured. What is definite is that the projection drops a field the engine reads by name, in a method whose own comment promises the caller's identity is what executes. Grading is triage's call — filed unlabeled.

Why the naive fix may not be right

context: context is the obvious change, and #7141 records why that direction needs its own evidence in the sibling package: plugin-security's middleware MUTATES the operation context in place (sc.__readScope = ...), so a forwarded envelope can carry access DEPTH resolved for a different object. For reports the object of the operation IS report.object_name, so that specific hazard may not apply here — but the isSystem fallback in the projection (context.isSystem ?? false) is doing real work and must survive whatever replaces it, and assertExportAllowed runs against the un-projected context just above. Worth its own card and its own reverse verification rather than a rider.

Dedup

Searched open issues for executeReport / report-service context projection, accessible_org_ids + projection, and plugin-reports + RLS: no hits. #7141 is the same shape in plugin-audit and is closed; it does not cover this file. Not inside #7135's completion scope — that card is annotation widening plus redundant-cast deletion with implementation bodies untouched, exactly as #6523's contract half separated from its consumer half. Standalone rather than a sub-issue.

Related: #7135 (where the annotation was widened and this was deliberately not folded in), #7141 (plugin-audit twin), #7070, #6523, #6206 (ruling).


Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions