Skip to content

sharing: a private external object whose driver serves owner_id without declaring it in fields gives every member every row — hasPhantomOwnerAnchor reads fields only, so the read filter goes unscoped (epic #27261, found by #27332's dev) #27425

Closed

Description

@objectstack-fleet

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Filing gate: ① product defect, exception: possible data exposure (reach measured first).
Reader: the epic PM (#27261) dispatches the fix inside the epic subtree after triage sets the lane; the fix lands in packages/plugins/plugin-sharing (read-filter provenance check).
Dedupe (open AND closed): "federated phantom owner_id read filter unscoped private external object columnMap hasPhantomOwnerAnchor" → 3 hits, all closed and the origin of the design, not this defect (#7858 read-scope lowering, #8119 single-record write gates, #21918 cascade anchors); "external object private sharing read scope member sees every row owner column served by driver not declared" → 37 hits, none this defect.
reach: public HTTP entry, measured once: in a booted composition of the P0 spike (plugin driver), on a sharingModel: 'private' external object whose driver returns owner_id but whose fields do not declare it, a member's GET /api/v1/data/<object> answered 2 of 2 rows, the admin-owned one included. With owner_id declared in fields, the same GET answered 1 (own only). Measured by #27332's dev (report 6489300223 on #27332, out-of-scope finding 1); the mechanism was confirmed by code reading in the independent review of PR #27422.
type: Bug

Part of #27261 (Tier A). Found while building #27332's lint warning (PR #27422).

维护者速读

  • 现象: 外部对象设成 private(仅所有者可见),若 driver 实际返回了 owner_id 列、但对象 fields 里没声明它,普通成员读列表能看到所有人的行;写仍按所有者拦。
  • 原因: 共享服务判断「owner_id 是不是虚列」只看 fields 声明(hasPhantomOwnerAnchor),没声明就当远端没有这列、读过滤整个不加;但 SQL driver 经 columnMap、或插件 driver 自己,都可能真的把这列返回来。
  • 影响: 数据越权读;作者按「private」以为已隔离。
  • 最小修法: 方向待定:要么读过滤在虚列时改为拒读(fail-closed,与写一致),要么把「served 但未声明」视同声明。feat(lint): security-external-owner-unmapped warns when a writable external object refuses every member write #27422 的 lint 警告已提示作者声明该字段,可先止血。
  • 需要您做的: 无(真正的取舍若出现,走 Fable 分析线程)。

Evidence

  • packages/plugins/plugin-sharing/src/sharing-service.ts:683: buildReadFilter returns null (unscoped) whenever hasPhantomOwnerAnchor is true.
  • federated-phantom-anchors.ts:150-153: hasPhantomOwnerAnchor is true iff the registered owner_id def equals the injected OWNER_FIELD_DEF. It never reads external.columnMap or what the driver returns.
  • Writes stay owner-scoped: matchesOwnerScope (sharing-service.ts:936-940) reads the row's served owner_id. So the same object refuses another owner's PATCH but lists their rows.
  • external.columnMap is applied by the SQL driver (packages/drivers/driver-sql/src/sql-driver.ts:4479-4481), not by the engine. A plugin driver may return owner_id on its own.
  • The plugin-sharing lowers __readScope own/unit to an owner_id predicate on federated objects, where owner_id is a phantom column #7858 design assumed "undeclared ⇒ not served". columnMap and plugin drivers break that assumption.

Task

  • Reads on a private / public_read external object never go unscoped because an owner column is undeclared. Pick the fail-closed shape (deny the scoped read, or scope on the served column) and pin the 2-of-2 case to own-only (or refused).
  • Pin both drivers: SQL with columnMap: { <col>: 'owner_id' }, and a plugin driver returning owner_id.
  • Check the unit read scope (owning_business_unit_id) for the same hole.

Clause-②: no · Metadata-protocol: none.

Activity

  1. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: 真挡得住的权限 | none | P②

    Triage: p1 · domain:server · area:access · security · 2026-10-29T06:56Z

    ⛔ Not a claim.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
    Claim: PM loop round 4 2026-10-29T07:42Z
    Session: session_017ihL2c5z3q4h8inrn5kwqV (epic PM); implementing card thread cse_017ihL2g7zSyVZAkBv268xuK
    Account: marchtian
    Branch: claude/project-thread-0jyi3e (the card thread's platform-assigned branch — measured platform exception to claude/issue-<n>-<slug>)
    Worktree: objectstack-issue-27425
    Domain: domain:server (triage, 06:56Z)
    Seat: epic:#27261#1 (epic PM; no domain:* seat). Maintainer order, 田騏 2026-10-29T04:21Z: 「影响你的任务进度的,你都统一接管」
    File surface: packages/plugins/plugin-sharing/src/** (provenance check, read filter, tests); a driver-sql test only if the SQL-shape pin needs one; .changeset/** (stop on breach; explain in the report)
    Container & model: M, mode:thread, model: default tier (dispatch-gates --tier: no path-derived mandate), effort: xhigh (requested; the thread runs at the platform default)
    Clause-②: no
    Metadata-protocol: none
    Responsibility: the #7858 phantom-anchor design assumed "undeclared ⇒ not served"; external.columnMap (driver-sql) and plugin drivers break it
    Thread-read: 6490131989
    Premise: holds — on origin/main 86edcaf8 hasPhantomOwnerAnchor reads declared fields only, and buildReadFilter returns unscoped when it is true. Ruling (triage): fail closed; the scope narrows, never widens.
    Serial constraints cleared: none in flight (#27277 and #27332 have landed)

  3. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
    Claim amendment (file surface) · 2026-10-29T07:45Z

    The fix is fail-closed: on a phantom owner anchor the read scope below org narrows to deny-all, and the bulk-write filter narrows the same way. Two kinds of text then become false: the explanation of the new lint rule (security-external-owner-unmapped), which says reads are not scoped, and any guide sentence that describes member reads on such an object.

    File surface adds:

    • the security-external-owner-unmapped entry in packages/lint/src/rule-explanations.ts;
    • only the sentences this fix makes false in content/docs/data-modeling/virtual-objects.mdx and content/docs/data-modeling/external-datasources.mdx;
    • any in-repo example or fixture metadata whose member reads this fix would remove (census owed in the dev report; each such file is named there).

    Same branch, same card thread, same claim otherwise.

  4. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
    Claim amendment 2 (shape + file surface) · 2026-10-29T07:56Z

    Accepted from the card thread's measurement:

    File surface adds only the sentences and pins this makes false:

    • packages/runtime/src/plugin-driver-external-object.spike.test.ts, step (e3) only;
    • packages/lint/src/validate-external-owner-sharing.ts, the "refused (403)" message clause, and its tests;
    • the matching sentence in packages/lint/src/rule-explanations.ts;
    • the virtual-objects.mdx callout's "(403)".
  5. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    epic:#27261#1 · marchtian · session_017ihL2g7zSyVZAkBv268xuK
    os-dev-report

    {
      "issue": 27425,
      "status": "done",
      "branch": "claude/project-thread-0jyi3e",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/27476",
      "session": "session_017ihL2g7zSyVZAkBv268xuK",
      "premise_still_valid": true,
      "summary": "Fail closed per the triage ruling. Owner provenance: owner_id counts as proven when fields declare it or an external.columnMap entry maps a remote column onto it (mapsRemoteColumnOntoOwner, PM-agreed refinement 07:56Z). On an unproven (phantom) anchor, buildReadFilter returns { id: '__deny_all__' } below org read depth (owner arm and share rows contribute nothing; the pending-approval id arm stays, giving { id: { $in } }); org stays null; the principal-less case reaches the existing deny-all. buildWriteFilter takes the same narrowing below org write depth. owning_business_unit_id: no gate composes a predicate over it; unit/unit_and_below resolve to an owner_id $in set, so they narrow the same way (pinned). Consequence measured: a member's PATCH on a private phantom object answers 404 RECORD_NOT_FOUND (pre-image read is scoped) instead of 403; public_read stays 403.",
      "tests": "federated-phantom-owner-scoping.test.ts: #7858 null pins flipped to deny-all at every non-org depth (read + bulk update/delete), org null, principal-less deny-all, external principal on public_read closed, approval arm kept; new rows-level block (plugin driver serving undeclared owner_id: no row; columnMap onto owner_id: own-only; driver not applying the map: no row; declared: own-only). New external-owner-anchor-sql-read.test.ts on real ObjectQL + SqlDriver(better-sqlite3) + sharing middleware: served-undeclared no row, mapped own-only, declared own-only, premise that the driver serves owner_id on all three. Spike (e3) re-pinned to 404 + deny sentinel in the scoped read. Lint: one case pinning the refusal wording per model. Ablation: sharing-service.ts at origin/main with head tests -> 12 red; restored green. Suites: plugin-sharing 58 files/1290; lint 177/7590; plugin-security 236/5111 (45 skipped); runtime spike + federated-boot-binding 16/16; rest external 15/15. typecheck (tsc + check:test-typecheck) green for plugin-sharing, lint, runtime (runtime after building driver-turso dist, a prerequisite for an untouched file).",
      "gates": "dispatch-gates --commands at bdaf6545 derives 91 families; all 91 run with exit codes captured before any pipe; 4 exited 3 (prerequisites) and were re-measured green: check-plugin-teardown-shape --self-test (fetched pinned fixture 621a4876), check:skill-examples, check:dual-build-cjs-loads, check:i18n (after a full packages build). --ran: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN (DERIVED zero). CI on bdaf6545: every completed suite green at 08:53Z (Lint & Repo Gates, TypeScript Type Check, Build Core, Test Core 1-8/8, Type Check UI, Build & E2E UI, Live E2E UI, Build Docs, Dogfood Regression Gate).",
      "dogfood_battery": "title carries '!' and Clause-② has the (narrowing) arm: bdaf6545 · os-verify-lock VERDICT command-exit 0 · Test Files 348 passed + 3 skipped / 351 · Tests 2806 passed + 20 skipped / 2826 · no failures",
      "line_budget": "426 changed lines (+371/-55, 11 files); under the 3000 human-merge threshold",
      "files_changed": [
        ".changeset/27425-phantom-owner-anchor-fails-closed.md",
        "content/docs/data-modeling/external-datasources.mdx",
        "content/docs/data-modeling/virtual-objects.mdx",
        "packages/lint/src/rule-explanations.ts",
        "packages/lint/src/validate-external-owner-sharing.test.ts",
        "packages/lint/src/validate-external-owner-sharing.ts",
        "packages/plugins/plugin-sharing/src/external-owner-anchor-sql-read.test.ts",
        "packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts",
        "packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts",
        "packages/plugins/plugin-sharing/src/sharing-service.ts",
        "packages/runtime/src/plugin-driver-external-object.spike.test.ts"
      ],
      "deviations": [
        "buildWriteFilter narrowed too (declared to the PM 07:44Z, accepted 07:45Z): it returned null on a phantom anchor, wider than checkEdit/checkDelete which refuse every row.",
        "columnMap onto owner_id counts as declared metadata (PM accepted 07:56Z): under fields-only provenance the SQL mapped case would lose the owner's own edits and contradict #27422's mapped-arm wording.",
        "Files outside plugin-sharing under surface amendments 1-2 (PM): lint rule-explanations sentence + validate-external-owner-sharing message clause and test; virtual-objects.mdx callout '(403)'; external-datasources.mdx one paragraph after the first example; runtime spike (e3).",
        "Changeset declares Clause-②: no (narrowing) with BREAKING banner and adr-0087 not-required (no-migration-prescription); the dispatch said 'Clause-②: no', the arm is what the BREAKING banner requires.",
        "Census: in-repo external objects with runtime effect are examples/app-showcase showcase_ext_customer and showcase_ext_order, both public_read_write, unaffected; no example file changed. Test fixtures that model the old behaviour are the pins updated above."
      ],
      "mcp_calls": "4, reads only: mcp__github__issue_read x2 (card + comments), mcp__github__pull_request_read get #27422, mcp__github__pull_request_read get_check_runs #27476; no write tools",
      "api_writes": "relay (fleet-write dispatch as objectstack-fleet[bot]): 1 action, pr_create #27476 (5997 bytes read back identical, assignee marchtian); this os-dev-report comment via post-stamped; plus git push of the branch. No assignee/label writes on the card.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
    Landed · 2026-10-29T10:04Z

    Reading Value
    PR #27476 merged 2026-10-29T10:02Z as f57eddcf0c9b7c35d8046d80e653aee672a65131 (merge queue), head 9cc5f83a
    Card closed completed by Fixes #27425

    State: pm:dispatched removed in the same act. It landed after the seat's ACCEPT and an independent full Fable review, but without the same-shape contract record, so it owes a review on its landing head (see the PR, and triage's claim 6493555143). Any must-fix there becomes a new card.

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

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpm:epicpriority:p1High: required for production / M2security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions