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

Activity

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions