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

Commit 5f75cfa

Browse files
fix(console): in-place edit and list inline edit honour the caller's field write grant, and the owner field the transfer grant (objectui#12103) (#12117)
Fixes #12103 Clause-②: yes Refs: #12082 (the affordance-to-grant family). #12082 is not addressed here: its object-level census backlog is untouched, including the record overlay's `OVERLAY_INLINE_EDIT` entries. ## Measured first: the server's per-caller answer agrees with enforcement The card's stop rule did not fire. Measured on a live backend before any code: objectstack `main` at `bf515e72`, `examples/app-showcase`, `objectstack dev --seed-admin --fresh` on a private port, run from a separate read-only objectstack worktree. The test user holds the `contributor` position, so their sets are `showcase_contributor`, `showcase_member_default` and `member_default`. Their field entry is `{ readable: true, editable: false }`, and the object carries no transfer grant. | Probe as that user | Raw answer | |---|---| | `GET /api/v1/auth/me/permissions` `objects.showcase_project` | `allowEdit: true`, `allowTransfer: false`, `modifyAllRecords: false` | | same answer, `fields["showcase_project.budget"]` | `{"readable": true, "editable": false}` | | `PATCH /api/v1/data/showcase_project/ID` with `{"budget":123}` | **403** `[Security] Field write denied: not permitted to edit [budget] on 'showcase_project'` | | `PATCH` with `{"owner_id": "REP_ID"}` | **403** `'owner_id' on 'showcase_project' is system-managed — changing record ownership on update requires the transfer grant (allowTransfer or modifyAllRecords)` | | CONTROL `PATCH` with `{"name": "…"}` | **200** | | CONTROL, admin: `PATCH` with `{"owner_id": "REP_ID"}` | **200** (admin's entry: `allowTransfer: true`) | So `/me/permissions` reports `editable: false` and `allowTransfer: false`, and enforcement refuses both writes. The client can follow an answer the server already gives, and no half of this belongs to objectstack. ## What changed The family's direction is one map from each affordance to the grant it reads. This PR therefore adds no per-component permission logic. Each surface asks its own row's field question through `resolveFieldAffordance`. - **`@object-ui/core`, `AFFORDANCE_GRANTS`.** `recordEdit` and `listInlineEdit` gain `field: 'write'`. That is the same update question `editFormFields` asks, so the record page's in-place edit and a list's in-cell edit ask each field exactly what an edit form asks through `fieldWriteGate`. The test `under a … grant each answers every field exactly as an edit form's gate does` pins that it is the same predicate. - **`@object-ui/plugin-detail`, `DetailSection` (the details body) and `HeaderHighlight` (the highlights strip).** Each field's `fieldEditable` now ANDs in `resolveFieldAffordance('recordEdit', perms, objectName, field)`. A refused field shows no pencil, does not enter the session on double-click, and stays a read display in edit mode. The object-level gate (`onEnterInlineEdit` / `inline.canEdit`) is unchanged. With no `objectName`, or no provider mounted, nothing is asked. - **`@object-ui/plugin-grid`, `ObjectGrid`.** The column enrichment pass that already marks read-only, computed or binary columns `editable: false` also marks a column whose field the caller may not write, using `resolveFieldAffordance('listInlineEdit', …)`. The cell keeps its read display while the rest of the row edits. `inlineEditable`, the object-level `listInlineEdit` verdict, still decides whether the grid edits at all. So the `5330afd` default (an absent `userActions.editInline` reads on, decided in `ListView`'s `inlineEditOffered`) composes unchanged: it decides whether the toggle is offered, and this narrows single columns inside an editing grid. - **`@object-ui/permissions`, the owner field.** The server's guard keys the owner field by name: `owner_id`, which the spec spells as the `OWNER_ID` key of `SystemFieldName`. No field type and no object-level owner declaration is read there; `ownership` only decides injection. So the field is identified by the spec's constant, and the console carries no literal. `MePermissionsProvider.checkField(object, owner, 'write')` now also requires the transfer grant, read with the spec's own predicate `objectPermissionGrants(objPerm, 'allowTransfer')`, which is `allowTransfer` or `modifyAllRecords`. The `write` question is documented as "the server's update rule", and the owner's update rule includes the transfer grant. Fixing it in the resolver therefore covers every `write` asker (edit forms, in-place edit, in-cell edit), and no affordance row has to remember it. `create` is not narrowed, because on insert the server stamps an empty owner to the caller and accepts the caller's own id. `MePermissionsResponse` declares the `allowTransfer?` bit the endpoint already serves. `@object-ui/permissions` gains a direct `@objectstack/spec ^17.7.0` dependency for `@objectstack/spec/security` and `@objectstack/spec/system`, which adds +3 lockfile lines. - **Docs:** the `@object-ui/core` and `@object-ui/permissions` READMEs. **Changeset:** `.changeset/12103-field-write-grants.md` (core and permissions `minor`, plugin-detail and plugin-grid `patch`). - **Census (`affordanceGrantMap-12082.test.tsx`).** `EXPECTED_FIELD_GRANT` gains `recordEdit` and `listInlineEdit`. Two new blocks cover the edit-form-equivalence pins and the owner/transfer matrix: absent and `false` refuse; `allowTransfer` and `modifyAllRecords` open; CONTROL: the grant moves no other field; an explicit `editable: false` on the owner still refuses; the insert question is not narrowed; fail-open with no provider. The `InlineEditSaveBar … updateVia :: update` write-site entry now also names `DetailSection.tsx` and `HeaderHighlight.tsx` as readers of `recordEdit`. No write call site was added or moved. The three Setup entries from #12089 are untouched, and none of that PR's files is touched. ### File surface: three additions beyond the claim, with the reason - `packages/plugin-detail/src/HeaderHighlight.tsx`: the highlights strip is the same in-place edit session as the details body, with one draft and one Save. Leaving it ungated would leave the card's own defect reachable on the same page. All four bounded-fix conditions hold: the same defect class, the same one-call shape as `DetailSection`, no other open PR touches the file (read on the 4 open PRs), and the same gate family. - `packages/permissions/package.json` and `pnpm-lock.yaml`: the spec imports above. - `packages/core/README.md` and `packages/permissions/README.md`: AGENTS.md commandment 2 (docs-driven). ## Clause-② inventory (for the seat's contract review) - `AFFORDANCE_GRANTS.recordEdit` and `AFFORDANCE_GRANTS.listInlineEdit` gain `field: 'write'`. No row is added or removed. - `FieldAffordance` (derived type) widens by `'recordEdit' | 'listInlineEdit'`. - `MePermissionsResponse['objects'][string]` gains `allowTransfer?: boolean`. - `checkField`'s action union is **unchanged** (`'read' | 'write' | 'create'`). Its `write` answer narrows for the owner field only. - `@object-ui/permissions` gains a runtime dependency on `@objectstack/spec`. ## Evidence (head `47a8eee`) - **Type-check**, every output echoing `type-check`, exit 0: `@object-ui/core`, `@object-ui/permissions`, `@object-ui/plugin-detail`, `@object-ui/plugin-grid` and `@object-ui/plugin-form`. Each package's `tsconfig.test.json` `--listFiles` includes its new test file. - **New pins**, all through the real `MePermissionsProvider` and the measured envelope: `affordanceGrantMap-12082.test.tsx`, `MePermissionsProvider.ownerTransfer-12103.test.tsx`, `DetailSection.fieldWriteGrant-12103.test.tsx` (details body and highlights strip) and `inlineEditFieldWriteGrant-12103.test.tsx` (grid). Result: `Test Files 4 passed (4)` / `Tests 66 passed (66)`. - **Union, narrowed** (repo root, no `--`): 35 files and 381 tests passed (grid inline-edit, permission, FLS and column suites, plugin-form map and field-gate pins, the whole `packages/permissions/`), then 31 files and 240 tests passed (ListView permission, inline and FLS suites, app-shell `ObjectView.objectBoundActions-7234` and `RecordDetailView.expandFls-7230`, every `DetailSection.*`, `HeaderHighlight.*` and `RecordHighlightsRenderer.*`, `DetailView.permissions`, `InlineEditSaveBar`). The whole of `packages/permissions/` plus `packages/plugin-detail/` also ran at `bccbfc5`: `Test Files 262 passed | 1 skipped (263)`. The only later changes are a comment rewording and the test-file typing fix. - **NOT MEASURED: the whole `packages/plugin-grid/` suite.** Reason: one run exceeded the foreground cap (killed at 590 s, no result). It is declared to CI. The grid files above are the narrowed set. - **Ablations**, each committed first and run through `ablation-replace.mjs`. Every leg reported `ok mutation landed` and `ok restored: blob == HEAD … git diff HEAD is empty`. Vitest aliases these packages to `src`, so no rebuild was needed. - `DetailSection` gate replaced by `true`: 3 red (refused field, owner, edit mode); the strip pins and the controls stayed green. - `HeaderHighlight` gate replaced by `true`: 3 red (refused highlight, owner, edit-mode display); the CONTROL stayed green. - `ObjectGrid` field question removed: 2 red (refused field, owner); the controls stayed green. - Provider owner rule disabled: 6 red across all 4 files. - **Gates**, exit 0: `check:metadata-write-doors`, `check:handler-key-reads`, `check:new-line-citations` (`VERDICT … 0 new citation(s)`), `check:control-bytes`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:esm-specifiers`, `check:self-import`, `check:phantom-deps`, `check:unused-deps`, `check:spec-symbols`, `check:installed-pin-claims`, `check:lockfile-integrity`, `check:lockfile-dedupe`, `check:pre-install-import-graph`, `check:side-effects-array`, `check:unreferenced-sources`, `check:doc-fences`, `check:doc-types`, `check-changeset-presence.mjs` and `check-changeset-no-major.mjs`. - **NOT MEASURED** (prerequisite: a whole-tree build): `check:readme-exports` (its own line: "the population COLLAPSED -- this run proves nothing"), `check:doc-snippets` (`[unbuilt-package]`) and `check:spec-floors`. In `check:spec-floors`, all 14 findings are `[no-artifact]` for unbuilt packages. The built `@object-ui/core` and `@object-ui/permissions` raised no finding, and the gate read the `17.7.0` `./security` and `./system` entries. The README edits add no fenced block. - **Eager closure** (console `vite build` at base `5330afd` and at `2ff9d43`; the last commit changes a test file only): base 3,246,298 bytes, head 3,246,484 bytes gzipped, **+186 bytes**. The chunk count stays at 290, and `vendor-objectstack` stays at 1561.8 KB. The spec modules were already in that chunk. Gate: `Console eager closure is 3170.4 KB gzipped … headroom: 34.2 KB`, exit 0. - **Lint, narrowed** to the 10 touched `.ts`/`.tsx` files. ① The population comes from eslint's own resolution: `--print-config` resolves a config for every file, and none is ignored. ② `--format json` reports 10 files. ③ Invariance: `eslint.config.js` enables no type-aware linting (no `projectService` and no `parserOptions.project`), so the diff cannot move a verdict on an untouched file. With inline config (how the package `lint` scripts run): 0 errors. With `--no-inline-config`: 1 error, `react-hooks/static-components` in `ObjectGrid.tsx`, which is identical at base (the same rule, with an inline disable). Warning counts per file are identical to base. ## Acceptance notes - `check:spec-symbols` refused a citation the spec does declare. A dotted citation of `OWNER_ID` on `SystemFieldName` next to a spec mention reads as "a key the spec does not declare". The spec exports `SystemFieldName` as a const and as a value-union type; the gate's `memberSetOfSymbol` fills `authored` from the declared type's members only (here the string members), so the const's keys never count. The comment was reworded instead. Carrier: none (承接者:无). This is a read of the gate's code, not a filed defect. - Edit forms strip `owner_id` from every payload by name (`SERVER_OWNED_FIELD_NAMES` in `plugin-form`'s `sanitize.ts`). A transfer-holder whose form layout draws the owner field therefore gets an enabled input whose change Save drops. This was not changed and not measured here (a read-only inference). The in-place and in-cell paths do write it, and the server accepts it from a transfer-holder (the admin control above). Carrier: none. - The role-based `PermissionProvider` has no transfer concept, so its `checkField` is unchanged. The console mounts `MePermissionsProvider`. --- _Generated by [Claude Code](https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a21ff9a commit 5f75cfa

15 files changed

Lines changed: 721 additions & 15 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/permissions': minor
4+
'@object-ui/plugin-detail': patch
5+
'@object-ui/plugin-grid': patch
6+
---
7+
8+
In-place edit on the record page and a list's in-cell edit honour the caller's field write grant, and the owner field honours the transfer grant (objectui#12103).
9+
10+
**What a caller saw.** A caller whose permission set marks a field `{ readable: true, editable: false }` was offered an editor for it in the record page's details body, in its highlights strip and in a list's grid cells, and Save answered 403 ("Field write denied"). The owner field was offered the same way to a caller without the transfer grant, and Save answered 403 ("requires the transfer grant"). Edit forms already refused both fields through `fieldWriteGate`.
11+
12+
**What changed.**
13+
14+
- **The map (`@object-ui/core`).** The `recordEdit` and `listInlineEdit` rows of `AFFORDANCE_GRANTS` gain `field: 'write'`, so the record page's in-place edit and a list's in-cell edit each ask every field the same update question an edit form asks, through `resolveFieldAffordance`. `FieldAffordance` therefore includes both rows.
15+
- **The details body and the highlights strip (`@object-ui/plugin-detail`).** `DetailSection` and `HeaderHighlight` ask that question of each field. A refused field shows no pencil, does not enter the edit session on double-click, and stays a read display in edit mode. The strip and the body share one session and one Save, so they answer alike.
16+
- **The grid (`@object-ui/plugin-grid`).** `ObjectGrid` marks a refused field's column not editable, so its cell keeps the read display while the rest of the row edits. Whether the grid is editable at all is still the object-level `listInlineEdit` verdict.
17+
- **The owner field (`@object-ui/permissions`).** The server refuses an update that writes the record's owner (the field the `@objectstack/spec` constant `SystemFieldName` names `OWNER_ID`) unless the caller holds `allowTransfer` or `modifyAllRecords`. `MePermissionsProvider`'s `checkField(object, owner, 'write')` now answers with that grant, read with the spec's `objectPermissionGrants`. The `write` question is the server's update rule for a field, so every surface that asks it (edit forms, in-place edit, in-cell edit) offers the owner field only to a caller who may transfer the record. `checkField(object, owner, 'create')` is unchanged, because on insert the server stamps an empty owner to the caller and accepts the caller's own id. `MePermissionsResponse` declares the `allowTransfer` bit the endpoint already serves, and `@object-ui/permissions` now depends on `@objectstack/spec` directly.
18+
19+
**Unchanged.** With no permission provider mounted no field question is asked, so a standalone embed and the designer preview behave as before. The server still enforces every grant; this only stops the console from offering a write it would refuse.
20+
21+
**Clause-②: yes (widening).** `AFFORDANCE_GRANTS.recordEdit` and `AFFORDANCE_GRANTS.listInlineEdit` gain a `field: 'write'` member, which widens the `FieldAffordance` type by those two rows. `MePermissionsResponse`'s object entry declares an optional `allowTransfer?: boolean`. `checkField`'s action union is unchanged; its `write` answer narrows for the owner field only. No export, prop or language-pack key is added or removed.

‎packages/core/README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,13 @@ row's Delete, and the rest — reads the grant it exercises from ONE table,
187187
`AFFORDANCE_GRANTS` (objectui#12082). A row names the CRUD-affordance bit the
188188
affordance needs, the object grant it exercises (`create`, `update` or
189189
`delete`) and, for an affordance that offers fields, the field question it asks
190-
(`create` for an insert, `write` for an update).
190+
(`create` for an insert, `write` for an update). The fields of a form, of the
191+
record page's in-place edit (`recordEdit`) and of a list's in-cell edit
192+
(`listInlineEdit`) each ask that question, so a field the caller's permission
193+
set marks `editable: false` is offered read-only on every one of them
194+
(objectui#12103). The `write` question is the server's whole update rule for a
195+
field, so for the record's owner field it also needs the transfer grant
196+
(`allowTransfer`, or `modifyAllRecords`).
191197

192198
`resolveAffordance` is the one verdict: the object's managed-object policy, the
193199
server's effective API operation set and the caller's grant must all allow it,

‎packages/core/src/utils/affordanceGrants.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@
4747
* way for both questions. So a create form follows what the server enforces on
4848
* insert, and adds no rule the server does not have.
4949
*
50+
* The `write` question is the server's whole update rule for the field, so it
51+
* also carries the one field whose update needs an OBJECT grant beyond
52+
* `allowEdit`: the record's owner, the field the spec's `SystemFieldName`
53+
* constant names `OWNER_ID`. The server refuses an update that writes it without
54+
* the transfer grant (`allowTransfer`, or `modifyAllRecords`), so the resolver
55+
* answers `write` for it with that grant too (objectui#12103). Every row that
56+
* asks `write` therefore offers the owner field only to a caller who may
57+
* transfer the record — the in-place edit, the list's in-cell edit and an edit
58+
* form alike, with no row of its own to forget.
59+
*
5060
* ## The verdict
5161
*
5262
* An affordance shows when the managed-object policy, the effective API
@@ -120,8 +130,12 @@ export const AFFORDANCE_GRANTS = {
120130
createFormFields: { crud: 'create', grant: 'create', field: 'create' },
121131
/** An edit form's fields (every `ObjectForm` layout) and its form-wide lock. */
122132
editFormFields: { crud: 'edit', grant: 'update', field: 'write' },
123-
/** The record page's Edit — the header CTA and the record body's in-place editing. */
124-
recordEdit: { crud: 'edit', grant: 'update' },
133+
/**
134+
* The record page's Edit — the header CTA and the record body's in-place
135+
* editing (the details body and the highlights strip), each of whose fields
136+
* asks the update question (objectui#12103).
137+
*/
138+
recordEdit: { crud: 'edit', grant: 'update', field: 'write' },
125139
/** The record page's Delete. */
126140
recordDelete: { crud: 'delete', grant: 'delete' },
127141
/**
@@ -133,8 +147,8 @@ export const AFFORDANCE_GRANTS = {
133147
listImport: { crud: 'import', grant: 'create', field: 'create' },
134148
/** The import wizard's template download — its endpoint answers 403 without the create grant. */
135149
importTemplate: { crud: null, grant: 'create' },
136-
/** A list's inline (in-cell) editing. */
137-
listInlineEdit: { crud: 'edit', grant: 'update' },
150+
/** A list's inline (in-cell) editing, each of whose cells asks the update question (objectui#12103). */
151+
listInlineEdit: { crud: 'edit', grant: 'update', field: 'write' },
138152
/** A list's bulk Delete. */
139153
listBulkDelete: { crud: 'delete', grant: 'delete' },
140154
/** A grid row's Edit. */

‎packages/permissions/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ such a field through and object admission decides, so `'write'` falls back to
148148
the object's `allowEdit` and `'create'` to its `allowCreate`. An explicit
149149
field-level entry answers both the same way.
150150

151+
One field's update needs more than its own grant: the record's owner, the
152+
field the spec's `SystemFieldName` constant names `OWNER_ID`. The server refuses an
153+
update that writes it unless the caller holds the transfer grant
154+
(`allowTransfer`, or `modifyAllRecords`), so `MePermissionsProvider` answers
155+
`'write'` for the owner field with that grant as well (objectui#12103).
156+
`'create'` is unchanged: on insert the server stamps an empty owner to the
157+
caller and accepts the caller's own id.
158+
151159
```tsx
152160
import { usePermissions } from '@object-ui/permissions';
153161

‎packages/permissions/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"react": "^18.0.0 || ^19.0.0"
3838
},
3939
"dependencies": {
40-
"@object-ui/types": "workspace:*"
40+
"@object-ui/types": "workspace:*",
41+
"@objectstack/spec": "^17.7.0"
4142
},
4243
"devDependencies": {
4344
"@types/react": "19.2.18",

‎packages/permissions/src/MePermissionsProvider.tsx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import type {
1919
PermissionCheckResult,
2020
FieldLevelPermission,
2121
} from '@object-ui/types';
22+
import { objectPermissionGrants } from '@objectstack/spec/security';
23+
import { SystemFieldName } from '@objectstack/spec/system';
2224
import { PermCtx, type PermissionContextValue } from './PermissionContext.js';
2325
import { createDiscardProofCache } from './discardProofCache.js';
2426

@@ -42,6 +44,13 @@ export interface MePermissionsResponse {
4244
allowDelete?: boolean;
4345
viewAllRecords?: boolean;
4446
modifyAllRecords?: boolean;
47+
/**
48+
* The transfer grant: may the caller change a record's owner. The server
49+
* refuses an update that writes the owner field without it (or without
50+
* `modifyAllRecords`, which implies it), and `checkField` reads it for that
51+
* field's `write` question (objectui#12103).
52+
*/
53+
allowTransfer?: boolean;
4554
/**
4655
* [objectstack#3391] Server-resolved effective API operation set for this object
4756
* (enum-ordered). Present only when the object tightens exposure via
@@ -307,6 +316,23 @@ export function MePermissionsProvider({
307316
const objKey = (object ?? '').toLowerCase();
308317
const key = `${objKey}.${field}`;
309318
const fieldPerm = data.fields?.[key] ?? data.fields?.[`${object}.${field}`];
319+
const objPerm = data.objects?.[objKey] ?? data.objects?.[object] ?? data.objects?.['*'];
320+
// [objectui#12103] An update that writes the record's OWNER is a
321+
// transfer: the server's security step refuses it unless the caller holds
322+
// the transfer grant, whatever the field's own entry says. So `write` for
323+
// that field needs the grant too, read with the spec's own predicate
324+
// (`allowTransfer`, or `modifyAllRecords`, which implies it). The field is
325+
// the one the spec names, as the server's guard keys it. `create` is not
326+
// narrowed: on insert the server stamps an empty owner to the caller and
327+
// accepts the caller's own id. An object the answer does not mention
328+
// takes the unknown-object default below.
329+
if (
330+
action === 'write' &&
331+
field === SystemFieldName.OWNER_ID &&
332+
!(objPerm ? objectPermissionGrants(objPerm, 'allowTransfer') : data.authenticated !== true)
333+
) {
334+
return false;
335+
}
310336
// An explicit entry answers `write` and `create` alike: the server's
311337
// field step refuses a non-editable field on insert and update both.
312338
if (fieldPerm) {
@@ -318,7 +344,6 @@ export function MePermissionsProvider({
318344
// field step lets the field through and object admission decides, so
319345
// the answer is the grant of the operation asked about (objectui#12082)
320346
// — `allowCreate` on an insert, `allowEdit` on an update.
321-
const objPerm = data.objects?.[objKey] ?? data.objects?.[object] ?? data.objects?.['*'];
322347
if (!objPerm) {
323348
// [objectstack-ai/objectstack#2926 ④] Unknown-object default is authentication-gated:
324349
// - authenticated session → fail-CLOSED. The server resolved this

‎packages/permissions/src/PermissionContext.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export interface PermissionContextValue {
2121
* permission set does not mention: the server's field step lets it through
2222
* and object admission decides — `allowEdit` on an update, `allowCreate` on
2323
* an insert. An explicit field-level entry answers both the same way.
24+
*
25+
* `write` is the server's whole update rule, so for the record's owner field
26+
* (`SystemFieldName.OWNER_ID`) it also needs the transfer grant — the server
27+
* refuses an update that writes the owner without it (objectui#12103).
2428
*/
2529
checkField: (object: string, field: string, action: 'read' | 'write' | 'create') => boolean;
2630
/** Get field permissions for an object */
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#12103 — `checkField(object, owner, 'write')` answers the server's
11+
* whole update rule for the owner field, which includes the transfer grant.
12+
*
13+
* The server's security step refuses an update that writes the record's owner
14+
* (`SystemFieldName.OWNER_ID`) unless the caller holds `allowTransfer` or
15+
* `modifyAllRecords`; measured on a live backend, `/me/permissions` reports the
16+
* grant per object and the refusal is a 403. So the `write` question for that
17+
* one field needs the grant, read with the spec's own predicate, while `create`
18+
* (the insert rule, which stamps and accepts the caller's own id), `read`, and
19+
* every other field are untouched.
20+
*/
21+
22+
import { describe, it, expect, afterEach } from 'vitest';
23+
import { render, cleanup } from '@testing-library/react';
24+
import React from 'react';
25+
import { SystemFieldName } from '@objectstack/spec/system';
26+
import { MePermissionsProvider, type MePermissionsResponse } from '../MePermissionsProvider';
27+
import { usePermissions } from '../usePermissions';
28+
import type { PermissionContextValue } from '../PermissionContext';
29+
30+
afterEach(cleanup);
31+
32+
const OBJECT = 'crm_account';
33+
const OWNER = SystemFieldName.OWNER_ID;
34+
const EDITOR = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false };
35+
36+
function envelope(
37+
objects: MePermissionsResponse['objects'],
38+
fields: MePermissionsResponse['fields'] = {},
39+
authenticated = true,
40+
): MePermissionsResponse {
41+
return { authenticated, userId: authenticated ? 'u1' : null, tenantId: null, roles: [], permissionSets: [], objects, fields };
42+
}
43+
44+
function checkFieldUnder(perms: MePermissionsResponse): PermissionContextValue['checkField'] {
45+
let captured: PermissionContextValue['checkField'] | null = null;
46+
function Probe() {
47+
captured = usePermissions().checkField;
48+
return null;
49+
}
50+
render(
51+
<MePermissionsProvider initialPermissions={perms}>
52+
<Probe />
53+
</MePermissionsProvider>,
54+
);
55+
if (!captured) throw new Error('the probe did not render');
56+
return captured;
57+
}
58+
59+
describe("checkField's write question for the owner field (objectui#12103)", () => {
60+
it('is refused without the transfer grant — false and absent alike', () => {
61+
expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, allowTransfer: false } }))(OBJECT, OWNER, 'write')).toBe(false);
62+
expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR } }))(OBJECT, OWNER, 'write')).toBe(false);
63+
});
64+
65+
it('is granted by allowTransfer, and by modifyAllRecords, which implies it', () => {
66+
expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, allowTransfer: true } }))(OBJECT, OWNER, 'write')).toBe(true);
67+
expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, modifyAllRecords: true } }))(OBJECT, OWNER, 'write')).toBe(true);
68+
});
69+
70+
it('reads the wildcard entry for an object the answer does not name, as the object fallback does', () => {
71+
expect(checkFieldUnder(envelope({ '*': { ...EDITOR, modifyAllRecords: true } }))(OBJECT, OWNER, 'write')).toBe(true);
72+
expect(checkFieldUnder(envelope({ '*': { ...EDITOR, allowTransfer: false } }))(OBJECT, OWNER, 'write')).toBe(false);
73+
});
74+
75+
it('keeps the unknown-object default: closed for a signed-in caller, open for an anonymous one', () => {
76+
expect(checkFieldUnder(envelope({}))(OBJECT, OWNER, 'write')).toBe(false);
77+
expect(checkFieldUnder(envelope({}, {}, false))(OBJECT, OWNER, 'write')).toBe(true);
78+
});
79+
80+
it("an explicit editable: false entry still refuses it with the grant; the grant does not open the field's own refusal", () => {
81+
const check = checkFieldUnder(
82+
envelope({ [OBJECT]: { ...EDITOR, allowTransfer: true } }, { [`${OBJECT}.${OWNER}`]: { readable: true, editable: false } }),
83+
);
84+
expect(check(OBJECT, OWNER, 'write')).toBe(false);
85+
});
86+
87+
it('CONTROL: create, read and every other field are untouched by the transfer grant', () => {
88+
const check = checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, allowTransfer: false } }));
89+
expect(check(OBJECT, OWNER, 'create')).toBe(true);
90+
expect(check(OBJECT, OWNER, 'read')).toBe(true);
91+
expect(check(OBJECT, 'name', 'write')).toBe(true);
92+
});
93+
});

‎packages/plugin-detail/src/DetailSection.tsx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
} from '@object-ui/components';
2828
import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff, Pencil } from 'lucide-react';
2929
import { SchemaRenderer, toRenderableSchema, useInlineEdit } from '@object-ui/react';
30+
import { resolveFieldAffordance } from '@object-ui/core';
31+
import { usePermissions } from '@object-ui/permissions';
3032
import {
3133
getCellRenderer,
3234
resolveCellRendererType,
@@ -167,6 +169,12 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
167169
* session to read, exactly as it has no draft.
168170
*/
169171
const serverFieldErrors = useInlineEdit()?.fieldErrors ?? null;
172+
/**
173+
* The caller's permissions, for the field question in-place edit asks of each
174+
* field (objectui#12103). With no provider mounted `isLoaded` is false and
175+
* the question is not asked, so a bare `DetailSection` behaves as before.
176+
*/
177+
const perms = usePermissions();
170178

171179
/**
172180
* What the copy affordance WRITES (objectui#8395).
@@ -378,7 +386,16 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
378386
// clipboard are the READ direction of the same refusal, and `isInlineExcluded`
379387
// (objectui#4221) already holds the WRITE direction of it.
380388
const isMaskedField = isMaskedDetailFieldType(field.type, objectDefField?.type);
381-
const fieldEditable = !isReadonly && !isComputedField && !isSystemField && !isInlineExcluded;
389+
// The CALLER's write grant on this field (objectui#12103): the field
390+
// question of the `recordEdit` row of the affordance-to-grant map, the same
391+
// `write` question an edit form asks (`fieldWriteGate`). A field the
392+
// permission set marks `editable: false` — or the owner field without the
393+
// transfer grant — stays a read display, so in-place edit never offers a
394+
// write the server refuses with 403. The flags above are the object's; this
395+
// one is the principal's, and it only narrows.
396+
const isWriteGranted = !objectName || resolveFieldAffordance('recordEdit', perms, objectName, field.name);
397+
const fieldEditable =
398+
!isReadonly && !isComputedField && !isSystemField && !isInlineExcluded && isWriteGranted;
382399
const canInlineEditField = fieldEditable && !!onEnterInlineEdit;
383400

384401
const displayValue = (() => {

0 commit comments

Comments
 (0)