Skip to content

fix(grid,list,app-shell): gate row Edit/Delete on the caller's permission, not just apiOperations - #4108

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-4096-row-crud-permission-gate
Aug 10, 2026
Merged

fix(grid,list,app-shell): gate row Edit/Delete on the caller's permission, not just apiOperations#4108
baozhoutao merged 1 commit into
mainfrom
claude/issue-4096-row-crud-permission-gate

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes#4096

What was wrong

The list row kebab's built-in Edit / Delete intersected only object-scoped layers — the ADR-0103 lifecycle bucket, userActions.edit/delete, and the server's effective API operation set (/me/permissionsapiOperations, #3720). apiOperations is the object's API exposure surface ("which verbs does this object publish"), and the report measured it byte-identical across two accounts with opposite allowEdit — 30 shared objects, 30/30 identical. A gate built only from object-scoped layers therefore fails open for every unprivileged caller.

Result: one screen, three different answers to "may this user write this object". The toolbar's New (affordances.create && can(obj, 'create')) and the record header's Edit/Delete (per-record write probe) were correct; the row kebab was not.

The fix — an intersection tightening, not a swap

apiOperations stays as the exposure layer. A principal-scoped verdict is ANDed on top: can(obj, 'update' | 'delete'), which MePermissionsProvider maps to /me/permissionsallowEdit / allowDelete — the toolbar's own source. No layer can re-open what another closed; a permission grant cannot resurrect an entry the bucket, userActions or apiOperations closed, and none of them survives a permission denial.

resolveRowCrudAffordances() gained two optional inputs, permissionUpdate? / permissionDelete?, with the same undefined ⇒ no narrowing semantics as the existing effectiveApiOperations (backward compatible). ObjectGrid fills them from the usePermissions() it already holds — no new data channel.

Per-face verdicts

#FaceVerdictEvidence
1packages/plugin-grid/src/ObjectGrid.tsx row kebab built-in Edit/Delete已改 / changedpermissionUpdate / permissionDelete ANDed in resolveRowCrudAffordances; filled at the call site (ObjectGrid.tsx).
2packages/app-shell/src/views/RelatedRecordActionsBridge.tsx related-list rows已改 / changed — same defect, second faceIt resolved resolveEffectiveCrudAffordances(childDef, getObjectApiOperations(objectName)) and nothing else, so onCreate / onEdit / onDelete were all offered to a principal with no write grant on the child. Now ANDs can(child, 'create'|'update'|'delete'). onView deliberately untouched — viewing is not a write.
3packages/plugin-list/src/ListView.tsx non-grid bulk delete已改 / changed — same diseasepermittedBulkActions gated the built-in delete on resolveEffectiveCrudAffordances(objectDef, effectiveApiOps).delete alone. All three layers describe the OBJECT, so a kanban/gallery board's most destructive control stayed visible for an account with no allowDelete. Now ANDs can(obj, 'delete'). Custom action ids still pass through untouched (own gates via the action runner).
4Bulk delete entry (objectCanDelete / onBulkDelete)已改 / changed — falls out of face 1, verified not left openThe grid's bulk bar (explicitBulkActions, bulkActionDefs filter, implicit ['delete']) all ride objectCanDelete, which is returned by the same resolver, so the principal layer reaches it by construction. Pinned explicitly rather than assumed. On #3492: its requiredPermissions work is a different, orthogonal gate — the ADR-0066 D4 capability gate for declared actions (mayInvoke/useCapabilityGate), which never covered the built-in delete. That is why this one was still open.

⛔ Untouched, as declared out of scope: packages/core/src/evaluator/** and packages/plugin-grid/src/components/RowActionMenu.tsx (another seat's in-flight evalRowPredicate surface, #3796 / #3792). The fix did not need them — the verdict arrives at RowActionMenu through the existing canEdit / canDelete props.

fail-open / fail-closed — the named regression risk

All three cases are pinned, in the shared consistency files (no new test file):

CaseExpectedPinned in
Account with the write grantstill sees Edit/DeleterowCrudEffectiveOps.test.tsx, rowCrudAffordances.test.ts, RelatedRecordActionsBridge.effectiveOps.test.tsx, ListView.permissions.test.tsx
Account without itdoes not see them (the bug)same four
No PermissionProviderstill sees them (fail-open preserved)same four

The no-provider case in rowCrudEffectiveOps.test.tsx runs the realusePermissions (the mock keeps the actual module reachable via importOriginal and returns it for that branch), so it exercises the genuine can: () => true fallback rather than an imitation of it. ListView.permissions.test.tsx renders with no provider mounted at all.

MePermissionsProvider fail-closed semantics (objPerm ? objPerm[k] !== false : data.authenticated !== true, #2926 ④) are inherited deliberately — "consistent with the toolbar" is the standard this card was given, and the toolbar's can(obj, 'create') has run under exactly these semantics on the same screen all along. Checked for the console's own grids specifically:

  • metadata-admin is not affected.packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx renders its own "ObjectGrid-like table" — it does not go through ObjectGrid, so this change cannot touch it. (grep -rln 'ObjectGrid' packages/app-shell/src/views/metadata-admin/ returns only preview/config/i18n files.)
  • sys_* object lists reached through ObjectViewObjectGrid are the only console surface in range, and they already run affordances.create && can(objectDef.name, 'create') for the New button. If /me/permissions.objects omitted sys_* for a console admin, New would already be missing today — so the map does 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), so the permissions-loadingallowed: false window is never observed by a rendered grid.

⚠️Not browser-verified. The card asked for a real-browser look "if it runs"; this session has no backend to serve /me/permissions, so the evidence above is code + test level. Flagging rather than implying otherwise.

PM mechanism assumptions — verified against origin/main @ c29ceff

All three confirmed, none falsified:

  1. ObjectGrid.tsx:1647 passed effectiveApiOperations: effectiveApiOps and ANDed no principal-scoped verdict; rowCrudAffordances.ts's docblock folded exactly three layers (bucket / userActions / apiOperations).
  2. ✅ Toolbar ObjectView.tsx:1798/1809/1863affordances.create && can(objectDef.name, 'create'); detail header RecordDetailView.tsx:1899-1902objectAffordances.edit && recordWriteAllowed / .delete && recordDeleteAllowed.
  3. MePermissionsProvider.check maps update → allowEdit, delete → allowDelete (MePermissionsProvider.tsx:230-236), and ObjectGrid.tsx:435 already held usePermissions() — no new data channel was needed.

The suggested route (explicit params on the pure function + call-site fill) was taken. useRecordEditable was not used on list rows — it is a 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 existing call sites there already AND can() themselves, so sinking would be idempotent but far broader. Noted as an open question.

Verification

Run in a 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

(Package-level pnpm --filter <pkg> test is fine for plugin-grid/plugin-list but the repo's vitest guard refuses it for app-shell — everything above was re-run from the repo root, which is what CI does.)

Negative check — the new cases are not vacuous. Each face's gate was temporarily reverted and the suite re-run:

# rowCrudAffordances.ts principal layer removed:
Tests 5 failed | 9 passed | 42 skipped (56) ← the "WITHOUT permission" + "independent" cases
# ListView.tsx `can(obj,'delete')` removed:
× a principal WITHOUT allowDelete loses it, and keeps the non-delete actions
# RelatedRecordActionsBridge.tsx `can(...)` removed:
× a principal WITHOUT them loses all three — on a fully exposed child
× gates create / update / delete independently

The "WITH permission" and "no provider" cases stayed green throughout — they assert unchanged behavior, which is the point.

Cross-package sweep direction: dependents (downstream consumers), pnpm --filter '...@object-ui/plugin-grid' — the prefix form. It resolves to 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 (grep over packages/apps/examples), and the signature change is additive-optional, so nothing downstream needed adapting; pnpm type-check covers all 9 and is green.

No new t() keys ⇒ check:i18n-keys not applicable. Changeset: .changeset/row-crud-permission-gate-4096.md (patch × 3).

Out of scope, per the issue author's own closing line

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

Extending the CEL predicate scope so os.user carries permission/role information is not in this PR.


Generated by Claude Code

…sion (#4096)
The list row kebab's built-in Edit/Delete intersected only object-scoped
layers — the ADR-0103 bucket, `userActions`, and the server's effective API
operation set (`/me/permissions` `apiOperations`). `apiOperations` is the
object's API EXPOSURE surface and is principal-independent (measured 30/30
identical between an account with `allowEdit` and one without), so the gate
failed open for every account with no write grant: Delete sat one click away
from users the server answers 403 to, and Edit opened a prefilled dialog that
could only fail on save. The toolbar's New and the record header's Edit/Delete
on the same screen were already gated correctly.
Ands the principal's own verdict — `can(obj, 'update' | 'delete')`, i.e.
`allowEdit` / `allowDelete`, the toolbar's source — on top of the existing
layers, on four surfaces:
- the grid row kebab (`resolveRowCrudAffordances` gained `permissionUpdate` /
`permissionDelete`, filled at the `ObjectGrid` call site);
- the grid bulk-delete bar, which rides the same object-level delete verdict;
- the non-grid bulk bar `ListView` renders itself;
- the related-list Create/Edit/Delete in `RelatedRecordActionsBridge`.
An intersection tightening, not a swap: `apiOperations` and every other layer
stay, and no layer can re-open what another closed. Fail-open is preserved
where it is the contract — `usePermissions()` with no `PermissionProvider`
answers `can: () => true`, so standalone embeds are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQbuY8A4jabkVwkP3GU9oe
@vercel

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 10, 2026 10:07am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.2 KB350 KB
Entry fileindex-CN8sxMaa.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)484.15KB106.78KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)140.66KB36.25KB
fields (index.js)229.40KB56.93KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.87KB10.80KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)61.49KB17.48KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)118.50KB30.66KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)237.80KB59.48KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)162.81KB39.67KB
plugin-grid (index.js)188.04KB49.91KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)110.04KB26.67KB
plugin-map (index.js)17.00KB5.32KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.95KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@baozhoutao
baozhoutao marked this pull request as ready for review August 10, 2026 10:23
@baozhoutao
baozhoutao added this pull request to the merge queueAug 10, 2026
Merged via the queue into main with commit aeb8424Aug 10, 2026
21 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-4096-row-crud-permission-gate branch August 10, 2026 10:24
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@baozhoutao@claude