Skip to content

finding: every sys_record_share grant row lands organization_id NULL — SharingService writes under a bare system context and the row literal never carries the column #14484

Description

@os-musk

Filed unassigned by the domain:engine execution seat while running the read-side census for #13564 (measurement card, no code lands there). Recorded here rather than folded into that card, per its Zone 1.

Measured read-only on objectstack-ai/objectstack@9e286e248866c40d2db662f69aa0ba6e71b4b096.

The observation

packages/plugins/plugin-sharing/src/sharing-service.ts is the only writer of sys_record_share in this repository, and it writes under a bare system context:

  • :1261await this.engine.insert('sys_record_share', row, { context: SYSTEM_CTX });
  • :1242 — the update half, same context
  • SYSTEM_CTX at :66 is { isSystem: true, positions: [], permissions: [] } — no tenantId

The inserted row literal (:1246:1259) carries id, object_name, record_id, recipient_type, recipient_id, access_level, source, source_id, granted_by, reason, created_at, updated_at — and no organization_id. git grep organization_id over the whole file returns exactly one hit, in an unrelated doc comment at :118.

Nothing else stamps it: SqlDriver.injectTenantOnInsert only fires when DriverOptions.tenantId is present, and ObjectQLEngine.buildDriverOptions only sets that when execCtx.tenantId !== undefined. A bare { isSystem: true } context therefore reaches the driver with no tenant to stamp from.

Every sys_record_share row on every deployment is written organization_id = NULL, including grants materialised by the sharing-rule evaluator.

sys_record_share carries the tenant column (it is one of the 59 platform objects resolveTenantField resolves), and it is unclassified in the #13491 per-object tenancy ledger (packages/objectql/src/tenancy/platform-object-tenancy.ts) — so no writer-repair or design fact has ever been recorded for it either way.

Why it is worth a card rather than a shrug

The object is not currently broken by this: its readers are unscoped too (11 bare-SYSTEM_CTX read sites in the same service), so writes and reads agree today. What the NULL costs is elsewhere:

  1. Tenant attribution on a grant table. A record-share grant is the row that answers "who was given access to this record" — and it currently answers it without saying in which organization.
  2. It is a live member of the NULL-org producer class the 2026-08-31 ruling on design: isSystem 写入是否在租户审计控制范围内?——#13178 类级装置(A/B/C)的共同前置,从未被裁过 #13491 addresses, on an object the ledger has not adjudicated.
  3. The project's own reading of what a NULL organization means under a wall is in the same file, [#6139] at :1500: "under single it is the one implicit tenant and DEPTH resolves normally; under group/isolated it is a missing constraint and the resolver's fail-closed obligation applies."
  4. Any future tenant-facing read of this object inherits plugin-security's Layer 0, whose strict organization_id = :tenant AND-composes over the driver's NULL-tolerant arm and wins — the exact asymmetry packages/services/service-storage/src/backfill-sys-file-organizations.ts was ordered to repair for sys_file.

What this finding does NOT claim

  • ⛔ Not a measured cross-tenant read. No database or runtime was available in this session.
  • ⛔ No repair is proposed. sys_file needed a maintainer order per table for its backfill (2026-08-28); the precedent in backfill-sys-file-organizations.ts says so in terms and forbids extending a sweep to a second table without one. Whether sys_record_share should be stamped forward, backfilled, or ruled legitimately org-less is a decision, not a measurement.

Duplicate search

Searched before filing (channel proven live — 36 results). Nearest neighbours, none of which is this: #10119 (an org-stamped rule's criteria sweep runs unscoped — the read side, and criteriaContext now addresses it), #8208 (a record created with no active organization), #11670 / #7676 (org-less rows on sys_permission_set / sys_sharing_rule), #11611 (the general "platform tables never carry organization_id — by design, or planned?" question). No open or closed issue names sys_record_share's writer.

Left ungraded and unassigned — domain:*, type and priority are triage's.

<!-- os-decision-facets -->
① 项目长远合理性(权重 ≥50%):一张授权表说不出「这条授权属于哪个组织」,是租户模型的根问题,不是记账瑕疵。而且它在 #13491 台账里是 unclassified —— 从来没人裁过它到底该不该带组织。⇒ 任何方向的裁定都把一个未判项变成已判项,是缩小不确定面。⚠️ 但「只盖章前推、不 backfill」会留下两套语义:新行有组织、存量行 NULL,读侧一收紧就分叉。
② 实际业务拉动:⚠️ 今天为零,而这是本卡最重要的诚实之处 —— 读侧也不带租户(同文件 11 处裸 SYSTEM_CTX 读),读写自洽,没有一位客户撞上;卡自己写明 ⛔ 不是实测的跨租户读取。
③ 防 AI 犯错:这才是真正的风险面。任何人将来给这张表加一个面向租户的读,就继承 plugin-security 的 Layer 0 严格 organization_id = :tenant,它与驱动那条容忍 NULL 的臂 AND 合成并且赢 ⇒ 所有存量授权在那一刻静默消失(不是响亮拒绝,是「这个人本来就没被授权过」)。sys_file 当初被下令 backfill,正是同一条不对称。
④ 创业阶段不扩散:「裁定它合法地无组织」零新增,但必须写进 #13491 台账,否则下一次审计再问一遍;「盖章 + backfill」是一次性数据迁移一条永久写入义务。

推荐:先裁范围,再裁方向。(a) 无论后续走哪支都该做、且不动数据的一步:把 sys_record_share#13491 台账的 unclassified 移出并写明理由;(b) 方向本身按 sys_file 先例逐表下令 —— 盖章前推 + backfill,还是裁定合法无组织。⛔ 席位不代裁:碰租户隔离边界,且一支含存量数据迁移,双重人工地板。

置信缺口(本分析看不见什么):没有数据库或运行时 ⇒「是否真能跨租户读到」既未证实也未证伪;也读不到现存部署里 sys_record_share 有多少行,而 backfill 的代价完全取决于那个数。

Generated by Claude Code

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions