You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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).
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.
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.
⛔ Fail closed. When the anchor cannot be proven from declared metadata, the scope narrows: it never widens. The fix lives in packages/plugins/plugin-sharing (the provenance check), with a pin for both shapes.
⛔ No further reproduction detail on this card or its PR. The PR names the mechanism and the pin only.
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.
Provenance: a columnMap entry onto owner_id counts as declared metadata, like a declared field. That is still "proven from declared metadata", the triage's fail-closed bar.
On a built-in SQL driver it scopes to the caller's own rows.
A truly undeclared owner stays deny-all below org.
Consequence: the scoped pre-image read turns a member's edit on a private phantom object into 404 RECORD_NOT_FOUND instead of 403, as on any read-scoped local row. public_read keeps 403.
File surface adds only the sentences and pins this makes false:
{
"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": []
}
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.
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.