Skip to content

fix(security): 守卫 owner_id 属主锚点 + bulk 写按属主收敛 (#3004, #2982) - #3018

Merged
os-zhuang merged 2 commits into
mainfrom
claude/owner-id-security-fix-m36705
Jul 16, 2026
Merged

fix(security): 守卫 owner_id 属主锚点 + bulk 写按属主收敛 (#3004, #2982)#3018
os-zhuang merged 2 commits into
mainfrom
claude/owner-id-security-fix-m36705

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

背景

owner_id 是行级属主的锚点——OWD 行级作用域据此判定 update/delete 是否属主。它故意不是readonly(属主可转移,见 registry.ts applySystemFields),因此 #2948 的静态 readonly 剥离不覆盖它、FLS 默认也不守卫它。本 PR 关闭它在写路径上的两个洞。

#3004owner_id 客户端可写、服务端无守卫

问题:非特权写入者可以在 insert 时伪造属主(把记录挂到别人名下),或在 update 时转移/弃权属主(owner_id: other / owner_id: null),从而规避行级属主门。

修复(plugin-security 中间件 step 3.5,把 owner_id 作为非特权写入者的系统托管字段):

  • INSERT:空值回填为已认证用户(批量行也回填——此前只有单条路径回填,批量插入的行留下 owner_id = NULL,对自己的创建者都不可见);供给的非本人属主是伪造 → 拒绝。
  • UPDATE:供给 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 变更不受影响。
  • 真机 dogfood 证明(packages/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts,走真实 HTTP + showcase app,9 用例):成员无法伪造/转移/弃权属主、原值回显通过、平台管理员可重新指派;成员 bulk update/delete 只动自己的行,同伴的行完好。
  • 在 ADR-0096 authz-conformance 账本新增两行(ownership-anchor-guardbulk-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)全绿。

关联

🤖 Generated with Claude Code


Generated by Claude Code

…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.
@vercel

vercelBot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
specCanceledCanceledJul 16, 2026 5:53am

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling size/l labels Jul 16, 2026
@github-actions

github-actionsBot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/dogfood, @objectstack/objectql, @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via packages/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx(via packages/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/cli.mdx(via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via packages/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via packages/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/access-recipes.mdx(via packages/plugins/plugin-security)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx(via packages/dogfood, packages/plugins/plugin-security, packages/plugins/plugin-sharing, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx(via packages/dogfood)
  • content/docs/permissions/explain.mdx(via @objectstack/plugin-security)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-security, packages/plugins/plugin-sharing, @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/objectql, @objectstack/plugin-security, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql, @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via packages/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/plugins/plugin-sharing, packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx(via @objectstack/objectql, @objectstack/plugin-security, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/ui/audience-based-interfaces.mdx(via packages/plugins/plugin-security)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/plugin-security, @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

#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).
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

对本 PR 做了一轮对抗式自审(多角度并行 + 校验),在第二个提交 e6f4cad 中收敛了若干边界洞:

engine(#2982 seam)

plugin-security(owner_id 守卫)

  • 键成员判定改用 hasOwnProperty(防原型污染伪造属主写)。
  • owner_id 必须为非空 scalar;数组/对象一律拒绝,不再 String() 强转(此前 owner_id:[self] 会被当作 self 并把锚点写坏 → 属主自锁)。
  • owner_id: undefined(mongo $set 会落成 null 的弃权)现在被拒。
  • 数组形态的 update change-set fail-closed,不再静默绕过。
  • no-op 回显的 pre-image 改用调用者上下文读取(而非 isSystem):既接上事务(同事务内回显不误拒),又关闭了 system 读带来的属主枚举 oracle。

plugin-sharingcomposeAnd 保留 { $and:[…], k:v } 的兄弟键(此前会丢 k),防止调用者的 AND 谓词在 bulk 写时被静默放宽。

specallowTransfer 文案更正:它经 insert/update 的 owner_id 门现已强制,不再是“M2 前不生效”。

单测已补(数组update拒绝 / undefined弃权 / 非scalar拒绝 / 原型链安全 / delete $in 收敛 / fail-closed)。回归全绿:objectql 867、plugin-security 457、plugin-sharing 99、相关 dogfood 34。

两个越界发现单列跟踪:公开表单 owner_id 伪造 #3022、删除 sys_user 时级联 set_null 与弃权守卫冲突 #3023


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review July 16, 2026 09:03
@os-zhuang
os-zhuang merged commit a16972b into mainJul 16, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/owner-id-security-fix-m36705 branch July 16, 2026 09:03
os-zhuang pushed a commit that referenced this pull request Jul 16, 2026
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
os-zhuang added a commit that referenced this pull request Jul 16, 2026
…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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude