Skip to content
Closed
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
19 changes: 10 additions & 9 deletions .changeset/10155-record-blocks-capability-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
'@object-ui/plugin-detail': patch
---

fix(plugin-detail): `requiredPermissions` on `record:details`, `record:highlights` and `record:related_list` is an ADR-0066 capability set, read fail-closed — it used to pass for every reader of the object
fix(plugin-detail): `requiredPermissions` on `record:highlights` and `record:related_list` is an ADR-0066 capability set, read fail-closed — it used to pass for every reader of the object

These three record blocks carried the same block-level gate objectui#10058
repaired on `record:quick_actions`, byte for byte, and were untouched by it.
Each evaluated every declared name through the permission context's
OBJECT-ACTION path, whose second argument is the closed object-action enum —
not the ADR-0066 system capability set the same word names on `action`, `app`,
`field` and `bulkAction`.
These two record blocks carried the same block-level gate objectui#10058
repaired on `record:quick_actions`, byte for byte, and were untouched by it
(`record:details` carried it too, and no longer reads the key at all —
objectui#10200). Each evaluated every declared name through the permission
context's OBJECT-ACTION path, whose second argument is the closed object-action
enum — not the ADR-0066 system capability set the same word names on
`action`, `app`, `field` and `bulkAction`.

Under the stock `/me/permissions` provider that path maps eight verbs (`read`,
`view`, `create`, `update`, `edit`, `delete`, `import`, `export`) and sends
Expand All @@ -19,10 +20,10 @@ no warning and no log — and five members of the enum itself (`manage`, `admin`
`share`, `configure`, `execute`) were swallowed by the same tail, none of them
being in that map either.

All three now read `hasCapabilities` over the reported `systemPermissions` and
Both now read `hasCapabilities` over the reported `systemPermissions` and
gate fail-closed: an unheld or unrecognised capability hides the block. The
object name leaves the verdict, because a system capability is not
object-scoped — on `record:details` and `record:highlights` the old
object-scoped — on `record:highlights` the old
`&& objectName` conjunct was a second silent fail-open, skipping the declared
gate entirely for a block rendered with no object name in its record context.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/plugin-detail': patch
---

fix(plugin-detail): `record:details` no longer reads `requiredPermissions` — the contract deliberately does not declare it on that block

`@objectstack/spec`'s `RecordDetailsProps` is a strict schema that does not
declare `requiredPermissions`, on purpose (the family docblock on that schema
says so), so a `record:details` document carrying the key is refused at
publish. The renderer nevertheless read it and hid the whole block behind it,
which made that gate reachable only through metadata the contract rejects.

By maintainer ruling on objectui#10200 the renderer stops reading the key on
this block: a `record:details` node carrying `requiredPermissions` now renders
its body whatever capabilities the viewer holds, and the "Insufficient
permissions to view details." notice is gone. If the spec later declares the
key on this block under ADR-0066, the renderer will read it then, following
the contract.

⚠️ `record:highlights` and `record:related_list` are unchanged and still gate
on the key, fail-closed, as the objectui#10155 entry describes. Server-side
record and field access is unaffected: this block-level gate was a browser-side
hide, never a data-access control.
8 changes: 5 additions & 3 deletions .changeset/8649-detail-renderer-undeclared-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@ defeats the declaration while a membership instrument still reports the member
as present. The pin now fails if the cast returns.

⚠️ **Three keys are deliberately NOT declared, and no runtime behaviour
changes.** `enforceFieldSecurity`, `redactFields` and `requiredPermissions` are
read by all three renderers and are **routed to the producer**, not declared
here and not retired here. Measured on the installed contract over the block-tag
changes.** `enforceFieldSecurity` and `redactFields` are read by all three
renderers, and `requiredPermissions` by `record:highlights` and
`record:related_list` (`record:details` stopped reading it, objectui#10200); all
three keys are **routed to the producer**, not declared here and not retired
here. Measured on the installed contract over the block-tag
map `ComponentPropsMap` — the authoring surface an author writes into — plus the
node envelope every block shares, with controls in the same pass:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@
* The contract DOES declare it, on the sibling block `record:quick_actions`,
* but NOT on the three this card covers. A word-frequency screen over the contract reads "present" and is
* wrong about exactly this; the per-schema census below is what separates
* them. ⇒ ROUTED TO THE PRODUCER, same floor.
* them. ⇒ ROUTED TO THE PRODUCER, same floor. ⚠️ Since then, by maintainer
* ruling on objectui#10200, `record-details.tsx` stopped reading it: its
* ledger entry left {@link ROUTED_KEYS} and is pinned ABSENT below instead.
* The census leg for the three blocks is what announces the day the
* contract declares it on `record:details`, and the read returns then.
*
* ## What each leg can and cannot prove
*
Expand Down Expand Up @@ -274,7 +278,18 @@ const CARD_BLOCKS = ['record:details', 'record:highlights', 'record:related_list
const ROUTED_KEYS = {
enforceFieldSecurity: ['record-details.tsx', 'record-highlights.tsx', 'record-related-list.tsx'],
redactFields: ['record-details.tsx', 'record-highlights.tsx', 'record-related-list.tsx'],
requiredPermissions: ['record-details.tsx', 'record-highlights.tsx', 'record-related-list.tsx'],
// `record-details.tsx` left this entry with objectui#10200 — see RETIRED_READS.
requiredPermissions: ['record-highlights.tsx', 'record-related-list.tsx'],
} as const;

/**
* Reads RETIRED by ruling rather than declared — the inverse ledger. Each entry
* is asserted ABSENT from its file, so a read that creeps back is red here, not
* just in the behavioural pin (`record-blocks.requiredPermissions-gate.test.tsx`).
*/
const RETIRED_READS = {
// objectui#10200: `RecordDetailsProps` deliberately declares no such key.
requiredPermissions: ['record-details.tsx'],
} as const;

/** The three files whose `schema` annotation the erasure used to destroy. */
Expand Down Expand Up @@ -386,6 +401,17 @@ describe('objectui#8649 — the routed-key ledger is not stale', () => {
}
}

for (const [key, files] of Object.entries(RETIRED_READS)) {
for (const file of files) {
it(`${file} no longer reads \`${key}\` (retired by ruling, objectui#10200)`, () => {
const source = maskedSource(file);
// Proof the file was read and masked, so the absence is a reading.
expect(source).toMatch(/RecordDetailsRendererProps/);
expect(source).not.toContain(`.${key}`);
});
}
}

/**
* ⭐ The assertion this block used to carry was
* `toMatch(/properties\??\.entries/)`, and it was WORTHLESS for the thing it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,32 @@
*/

/**
* `requiredPermissions` on `record:details`, `record:highlights` and
* `record:related_list` — the BLOCK-LEVEL gate, on all three sibling blocks at
* once (objectui#10155).
* `requiredPermissions` on `record:highlights` and `record:related_list` — the
* BLOCK-LEVEL gate (objectui#10155) — and its deliberate ABSENCE on
* `record:details` (objectui#10200).
*
* ## What this file pins
*
* The key is an **ADR-0066 system capability set** — the one meaning the word
* carries on `action`, `app`, `field` and `bulkAction` — read through the
* permission context's capability path and gating **fail-closed**: an unheld
* or unrecognised capability hides the block. objectui#10058 settled that for
* `record:quick_actions`; these three carried the identical call and were
* untouched by it.
* `record:quick_actions`; the record blocks carried the identical call and
* were untouched by it.
*
* All three used to read `perms.can(objectName, name)`, whose second argument
* They used to read `perms.can(objectName, name)`, whose second argument
* is the closed object-action enum. Under the stock `/me/permissions` provider
* a name outside the eight mapped verbs falls through that provider's
* `?? 'allowRead'` tail to the object's read bit — so a capability nobody
* holds passed for every reader of the object, silently.
*
* ⚠️ `record:details` carried the same gate until objectui#10200. Its
* contract, `@objectstack/spec`'s strict `RecordDetailsProps`, deliberately
* does not declare the key, so by maintainer ruling the renderer stopped
* reading it there: a `record:details` document carrying the key no longer
* gates the block. That block is pinned below as the inverse, against the same
* stock provider verdict that still closes its siblings.
*
* ⭐ Every pin below mounts a REAL stock provider and reads its real verdicts.
* A mocked `usePermissions` cannot discriminate the two reading paths — it IS
* whichever path the mock chooses to implement, which is how this family's
Expand Down Expand Up @@ -60,6 +67,13 @@ import { RecordRelatedListRenderer } from '../record-related-list';
* implement.
*/
const canSpy = vi.fn();
/**
* Records what the CAPABILITY path was asked, the same way — a wrapper that
* delegates. The `record:details` detector below asserts it is NOT asked about
* that block's authored names, and the sibling control in the same render
* proves the wrapper fires.
*/
const capSpy = vi.fn();
vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
return {
Expand All @@ -70,6 +84,7 @@ vi.mock('@object-ui/permissions', async (importOriginal) => {
...real,
can: (object: string, action: any) => { canSpy(object, action); return real.can(object, action); },
cannot: (object: string, action: any) => { canSpy(object, action); return real.cannot(object, action); },
hasCapabilities: (names: string[]) => { capSpy(names); return real.hasCapabilities(names); },
};
},
};
Expand Down Expand Up @@ -129,14 +144,21 @@ interface BlockCase {
base: Record<string, unknown>;
}

/**
* `record:details` — NOT a gated block since objectui#10200, so it is not in
* {@link BLOCKS}. Kept in the same shape so its inverse pin below mounts it
* exactly the way the gated siblings are mounted. `refusal` is the notice it
* used to emit, which is what the inverse pin asserts is gone.
*/
const DETAILS: BlockCase = {
key: 'record:details',
shown: 'detail-view',
refusal: /insufficient permissions to view details/i,
node: (schema: Record<string, unknown>) => <RecordDetailsRenderer schema={schema as any} />,
base: { fields: ['name'] },
};

const BLOCKS: BlockCase[] = [
{
key: 'record:details',
shown: 'detail-view',
refusal: /insufficient permissions to view details/i,
node: (schema: Record<string, unknown>) => <RecordDetailsRenderer schema={schema as any} />,
base: { fields: ['name'] },
},
{
key: 'record:highlights',
shown: 'header-highlight',
Expand All @@ -153,6 +175,9 @@ const BLOCKS: BlockCase[] = [
},
];

/** Named, so a pin below that means one block cannot drift onto another by index. */
const [HIGHLIGHTS, RELATED_LIST] = BLOCKS;

function bound(block: BlockCase, schema: Record<string, unknown>, objectName = 'crm_account') {
return (
<RecordContextProvider objectName={objectName} recordId="rec-1" data={{ id: 'rec-1' }} dataSource={ds as any}>
Expand All @@ -163,9 +188,68 @@ function bound(block: BlockCase, schema: Record<string, unknown>, objectName = '

beforeEach(() => {
canSpy.mockClear();
capSpy.mockClear();
cleanup();
});

describe('record:details — `requiredPermissions` no longer gates the block (objectui#10200)', () => {
/**
* Maintainer ruling on objectui#10200: `@objectstack/spec`'s strict
* `RecordDetailsProps` deliberately does not declare `requiredPermissions`,
* so the renderer stops reading it there. These pins are the ruling's
* "a `record:details` document carrying the key no longer gates the block".
*
* ⭐ Every pin renders `record:highlights` with the SAME key under the SAME
* provider in the SAME tree, and asserts it is still refused. That sibling is
* the lit control: it proves the provider really reports the capability as
* unheld, so the `record:details` body rendering is about the block no longer
* asking — not about a provider that would have said yes anyway. On the
* pre-ruling renderer every `record:details` assertion below goes red.
*/
const gated = { requiredPermissions: ['crm.manage'] };

it('renders the block when the declared capability is NOT held (REPORTED-empty capability set)', async () => {
render(
<MePermissionsProvider initialPermissions={me([])}>
{bound(DETAILS, gated)}
{bound(HIGHLIGHTS, gated)}
</MePermissionsProvider>,
);
// Control first: the same verdict still closes the gated sibling.
expect(await screen.findByText(HIGHLIGHTS.refusal)).toBeInTheDocument();
expect(screen.queryByTestId(HIGHLIGHTS.shown)).not.toBeInTheDocument();
// The ruling: the key is not read, so nothing hides `record:details`.
expect(await screen.findByTestId(DETAILS.shown)).toBeInTheDocument();
expect(screen.queryByText(DETAILS.refusal)).not.toBeInTheDocument();
});

it('renders the block with NO objectName in the record context, where it used to gate too', async () => {
render(
<MePermissionsProvider initialPermissions={me([])}>
{bound(DETAILS, gated, '')}
{bound(HIGHLIGHTS, gated, '')}
</MePermissionsProvider>,
);
expect(await screen.findByText(HIGHLIGHTS.refusal)).toBeInTheDocument();
expect(await screen.findByTestId(DETAILS.shown)).toBeInTheDocument();
expect(screen.queryByText(DETAILS.refusal)).not.toBeInTheDocument();
});

it('DETECTOR: the renderer never asks the CAPABILITY path about the authored names', async () => {
render(<MePermissionsProvider initialPermissions={me([])}>{bound(DETAILS, gated)}</MePermissionsProvider>);
expect(await screen.findByTestId(DETAILS.shown)).toBeInTheDocument();
expect(capSpy).not.toHaveBeenCalledWith(['crm.manage']);
// …nor the object-action path, which is what it asked before objectui#10155.
expect(canSpy).not.toHaveBeenCalledWith(expect.anything(), 'crm.manage');
});

it('the capability spy is WIRED — the gated sibling fires it, so the negative above is a reading', async () => {
render(<MePermissionsProvider initialPermissions={me([])}>{bound(HIGHLIGHTS, gated)}</MePermissionsProvider>);
expect(await screen.findByText(HIGHLIGHTS.refusal)).toBeInTheDocument();
expect(capSpy).toHaveBeenCalledWith(['crm.manage']);
});
});

// ⛔ `$key` must not be followed by `.` — vitest reads `$key.requiredPermissions`
// as a property PATH and interpolates `undefined`, so every failure in this suite
// would name no block at all.
Expand Down Expand Up @@ -294,31 +378,20 @@ describe('DISCRIMINATOR ② and the object name — which differs per block (obj
* `allowRead: false` leg is pinned separately below as the object-read call
* this card must NOT move.
*/
it('record:details — ②: holding the capability renders even with `allowRead: false`', async () => {
render(<MePermissionsProvider initialPermissions={me(['crm.manage'], { allowRead: false })}>{bound(BLOCKS[0], { requiredPermissions: ['crm.manage'] })}</MePermissionsProvider>);
expect(await screen.findByTestId('detail-view')).toBeInTheDocument();
});

it('record:highlights — ②: holding the capability renders even with `allowRead: false`', async () => {
render(<MePermissionsProvider initialPermissions={me(['crm.manage'], { allowRead: false })}>{bound(BLOCKS[1], { requiredPermissions: ['crm.manage'] })}</MePermissionsProvider>);
render(<MePermissionsProvider initialPermissions={me(['crm.manage'], { allowRead: false })}>{bound(HIGHLIGHTS, { requiredPermissions: ['crm.manage'] })}</MePermissionsProvider>);
expect(await screen.findByTestId('header-highlight')).toBeInTheDocument();
});

it('record:related_list — ②: holding the capability renders, with the object-read gate satisfied', async () => {
render(<MePermissionsProvider initialPermissions={me(['crm.manage'], { allowRead: true })}>{bound(BLOCKS[2], { requiredPermissions: ['crm.manage'] })}</MePermissionsProvider>);
render(<MePermissionsProvider initialPermissions={me(['crm.manage'], { allowRead: true })}>{bound(RELATED_LIST, { requiredPermissions: ['crm.manage'] })}</MePermissionsProvider>);
expect(await screen.findByTestId('related-list')).toBeInTheDocument();
});

it('record:details — gates with NO objectName in the record context, a system capability is not object-scoped', async () => {
it('record:highlights — gates with NO objectName in the record context, a system capability is not object-scoped', async () => {
// The `&& objectName` conjunct skipped the gate outright when the name was
// empty, so a declared gate on a block outside a record context did nothing.
render(<MePermissionsProvider initialPermissions={me([])}>{bound(BLOCKS[0], { requiredPermissions: ['crm.manage'] }, '')}</MePermissionsProvider>);
expect(await screen.findByText(/insufficient permissions to view details/i)).toBeInTheDocument();
expect(screen.queryByTestId('detail-view')).not.toBeInTheDocument();
});

it('record:highlights — gates with NO objectName in the record context, for the same reason', async () => {
render(<MePermissionsProvider initialPermissions={me([])}>{bound(BLOCKS[1], { requiredPermissions: ['crm.manage'] }, '')}</MePermissionsProvider>);
render(<MePermissionsProvider initialPermissions={me([])}>{bound(HIGHLIGHTS, { requiredPermissions: ['crm.manage'] }, '')}</MePermissionsProvider>);
expect(await screen.findByText(/insufficient permissions to view highlights/i)).toBeInTheDocument();
expect(screen.queryByTestId('header-highlight')).not.toBeInTheDocument();
});
Expand All @@ -339,7 +412,7 @@ describe('record:related_list — the OBJECT-READ call this card does not move (
it('a denied object read hides the section entirely — no refusal node, no list', async () => {
render(
<MePermissionsProvider initialPermissions={me(['crm.manage'], { allowRead: false })}>
{bound(BLOCKS[2], {})}
{bound(RELATED_LIST, {})}
{/* The block renders NOTHING when refused, so this sibling is what the
assertions below can wait for — without it the queries would read an
empty tree that had simply not rendered yet. */}
Expand All @@ -353,7 +426,7 @@ describe('record:related_list — the OBJECT-READ call this card does not move (
});

it('an allowed object read renders the section — the positive control for the pin above', async () => {
render(<MePermissionsProvider initialPermissions={me(['crm.manage'])}>{bound(BLOCKS[2], {})}</MePermissionsProvider>);
render(<MePermissionsProvider initialPermissions={me(['crm.manage'])}>{bound(RELATED_LIST, {})}</MePermissionsProvider>);
expect(await screen.findByTestId('related-list')).toBeInTheDocument();
expect(canSpy).toHaveBeenCalledWith('crm_contact', 'read');
});
Expand Down
Loading
Loading