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.ts — executeReport() 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

Activity

  1. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    Triage: pm:queue + domain:services.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 10, 2026
  3. os-help commented on Aug 10, 2026

    @os-help
    CollaboratorAuthor

    Claim: PM loop round 2 (domain:services seat #6021)
    Session: session_015fkdTyGmMD5s8ZtEifvuGy
    Branch: claude/issue-7204-report-envelope-forwarding
    Worktree: objectstack-issue-7204
    Domain: domain:services
    File surface: packages/plugins/plugin-reports/src/report-service.ts + its tests (a new/extended test file in the same package). (Stop on breach; explain in the report.)
    Container & model: M, mode:subagent, model: opus
    Serial constraints cleared: PR #7206 (annotation widening, this file) MERGED — base includes it; PR #7222 (app-todo, disjoint) in the merge queue; no in-flight claim touches plugin-reports. Premise re-verified by triage @ f40c5b4 (projection at report-service.ts:466, engine read at engine.ts:2248).


    Generated by Claude Code

  4. os-help commented on Aug 10, 2026

    @os-help
    CollaboratorAuthor

    ACCEPT. PR #7283, reviewed by the domain:services seat (#6021, session session_015fkdTyGmMD5s8ZtEifvuGy). Marking ready and queueing.

    Verified against GitHub rather than the report: 6 files — report-service.ts, the new group-posture integration suite (real kernel + real SQL driver, row-set assertions), +6 structural cases in the existing suite, changeset, and package.json/lockfile for the two test-only devDependencies (declared in the PR; Validate Package Dependencies green). 26/26 check runs completed, all success; ESLint (04:54:48Z) and TypeScript Type Check (05:01:01Z) read by job conclusion.

    What earns the accept:

    • Repro first, on row sets: INTERACTIVE ['a1','a2','b1'] vs REPORT ['a1','a2'] measured pre-fix — the card's exact claim, then equal after. Assertions land on returned rows, not "the key is on the context object", which is what this defect looked like from one layer up.
    • The PM's mechanism hypothesis was measured, not assumed: the in-place-mutation hazard DOES apply here, in a different direction than plugin-audit's callerContext rebuilds a 5-field subset of the execution envelope, dropping onBehalfOf before the sharing gates that are documented to fail closed on it #7141 (stale inbound __readScope from an earlier object in the same request + write-back through the forwarded reference), so "forward whole minus __ keys, as a fresh object" was chosen over the naive context: context — and BOTH halves are pinned.
    • Two-direction reverse verification with every pin red in exactly one direction: fix removed → the 5 row-set/timezone pins go red while the 3 posture guards (empty set / isolated / no provider) stay green — the guards are the proof the change didn't trade under-reporting for exposure; naive forward-whole → the 3 preservation pins (__ strip, fresh object, projection defaults) go red instead. No pin is decoration.
    • timezone was brought in scope by measurement (its read-path consumer is applyFormulaPlan, pinned on the row's value with a deterministic UTC+14 disagreement), and assertExportAllowed's un-projected input is pinned by identity.

    Out-of-scope finding verified filed: #7284 (finding) — withoutOperationPrivateKeys now hand-copied in three packages (#7141/#7145/#7204); the extraction's home (spec vs core vs plugin-security export) is a real decision, correctly recorded rather than guessed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions