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
27 changes: 27 additions & 0 deletions .changeset/12082-setup-holders-grant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@object-ui/app-shell': patch
---

Setup's permission-set and position holders offer Add and Remove only to a
caller holding the grant the server asks for those writes (objectui#12082).

A permission set's holders (`AssignedUsersSection`) and a position's holders
(`PositionHoldersSection`) offered Add and a per-row Remove to whoever reached
the page. Reaching Setup is not that grant: the Setup app asks `setup.access`,
and a caller can hold it while the server refuses their write of an assignment
row at its object CRUD step. objectstack's shipped organization administrator
set is such a caller: it holds `setup.access` and is read-only on both
assignment objects. That administrator was offered writes the server refused.

- **Add** creates a junction row under the item, so it reads the `relatedNew`
row of the affordance-to-grant map (the create grant), the row a related
list's Add existing reads. **Remove** deletes one, so it reads
`relatedRowDelete` (the delete grant). Each is asked of the assignment
object, `sys_user_permission_set` or `sys_user_position`, with its schema as
the registry serves it.
- A closed row withholds its affordance: the Add button and the picker it
opens, and each row's Remove. The holders still list, since listing them
asks only to read.
- With no permission provider mounted every grant reads open, as before.

No row is added to `AFFORDANCE_GRANTS`, and no export, prop or type changes.
13 changes: 11 additions & 2 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,11 +561,20 @@ family for them: it uses the same metadata-admin list and editors at
(`features.tenancyPosture`) and the session's `isPlatformAdmin`, which is the
server's own `PLATFORM_ADMIN` rung. It applies in every scope, because the
server refuses a package-door draft of a catalog item the same way. Assigning
holders stays writable.
holders is not under this gate; the next item says which gate it is under.
- **The Setup half of an item.** It renders under the definition: a permission
set's holders (`AssignedUsersSection`), and a position's holders
(`PositionHoldersSection`). Both read and write assignment rows by the
item's name.
item's name. The list asks only to read. Add and Remove each read their row
of the affordance-to-grant map in `@object-ui/core`, asked of the assignment
object (`sys_user_permission_set`, `sys_user_position`): Add reads
`relatedNew` (the create grant) and Remove `relatedRowDelete` (the delete
grant). Reaching Setup is not that grant. The Setup app asks `setup.access`,
and objectstack's shipped organization administrator set holds it while it is
read-only on both assignment objects (objectstack's own pin for that is the
test `organization_admin is read-only on global RBAC tables to prevent
privilege escalation`), so that administrator sees the holders with no Add and
no Remove.

The list has an active switch and an active/inactive filter, through
`catalog-activation.ts`. Both show the activation ledger's state
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';
import { DETAIL_DEFAULT_TRANSLATIONS } from '@object-ui/plugin-detail';
import { effectiveOperationsArray, resolveEffectiveApiMethods } from '@objectstack/spec/data';

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
Expand Down Expand Up @@ -170,14 +171,20 @@ function metadataFor(objects: unknown[]) {
/**
* Mounts the record page and waits for the comment to show. `perms` null is
* the no-provider host (a standalone embed), where every grant reads open.
* `objects` is the page's `objects` prop, the schemas the feed's gates read.
*/
async function mount(perms: MePermissionsResponse | null, dataSource = makeDataSource(), registry: unknown[] = REGISTRY.records) {
async function mount(
perms: MePermissionsResponse | null,
dataSource = makeDataSource(),
registry: unknown[] = REGISTRY.records,
objects: unknown[] = OBJECTS,
) {
const page = (
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>
<MetadataCtx.Provider value={metadataFor(registry)}>
<RecordDetailView
dataSource={dataSource}
objects={OBJECTS}
objects={objects}
onEdit={() => {}}
objectNameOverride={OBJECT_NAME}
recordIdOverride={RECORD_ID}
Expand Down Expand Up @@ -284,6 +291,55 @@ describe("the record feed's reaction control reads feedReactionAdd / feedReactio
});
});

/**
* The two feed objects as objectstack registers them (`plugin-audit`): their
* policy keys (`managedBy`, `userActions`) and the `enable` block the server
* derives the effective operation set from. The other cases pass an `objects`
* prop holding neither, so the policy leg there reads the default bucket.
*/
const REGISTERED_COMMENT = {
name: COMMENT,
label: 'Comment',
managedBy: 'platform',
enable: { trackHistory: true, searchable: true, apiEnabled: true, clone: false },
};
const REGISTERED_REACTION = {
name: REACTION,
label: 'Comment Reaction',
managedBy: 'platform',
userActions: { edit: false, import: false },
enable: {
trackHistory: false,
searchable: false,
apiEnabled: true,
apiMethods: ['get', 'list', 'create', 'delete', 'bulk'],
feeds: false,
activities: false,
clone: false,
},
};

describe('against the registered feed schemas in the page objects (objectui#12082)', () => {
it("under a full grant the registered policy closes nothing the feed offers: the composer, the reaction add and the take-back are each offered", async () => {
// The reaction object's effective operation set, derived by the spec from
// its `enable` block (no export grant is held, as in `SHAPE.full`).
const reactionOperations = effectiveOperationsArray(
resolveEffectiveApiMethods(REGISTERED_REACTION.enable, { userExportAllowed: false }),
);
const { view, dataSource } = await mount(
answer({ [COMMENT]: SHAPE.full, [REACTION]: { ...SHAPE.full, apiOperations: reactionOperations } }),
makeDataSource(),
[...OBJECTS, REGISTERED_COMMENT, REGISTERED_REACTION],
[...OBJECTS, REGISTERED_COMMENT, REGISTERED_REACTION],
);
expect(composer(view)).not.toBeNull();
expect(addReaction(view)).not.toBeNull();
fireEvent.click(thumbsChip(view)!);
await act(async () => {});
expect(dataSource.delete.mock.calls).toEqual([[REACTION, 'r1']]);
});
});

describe('on the column store the reaction control reads feedReactionColumn: update on sys_comment (objectui#12082)', () => {
it.each([
['create-only', SHAPE.createOnly, false],
Expand Down
144 changes: 104 additions & 40 deletions packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,28 @@
* (`ai_seat`) is just one of them. The generic add-by-picker engine (spec
* RecordRelatedListProps.add) powers the capability; this is the polished
* surface for the high-value case.
*
* ## Who may change the holders (objectui#12082)
*
* ADD and REMOVE are offered only where the caller holds the grant the server
* asks of them, on the grant object itself — see {@link GRANT_OBJECT} and the
* gate in the component. Listing the holders asks only to read, so a caller
* who may read but not write the grants sees the list with no Add and no
* Remove, and an empty list that does not tell them to click Add.
*/

import * as React from 'react';
import { Button } from '@object-ui/components';
import { resolveAffordance, type SchemaLike } from '@object-ui/core';
import { RecordPickerDialog } from '@object-ui/fields';
import { usePermissions } from '@object-ui/permissions';
import { useAdapter, useMetadata } from '@object-ui/react';
import { Plus, X, Users, Loader2, AlertCircle } from 'lucide-react';
import { useMetadataLocale } from './i18n.js';

/** The object a permission set's direct grants are rows of: ADD creates one, REMOVE deletes one. */
const GRANT_OBJECT = 'sys_user_permission_set';

export interface AssignedUsersSectionProps {
/** The permission set's machine name (e.g. `ai_seat`, `admin_full_access`). */
permissionSetName: string;
Expand All @@ -64,7 +77,14 @@ interface AssignedRow {
via: Array<{ kind: 'direct' } | { kind: 'position'; position: string }>;
}

/** Minimal locale-aware copy (zh vs everything-else) — keeps the surface in the user's language. */
/**
* Minimal locale-aware copy (zh vs everything-else) — keeps the surface in the user's language.
*
* The empty state is two sentences: `empty` says nobody holds the set, and
* `emptyAddHint` tells the reader to click Add, so it is said only where the
* Add button is offered (`canAdd`, in the component). `sentenceGap` joins
* them: a space in English, none after a CJK full stop.
*/
function useCopy() {
const zh = useMetadataLocale() === 'zh-CN';
return React.useMemo(
Expand All @@ -74,7 +94,9 @@ function useCopy() {
title: '已分配用户',
add: '添加用户',
remove: '移除',
empty: '还没有分配任何用户。点击「添加用户」来分配。',
empty: '还没有分配任何用户。',
emptyAddHint: '点击「添加用户」来分配。',
sentenceGap: '',
loading: '加载中…',
pickTitle: '选择要分配的用户',
seatFull: (n: number) =>
Expand All @@ -90,7 +112,9 @@ function useCopy() {
title: 'Assigned Users',
add: 'Add user',
remove: 'Remove',
empty: 'No users assigned yet. Click "Add user" to assign.',
empty: 'No users assigned yet.',
emptyAddHint: 'Click "Add user" to assign.',
sentenceGap: ' ',
loading: 'Loading…',
pickTitle: 'Select users to assign',
seatFull: (n: number) =>
Expand Down Expand Up @@ -163,12 +187,39 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);

/**
* May the caller add or remove a holder? Each is a write of a
* {@link GRANT_OBJECT} row, so each reads its row of the affordance-to-grant
* map (`resolveAffordance` in `@object-ui/core`), asked of that object with
* its schema as the registry serves it: ADD creates a junction row under the
* set, the `relatedNew` row a related list's Add existing reads; REMOVE
* deletes one, the `relatedRowDelete` row a related list row's Delete reads.
*
* Setup's own gate is not this one. The Setup app asks `setup.access`, and
* a caller can hold it while the server refuses their write of a grant row
* at its object CRUD step — objectstack's shipped organization administrator
* set is such a caller (that repo's pin `organization_admin is read-only on
* global RBAC tables to prevent privilege escalation`). So a closed row
* withholds its affordance: the Add button and the picker it opens, each
* row's Remove. With no permission provider every grant reads open, as
* before.
*/
const perms = usePermissions();
const grantSource = {
objectSchema: ((metadataStore.objects ?? []).find((o) => (o as { name?: unknown } | null)?.name === GRANT_OBJECT) ??
null) as SchemaLike | null,
objectName: GRANT_OBJECT,
perms,
};
const canAdd = resolveAffordance('relatedNew', grantSource).allowed;
const canRemove = resolveAffordance('relatedRowDelete', grantSource).allowed;

const load = React.useCallback(async () => {
setLoading(true);
try {
// Direct grants, by the set's NAME.
const grants = asArray(
await adapter.find('sys_user_permission_set', { $filter: { permission_set: permissionSetName }, $top: 500 }),
await adapter.find(GRANT_OBJECT, { $filter: { permission_set: permissionSetName }, $top: 500 }),
);

// Effective holders = direct grants ∪ holders of every position that
Expand Down Expand Up @@ -251,7 +302,7 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
if (!uid || assignedIds.has(uid)) continue;
// By name alone: no catalog row is read and no legacy
// `permission_set_id` is written (module doc).
await adapter.create('sys_user_permission_set', {
await adapter.create(GRANT_OBJECT, {
user_id: uid,
permission_set: permissionSetName,
});
Expand All @@ -278,7 +329,7 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
async (grantId: string) => {
setError(null);
try {
await adapter.delete('sys_user_permission_set', grantId);
await adapter.delete(GRANT_OBJECT, grantId);
await load();
} catch {
/* keep the row; a failed delete is non-destructive */
Expand All @@ -297,19 +348,21 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
<span className="text-xs text-muted-foreground font-normal">{c.countOf(rows.length)}</span>
)}
</div>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => {
setError(null);
setPickerOpen(true);
}}
className="gap-1 h-8 text-xs"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
{c.add}
</Button>
{canAdd && (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => {
setError(null);
setPickerOpen(true);
}}
className="gap-1 h-8 text-xs"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
{c.add}
</Button>
)}
</div>

{error && (
Expand All @@ -335,7 +388,12 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
{c.loading}
</div>
) : rows.length === 0 ? (
!everyoneBound && <div className="text-xs text-muted-foreground italic py-3">{c.empty}</div>
!everyoneBound && (
<div className="text-xs text-muted-foreground italic py-3">
{/* The click-Add sentence names a button only `canAdd` renders. */}
{canAdd ? c.empty + c.sentenceGap + c.emptyAddHint : c.empty}
</div>
)
) : (
<ul className="divide-y rounded-md border">
{rows.map((r) => (
Expand All @@ -360,16 +418,20 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
)}
</div>
{r.grantId ? (
<Button
variant="ghost"
size="sm"
onClick={() => void removeUser(r.grantId!)}
aria-label={c.remove}
title={c.remove}
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0"
>
<X className="h-4 w-4" />
</Button>
// A direct grant: removable only where the delete grant on
// the grant object is held (`canRemove`, above).
canRemove && (
<Button
variant="ghost"
size="sm"
onClick={() => void removeUser(r.grantId!)}
aria-label={c.remove}
title={c.remove}
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0"
>
<X className="h-4 w-4" />
</Button>
)
) : (
<span className="shrink-0 text-[10px] text-muted-foreground/70" title={c.positionHeldHint}>
—
Expand All @@ -380,16 +442,18 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
</ul>
)}

<RecordPickerDialog
open={pickerOpen}
onOpenChange={(o: boolean) => setPickerOpen(o)}
multiple
dataSource={adapter}
objectName="sys_user"
title={c.pickTitle}
onSelect={() => {}}
onSelectRecords={(records: any[]) => void addUsers(records)}
/>
{canAdd && (
<RecordPickerDialog
open={pickerOpen}
onOpenChange={(o: boolean) => setPickerOpen(o)}
multiple
dataSource={adapter}
objectName="sys_user"
title={c.pickTitle}
onSelect={() => {}}
onSelectRecords={(records: any[]) => void addUsers(records)}
/>
)}
</div>
);
}
Expand Down
Loading
Loading