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
77 changes: 77 additions & 0 deletions .changeset/12082-affordance-grant-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
'@object-ui/core': minor
'@object-ui/permissions': minor
'@object-ui/plugin-form': patch
'@object-ui/app-shell': patch
'@object-ui/fields': patch
'@object-ui/plugin-list': patch
'@object-ui/plugin-grid': patch
'@object-ui/plugin-detail': patch
'@object-ui/console': patch
---

A create form asks its fields the create question, so a role that may create
records but not edit them can fill and submit the form (objectui#12082). Every
console affordance that offers a write now reads the grant it exercises from
one map.

**The defect.** A create form gated each field on `checkField(object, field,
'write')`, whose fallback for a field the permission set does not mention is
the object's `allowEdit`. Under a grant of `allowCreate: true, allowEdit: false`
every field of the create form rendered disabled, the outbound filter stripped
every field from the body, and the save posted an empty record that the server
refused for its required fields — while the server accepts the same create.

**The server's insert rule, which the create question follows.** The server's
field-level write step refuses a write that names a field whose explicit
field-level entry has `editable: false`; a field with no entry passes it, and
object admission decides the operation (`allowCreate` for an insert,
`allowEdit` for an update). So a create-form field now reads its explicit entry
when there is one and the object's create grant when there is none. A field the
permission set marks `editable: false` stays disabled and out of the body.

**Clause-②: yes (widening)**

- `@object-ui/core` exports the affordance-to-grant map: `AFFORDANCE_GRANTS`
(one row per affordance: the CRUD-affordance bit it needs, the object grant it
exercises and, for an affordance that offers fields, the field question it
asks), `resolveAffordance` (managed-object policy ∧ the server's effective API
operation set ∧ the caller's grant, with the row's `userActions` predicates
surfaced only when all three allow it), `resolveFieldAffordance`,
`formFieldsAffordance`, and their types (`ConsoleAffordance`,
`FieldAffordance`, `AffordanceGrant`, `FieldAffordanceGrant`,
`AffordanceGrantRow`, `AffordanceGrantPrincipal`, `FieldAffordancePrincipal`,
`AffordanceSource`, `AffordanceVerdict`).
- `@object-ui/permissions`: `checkField`'s action accepts `'create'` beside
`'read'` and `'write'`. `MePermissionsProvider` answers it from the explicit
field entry when there is one and from `allowCreate` otherwise; the
role-based `PermissionProvider` answers it as it answers `'write'`.

**Behaviour, by package.** With no permission provider mounted every grant
still reads open, as before.

- `@object-ui/plugin-form`: every `ObjectForm` layout's fields and outbound
filter ask the question of the form's mode (create or edit). The form-wide
lock, with its "You don't have permission to …" notice, also engages when the
caller's object grant for the form's mode is denied, not only when the
managed-object policy or the effective API operation set closes it. A
create-mode `MasterDetailForm`'s line cells ask the create question of the
child object, since every line there is a new record.
- `@object-ui/app-shell`: the record page's Edit and Delete (and the record
body's in-place editing) read the caller's update / delete grant; they read
none before. The import wizard's write targets ask the create question, so a
caller offered Import keeps every field the insert accepts. List New / Import,
the related lists and the Attachments panel read the map with the verdicts
they had.
- `@object-ui/fields`: a lookup's "Create new" reads the create grant (and the
managed-object policy and operation set) of the object the field references;
it read no grant before.
- `@object-ui/plugin-grid`: row Edit / Delete, in-place editing, the template
download and the add-record row read the map; the add-record row now also
honours the object's managed-object policy and effective `create` operation.
- `@object-ui/plugin-detail`: `record:details` in-place editing reads the
caller's update grant; the detail header's object gate adds the effective
operation set.
- `@object-ui/plugin-list` and `@object-ui/console`: bulk Delete, the
inline-edit toggle and the profile page's language field read the map with
the verdicts they had.
13 changes: 8 additions & 5 deletions apps/console/src/pages/system/ProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { useUpload } from '@object-ui/providers';
import { useObjectTranslation, type TranslateFn } from '@object-ui/i18n';
import { useAdapter, extractFieldErrors, extractWriteErrorMessage } from '@object-ui/react';
import { usePermissions } from '@object-ui/permissions';
import { resolveFieldAffordance } from '@object-ui/core';
import { CheckCircle2, AlertCircle, User, Lock, Upload, Loader2, X, Globe } from 'lucide-react';

/**
Expand Down Expand Up @@ -461,9 +462,11 @@ interface LanguageCardProps {
* truth rather than rendering blank.
*
* Availability is asked, not discovered from a rejection: the card renders
* nothing until the row has been read, and `checkField('sys_user', 'locale',
* 'write')` — which consults field-level permissions and falls back to the
* object gate's `allowEdit` — decides between an editable control and a
* nothing until the row has been read, and the field question an edit asks —
* the `editFormFields` row of the affordance-to-grant map, read through
* `resolveFieldAffordance` from `@object-ui/core` (objectui#12082): it consults
* field-level permissions and falls back to the object gate's `allowEdit` —
* decides between an editable control and a
* read-only one carrying the reason. A failed read hides the card, the same
* "render nothing rather than something you will have to retract" idiom
* `LocaleSwitcher` uses for a locale list it does not have yet. That is the
Expand All @@ -472,7 +475,7 @@ interface LanguageCardProps {
function LanguageCard({ userId }: LanguageCardProps) {
const { t, offerableLanguages } = useObjectTranslation();
const adapter = useAdapter();
const { checkField } = usePermissions();
const perms = usePermissions();

const [stored, setStored] = useState<string | null>(null);
const [choice, setChoice] = useState<string>(USE_DEPLOYMENT_DEFAULT);
Expand Down Expand Up @@ -519,7 +522,7 @@ function LanguageCard({ userId }: LanguageCardProps) {

if (!adapter || !rowRead || offerableLanguages === null) return null;

const writable = checkField('sys_user', 'locale', 'write');
const writable = resolveFieldAffordance('editFormFields', perms, 'sys_user', 'locale');
const dirty = choice !== (stored ?? USE_DEPLOYMENT_DEFAULT);

const handleSave = async (e: React.FormEvent) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ vi.mock('@object-ui/react', async (importOriginal) => ({

vi.mock('@object-ui/permissions', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
usePermissions: () => ({ checkField: () => true }),
usePermissions: () => ({ isLoaded: true, checkField: () => true }),
}));

const { ProfilePage } = await import('../ProfilePage');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ vi.mock('@object-ui/react', async (importOriginal) => ({

vi.mock('@object-ui/permissions', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
usePermissions: () => ({ checkField: () => writable.value }),
usePermissions: () => ({ isLoaded: true, checkField: () => writable.value }),
}));

const { ProfilePage } = await import('../ProfilePage');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ vi.mock('@object-ui/react', async (importOriginal) => ({

vi.mock('@object-ui/permissions', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
usePermissions: () => ({ checkField: () => true }),
usePermissions: () => ({ isLoaded: true, checkField: () => true }),
}));

const { ProfilePage } = await import('../ProfilePage');
Expand Down
2 changes: 1 addition & 1 deletion content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ as well as a simple form whose mobile `stepper` option routes it through the
wizard, and the parent operation of a master-detail form. Every layout also
strips what a form never writes, on a create as on an edit: server-owned,
computed and read-only columns, keys the object does not declare, and fields the
caller's field-level security refuses — which every layout renders disabled. Every layout also disables every field of a managed object whose `userActions` do not open the form's mode, and of an object whose `create` or `update` the server's effective API operations deny. While that lock holds, the form shows one notice naming the object and the missing permission, and a `wizard` disables Next and its final submit button. A field whose sameness cannot be proven is sent: `5` and `'5'`, `null` and
caller's field-level security refuses — which every layout renders disabled. A field asks the question of the form's mode: a create form asks whether the caller may set it on a NEW record (the server's insert rule — a field the permission set does not mention falls back to the object's `allowCreate`), and an edit form whether they may change it on an existing one (`allowEdit`), so a role that may create but not edit can fill a create form. Every layout also disables every field of a managed object whose `userActions` do not open the form's mode, of an object whose `create` or `update` the server's effective API operations deny, and of an object whose create (create form) or update (edit form) grant the caller does not hold. While that lock holds, the form shows one notice naming the object and the missing permission, and a `wizard` disables Next and its final submit button. A field whose sameness cannot be proven is sent: `5` and `'5'`, `null` and
`''`, and a lookup id and its expanded object all count as different. A save with
nothing changed still sends the full payload, as it always has. The `ifMatch`
concurrency guard is unchanged, and its **Overwrite** choice now resends only the
Expand Down
39 changes: 20 additions & 19 deletions packages/app-shell/src/views/ObjectDataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { formatMetadataError } from '@object-ui/data-objectstack';
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import { usePermissions, useFieldPermissions } from '@object-ui/permissions';
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
import { resolveFilterPlaceholders } from '@object-ui/core';
import { resolveAffordance, resolveFilterPlaceholders, type SchemaLike } from '@object-ui/core';
import { normalizeFilterOperator, ViewFilterRuleSchema } from '@objectstack/spec/ui';
import type { ViewFilterRule } from '@objectstack/spec/ui';
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState.js';
Expand Down Expand Up @@ -73,7 +73,7 @@ import {
PREVIEW_QUERY_VALUE,
} from '../preview/PreviewModeContext.js';
import { useTenancyPosture } from '../hooks/useTenancyPosture.js';
import { resolveEffectiveCrudAffordances, type RowCrudPredicates } from '../utils/crudAffordances.js';
import type { RowCrudPredicates } from '../utils/crudAffordances.js';

/** Field types the auto-derived user-filter bar offers as dropdowns. */
const USER_FILTER_TYPES = new Set(['select', 'multiselect', 'radio', 'enum', 'boolean']);
Expand Down Expand Up @@ -195,7 +195,8 @@ export function ObjectDataPage({ dataSource, objects }: any) {
const { objectLabel, objectPluralLabel, fieldLabel } = useObjectLabel();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { can, getObjectApiOperations } = usePermissions();
const perms = usePermissions();
const { can } = perms;
const { canRead } = useFieldPermissions(objectName ?? '');
const { user, activeOrganization } = useAuth();
const { isAdmin } = useWorkspaceAdminStatus();
Expand Down Expand Up @@ -423,17 +424,19 @@ export function ObjectDataPage({ dataSource, objects }: any) {
// objectstack#3391 effective-API-operation intersection was absent, so the toolbar could
// offer a create the server would 405.
//
// Resolved exactly as `ObjectView` does: the spec's bucket/`userActions`
// matrix (ADR-0103, delegated to `resolveCrudAffordances`), INTERSECTED with
// the server-resolved effective API operations for this object. `undefined`
// (unrestricted object / old backend) leaves the bucket affordances as-is.
const affordances = React.useMemo(
// Resolved exactly as `ObjectView` does, through the SAME `listNew` row of
// the affordance-to-grant map (`resolveAffordance` in `@object-ui/core`,
// objectui#12082): the spec's bucket/`userActions` matrix (ADR-0103,
// delegated to `resolveCrudAffordances`), INTERSECTED with the
// server-resolved effective API operations for this object, AND the caller's
// create grant. `undefined` operations (unrestricted object / old backend)
// leave the bucket affordances as-is.
const newVerdict = React.useMemo(
() =>
resolveEffectiveCrudAffordances(
objectDef as any,
objectDef ? getObjectApiOperations(objectDef.name) : undefined,
),
[objectDef, getObjectApiOperations],
objectDef
? resolveAffordance('listNew', { objectSchema: objectDef as SchemaLike, objectName: objectDef.name, perms })
: { allowed: false },
[objectDef, perms],
);

/**
Expand All @@ -460,10 +463,8 @@ export function ObjectDataPage({ dataSource, objects }: any) {
* ONE RENDER POINT here, unlike `ObjectView`: this page has no phone FAB —
* the whole PageHeader lives under `hidden sm:block`.
*/
const objectCanCreate = !!objectDef && affordances.create && can(objectDef.name, 'create');
const createPredicates: RowCrudPredicates | undefined = objectCanCreate
? affordances.createPredicates
: undefined;
const objectCanCreate = newVerdict.allowed;
const createPredicates: RowCrudPredicates | undefined = newVerdict.predicates;
/** `visibleWhen` — fails CLOSED, declared-ness by `?? true` rather than by
* truthiness, so `visibleWhen: false` (the objectui#3492 shape) hides "New"
* instead of reading as "ungated". The `true` default is a boolean, which
Expand Down Expand Up @@ -543,8 +544,8 @@ export function ObjectDataPage({ dataSource, objects }: any) {
<>
{/* [#5164] `objectCanCreate && createVisible` — the bucket +
object-level `userActions` + objectstack#3391 effective-operations
verdict (all folded into `affordances.create`) AND the
principal's grant, then the toolbar-scope `visibleWhen` layer
verdict AND the principal's grant (the map's `listNew` row,
objectui#12082), then the toolbar-scope `visibleWhen` layer
on top of it. Greyed, not gone, is the `disabledWhen` case. */}
{objectCanCreate && createVisible && (
<Button
Expand Down
36 changes: 19 additions & 17 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ComponentType } from 'react';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { resolveFilterPlaceholders, DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, leadWithNameField, type FilterTokenScope } from '@object-ui/core';
import { resolveFilterPlaceholders, DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, leadWithNameField, resolveAffordance, type FilterTokenScope, type SchemaLike } from '@object-ui/core';
import {
parseUserFilterParams,
applyUserFilterParams,
Expand Down Expand Up @@ -70,7 +70,7 @@ import { useMobileViewSwitcherRegistration } from '../layout/MobileViewSwitcherC
import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext.js';
import { ManagedByBadge } from '../components/ManagedByBadge.js';
import { RecordDetailView } from './RecordDetailView.js';
import { resolveEffectiveCrudAffordances, type RowCrudPredicates } from '../utils/crudAffordances.js';
import type { RowCrudPredicates } from '../utils/crudAffordances.js';
import { createIdentityImportDataSource, IDENTITY_IMPORT_OBJECT, type IdentityPasswordPolicy } from './identityImport.js';
import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields } from './IdentityImportPanels.js';
import { importTargetFields } from './importTargetFields.js';
Expand Down Expand Up @@ -1895,7 +1895,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
const { user, activeOrganization } = useAuth();
const { isAdmin } = useWorkspaceAdminStatus();
const perms = usePermissions();
const { can, getObjectApiOperations } = perms;

// [ADR-0066 / objectstack#7494] Hand the adapter the session's REPORTED
// system capabilities so its `updateViewConfig` gate has something to judge
Expand Down Expand Up @@ -2057,11 +2056,18 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
// operation set for this object (from /me/permissions apiOperations), so the
// toolbar never offers Import/Export/New/Edit/Delete the server would 405.
// `undefined` (unrestricted object / old backend) leaves affordances as-is.
// The identity-import bypass below is independent of `affordances.import`.
const affordances = useMemo(
() => resolveEffectiveCrudAffordances(objectDef as any, getObjectApiOperations(objectDef.name)),
[objectDef, getObjectApiOperations],
);
// objectui#12082: New and Import are the `listNew` / `listImport` rows of
// the affordance-to-grant map (`resolveAffordance` in `@object-ui/core`),
// which ANDs the caller's create grant onto that intersection itself and
// surfaces the row's `userActions` predicates only when all three allow it.
// The identity-import bypass below is independent of the `listImport` row.
const { newVerdict, importVerdict } = useMemo(() => {
const source = { objectSchema: objectDef as SchemaLike, objectName: objectDef.name, perms };
return {
newVerdict: resolveAffordance('listNew', source),
importVerdict: resolveAffordance('listImport', source),
};
}, [objectDef, perms]);

// Externally-triggered refreshes (e.g. global ModalForm submit, undo, redo)
// reach every `refreshKey` reader through the sum declared with the counter
Expand Down Expand Up @@ -2089,10 +2095,8 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
* rendered twice, so both consume these SAME two values; computing the
* predicate once here is what keeps them from disagreeing with each other.
*/
const objectCanCreate = affordances.create && can(objectDef.name, 'create');
const createPredicates: RowCrudPredicates | undefined = objectCanCreate
? affordances.createPredicates
: undefined;
const objectCanCreate = newVerdict.allowed;
const createPredicates: RowCrudPredicates | undefined = newVerdict.predicates;
/** `visibleWhen` — fails CLOSED, declared-ness by `?? true`. As Import. */
const createVisible = useRowPredicate(createPredicates?.visibleWhen ?? true, null, {
fallback: false,
Expand Down Expand Up @@ -2150,13 +2154,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
* the same posture the related-list bridge takes, because a predicate may
* not RE-OPEN what the bucket, the effective API operations (objectstack#3391) or the
* principal's grant have closed. The identity-import bypass below is a
* different affordance entirely (it does not read `affordances.import`) and
* different affordance entirely (it does not read the `listImport` row) and
* is deliberately left outside this layer.
*/
const objectCanImport = affordances.import && can(objectDef.name, 'create');
const importPredicates: RowCrudPredicates | undefined = objectCanImport
? affordances.importPredicates
: undefined;
const objectCanImport = importVerdict.allowed;
const importPredicates: RowCrudPredicates | undefined = importVerdict.predicates;
/**
* `visibleWhen` — fails CLOSED, and counts as DECLARED by `?? true` rather
* than by truthiness, so `visibleWhen: false` hides Import instead of
Expand Down
Loading
Loading