Skip to content

Row-level write gate consults neither modifyAllRecords nor sys_record_share.access_level — both declared write-widening mechanisms are inert #5492

Description

@yinlianghui

Part of objectstack-ai/hotcrm#705

Found during HotCRM's 17.0 GA acceptance sweep on @objectstack/* 17.0.0-rc.2, permission matrix phase. Two write-widening mechanisms, both inert; filed together because they share one root: the row write gate consults neither.

Measured

The effective row write gate is: owner_id == caller AND (member_default's wildcard RLS owner_only_writes: created_by == caller, OR an explicit RLS update-widener). Nothing else widens writes.

1. modifyAllRecords is inert. A manager profile with declared viewAll + modifyAll on its core objects got 403 row-level on every cross-owner write probed (update AND delete, four objects). Reads widen exactly as declared (43/43, 9/9); writes never.

2. Edit-level shares grant read only. All three edit-level sharing rules verified to materialize in sys_record_share (incl. a fixture created after rule evaluation — the hook fires; and clean revocation on unbinding) and to widen reads exactly. But a PATCH by the share target on the shared record → 403 row-level, every time. The write gate does not consult sys_record_share.access_level.

Corollary from the other side: read-level shares correctly deny writes — the share machinery distinguishes levels on paper, and the write gate then ignores the distinction.

Reproduction

# as a manager-profile user with modifyAllRecords on crm_opportunity
PATCH /api/v1/data/crm_opportunity/ {"next_step":"x"}
→ 403 "[Security] … not permitted to update this 'crm_opportunity' record (row-level security)"
# share row present (object=crm_account, access_level=edit, target=):
PATCH that account as the share target → 403 (same shape)

Impact

A manager cannot correct, reassign, or clean up anything they didn't personally create; every "manager fixes it" workflow is unimplementable. Org-admin is the only identity that can write across owners.

Prior related issues (checked)

Evidence: HotCRM acceptance run w4/results.jsonl (M3/M4/M8). Sibling: objectstack-ai/hotcrm#703 (the same member_default wildcard RLS supplies the created_by co-gate).

Blocked-by: #6428

Activity

  1. hotlong commented on Aug 7, 2026

    @hotlong
    Contributor

    Triage (maintainer-directed batch, 2026-08-07): routed domain:identity — the "(row-level security)" denial is packages/plugins/plugin-security/src/security-plugin.ts:1192's pre-image gate; it must honor modifyAllRecords and sys_record_share.access_level write-wideners. bug + target:v17 retained. Anchored by code read; triage seat may veto.


    Generated by Claude Code

  2. self-assigned this
    on Aug 7, 2026
  3. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    Contributor

    认领(identity 车道 PM,session_01JwwiU9bjhwy2SWj13ho8uv,第 14 轮,2026-08-07 12:1xZ)


    Generated by Claude Code

  4. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    Contributor

    dev 回执 —— needs_decision,不带 PR(identity 车道第 14 轮,session_01JwwiU9bjhwy2SWj13ho8uv,分支 claude/issue-5492-write-gate-wideners,未推;代码零改动)

    一、前提对 origin/main(01faeb13a)逐条重测:两个症状全部仍在

    引擎级复现:真实 SecurityPlugin 中间件 + 真实 SharingService + 真实 sharing 中间件,同一 in-memory engine;权限集直接取平台真实种子 defaultPermissionSets 里的 member_default;对象是普通租户业务对象(sharingModel: 'private',没有 access.default: 'private' —— 后者才是 ADR-0066 ① 旁路门认的 posture);调用方 positions = ['org_member'],即 better-auth member 的真实映射。

    探针 main 实测 同一上下文下 ISharingService 的判定
    manager(viewAll+modifyAll)跨 owner UPDATE 403 (row-level security) canEdit = true
    manager 跨 owner DELETE 403 (row-level security) canDelete = true
    edit 级共享目标 UPDATE 403 (row-level security) canEdit = true
    edit 级共享目标 DELETE(另给 delete CRUD 位) 403 canDelete = false(正确)
    read 级共享目标 UPDATE 403 —(正确)
    读侧对照:manager getReadFilter undefined(完全放开) —
    读侧对照:edit 目标 getReadFilter {$or:[{owner_id:me},{id:{$in:[shared]}}]} —

    #5816 的 resolveSharingCanEdit 没有修到这条路:它在 security-plugin.ts 全仓只有一个调用点(:3484,ADR-0055 controlled_by_parent 的 master 判定),普通行的前像写门根本不经过它。premise_still_valid: true,ISSUE 的两条测量逐条成立,报错形状与分诊锚定一致。

    二、根因:行级写权限today 是两个互不知情的权威 AND,三种声明过的扩权只活在其中一个里面

    1. plugin-security 前像门(security-plugin.ts:1143-1204,报错 :1192):只看 RLS —— Layer 0 租户墙 AND Layer 1 业务 RLS。
    2. plugin-sharing 中间件(sharing-plugin.ts:826):canEdit / canDelete —— owner + DEPTH、edit 级 sys_record_share、modifyAllRecords 旁路。

    DEPTH、share、modifyAll 三种扩权只存在于 (2);而 (1) 里坐着平台自己种下的所有权地板 owner_only_writes / owner_only_deletes(default-permission-sets.ts:365,372,created_by == current_user.id,适用域 positions: ['org_member'])—— 同一条「所有权」契约的第二份实现,而且是不认任何扩权的那份。凡是持 member_default + org_member 的人(= 每一个普通成员,manager 也是普通成员),写门就永远是 created_by == me,声明什么都盖不过去。

    modifyAllRecords 之所以在 (1) 里失效,是 computeLayeredRlsFilter 的 Layer 1 短路仍带着 posture 门(:3086/:3090/:3139):posturePermits = isPrivate || tenancyDisabled || isBetterAuthManaged,普通业务对象不满足。注意同一个平台对「modifyAll 在普通对象上扩不扩写」已经有两个不同答案:ISecurityService.hasWriteBypass(:676)不带 posture 门(所以 sharing 门放行),RLS 门带(所以前像门否决)。

    #5493 是同一结构的镜像:那边是 RLS 放行、sharing 门先 FORBIDDEN;这边是 sharing 放行、RLS 门否决。两个门互相否决对方的声明扩权 —— 于是带 sharing rule 的对象上,app 既不能靠自著 RLS update-widener 扩写(#5493 堵死),也不能靠 share/modifyAll 扩写(本单堵死),两条退路同时没有。

    三、声明原文(逐动作),这是裁决的输入

    • packages/spec/src/security/permission.zod.ts:168-180:viewAllRecords = "Super-user read access. Bypasses Sharing Rules and Ownership checks.";modifyAllRecords = "Super-user write access. Bypasses Sharing Rules and Ownership checks." —— 原文只说旁路 Sharing 与 Ownership,没有说旁路 RLS 策略。而 owner_only_writes 恰恰是一条写成 RLS 的 ownership check,正卡在这两个词之间。
    • modifyAllRecords 的动作范围:permission-evaluator.ts:49 MODIFY_ALL_WRITE_KEYS = allowEdit + allowDelete + transfer/restore/purge → 含 delete;SharingService.canDelete 也认它。→ 兑现面应为 update 和 delete。
    • edit 级 share 的动作范围:ADR-0111 D3 + spec/contracts/sharing-service.ts:133-171:"update → canEdit(ownership/depth or a write-level share);delete → ownership + write-DEPTH + modifyAllRecords only"。→ 兑现面只有 update,不含 delete(read 级不扩写)。
    • ⛔ 反向红线(派发令的停手条件,原文) —— ADR-0066 ①(Accepted,0066-unified-authorization-model.md:50):"viewAllRecords bypasses read-side RLS and modifyAllRecords bypasses write-side RLS for that object — but only when the object's posture permits it (platform-global or private)"。HotCRM 的对象是普通租户业务对象 → 按字面,modifyAll 不得旁路写侧 RLS。
    • 另一条相关:ADR-0095 D1 的 "accepted delta (b)" 把 owner_only_writes 从 org OR created_by 收紧成 org AND created_by,是架构师明确接受并写进 BREAKING release note 的收紧;本单任何扩权都会部分回退它。

    四、三个选项,代价都是实测的

    A. 去掉 Layer 1 短路的 posture 门(Layer 0 豁免不动)。实测:plugin-security 全量 36 files / 774 tests 全绿 —— 没有任何测试钉住 Layer 1 的 posture 门(authz-matrix-gate.test.ts 钉的是 Layer 0 豁免;task 上 platform_admin 的 Layer 1 本来就是空的)。效果:manager 的 update + delete 修好;edit 级共享仍然 403。代价:字面反 ADR-0066 ①;且把「任一集合里的 '*': {modifyAllRecords:true}」提升为可越过 app 自著业务 RLS —— 半径由 resolveObjectPermission 的 union 决定(见下 d)。只解一半症状。

    B. 前像门把「平台自己的所有权地板」与「app 自著 RLS」分层合成(推荐方向)。行必须满足 Layer0 AND app 自著 RLS;所有权地板可被按声明的写权威顶替,而该权威直接委托 ISharingService(update → canEdit,delete → canDelete),所以 ADR-0111 D3 的动作边界是继承来的、不是重写的;provenance 判定照抄 ADR-0105 D3 的既有形状(platform-tenant-policies.ts 的 (object,name,using) 身份键,不新增可授权开关)。实测:plugin-security 全量 36 files / 775 tests 全绿,四个探针全部按声明落位 —— manager update/delete 200、edit 共享 update 200、edit 共享 delete 仍 403(由 canDelete 判的,不是 CRUD 位判的)、read 共享 update 仍 403。

    ⚠️ B 有一个实测的 fail-open,必须先改契约才能落地:canEdit/canDelete 把「我放行」和「我不管这个对象」合并成同一个 true(无 owner_id 列 / public 对象 / 部署里没有 plugin-sharing 都 return true)。实测:在没有 owner_id 列的对象上(作者自定义对象最常见的形状),普通成员跨 creator 的 UPDATE 在 B 下变成 200(main 上是 403),sharing.canEdit = true。也就是说平台的 created_by 地板今天是这类对象唯一的行级写门(#1985 那一类)。要正确实现 B,ISharingService 必须能区分三态(放行 / 不表态 / 拒绝),这要动 packages/spec/src/contracts/sharing-service.ts + plugin-sharing/sharing-service.ts —— 都在本单硬禁改面之外(派发令:⛔ 不动 spec;plugin-sharing 串在 #5493 之后)。在 security 侧自己重算 owner/depth/share/bypass = 第二份实现,违反「一个契约一份实现」,我没有做。

    C. 维持现状,把声明改对。承认行级写 = 两个权威的 AND,并在 spec/文档里写明 modifyAllRecords 与 sys_record_share.access_level 会被平台种子的所有权地板 AND 掉,同时给 app 一条受支持的退路。代价:默认种子下「manager 修别人的记录」无解,access_level: 'edit' 对写永远无效 —— 等于把 ADR-0049 / ADR-0078 的 declared ≠ enforced 固化在安全层自身。

    五、建议:选 B,但分两步走

    先补 ISharingService 的三态写判定(spec 契约 + plugin-sharing 实现,一个 PR),再在 plugin-security 前像门做 provenance 分层合成(第二个 PR = 本单)。三轴:

    • 真实业务需求:HotCRM 17.0 GA 188 探针实测,四对象 update/delete 全 403;三条 edit 级 sharing rule 已正确物化、读侧已按级别区分 —— 机器在跑,只有写侧不认。不是投机能力面,是已发布能力的兑现。
    • 长期健全:今天同一条「所有权」契约有两份实现,赢的是不认扩权的那份;B 把地板收敛到唯一一个知道扩权的权威,On objects that carry sharing rules, sharing middleware answers FORBIDDEN before RLS update-wideners are consulted — the identical widener works on rule-less objects #5493 的镜像症状也才有对偶解。A 只解一半且要反 ADR 字面,C 把缺陷写进声明。
    • 让 AI 写的元数据不易出错:B 的三态契约把「不表态」显式化,消灭 true 既表示放行又表示弃权这种最容易被误读的返回值 —— 上面那个 fail-open 就是这个歧义直接变出来的。A / C 都不解决它。

    若维护者裁 A 或 C,请一并给 ADR(0066 ① 的修订 / 新 ADR),我按裁决执行,不自行反转。

    六、必答项 c/d/e

    范围外发现已另立:#6277(vama-write-path-convergence.test.ts 的 DELETE 用例喂的是引擎会拒绝的调用形状,安全侧前像门在那一半从未被执行;finding 标签,未修)。


    Generated by Claude Code

  5. removed their assignment
    on Aug 7, 2026
  6. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    Contributor

    PM 复核:dev 的 needs_decision 成立,本单转决策箱(identity 车道 PM,session_01JwwiU9bjhwy2SWj13ho8uv,第 14 轮;摘 pm:dispatched/pm:queue → needs-user-decision,认领解除,代码零改动即正确交付)。

    完整分析见上方 dev 评论(三选项 + 实测代价 + 声明原文 + E1/E2 实验证据)。PM 独立复核后同意推荐 B,分两步落(先补 ISharingService 三态写判定,再做前像门 provenance 分层合成):


    Generated by Claude Code

  7. os-project-manager commented on Aug 7, 2026

    @os-project-manager
    Collaborator

    Maintainer ruling — 2026-08-07. The maintainer reviewed the PM's three-axis analysis of the decision inbox and accepted the recommendations (「你帮我综合评估,我接受你的建议」). Recorded by the PM session; needs-user-decision comes off with this comment.

    Decision: enforce both declared write-widening mechanisms. The row-level write gate must consult modifyAllRecords (profile axis) and sys_record_share.access_level = 'edit' (share axis). This is the enforce side, not retire: both mechanisms are declared, documented, and have measured business pull (the HotCRM sweep showed every "manager fixes it" workflow unimplementable; the share machinery already materializes and revokes rows correctly — only the gate ignores the level).

    Constraints for the implementing lane:


    Generated by Claude Code

  8. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    Contributor

    裁决执行计划(identity 车道 PM,session_01JwwiU9bjhwy2SWj13ho8uv,2026-08-07 18:3xZ)

    按 16:59Z 裁决(B 案两步 + 与 #5491 同批)拆链:

    1. step1 = ISharingService 写判定补三态(放行/不表态/拒绝)—— #5492 裁决 B 案的 step1,二态 canEdit 已实测产出 fail-open #6428(ISharingService 写判定三态,spec 契约面)—— 已按跨座位转移协议代立,packages/spec 恒归 spec 座位,等分诊路由;本单与 member_default's * wildcard object grant (C/R/E) union-merges into every org member — app-side explicit-allow object gates are erased on three axes #5491 均已挂 Blocked-by: #6428(标签 pm:blocked 同步)。
    2. step2 = 配对单 PR(本单前像门 provenance 分层合成 + member_default's * wildcard object grant (C/R/E) union-merges into every org member — app-side explicit-allow object gates are erased on three axes #5491 种子通配符摘除,含迁移叙事 changeset)—— ISharingService 写判定补三态(放行/不表态/拒绝)—— #5492 裁决 B 案的 step1,二态 canEdit 已实测产出 fail-open #6428 落地后由本车道一个 dev 一批交付(维护者同批要求优先于同包禁并行,两半互为对方的安全前提);测试面按裁决逐轴:modifyAll 跨 owner update+delete、edit-share update 通过、read-share 仍拒、显式 deny 生效、正向探针证 profile 授权仍工作、既有拒绝断言原样保留。
    3. 落地后跟进 spec 契约文档漂移:ISharingService.canEdit 未提 modifyAllRecords 旁路(canDelete 提了) #5125(文档随裁决)。

    Generated by Claude Code

  9. baozhoutao commented on Aug 8, 2026

    @baozhoutao
    Contributor

    给本单配对 PR 的一条实测记录(来自 #6277 / PR #6502,不改本单范围):

    packages/plugins/plugin-security/src/vama-write-path-convergence.test.ts 的 DELETE 半边此前喂的是 options: { id } —— 引擎会拒绝的调用袋 —— 所以 SecurityPlugin.extractSingleId 返回 null,#1994 行级前像写门在该半边从未执行。PR #6502 已把形状改成 options: { where: { id } } 并钉住(中间件链终点改为 fake engine 的写动词,以 assertEngineDeleteDispatch / assertEngineUpdateDispatch 开头)。

    对本单有用的是第二个测量,它说明该文件目前仍然看不见本单的缺陷:

    delete 半边,三个 principal 逐一测量
      options: { id }             extractSingleId -> null             computeRlsFilter: 从未调用
      options: { where: { id } }  extractSingleId -> 'rec_ownerless'  computeRlsFilter('delete'): 各 1 次,均返回 null
    

    computeRlsFilter 对该文件的 admin_full_access / compliance_auditor / member_default 全部返回 null —— 这三个 fixture 都没有 authored RLS policy,不像真实的 member_default(带通配 owner_only_writes,即本单量到的 created_by == caller co-gate)。所以门现在是可达的,但它的重读/拒绝分支仍未被行使,判决前后一致、无断言变红。

    含义:配对 PR 若想让「modifyAllRecords 扩到行级写门」这件事在该文件上有 before-red 证据,需要自带一个授权了 RLS policy 的 principal;仅靠该文件现有的三个 permission set 是量不出来的。形状这一层的障碍 #6502 已经清掉,门本身现在真的会被问到。


    Generated by Claude Code

  10. self-assigned this
    on Aug 8, 2026
  11. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1 (identity seat) — paired dispatch, this card + #5491, one dev, one PR (maintainer ruling 2026-08-07 16:59Z: same batch, neither half lands alone)
    Session: session_01BM1tNf5U3nEbHKR4fo5qVQ
    Branch: claude/issue-5492-write-gate-pair
    Worktree: objectstack-issue-5492
    Domain: domain:identity
    File surface: packages/plugins/plugin-security/src/security-plugin.ts (pre-image gate provenance composition), packages/plugins/plugin-security/src/objects/default-permission-sets.ts (wildcard removal), plugin-security tests (incl. vama-write-path-convergence.test.ts), .changeset/. ⛔ No spec / plugin-sharing edits — the tri-state contract landed via PR #6564 (merged 05:25Z). Stop on breach; explain in the report.
    Serial constraints cleared: predecessor #6428 → PR #6564 MERGED (verified merged: true); same-day churn on plugin-security noted (#6612, #6552 — dev must re-verify anchors against current origin/main); in-flight scan: no open PR/branch intersects plugin-security (nearest, #6647, touches packages/runtime only); pm:epic index empty. domain:identity in-flight before this claim: 0.
    Container class: M — mode:subagent (shared container), verification radius is the plugin-security suite.


    Generated by Claude Code

  12. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #6684 (paired with #5491; identity seat, round 1 review).

    Verified against the diff and CI, not the report's claims:

    1. Composition per the ruling and the feat(sharing): ISharingService 的每行写判定补三态(放行/不表态/拒绝)(#6428) #6564 §7 prescription: the pre-image gate consults checkEdit/checkDelete and composes by provenance — allow replaces only the platform's own created_by floor (matched by the ADR-0105 D3 (object,name,using) provenance key, new platform-ownership-policies.ts + 5-case pin suite); abstain/deny leave it standing; Layer 0 and app-authored policies untouched; the security side recomputes nothing, so the ADR-0111 D3 verb boundary is inherited. ADR-0066 ① is left intact — this is the platform floor deferring to the platform's own ownership authority, not modifyAllRecords bypassing write-side RLS.
    2. Every ruling test axis present and green in row-write-widener-composition.test.ts (real middleware stack, real seed, ordinary tenant object): modifyAll cross-owner update and delete; edit-share update passes; edit-share delete refused; read-share still refused; explicit deny holds; positive own-row probe; the E2 owner-less floor pin. Refusals assert the ADR-0112 envelope (code + status + verbatim sentence), never bare toThrow().
    3. Reverse verification, both halves, direction predicted first: reverting the composition → exactly the 3 widening cases red with the issue's verbatim denial string, all guard cases green (the fix widens and only widens); restoring the wildcard → exactly the 9 explicit-allow pins red, delete axis green (already profile-driven).
    4. Repo-wide pin sweep: full workspace green post-merge (130/130 test tasks, 120/120 typecheck), fixture migration triaged case-by-case with no denial assertion relaxed — authz-matrix-gate's EXPECTED_MATRIX unchanged byte-for-byte.
    5. All five authority faces declared (gate changed; plugin-sharing untouched-by-design — On objects that carry sharing rules, sharing middleware answers FORBIDDEN before RLS update-wideners are consulted — the identical widener works on rule-less objects #5493's anchor; resolveSharingCanEdit, attachment hooks, audit comment gate verified unaffected with their suites green and unmodified).
    6. CI verified per-job: 25 runs, 23 success + 2 skipped, 0 failures — ESLint and TypeScript Type Check conclusions both success; Check Changeset green including the adr-0087 disposition the dev added after catching that gate's red in-flight.
    7. Mandatory On objects that carry sharing rules, sharing middleware answers FORBIDDEN before RLS update-wideners are consulted — the identical widener works on rule-less objects #5493 answer: unaffected, measured — the dedicated control (authored RLS widener admits at this gate, sharing middleware still refuses with FORBIDDEN, asserted in both directions) will be quoted in On objects that carry sharing rules, sharing middleware answers FORBIDDEN before RLS update-wideners are consulted — the identical widener works on rule-less objects #5493's repricing when this lands.
    8. Scope deviation (dogfood test fixtures) was anticipated by the dispatch's own migration-story clause and is test-only — accepted. Out-of-scope findings describeHighPrivilegeBits's doc comment still cites member_default as the "plain wildcard baseline" example, which it no longer is #6696 / modifyAllRecords still does not widen a by-id write on an object with NO owner field (sharing abstains, the platform created_by floor holds) #6698 verified filed (finding, unassigned, for triage).

    Proceeding to ready + merge queue. #5125 (docs follow the ruling) and #5493 repricing follow at MERGED.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions