fix(security): 守卫 owner_id 属主锚点 + bulk 写按属主收敛 (#3004, #2982)#3018
Conversation
…ible rows (#3004, #2982) owner_id is the row-ownership anchor OWD scoping keys off. Two write-path holes: - #3004: owner_id was accepted verbatim from clients on insert/update with no server guard. It is deliberately not `readonly` (ownership transfers), so the static-readonly strip never covered it and FLS doesn't gate it — a member could forge a record under another user (insert) or transfer/disown one (update), evading the owner gate. plugin-security step 3.5 now treats owner_id as system-managed for non-privileged writers: empty is auto-stamped to the acting user (batch rows too), a foreign owner on insert is denied, and a supplied owner_id on update is a transfer/disown that is denied — the unchanged no-op echo is tolerated via a pre-image compare, a bulk change-set fails closed. Both require the transfer grant (allowTransfer / modifyAllRecords). isSystem stays exempt; delegation intersects both principals (ADR-0090 D10). - #2982: update({multi:true}) / bulk delete rebuilt the driver AST from options.where AFTER the middleware chain, discarding the owner/RLS write filter plugin-sharing and plugin-security compose onto opCtx.ast — so a member's bulk write hit every matching row, including peers'. The engine now seeds opCtx.ast before the chain and hands the composed AST to updateMany/deleteMany, matching single-id write scoping. Proven end-to-end (owner-anchor-and-bulk-writes.dogfood.test.ts) and pinned in the authz-conformance ledger.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📓 Docs Drift CheckThis PR changes 5 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
#2982) Adversarial code review of the fix surfaced several edge holes; close them: engine.ts (#2982 seam): - delete() gains update()'s scalar-id guard, so an id-list bulk delete ({ id: { $in: [...] } }, multi) routes to the scoped deleteMany instead of a garbage single-id driver.delete — closing the same #2982 hole on delete. - both multi branches now consume the seeded opCtx.ast and fail CLOSED (throw) if it is absent, instead of rebuilding an unscoped { object, where } fallback that would silently drop every composed row filter (AGENTS.md PD #12). plugin-security step 3.5 (owner_id guard): - own-property (hasOwnProperty) membership test — a polluted prototype can no longer spoof an ownership write. - owner_id must be a non-empty SCALAR; a non-scalar (array/object) is denied, not String()-coerced (which let owner_id:[self] pass and corrupt the anchor). - owner_id:undefined on update is now denied (mongo $set would persist null = an ungated disown). - array-shaped update change-sets fail CLOSED instead of silently bypassing. - the no-op-echo pre-image is read under the CALLER's context, not isSystem: threads the open transaction (no spurious in-tx denial) and closes the owner-enumeration oracle a system read opened. - update short-circuits the field-set lookup when the change-set omits owner_id. plugin-sharing composeAnd: preserve sibling top-level keys of a { $and:[...], k:v } filter (was dropping k) — prevents a caller's AND-ed predicate from silently widening a bulk write. spec: allowTransfer describe text corrected — it is enforced NOW via the insert/update owner_id door, not only the pending M2 transfer op. Follow-ups filed for out-of-scope findings: public-form owner_id forge (#3022) and cascade set_null vs the disown guard on sys_user deletion (#3023).
|
对本 PR 做了一轮对抗式自审(多角度并行 + 校验),在第二个提交 engine(#2982 seam)
plugin-security(owner_id 守卫)
plugin-sharing spec 单测已补(数组update拒绝 / undefined弃权 / 非scalar拒绝 / 原型链安全 / delete 两个越界发现单列跟踪:公开表单 owner_id 伪造 #3022、删除 Generated by Claude Code |
Integrates PR #3018 (owner_id anchor guard + bulk write scoping, #3004/#2982). Conflict: authz-conformance.matrix.ts — kept both sides' new ledger rows (ownership-anchor-guard, bulk-write-owner-scoping, public-form-managed-anchors). api-surface.json regenerated post-merge (check passes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr7QwD4Fx9KYkLH1xDMwEo
…dupe pre-image reads (#3053) Two hardening follow-ups from the #3018 adversarial review. ① The step-2.9 anti-oracle predicate guard is now middleware-order-independent for writes. #2982 made bulk update/delete carry opCtx.ast, first bringing them under the guard; it inspected opCtx.ast.where, which plugin-sharing (a sibling middleware of unguaranteed order) may already have composed an owner_id match into — so on an FLS-hidden-owner_id object a legitimate bulk write could 403. The guard now inspects opCtx.options.where (the caller's untouched predicate) for update/delete. Reads unchanged. ② Pre-image read dedup: a single fail-closed readRowById helper backs the provenance gates, and a memoized getCallerPreImage collapses the owner-anchor echo (3.5) and RLS check post-image (3.6) — the identical (object, id, caller-context) row — into one read per operation. Pure refactor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
背景
owner_id是行级属主的锚点——OWD 行级作用域据此判定 update/delete 是否属主。它故意不是readonly(属主可转移,见registry.ts applySystemFields),因此 #2948 的静态 readonly 剥离不覆盖它、FLS 默认也不守卫它。本 PR 关闭它在写路径上的两个洞。#3004 —
owner_id客户端可写、服务端无守卫问题:非特权写入者可以在 insert 时伪造属主(把记录挂到别人名下),或在 update 时转移/弃权属主(
owner_id: other/owner_id: null),从而规避行级属主门。修复(
plugin-security中间件 step 3.5,把owner_id作为非特权写入者的系统托管字段):owner_id = NULL,对自己的创建者都不可见);供给的非本人属主是伪造 → 拒绝。owner_id即转移/弃权 → 拒绝;单条原值回显(表单保存把未变的属主发回)通过 pre-image 比对被容忍;批量 change-set 携带owner_id无 pre-image 可比 → fail-closed。allowTransfer,或蕴含它的modifyAllRecords)。ctx.isSystem全程豁免(导入 / OAuth 登录 / cron 快照 / seed 认领);委托场景下双方都须持有授权(ADR-0090 D10 交集)。#2982 — bulk update/delete 跳过 private 对象的属主收敛
问题:
update({ multi: true })/ 批量 delete 在中间件链之后用options.where重建 driver AST,丢弃了plugin-sharing(buildWriteFilter)与plugin-security已 AND-合成到opCtx.ast上的属主/RLS 写过滤——于是成员的批量写会命中每一条匹配行,包括同伴的。修复(
objectql/engine.ts):对无单一 id 的 update/delete,在中间件链之前用调用者谓词预置opCtx.ast(与读路径同一 seam),再把中间件组合后的 AST 交给driver.updateMany/driver.deleteMany——bulk 写从此和单条写一样被收敛到调用者可编辑的行。验证
objectql/engine.test.ts— bulk update/delete 尊重中间件注入的opCtx.ast;单条 update 不被预置 AST(仍走 pre-image 路径)。plugin-security/security-plugin.test.ts— owner 守卫矩阵:insert 伪造拒绝 / 本人放行 / 转移授权放行 / modifyAllRecords 放行、批量 insert 逐行回填 + 伪造拒绝、update 转移拒绝 / 弃权拒绝 / 原值回显容忍 / 授权转移放行、bulk change-set fail-closed、无 owner_id 变更不受影响。packages/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts,走真实 HTTP + showcase app,9 用例):成员无法伪造/转移/弃权属主、原值回显通过、平台管理员可重新指派;成员 bulk update/delete 只动自己的行,同伴的行完好。ownership-anchor-guard、bulk-write-owner-scoping),固定为 enforced 防回归。objectql(865)、plugin-security(452)、plugin-sharing(99)、rest(275)全绿;相关 dogfood 套件(private/public-read OWD、scope-depth、attachments、agent 交集、delegation、RLS、CBP)全绿。关联
owner_id,本 PR 补齐。🤖 Generated with Claude Code
Generated by Claude Code