Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

列表行内建【编辑】【删除】没接权限门:只与 apiOperations 求交,而它与用户无关 ⇒ 无写权账号恒可见 #4096

Closed

Description

@baozhoutao

现象

对象列表页,每一行右侧「更多操作」溢出菜单里的内建【编辑】【删除】,对没有 allowEdit / allowDelete 的账号照常渲染。

点【编辑】能打开字段全部预填的编辑对话框,直到点【更新】才由服务端拒掉。服务端门禁是硬的(见下「服务端是对的」一节),所以这不是越权写入,但:

  • 「删除」这种破坏性入口摆在无权账号面前,误点成本高;
  • 用户被引导走进一个注定失败的表单;
  • 同一个页面里另外两处权限门是对的,所以这更像「做漏了一处」而不是「还没做」。

实测于 @objectstack/*@17.0.0-rc.5,zh-CN Console,一个下游业务应用的自建对象上。

根因:这一处的门用了 apiOperations,而它与用户无关

同一份 GET /api/v1/auth/me/permissions 响应里,同一个对象:

账号 allowCreate allowEdit allowDelete apiOperations
A(对该对象零写权) false false false get,list,create,update,delete,upsert,bulk,aggregate,history,search,import
B(对该对象有 update 权) false true false 完全相同的 11 个 verb
C(只读) false false false 完全相同的 11 个 verb

量化判据:把 A 与 B 的响应逐对象比对,30 个共有对象的 apiOperations 100% 相同(差异 0 个),而 allowEdit 明确不同。

这与 spec 对该字段的定义一致:

Server-resolved effective API operations for this object … Present only when the object tightens exposure via apiMethods

即 apiOperations 是对象的 API 暴露面(「这个对象开放了哪些 verb」),不是「当前用户能做哪些操作」。拿它与行动作求交 ⇒ 恒 fail-open。

同页三处门的对照(两对一错)

界面位置 用的判据 结果
列表工具栏【新建】/【导入】 can(obj, 'create') → allowCreate ✅ 正确隐藏
记录详情页头【编辑】/【删除】 逐记录 update/delete 权限判定 ✅ 正确隐藏
列表行「更多操作」内建【编辑】【删除】 只与 apiOperations 求交 ❌ 恒可见

复现

  1. 任一对象,准备两个账号:X 的权限集给该对象 allowEdit/allowDelete = false(只读),Y 给 allowEdit = true;
  2. 两个账号分别打开该对象的列表页;
  3. 展开任意一行右侧的「更多操作」溢出菜单。

实际:X 与 Y 看到的菜单项完全相同,都含【编辑】【删除】。
期望:X 看不到【编辑】【删除】—— 与同页工具栏【新建】按 allowCreate 隐藏的口径一致。

对照观察:同一个 X 账号,列表工具栏的【新建】【导入】确实是隐藏的,详情页头的【编辑】【删除】确实也是隐藏的。三处判据不统一。

服务端是对的(未越权,纯 UI 门缺失)

动作 账号 HTTP 响应 DB
PATCH /api/v1/data/<obj>/{id} X 403 PERMISSION_DENIED: operation 'update' … not permitted 未改写
DELETE /api/v1/data/<obj>/{id} X 403 PERMISSION_DENIED: operation 'delete' … 记录仍在

下游能不能自己绕过去?——实测:不能

在 rc.5 上做过真实探针(验完已回滚):

userActions: {
  edit:   { visibleWhen: P`os.user.email == "someone@example.test"` },
  delete: { visibleWhen: P`record.status == "disabled"` },
}

结论三条:

  1. ✅ 内建行 Edit/Delete 确实吃 userActions.edit/delete.visibleWhen —— 行动作声明 visible: false 仍然渲染该菜单项——可见性门用真值判断(#3492 在行菜单上的未收口部分) #3758 的「声明即生效」门(visibleWhen != null)在 rc.5 里是通的,通道存在;

  2. ✅ 谓词上下文里有 os.user,rc.5 实测作用域为 { current_user, user, ctx.user, os.user, app, data, features };

  3. ❌ 但 os.user 里没有任何权限信息。三个不同授权的账号,会话 user 对象逐字相同:

    { "role": "user", "positions": ["user", "org_member"], "isPlatformAdmin": false }

    roles 键不存在;positions 恒为 ["user","org_member"](该项目的权限集直绑用户,不走挂岗)。

⇒ CEL 表达不出「当前用户对本对象有无写权」。唯一凑得出来的写法是把账号或角色名硬编码进对象元数据,等于把权限模型抄第二份、必然漂移 —— 不是可接受的解。

期望的修法

列表行内建【编辑】【删除】的可见性,改用与工具栏同一份有效权限判据(allowEdit / allowDelete,以及既有的逐记录权限钩子),而不是 apiOperations。

apiOperations 作为「对象暴露面」仍可继续参与判定(暴露面没开 delete 时当然也不该显示),但它不能单独充当用户权限门。

相关

若认为 os.user 该带上权限/角色信息(那样下游至少有自救通道),可另开一条;本条只针对「内建行动作没接权限门」。

Activity

  1. claude commented on Aug 10, 2026

    @claude
    Contributor

    Triage: pm:queue.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

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

    @baozhoutao
    ContributorAuthor

    Claim: PM loop round 1 (maintainer direct dispatch — /pm-dispatch https://github.com/objectstack-ai/objectui/issues/4096)
    Session: session_012JW7uQKdaPKkrT8fUpszRX
    Branch: claude/issue-4096-row-crud-permission-gate
    Worktree: objectui-issue-4096
    Domain: objectui whole-repo seat (repo:objectui)
    File surface: packages/plugin-grid/src/rowCrudAffordances.ts, packages/plugin-grid/src/ObjectGrid.tsx (the resolveRowCrudAffordances call site only), packages/plugin-grid/src/__tests__/**, and — pending the dev's per-face verdict — packages/app-shell/src/views/RelatedRecordActionsBridge.tsx + its __tests__. ⛔ Declared OUT: packages/core/src/evaluator/** and packages/plugin-grid/src/components/RowActionMenu.tsx (another seat's in-flight surface, see below) — stop and report on breach.
    Container & model: M (multi-face semantic gate + fail-open/fail-closed judgment, not mechanical), mode:cloud, model: opus
    Serial constraints cleared: objectui has exactly two open PRs (#3598 release, #4093 dependabot) — neither touches this surface. The seven pm:dispatched cards in this repo (#3796, #3792, #3670, #3641, #3561, #3528, #3339) are held by a different account (yinlianghui) and have no branches/PRs yet. The only adjacency is #3796/#3792, which land in evalRowPredicate (packages/core/src/evaluator/**, consumed by RowActionMenu.tsx); both of those paths are excluded from this card's surface above, so the two are file-disjoint. Last main commits on this surface: 9154d9e (#4081), 5bfaabd — no same-day churn.

    Premise re-verified against origin/main @ c29ceff before dispatch:

    • packages/plugin-grid/src/ObjectGrid.tsx:1647 calls resolveRowCrudAffordances({… effectiveApiOperations: effectiveApiOps }) and ANDs no principal-scoped permission — rowCrudAffordances.ts folds only (a) the ADR-0103 bucket, (b) userActions, (c) apiOperations.
    • The two correct gates on the same screen both AND a principal-scoped verdict on top of the same affordance resolver: toolbar ObjectView.tsx:1798/1809/1863 → affordances.create && can(objectDef.name, 'create'); detail header RecordDetailView.tsx:1899-1902 → objectAffordances.edit && recordWriteAllowed / .delete && recordDeleteAllowed.
    • MePermissionsProvider.check maps update → allowEdit, delete → allowDelete (MePermissionsProvider.tsx:230-236), so the principal-scoped source the report asks for is already reachable — and ObjectGrid already holds it (usePermissions() at ObjectGrid.tsx:435).

    ⇒ premise holds; dispatching.


    Generated by Claude Code

  4. baozhoutao commented on Aug 10, 2026

    @baozhoutao
    ContributorAuthor

    Dispatched: mode:cloud, model: claude-opus-5, dev session session_01FQbuY8A4jabkVwkP3GU9oe, outcome branch claude/issue-4096-row-crud-permission-gate.

    Scope handed to the dev, for the record:

    • The maintainer's own scope line in the body (os.user gaining permission/role info in CEL scope ⇒ separate card) is passed through verbatim as a ruling, not re-opened.
    • The fix is framed as an intersection tightening: the apiOperations layer stays; a principal-scoped verdict is ANDed on top — matching the toolbar's affordances.create && can(obj, 'create').
    • Four faces must each get an explicit verdict in the PR body (changed / already compliant with evidence / explicitly out of scope with a reason): the grid row kebab, RelatedRecordActionsBridge related-list rows, ListView's delete affordance, and the bulk-delete path.
    • Named regression risk: usePermissions() fails open with no provider (can: () => true), while MePermissionsProvider.check fails closed for an authenticated principal whose object is absent from /me/permissions.objects. Both directions plus the no-provider case need pinned tests.

    Generated by Claude Code

  5. baozhoutao commented on Aug 10, 2026

    @baozhoutao
    ContributorAuthor
    {
      "issue": 4096,
      "status": "done",
      "branch": "claude/issue-4096-row-crud-permission-gate",
      "pr": "https://github.com/objectstack-ai/objectui/pull/4108",
      "premise_still_valid": true,
      "summary": "Premise confirmed verbatim against origin/main @ c29ceff: ObjectGrid.tsx:1647 passed only effectiveApiOperations into resolveRowCrudAffordances, which folds three OBJECT-scoped layers (ADR-0103 bucket / userActions / apiOperations) and no principal-scoped verdict — so the row kebab's built-in Edit/Delete failed open for every account with no write grant, while the toolbar (affordances.create && can(obj,'create')) and the record header (objectAffordances.edit && recordWriteAllowed) on the same screen were correct. Fixed as an INTERSECTION TIGHTENING, not a swap: apiOperations and every other layer stay; can(obj,'update'|'delete') (MePermissionsProvider's allowEdit/allowDelete — the toolbar's own source) is ANDed on top. resolveRowCrudAffordances gained optional permissionUpdate/permissionDelete with the same `undefined => no narrowing` semantics as effectiveApiOperations; ObjectGrid fills them from the usePermissions() it already held at line 435 (no new data channel). Four faces, each with an explicit verdict: (1) grid row kebab — CHANGED; (2) RelatedRecordActionsBridge related-list rows — CHANGED, confirmed the same defect on the child object (create/edit/delete all fail-open; onView deliberately left untouched); (3) ListView.tsx:887 non-grid bulk delete — CHANGED, confirmed the same disease on the kanban/gallery bulk bar; (4) grid bulk-delete path (objectCanDelete / onBulkDelete) — CHANGED by construction (same resolver) and pinned explicitly rather than assumed. On #3492: its requiredPermissions work is an orthogonal ADR-0066 D4 capability gate for DECLARED actions (mayInvoke / useCapabilityGate) and never covered the built-in delete — that is why this entry was still open. All three PM mechanism assumptions verified true, none falsified. Declared-out paths (packages/core/src/evaluator/**, RowActionMenu.tsx) untouched — the verdict reaches RowActionMenu through the existing canEdit/canDelete props. useRecordEditable deliberately NOT used on list rows (per-record HTTP probe, N rows => 2N requests); the object-level verdict is what the toolbar uses and what the issue asked for. Sinking the layer into @object-ui/core's resolveEffectiveCrudAffordances was NOT done — minimum change was the default instruction and the four call sites there already AND can() themselves; recorded as an open question instead.",
      "tests": "Dedicated worktree off origin/main @ c29ceff. Dependency closure built first (pnpm --filter '@object-ui/plugin-grid...' --filter '@object-ui/plugin-list...' --filter '@object-ui/app-shell...' build) so the cross-package type surface is not read from stale dist/*.d.ts. | `pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ packages/app-shell/` => Test Files 400 passed (400), Tests 3900 passed | 1 skipped (3901). | `pnpm exec vitest run apps/console/` => Test Files 29 passed (29), Tests 300 passed (300). | `pnpm type-check` => Tasks: 78 successful, 78 total. | NOTE: the repo's vitest guard refuses package-level `pnpm --filter @object-ui/app-shell test` (it silently runs apps/console and reports a false green); everything above was run from the repo root, which is what CI does. | NEGATIVE CHECK (the new cases are not vacuous): each face's gate temporarily reverted and re-run — rowCrudAffordances principal layer removed => 5 failed | 9 passed | 42 skipped; ListView can(obj,'delete') removed => 'a principal WITHOUT allowDelete loses it' FAILED; RelatedRecordActionsBridge can(...) removed => 'a principal WITHOUT them loses all three' + 'gates create/update/delete independently' FAILED. The 'WITH permission' and 'no provider' cases stayed green throughout — they assert unchanged behavior. | TEST LANDING: shared consistency coverage, no new file — packages/plugin-grid/src/__tests__/rowCrudAffordances.test.ts (pure fn, 9 new cases), packages/plugin-grid/src/__tests__/rowCrudEffectiveOps.test.tsx (real ObjectGrid, row kebab + bulk bar in ONE file so a future divergence fails a test), packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.effectiveOps.test.tsx, packages/plugin-list/src/__tests__/ListView.permissions.test.tsx. | FAIL-OPEN/FAIL-CLOSED: all three scenarios pinned on all four faces — (a) account WITH the write grant still sees Edit/Delete, (b) account WITHOUT it does not, (c) NO PermissionProvider still sees them. The (c) case in rowCrudEffectiveOps.test.tsx runs the REAL usePermissions (mock keeps the actual module reachable via importOriginal) so it exercises the genuine `can: () => true` fallback, not an imitation; ListView.permissions.test.tsx renders with no provider mounted at all. | CONSOLE fail-closed check: metadata-admin is NOT affected — ResourceListPage.tsx renders its own 'ObjectGrid-like table' and never routes through ObjectGrid. sys_* lists reached via ObjectView -> ObjectGrid already run can(obj,'create') for the New button, so /me/permissions.objects must already carry them (or a '*' entry, which check honors); per-key absence is permissive anyway (allowEdit !== false). No loading flicker: MePermissionsProvider renders loadingFallback and does not mount children until the set resolves (MePermissionsProvider.tsx:303). NOT browser-verified — no backend in this session to serve /me/permissions; flagged in the PR body rather than implied otherwise. | SWEEP DIRECTION: dependents (downstream consumers), prefix form `pnpm --filter '...@object-ui/plugin-grid'` => 9 packages (app-shell, console, example-byo-backend-console, example-console-starter, plugin-designer, plugin-grid, plugin-report, plugin-view, site). resolveRowCrudAffordances has no consumer outside plugin-grid itself; the signature change is additive-optional; type-check covers all 9 and is green. | Changeset present: .changeset/row-crud-permission-gate-4096.md (patch x3: plugin-grid, plugin-list, app-shell). No new t() keys => check:i18n-keys not applicable. CI not polled — PM owns convergence.",
      "open_questions": [
        "Should the principal layer be SUNK into @object-ui/core's resolveEffectiveCrudAffordances (a third parameter carrying the principal verdict) instead of living at each call site? Four existing call sites there (toolbar / detail / form / related list) already AND can() themselves, so sinking is idempotent for them but touches a much wider scope. Went minimum-change per the card's default; the DRY argument is real and would collapse three of this PR's four gates into one definition. PM's call.",
        "RelatedRecordActionsBridge's onCreate is now also gated on can(child,'create') — strictly the issue is about Edit/Delete, but leaving create as the only ungated affordance on that surface would have re-created the exact inconsistency this card is closing. Flagging as a deliberate scope judgement, not an oversight.",
        "The fix inherits MePermissionsProvider's fail-closed default for an authenticated principal whose object is absent from /me/permissions.objects (#2926 IV). That is the toolbar's semantics and 'consistent with the toolbar' was this card's standard, and metadata-admin was verified out of range — but it is a behavioural inheritance, not a neutral one, and a host whose permission endpoint under-reports objects will now lose row Edit/Delete where it previously kept them."
      ],
      "out_of_scope_findings": [
        "Per the issue author's own closing line, extending the CEL predicate scope so os.user carries permission/role information is NOT in this PR. Worth a separate card: the downstream probe in the issue body established that CEL currently CANNOT express 'does the current user have write permission on this object', so an author whose needs differ from the built-in gate has no self-service channel at all — the only expressible workaround is hardcoding account/role names into object metadata, i.e. a second copy of the permission model that will drift.",
        "ListView.permissions.test.tsx's pre-existing fixtures set objectPermissions: { read, create, update, delete } on a role config, but @object-ui/permissions' evaluatePermission (evaluator.ts:66) reads roleConfig.actions?.includes(action) and ignores objectPermissions entirely — objectPermissions only drives the FIELD-level path. The existing FLS tests pass because they assert through checkField. Any future test that expects the config-based PermissionProvider to deny an OBJECT action via objectPermissions will silently assert nothing. The new cases in that file set `actions` explicitly and say so in a comment, but the trap is still there for the next author."
      ]
    }

    Generated by Claude Code

  6. baozhoutao commented on Aug 10, 2026

    @baozhoutao
    ContributorAuthor

    ACCEPT — PR #4108 (fix(grid,list,app-shell): gate row Edit/Delete on the caller's permission, not just apiOperations), 11 files, +548/−30.

    What shipped: apiOperations is kept as the exposure layer and a principal-scoped verdict is ANDed on top — can(obj, 'update' | 'delete') → /me/permissions allowEdit / allowDelete, the toolbar's own source. resolveRowCrudAffordances() gained optional permissionUpdate / permissionDelete with the same undefined ⇒ no narrowing semantics as effectiveApiOperations; ObjectGrid fills them from the usePermissions() it already held.

    Reviewed against GitHub, not against the report:

    • Four faces, four verdicts, all changed — grid row kebab; RelatedRecordActionsBridge related-list Create/Edit/Delete (verified objectName in that scope is the child — objects.find(o => o.name === objectName) → childDef — so can(objectName, …) is the child's grant, and onView is correctly left alone); ListView's non-grid bulk delete; and the grid bulk bar, which rides objectCanDelete from the same resolver and is now pinned rather than assumed. The dev's read on fix(plugin-grid): 批量操作按钮忽略 requiredPermissions,且布尔 visible 被判为故障导致按钮全员隐藏 #3492 is right and worth keeping: its requiredPermissions work is the ADR-0066 D4 capability gate for declared actions, which never covered the built-in delete — that is why this one survived.
    • The three fail-open / fail-closed cases are pinned in the shared consistency files, no new test file. The no-provider case runs the real usePermissions via importOriginal, so it exercises the genuine can: () => true fallback rather than an imitation.
    • The new cases are not vacuous — each face's gate was reverted and the suite re-run; the "WITHOUT permission" cases go red, the "WITH permission" and "no provider" cases stay green throughout. That negative check is what makes the green mean something here.
    • Cross-package sweep direction stated and correct: dependents (prefix '...@object-ui/plugin-grid', 9 packages), with the dependency closure built before typecheck so the cross-package surface is not read from stale dist/*.d.ts.
    • Scope clean: Fixes #4096 on the first line, changeset present (patch × 3), no content/docs/releases/ edits, and the declared-out paths (packages/core/src/evaluator/**, RowActionMenu.tsx — another seat's in-flight evalRowPredicate surface) are untouched.
    • CI: 20 check runs, 18 success + 2 skipped (dependabot, Test (coverage)); Lint and Type Check — the jobs that carry the gate families — are both completed: success.

    Two things recorded rather than reworked:

    1. File surface amended. packages/plugin-list/src/ListView.tsx + its test were outside the surface declared in the claim comment. It is face 3 of the four this card explicitly asked the dev to adjudicate, and the PR discloses it; no in-flight card in this repo touches plugin-list, so there is no collision. Recording the amendment here rather than treating a disclosed, authorized expansion as a breach.
    2. Not browser-verified — the dev session had no backend to serve /me/permissions, so the console-grid analysis (metadata-admin renders its own table and never goes through ObjectGrid; sys_* lists already run can(obj, 'create') for the New button) is code + test level. Flagged by the dev rather than implied otherwise, which is the right call; the browser pass belongs to whoever next dogfoods the console.

    Also noted: the dev's turn ended without the os-dev-report marker comment. Not a blocker — the PR body carries every element the report contract asks for (premise verdicts, per-face table, commands + output, the two rejected routes with reasons), which is exactly the case the report-loss fallback covers.

    Driving to landing now: ready for review + merge queue.


    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