Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/12103-field-write-grants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/core': minor
'@object-ui/permissions': minor
'@object-ui/plugin-detail': patch
'@object-ui/plugin-grid': patch
---

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).

**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`.

**What changed.**

- **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.
- **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.
- **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.
- **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.

**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.

**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.
8 changes: 7 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,13 @@ row's Delete, and the rest — reads the grant it exercises from ONE table,
`AFFORDANCE_GRANTS` (objectui#12082). A row names the CRUD-affordance bit the
affordance needs, the object grant it exercises (`create`, `update` or
`delete`) and, for an affordance that offers fields, the field question it asks
(`create` for an insert, `write` for an update).
(`create` for an insert, `write` for an update). The fields of a form, of the
record page's in-place edit (`recordEdit`) and of a list's in-cell edit
(`listInlineEdit`) each ask that question, so a field the caller's permission
set marks `editable: false` is offered read-only on every one of them
(objectui#12103). The `write` question is the server's whole update rule for a
field, so for the record's owner field it also needs the transfer grant
(`allowTransfer`, or `modifyAllRecords`).

`resolveAffordance` is the one verdict: the object's managed-object policy, the
server's effective API operation set and the caller's grant must all allow it,
Expand Down
22 changes: 18 additions & 4 deletions packages/core/src/utils/affordanceGrants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@
* way for both questions. So a create form follows what the server enforces on
* insert, and adds no rule the server does not have.
*
* The `write` question is the server's whole update rule for the field, so it
* also carries the one field whose update needs an OBJECT grant beyond
* `allowEdit`: the record's owner, the field the spec's `SystemFieldName`
* constant names `OWNER_ID`. The server refuses an update that writes it without
* the transfer grant (`allowTransfer`, or `modifyAllRecords`), so the resolver
* answers `write` for it with that grant too (objectui#12103). Every row that
* asks `write` therefore offers the owner field only to a caller who may
* transfer the record — the in-place edit, the list's in-cell edit and an edit
* form alike, with no row of its own to forget.
*
* ## The verdict
*
* An affordance shows when the managed-object policy, the effective API
Expand Down Expand Up @@ -120,8 +130,12 @@ export const AFFORDANCE_GRANTS = {
createFormFields: { crud: 'create', grant: 'create', field: 'create' },
/** An edit form's fields (every `ObjectForm` layout) and its form-wide lock. */
editFormFields: { crud: 'edit', grant: 'update', field: 'write' },
/** The record page's Edit — the header CTA and the record body's in-place editing. */
recordEdit: { crud: 'edit', grant: 'update' },
/**
* The record page's Edit — the header CTA and the record body's in-place
* editing (the details body and the highlights strip), each of whose fields
* asks the update question (objectui#12103).
*/
recordEdit: { crud: 'edit', grant: 'update', field: 'write' },
/** The record page's Delete. */
recordDelete: { crud: 'delete', grant: 'delete' },
/**
Expand All @@ -133,8 +147,8 @@ export const AFFORDANCE_GRANTS = {
listImport: { crud: 'import', grant: 'create', field: 'create' },
/** The import wizard's template download — its endpoint answers 403 without the create grant. */
importTemplate: { crud: null, grant: 'create' },
/** A list's inline (in-cell) editing. */
listInlineEdit: { crud: 'edit', grant: 'update' },
/** A list's inline (in-cell) editing, each of whose cells asks the update question (objectui#12103). */
listInlineEdit: { crud: 'edit', grant: 'update', field: 'write' },
/** A list's bulk Delete. */
listBulkDelete: { crud: 'delete', grant: 'delete' },
/** A grid row's Edit. */
Expand Down
8 changes: 8 additions & 0 deletions packages/permissions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ such a field through and object admission decides, so `'write'` falls back to
the object's `allowEdit` and `'create'` to its `allowCreate`. An explicit
field-level entry answers both the same way.

One field's update needs more than its own grant: the record's owner, the
field the spec's `SystemFieldName` constant names `OWNER_ID`. The server refuses an
update that writes it unless the caller holds the transfer grant
(`allowTransfer`, or `modifyAllRecords`), so `MePermissionsProvider` answers
`'write'` for the owner field with that grant as well (objectui#12103).
`'create'` is unchanged: on insert the server stamps an empty owner to the
caller and accepts the caller's own id.

```tsx
import { usePermissions } from '@object-ui/permissions';

Expand Down
3 changes: 2 additions & 1 deletion packages/permissions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"react": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"@object-ui/types": "workspace:*"
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.7.0"
},
"devDependencies": {
"@types/react": "19.2.18",
Expand Down
27 changes: 26 additions & 1 deletion packages/permissions/src/MePermissionsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import type {
PermissionCheckResult,
FieldLevelPermission,
} from '@object-ui/types';
import { objectPermissionGrants } from '@objectstack/spec/security';
import { SystemFieldName } from '@objectstack/spec/system';
import { PermCtx, type PermissionContextValue } from './PermissionContext.js';
import { createDiscardProofCache } from './discardProofCache.js';

Expand All @@ -42,6 +44,13 @@ export interface MePermissionsResponse {
allowDelete?: boolean;
viewAllRecords?: boolean;
modifyAllRecords?: boolean;
/**
* The transfer grant: may the caller change a record's owner. The server
* refuses an update that writes the owner field without it (or without
* `modifyAllRecords`, which implies it), and `checkField` reads it for that
* field's `write` question (objectui#12103).
*/
allowTransfer?: boolean;
/**
* [objectstack#3391] Server-resolved effective API operation set for this object
* (enum-ordered). Present only when the object tightens exposure via
Expand Down Expand Up @@ -307,6 +316,23 @@ export function MePermissionsProvider({
const objKey = (object ?? '').toLowerCase();
const key = `${objKey}.${field}`;
const fieldPerm = data.fields?.[key] ?? data.fields?.[`${object}.${field}`];
const objPerm = data.objects?.[objKey] ?? data.objects?.[object] ?? data.objects?.['*'];
// [objectui#12103] An update that writes the record's OWNER is a
// transfer: the server's security step refuses it unless the caller holds
// the transfer grant, whatever the field's own entry says. So `write` for
// that field needs the grant too, read with the spec's own predicate
// (`allowTransfer`, or `modifyAllRecords`, which implies it). The field is
// the one the spec names, as the server's guard keys it. `create` is not
// narrowed: on insert the server stamps an empty owner to the caller and
// accepts the caller's own id. An object the answer does not mention
// takes the unknown-object default below.
if (
action === 'write' &&
field === SystemFieldName.OWNER_ID &&
!(objPerm ? objectPermissionGrants(objPerm, 'allowTransfer') : data.authenticated !== true)
) {
return false;
}
// An explicit entry answers `write` and `create` alike: the server's
// field step refuses a non-editable field on insert and update both.
if (fieldPerm) {
Expand All @@ -318,7 +344,6 @@ export function MePermissionsProvider({
// field step lets the field through and object admission decides, so
// the answer is the grant of the operation asked about (objectui#12082)
// — `allowCreate` on an insert, `allowEdit` on an update.
const objPerm = data.objects?.[objKey] ?? data.objects?.[object] ?? data.objects?.['*'];
if (!objPerm) {
// [objectstack-ai/objectstack#2926 ④] Unknown-object default is authentication-gated:
// - authenticated session → fail-CLOSED. The server resolved this
Expand Down
4 changes: 4 additions & 0 deletions packages/permissions/src/PermissionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface PermissionContextValue {
* permission set does not mention: the server's field step lets it through
* and object admission decides — `allowEdit` on an update, `allowCreate` on
* an insert. An explicit field-level entry answers both the same way.
*
* `write` is the server's whole update rule, so for the record's owner field
* (`SystemFieldName.OWNER_ID`) it also needs the transfer grant — the server
* refuses an update that writes the owner without it (objectui#12103).
*/
checkField: (object: string, field: string, action: 'read' | 'write' | 'create') => boolean;
/** Get field permissions for an object */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#12103 — `checkField(object, owner, 'write')` answers the server's
* whole update rule for the owner field, which includes the transfer grant.
*
* The server's security step refuses an update that writes the record's owner
* (`SystemFieldName.OWNER_ID`) unless the caller holds `allowTransfer` or
* `modifyAllRecords`; measured on a live backend, `/me/permissions` reports the
* grant per object and the refusal is a 403. So the `write` question for that
* one field needs the grant, read with the spec's own predicate, while `create`
* (the insert rule, which stamps and accepts the caller's own id), `read`, and
* every other field are untouched.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import React from 'react';
import { SystemFieldName } from '@objectstack/spec/system';
import { MePermissionsProvider, type MePermissionsResponse } from '../MePermissionsProvider';
import { usePermissions } from '../usePermissions';
import type { PermissionContextValue } from '../PermissionContext';

afterEach(cleanup);

const OBJECT = 'crm_account';
const OWNER = SystemFieldName.OWNER_ID;
const EDITOR = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false };

function envelope(
objects: MePermissionsResponse['objects'],
fields: MePermissionsResponse['fields'] = {},
authenticated = true,
): MePermissionsResponse {
return { authenticated, userId: authenticated ? 'u1' : null, tenantId: null, roles: [], permissionSets: [], objects, fields };
}

function checkFieldUnder(perms: MePermissionsResponse): PermissionContextValue['checkField'] {
let captured: PermissionContextValue['checkField'] | null = null;
function Probe() {
captured = usePermissions().checkField;
return null;
}
render(
<MePermissionsProvider initialPermissions={perms}>
<Probe />
</MePermissionsProvider>,
);
if (!captured) throw new Error('the probe did not render');
return captured;
}

describe("checkField's write question for the owner field (objectui#12103)", () => {
it('is refused without the transfer grant — false and absent alike', () => {
expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, allowTransfer: false } }))(OBJECT, OWNER, 'write')).toBe(false);
expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR } }))(OBJECT, OWNER, 'write')).toBe(false);
});

it('is granted by allowTransfer, and by modifyAllRecords, which implies it', () => {
expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, allowTransfer: true } }))(OBJECT, OWNER, 'write')).toBe(true);
expect(checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, modifyAllRecords: true } }))(OBJECT, OWNER, 'write')).toBe(true);
});

it('reads the wildcard entry for an object the answer does not name, as the object fallback does', () => {
expect(checkFieldUnder(envelope({ '*': { ...EDITOR, modifyAllRecords: true } }))(OBJECT, OWNER, 'write')).toBe(true);
expect(checkFieldUnder(envelope({ '*': { ...EDITOR, allowTransfer: false } }))(OBJECT, OWNER, 'write')).toBe(false);
});

it('keeps the unknown-object default: closed for a signed-in caller, open for an anonymous one', () => {
expect(checkFieldUnder(envelope({}))(OBJECT, OWNER, 'write')).toBe(false);
expect(checkFieldUnder(envelope({}, {}, false))(OBJECT, OWNER, 'write')).toBe(true);
});

it("an explicit editable: false entry still refuses it with the grant; the grant does not open the field's own refusal", () => {
const check = checkFieldUnder(
envelope({ [OBJECT]: { ...EDITOR, allowTransfer: true } }, { [`${OBJECT}.${OWNER}`]: { readable: true, editable: false } }),
);
expect(check(OBJECT, OWNER, 'write')).toBe(false);
});

it('CONTROL: create, read and every other field are untouched by the transfer grant', () => {
const check = checkFieldUnder(envelope({ [OBJECT]: { ...EDITOR, allowTransfer: false } }));
expect(check(OBJECT, OWNER, 'create')).toBe(true);
expect(check(OBJECT, OWNER, 'read')).toBe(true);
expect(check(OBJECT, 'name', 'write')).toBe(true);
});
});
19 changes: 18 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
} from '@object-ui/components';
import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff, Pencil } from 'lucide-react';
import { SchemaRenderer, toRenderableSchema, useInlineEdit } from '@object-ui/react';
import { resolveFieldAffordance } from '@object-ui/core';
import { usePermissions } from '@object-ui/permissions';
import {
getCellRenderer,
resolveCellRendererType,
Expand Down Expand Up @@ -167,6 +169,12 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
* session to read, exactly as it has no draft.
*/
const serverFieldErrors = useInlineEdit()?.fieldErrors ?? null;
/**
* The caller's permissions, for the field question in-place edit asks of each
* field (objectui#12103). With no provider mounted `isLoaded` is false and
* the question is not asked, so a bare `DetailSection` behaves as before.
*/
const perms = usePermissions();

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

const displayValue = (() => {
Expand Down
Loading
Loading