Repository navigation
fix(plugin-sharing)!: an unproven owner anchor on a federated object narrows the read and bulk-write scopes, never leaves them unscoped - #27476
Conversation
…narrows the read and bulk-write scopes The sharing service decided whether a federated object's owner_id was real from the declared fields alone, and read the registry's injected anchor as "no owner column": the read filter went unscoped. A driver can still serve owner_id, so a private object answered every row to every member. The owner now counts as proven when fields declare owner_id or an external.columnMap entry maps a remote column onto it. Otherwise the read filter admits no row by ownership below org depth (the pending-approval arm stays), and the bulk write filter admits none either. A member's edit on such a private object now answers 404 (its pre-image read is scoped too). Pins: a real SqlDriver over a remote owner column (served-undeclared refused, mapped own-only, declared own-only) and the plugin-driver shapes over the sharing double. The spike's (e3) pin, the lint warning's refusal wording and two guide sentences follow the 404. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2g7zSyVZAkBv268xuK
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9806e8adbd649d646109e8eeff75ac9013e0d1ee && git checkout 9806e8adbd649d646109e8eeff75ac9013e0d1ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 273137d227f79ac91b783bc3d25f09e51e7bad4e 9cc5f83a65a200f4be69921b3e0a50c69db3cf28 && git checkout -B drift-repro 273137d227f79ac91b783bc3d25f09e51e7bad4e && git merge --no-ff 9cc5f83a65a200f4be69921b3e0a50c69db3cf28
node scripts/docs-audit/affected-docs.mjs --json 273137d227f79ac91b783bc3d25f09e51e7bad4e
|
…e read scope as the cause of the 404 On the default model the member's read scope admits no row, so lists are empty and an edit answers 404; the write-time owner checks and their 403 are the public_read case. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2g7zSyVZAkBv268xuK
|
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV 维护者速读
席位意见:ACCEPT。独立 Fable 完整复核 ACCEPT-with-nits,两处文字已在 席位核对
|
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract-review-claim: Triage seat. This PR is in the merge queue with Triage reviews now, at Generated by Claude Code |
|
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV Triage's claim 6493555143 is right. The seat queued this PR without the same-shape The PR waits for triage's contract review on |
|
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV The withdrawal did not take effect. Converting to draft and disabling auto-merge did not remove the PR from the merge queue. The queue merged head This PR therefore landed without the same-shape contract record and owes the review after landing that triage's claim 6493555143 already names, on the landing head |
Contract reviewServed-tier: ① Derived judgments
Read doors checked for a bypass of the new check (the security rule): ② Semver level
③ Boundary flags
④ Design judgmentThe narrowing against the published spec on Attempts to break it, each closed in the live tree: a read door around the middleware (none: ①'s door list); a plugin driver that ignores Findings — none is must-fix:
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV The seat's answer to each should-fix in the contract review 6493803142 (PASS on
|
Requested by 田騏 · project thread
Fixes #27425
Clause-②: no (narrowing)
Metadata-protocol: none
维护者速读
owner_id只有在fields声明、或external.columnMap把某个远端列映射到owner_id时,才算"有所有者列"。否则(平台注入的锚点)读过滤在org以下的读深度不再返回null(不加过滤),而是 deny-all(只保留待审批记录那条按 id 的分支);批量写过滤同样收紧。fail-closed:证明不了就收窄,绝不放宽。fields声明,没声明就当"没有所有者列"、读不加范围;但 driver 仍可能返回owner_id,于是private对象对成员不设防。private外部对象上,成员读不到行;成员 PATCH 由 403 变为 404(编辑前的读同样被收窄)。public_read仍是 403。org深度、Modify All、系统调用、本地对象、声明了owner_id的外部对象都不变。示例里的两个外部对象是public_read_write,不受影响。packages/plugins/plugin-sharing/src/sharing-service.ts(buildReadFilter/buildWriteFilter的 phantom 分支挪到org判定之后并改为收窄)、federated-phantom-anchors.ts(mapsRemoteColumnOntoOwner)。pnpm --filter @objectstack/plugin-sharing exec vitest run src/federated-phantom-owner-scoping.test.ts src/external-owner-anchor-sql-read.test.ts,预期 55 个用例全过。What changes
Record sharing decides whether a federated object's
owner_idis a real column by provenance (hasPhantomOwnerAnchor). That test readfieldsonly, and atrueanswer madebuildReadFilter/buildWriteFilterreturnnull(#7858: "ownership contributes nothing"). A driver can still serveowner_idwithout the schema declaring it, so the unscoped read handed aprivateobject's rows to members.fieldsdeclaresowner_id(unchanged) or anexternal.columnMapentry maps a remote column ontoowner_id(new:mapsRemoteColumnOntoOwner). The built-in SQL drivers apply the map and serve the column; a driver that does not apply it serves noowner_id, so the owner predicate admits no row there (plugin drivers are floored by the engine since fix(objectql)!: floor a plugin driver on an external datasource — the engine applies the query, up to 1000 rows #27403). The map counting as declared keeps the wording of feat(lint): security-external-owner-unmapped warns when a writable external object refuses every member write #27422's warning true ("serves the check on a built-in SQL driver").orgread depth: no owner predicate and no share rows (a grant on such an object is already refused as inert byinertGrantReason); the pending-approval arm stays (an id list the approvals service vouches for). The answer is{ id: { $in: approvalIds } }or{ id: '__deny_all__' }.orgstill returnsnull(it never reads the owner column). The principal-less case now reaches the existing deny-all.{ id: '__deny_all__' }beloworg). It returnednullbefore, wider thancheckEdit/checkDelete, which refuse every row of such an object. This is the declared deviation from the card's read-only task list.owning_business_unit_id). Checked: no read or write gate composes a predicate over it.unit/unit_and_belowresolve to anowner_id $inset through the hierarchy resolver, so they take the same narrowing (pinned at every non-orgdepth).Follow-on wording, each a sentence this fix makes false (surface amendments 1 and 2 on the card): the
security-external-owner-unmappedmessage names404onprivateand keeps403elsewhere; itsos explaintext says reads are refused; the virtual-objects callout says404; the external-datasources guide says whatprivateneeds after its first example.Tests
federated-phantom-owner-scoping.test.ts: the plugin-sharing lowers__readScopeown/unit to anowner_idpredicate on federated objects, whereowner_idis a phantom column #7858nullpins flip to deny-all at every non-orgdepth (read and bulk write),orgstaysnull, principal-less deny-all, an external principal on apublic_readphantom object closed, the approval arm kept. New rows-level block over the sharing double: a plugin driver returning undeclaredowner_idreads no row;columnMapontoowner_idreads own-only; a driver that does not apply the map reads no row; declared reads own-only.external-owner-anchor-sql-read.test.ts: realObjectQL+SqlDriver(better-sqlite3) + the sharing middleware over a remote owner column. Served but neither declared nor mapped: no row;columnMapontoowner_id: own-only; declared: own-only. A premise case shows the driver servesowner_idon all three.plugin-driver-external-object.spike.test.ts(e3): 404RECORD_NOT_FOUND, nodriver.update, and the scoped pre-image read carries the deny sentinel.validate-external-owner-sharing.test.ts: one case pinning the refusal wording per model.sharing-service.tsatorigin/mainand the tests at head, 12 pins go red (every new read/write deny-all pin, both rows-level undeclared cases, the SQL undeclared case); restored, green.Verification
Local, at head.
pnpm --filter @objectstack/plugin-sharing test: 58 files / 1290 tests passed.pnpm --filter @objectstack/lint test: 177 files / 7590 passed.@objectstack/plugin-security: 236 files / 5111 passed. Runtime spike + federated boot binding: 16 passed. REST external tests: 15 passed.typecheck(tsc +check:test-typecheck) clean for plugin-sharing, lint and runtime. Derived gates: see the os-dev report on the card.🤖 Generated with Claude Code
https://claude.ai/code/session_017ihL2g7zSyVZAkBv268xuK