From 24480304a2c74ae5c6f7548ccd97e448a31e1ec0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 20:38:11 +0000 Subject: [PATCH 1/5] fix(plugin-detail): fail closed when a related-list column has no resolvable identity `record:related_list` filters `columns` against the field-security allow-list built from `enforceFieldSecurity` / `redactFields`, and its else-branch kept any entry whose identity it could not resolve. The block resolves identity through `columnIdentity`, which deliberately refuses the table library's own `accessorKey` (objectui#3104), while `RelatedList` renders a column as `accessorKey || columnIdentity(c)`. A column authored `{ accessorKey: 'salary' }` was therefore named by nobody in the filter, skipped both the FLS check and the redact list, and painted its real values through the table's own key. An entry the fold cannot name is now excluded. The filtering path is the only one touched: with neither key set the fold does not run and `columns` is handed down by reference, exactly as before. Pinned end to end over the real table, with the live control in the same render (a resolvable, allowed column must still show its values) and the counter-probe that bounds the change. The member-level pin that recorded the fail-open branch is flipped, and the console parity ledger's prose for the key moves with it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- .../8793-related-list-identity-fail-closed.md | 26 ++ .../registry-inputs-spec-parity.test.ts | 2 +- ...RelatedListRenderer.columnMembers.test.tsx | 39 ++- ...unresolvedIdentityFailClosed-8793.test.tsx | 234 ++++++++++++++++++ .../src/renderers/record-related-list.tsx | 15 +- 5 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 .changeset/8793-related-list-identity-fail-closed.md create mode 100644 packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx diff --git a/.changeset/8793-related-list-identity-fail-closed.md b/.changeset/8793-related-list-identity-fail-closed.md new file mode 100644 index 0000000000..ee0702d434 --- /dev/null +++ b/.changeset/8793-related-list-identity-fail-closed.md @@ -0,0 +1,26 @@ +--- +"@object-ui/plugin-detail": minor +--- + +fix(plugin-detail): `record:related_list` field security now fails closed on a column it cannot name + +The block filters `columns` against the field-security allow-list built from +`enforceFieldSecurity` / `redactFields`. Its else-branch KEPT any entry whose +identity it could not resolve. That branch was the bypass: the block resolves a +column through `columnIdentity`, which deliberately refuses the table library's +own `accessorKey` (objectui#3104 — TanStack's column key is not ObjectStack +metadata identity), while `RelatedList` renders a column as +`accessorKey || columnIdentity(c)`. So a column authored `{ accessorKey: 'salary' }` +was named by nobody in the filter, skipped both the FLS check and the redact +list, and then painted its real values through the table's own key. + +An entry the security fold cannot check is now excluded rather than kept. + +**Behaviour change, deliberately narrowing.** On a related list that switches +the filter on, a column whose identity resolves to none of `field` / `name` / +`fieldName` / `key` stops rendering — including one authored purely in the +`accessorKey` spelling, and including the case where the redacted or denied +field is some other column entirely. Lists that set neither key are untouched: +the fold does not run there and `columns` is still handed down by reference. +The protocol-declared spelling for this key is a field-name string +(`RecordRelatedListProps.columns`), which resolves and is unaffected. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 001161d941..f9892e8811 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2371,7 +2371,7 @@ const MEMBER_PINS: Record = { }, 'record:related_list.columns': { file: 'packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx', - pins: 'THE `page:header.actions` HOLE ON THIS KEY, asserted as the gap it is: the registration declares `of: \'string\'` and the block folds FIVE member spellings — a bare string, the spec-canonical `{ field }`, the legacy `{ name }` / `{ fieldName }`, and `{ key }`, a tail fallback that is this block\'s alone (`columnIdentity` REFUSES it, asserted next to it, which is the whole content of "tail"). Canonical-first is proven both ways on one mixed `{ field, name }` member, so a fold reading either key alone fails. Two rows carry the sharp edge: a member whose identity does not resolve is KEPT rather than dropped, so an entry the fold cannot name is an entry it cannot filter (`accessorKey` is the instance — excluded from `columnIdentity` on purpose, read by `RelatedList` as `accessorKey || columnIdentity`, so a redacted column authored that way is kept AND rendered: filed as objectui#8793, and this row reds when it lands), and a mixed set must come back SHORTER and in order, which is the non-vacuity a single-member array cannot give. Every positive carries its control in the same call (the member survives when a DIFFERENT field is redacted), and the instrument itself — `redactFields`, a renderer-only key on neither the spec nor `inputs` — is asserted to be undeclared so the file cannot be read as licensing it. The end-to-end half (an object member reaching the screen with VALUES) is objectui#5022\'s file, which drives `RelatedList` directly and never runs this fold (objectui#8071).', + pins: 'THE `page:header.actions` HOLE ON THIS KEY, asserted as the gap it is: the registration declares `of: \'string\'` and the block folds FIVE member spellings — a bare string, the spec-canonical `{ field }`, the legacy `{ name }` / `{ fieldName }`, and `{ key }`, a tail fallback that is this block\'s alone (`columnIdentity` REFUSES it, asserted next to it, which is the whole content of "tail"). Canonical-first is proven both ways on one mixed `{ field, name }` member, so a fold reading either key alone fails. Two rows carry the sharp edge: a member whose identity does not resolve is DROPPED — the fold fails closed (objectui#8793), because an entry it cannot name is an entry it cannot check, while `RelatedList` renders that entry anyway as `accessorKey || columnIdentity` (`accessorKey` is the instance, excluded from `columnIdentity` on purpose), so keeping it was a field-security bypass; the same row pins that the drop is by unresolvability rather than by matching the redacted name, and that an UNFILTERED list still hands the member down untouched — and a mixed set must come back SHORTER and in order, which is the non-vacuity a single-member array cannot give. Every positive carries its control in the same call (the member survives when a DIFFERENT field is redacted), and the instrument itself — `redactFields`, a renderer-only key on neither the spec nor `inputs` — is asserted to be undeclared so the file cannot be read as licensing it. The end-to-end half (an object member reaching the screen with VALUES) is objectui#5022\'s file, which drives `RelatedList` directly and never runs this fold (objectui#8071).', }, 'record:related_list.dataSource': { file: 'packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.elementDataSource.test.tsx', diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx index 946ae10203..dd12ca38cc 100644 --- a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx @@ -29,6 +29,11 @@ * the canonical-first `field` / `name` / `fieldName` resolution shared with the * rest of the repo, and `key` is a tail fallback this block adds on top. * + * A member that resolves to NONE of them is excluded from the filtered set + * (objectui#8793): the fold fails closed, because an entry it cannot name is an + * entry it cannot check against the field-security allow-list, while + * `RelatedList` would render it anyway through the table library's own key. + * * The end-to-end half — an object column reaching the screen with VALUES rather * than a header over blank cells — is pinned next door in * `RelatedList.columnIdentityAccessor.test.tsx` (objectui#5022), which renders @@ -166,19 +171,29 @@ describe('record:related_list — the `columns` MEMBER shape the renderer reads expect(await columnsAfterFold([mixed], ['subject'])).toEqual([mixed]); }); - it('keeps a member whose identity it cannot resolve, rather than dropping it', async () => { - // `colName` returns null and the filter's else-branch keeps the entry - // (`return n ? allowed.has(n) : true`). Pinned because it is the member - // contract's sharp edge: an entry the fold cannot NAME is an entry the fold - // cannot filter, so whatever the entry means downstream is unfiltered. - // `accessorKey` is the concrete instance — the table LIBRARY's own key, - // excluded from `columnIdentity` on purpose (objectui#3104) and read by - // `RelatedList` as `c?.accessorKey || columnIdentity(c)`. So a column - // authored that way is kept by this fold AND rendered by the table: filed - // as objectui#8793. Pinned as the CURRENT behaviour it is, which means this - // row reds when that lands — deliberately, so the fix cannot be quiet. + it('DROPS a member whose identity it cannot resolve — the fold fails CLOSED (objectui#8793)', async () => { + // `colName` returns null and the filter's else-branch EXCLUDES the entry + // (`return n ? allowed.has(n) : false`). The member contract's sharp edge, + // now pointing the safe way: an entry the fold cannot NAME is an entry the + // fold cannot check, and `RelatedList` renders it anyway as + // `c?.accessorKey || columnIdentity(c)` — so keeping it was a field-security + // bypass, not a tolerance. `accessorKey` is the concrete instance: the table + // LIBRARY's own key, excluded from `columnIdentity` on purpose + // (objectui#3104). Until objectui#8793 this row pinned the opposite. expect(columnIdentity({ accessorKey: 'status' })).toBeUndefined(); - expect(await columnsAfterFold([{ accessorKey: 'status' }], ['status'])).toEqual([ + expect(await columnsAfterFold([{ accessorKey: 'status' }], ['status'])).toEqual([]); + + // The drop is by UNRESOLVABILITY, not by matching the redacted name — this + // is the shape of the change's blast radius and it belongs in the pin, not + // only in the PR that made it. Redacting a DIFFERENT field drops it just + // the same, because the fold still cannot say what the column is. + h.captured = null; + expect(await columnsAfterFold([{ accessorKey: 'status' }], ['some_other_field'])).toEqual([]); + + // …and the bound on that radius: with nothing to filter, the fold does not + // run and the same member is handed down untouched. + h.captured = null; + expect(await columnsAfterFold([{ accessorKey: 'status' }])).toEqual([ { accessorKey: 'status' }, ]); }); diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx new file mode 100644 index 0000000000..159f96880c --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx @@ -0,0 +1,234 @@ +/** + * 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#8793 — `record:related_list`'s field-security fold must fail + * CLOSED on a column whose identity it cannot resolve. + * + * ## The defect, in one sentence + * + * The block filtered `columns` against the field-security allow-list with an + * else-branch that KEPT any entry it could not name. `columnIdentity` + * deliberately refuses the table library's own `accessorKey` (objectui#3104 — + * it is TanStack's column key, not ObjectStack metadata identity), so a column + * authored `{ accessorKey: 'salary' }` resolved to nothing, took that branch, + * skipped both `enforceFieldSecurity` and `redactFields`, and then rendered its + * real values through `RelatedList`'s own `accessorKey || columnIdentity` read. + * A security filter and a renderer disagreeing about what a column IS is the + * whole mechanism; the else-branch is where the disagreement pays out. + * + * ## Why these pins render the REAL table + * + * The member-level fold is pinned next door in + * `RecordRelatedListRenderer.columnMembers.test.tsx`, which MOCKS `RelatedList` + * and reads the column array the block hands down. That file cannot see the + * half that makes this a data-exposure bug rather than a filtering nit: the + * VALUE on screen. So these cases mount the block over the real `RelatedList` + * and the real `data-table`, and assert on rendered cells. + * + * ## The instrument, stated rather than smuggled + * + * `enforceFieldSecurity` / `redactFields` are renderer-only keys — on neither + * `@objectstack/spec`'s `RecordRelatedListProps` nor this block's registered + * `inputs` (asserted in the columnMembers file). They are used here because + * they are the ONLY switch that makes the block read a column member at all; + * their presence here is not evidence that they are an authoring surface. + */ + +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { RecordContextProvider } from '@object-ui/react'; +import { PermissionProvider } from '@object-ui/permissions'; +import type { ObjectPermissionConfig, RoleDefinition } from '@object-ui/types'; +import { RecordRelatedListRenderer } from '../renderers/record-related-list'; +import { RelatedList } from '../RelatedList'; + +/** + * Desktop, pinned rather than inherited (the reason spelled out in + * `RelatedList.columnIdentityAccessor.test.tsx`): under the 768 breakpoint a + * `type="table"` list renders a card gallery with no cells to read. + */ +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +const fields = { + subject: { type: 'text', label: 'Subject' }, + status: { type: 'text', label: 'Status' }, + salary: { type: 'text', label: 'Salary' }, +}; + +const rows = [ + { id: 'c1', subject: 'Fix the pump', status: 'open', salary: '90000' }, + { id: 'c2', subject: 'Replace filter', status: 'closed', salary: '120000' }, +]; + +const makeDS = () => ({ + find: vi.fn(async () => rows), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields })), +}); + +/** Mount the block over the REAL `RelatedList` with the given schema extras. */ +function renderBlock(extra: Record, wrap?: (node: React.ReactNode) => React.ReactElement) { + const node = ( + + + + ); + return render(wrap ? wrap(node) : node); +} + +/** Every rendered body cell's text, in DOM order. */ +const cellTexts = () => screen.getAllByRole('cell').map((c) => (c.textContent || '').trim()); + +/** Wait until the fetched rows have painted, then answer with the cell texts. */ +async function paintedCells(): Promise { + await waitFor(() => expect(screen.getAllByRole('cell').length).toBeGreaterThan(0)); + return cellTexts(); +} + +const roles: RoleDefinition[] = [{ name: 'restricted', label: 'Restricted' }]; + +/** `read` on `contact`, and `read` on every field EXCEPT the ones named. */ +function permsDenying(...deniedFields: string[]): ObjectPermissionConfig[] { + return [ + { + object: 'contact', + roles: { + restricted: { + actions: ['read'], + fieldPermissions: deniedFields.map((field) => ({ field, read: false })), + }, + }, + }, + ]; +} + +describe('objectui#8793 — an unresolvable column identity is EXCLUDED, not kept', () => { + it('THE REPORTED LEG — a redacted column authored in the table library key does not reach the screen', async () => { + // `salary` is redacted. Authored as `{ field: 'salary' }` it was always + // dropped; authored as the table's own `accessorKey` it used to sail + // through the fold and render its values. + renderBlock({ + columns: [ + { field: 'subject', label: 'Subject' }, + { accessorKey: 'salary', header: 'Salary' }, + ], + redactFields: ['salary'], + }); + + const cells = await paintedCells(); + + // THE LIVE CONTROL, in the same render: a column whose identity DOES + // resolve and IS allowed still renders its values. Without it, "the + // redacted value is gone" is equally satisfied by a fold that filtered + // everything out, and a reviewer could not tell the repair from a rout. + expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter'])); + + // The exposure itself: neither row's salary is on screen. + expect(cells).not.toContain('90000'); + expect(cells).not.toContain('120000'); + expect(screen.queryByText('90000')).not.toBeInTheDocument(); + }); + + it('THE BOUNDARY — three columns, three different reasons', async () => { + // allowed + resolvable -> renders + // denied + resolvable -> dropped by the allow-list (always was) + // denied + unresolvable-> dropped by the fail-closed branch (this fix) + renderBlock({ + columns: [ + { field: 'subject', label: 'Subject' }, + { field: 'status', label: 'Status' }, + { accessorKey: 'salary', header: 'Salary' }, + ], + redactFields: ['status', 'salary'], + }); + + const cells = await paintedCells(); + expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter'])); + expect(cells).not.toContain('open'); + expect(cells).not.toContain('closed'); + expect(cells).not.toContain('90000'); + }); + + it('THE FLS LEG — a field-security denial reaches the unresolvable column too', async () => { + // The same fold, driven through `enforceFieldSecurity` rather than + // `redactFields`. Pinned separately because the two legs are the same + // branch in the block but NOT the same story downstream — see the census + // case below. + renderBlock( + { + columns: [ + { field: 'subject', label: 'Subject' }, + { accessorKey: 'salary', header: 'Salary' }, + ], + enforceFieldSecurity: true, + }, + (node) => ( + + {node} + + ), + ); + + const cells = await paintedCells(); + expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter'])); + expect(cells).not.toContain('90000'); + }); + + it('COUNTER-PROBE — with neither key set the fold never runs and the same column still renders', async () => { + // The bound on the blast radius, measured rather than argued: this change + // moves nothing on a related list that does not switch the filter on, and + // no in-repo producer switches it on. An unresolvable column on an + // unfiltered list renders exactly as before. + renderBlock({ + columns: [ + { field: 'subject', label: 'Subject' }, + { accessorKey: 'salary', header: 'Salary' }, + ], + }); + + const cells = await paintedCells(); + expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter', '90000'])); + }); + + it('CENSUS — `RelatedList` runs its OWN field-security filter, and that one reads `accessorKey`', async () => { + // Falsifies the brief's assumption that the block's fold is the only + // security gate on this path. `RelatedList.filterFLS` resolves a column as + // `accessorKey || columnIdentity(c)` and calls `perms.checkField`, so the + // FLS leg of the bypass was already caught HERE whenever a + // PermissionProvider was mounted and loaded. Driven directly — no block, + // no fold — so the assertion is about that filter alone. + // + // ⇒ what objectui#8793 actually moves for users is the REDACT leg, which + // has no second gate. The FLS leg is repaired one layer earlier than it + // was, at the layer that names the policy. + render( + + + , + ); + + const cells = await paintedCells(); + expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter'])); + expect(cells).not.toContain('90000'); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-related-list.tsx b/packages/plugin-detail/src/renderers/record-related-list.tsx index 70d3e5761d..0e93902d79 100644 --- a/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -189,7 +189,20 @@ const RecordRelatedListBody: React.FC = ({ ); filteredColumns = rawColumns.filter((c) => { const n = colName(c); - return n ? allowed.has(n) : true; + // Fail CLOSED on an entry this fold cannot NAME (objectui#8793). The + // else-branch used to KEEP such an entry, and that was the bypass: the + // block resolves identity through `colName`, which deliberately refuses + // the table library's own `accessorKey` (objectui#3104), while + // `RelatedList` renders a column as `accessorKey || columnIdentity(c)`. + // So a column authored `{ accessorKey: 'salary' }` was named by nobody + // here, skipped both `enforceFieldSecurity` and `redactFields`, and then + // painted its real values through the table's own key. An entry the + // security fold cannot check is an entry it must not pass. + // + // Scoped to the filtering path only: with neither key set this whole + // branch is skipped and `columns` is handed down by reference, so an + // ordinary related list renders exactly what it always did. + return n ? allowed.has(n) : false; }); } From fb1fb875bce6a2361ab544e62bb896a615bb6f67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 20:56:01 +0000 Subject: [PATCH 2/5] test(plugin-detail): pin the measured limit of the fail-closed repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blast-radius measurement for the related-list identity fix turned up a second, independent hole on the same path: when the security fold removes every authored column, `RelatedList` reads the empty array as "no columns authored" and derives a replacement set from the child object's schema — a path `redactFields` never reaches, so the redacted field comes back. Reachable today without the fix (a resolvable `{ field: 'salary' }` redacted on its own empties the array the same way), filed as objectui#9053, and pinned here as the current behaviour it is so the bound on this repair is legible in the tests rather than only in prose. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- ...unresolvedIdentityFailClosed-8793.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx index 159f96880c..80e3634d51 100644 --- a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx @@ -199,6 +199,29 @@ describe('objectui#8793 — an unresolvable column identity is EXCLUDED, not kep expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter', '90000'])); }); + it('THE LIMIT OF THIS REPAIR — an EMPTIED column set falls through to auto-derived columns (objectui#9053)', async () => { + // Pinned as the CURRENT behaviour it is, so the bound on this fix is legible + // to the next reader instead of living only in a pull-request body. + // + // When the fold removes EVERY authored member, `RelatedList` reads the empty + // array as "no columns were authored" and derives a set from the child + // object's schema instead. That derivation runs the FK filter, `pruneEmpty` + // and its own FLS filter — but `redactFields` is a BLOCK-level concept that + // never reaches it, so the redacted field comes back. + // + // Reachable today, without this change: a resolvable `{ field: 'salary' }` + // redacted on its own empties the array exactly the same way. Filed as + // objectui#9053 — a hole this card neither opened nor closes; this row will + // red when that one lands, which is the point of pinning it here. + renderBlock({ + columns: [{ accessorKey: 'salary', header: 'Salary' }], + redactFields: ['salary'], + }); + + const cells = await paintedCells(); + expect(cells).toContain('90000'); + }); + it('CENSUS — `RelatedList` runs its OWN field-security filter, and that one reads `accessorKey`', async () => { // Falsifies the brief's assumption that the block's fold is the only // security gate on this path. `RelatedList.filterFLS` resolves a column as From 424672f350ab8e319fa023d91143b2b035b9a565 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 09:30:01 +0000 Subject: [PATCH 3/5] test(plugin-detail): re-pin the repair's limit on what objectui#9090 made true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `THE LIMIT OF THIS REPAIR` row pinned objectui#9053 as the behaviour it was at the time: an emptied column set fell through to `RelatedList`'s auto-derivation, and the redacted field came back through it. The row said it would red when that landed. objectui#9090 landed and it did. The fall-through is unchanged and is still this repair's bound, so the row keeps pinning it — with `status`, never authored, as the live control that proves the derivation ran — and now also pins that the redacted value no longer survives it, which is objectui#9090's repair seen from this block. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...unresolvedIdentityFailClosed-8793.test.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx index 80e3634d51..26e18fa364 100644 --- a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx @@ -199,27 +199,37 @@ describe('objectui#8793 — an unresolvable column identity is EXCLUDED, not kep expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter', '90000'])); }); - it('THE LIMIT OF THIS REPAIR — an EMPTIED column set falls through to auto-derived columns (objectui#9053)', async () => { - // Pinned as the CURRENT behaviour it is, so the bound on this fix is legible - // to the next reader instead of living only in a pull-request body. + it('THE LIMIT OF THIS REPAIR — an EMPTIED set still falls through to auto-derived columns, and objectui#9090 filters THOSE too', async () => { + // The bound on this repair, pinned as the behaviour it is instead of living + // only in a pull-request body. // // When the fold removes EVERY authored member, `RelatedList` reads the empty // array as "no columns were authored" and derives a set from the child - // object's schema instead. That derivation runs the FK filter, `pruneEmpty` - // and its own FLS filter — but `redactFields` is a BLOCK-level concept that - // never reaches it, so the redacted field comes back. + // object's schema instead. That derivation is a path the block's own list + // never reached, so when this row was first written the redacted field came + // back through it — filed as objectui#9053 and pinned here red-on-landing. // - // Reachable today, without this change: a resolvable `{ field: 'salary' }` - // redacted on its own empties the array exactly the same way. Filed as - // objectui#9053 — a hole this card neither opened nor closes; this row will - // red when that one lands, which is the point of pinning it here. + // objectui#9090 landed first and closed it: the block now hands `redactFields` + // DOWN as well, and `RelatedList` filters the derived set by the same + // `accessorKey || columnIdentity` key it renders through. The fall-through + // itself is unchanged — that is still the bound — but the redacted value no + // longer survives it, so this row now pins the pair. renderBlock({ columns: [{ accessorKey: 'salary', header: 'Salary' }], redactFields: ['salary'], }); const cells = await paintedCells(); - expect(cells).toContain('90000'); + + // THE FALL-THROUGH, proven rather than assumed: `status` was never authored, + // so its values can only be on screen because the derivation ran. Without + // this control the assertion below is equally satisfied by a list that + // rendered no columns at all — a different outcome with the same shape. + expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'open'])); + + // …and the redacted field does not come back with it (objectui#9090). + expect(cells).not.toContain('90000'); + expect(cells).not.toContain('120000'); }); it('CENSUS — `RelatedList` runs its OWN field-security filter, and that one reads `accessorKey`', async () => { From 79c23b93ee3fba50f2c489aa34a0bbc4dd99a67b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 09:32:29 +0000 Subject: [PATCH 4/5] test(plugin-detail): pin the one leg this repair still moves on the merged base Re-running this file's ablation on the merged head found it can no longer fail for this change: objectui#9090 gave the redact leg a second gate downstream (`filterRedacted`, resolving `accessorKey || columnIdentity`), and `filterFLS` has always been the second gate for a declared field that FLS denies. Mutating the block's else-branch back to `true` left all six rendered-cell rows green. Rows that cannot fail are not evidence. The residual leg is a key the child object never declares: `perms.checkField` default-allows it downstream, so the block's fold is the only gate, and the fold cannot name it. Pinned over the real `RelatedList` and the real table with the live control in the same render. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...unresolvedIdentityFailClosed-8793.test.tsx | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx index 26e18fa364..69c355c7b1 100644 --- a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx @@ -64,8 +64,10 @@ const fields = { }; const rows = [ - { id: 'c1', subject: 'Fix the pump', status: 'open', salary: '90000' }, - { id: 'c2', subject: 'Replace filter', status: 'closed', salary: '120000' }, + // `computed_total` is deliberately ABSENT from `fields` above: a key the child + // object never declares, which is the shape the residual leg below is about. + { id: 'c1', subject: 'Fix the pump', status: 'open', salary: '90000', computed_total: 'SECRET-42' }, + { id: 'c2', subject: 'Replace filter', status: 'closed', salary: '120000', computed_total: 'SECRET-43' }, ]; const makeDS = () => ({ @@ -232,6 +234,50 @@ describe('objectui#8793 — an unresolvable column identity is EXCLUDED, not kep expect(cells).not.toContain('120000'); }); + it('THE RESIDUAL LEG — FLS on a key the child object never declares, which nothing downstream refuses', async () => { + // The one leg where this repair still moves a rendered cell, and therefore + // the case this file needs in order to stay able to fail. + // + // objectui#9090 gave the REDACT leg a second gate downstream + // (`RelatedList.filterRedacted`, resolving `accessorKey || columnIdentity`), + // and `filterFLS` beside it has always been the second gate for a DECLARED + // field that field security denies — the CENSUS case below pins exactly + // that. Both of those resolve the entry the block could not, so neither can + // see this arm any more. + // + // What neither of them refuses is a key the permission evaluator has no + // opinion about: `computed_total` is not in the child object's `fields`, so + // `perms.checkField` default-ALLOWS it and the derived/authored column + // sails through downstream. The block's fold is the only thing standing + // between it and the screen, and the fold cannot name it — which is exactly + // when it must not pass. + renderBlock( + { + columns: [ + { field: 'subject', label: 'Subject' }, + { accessorKey: 'computed_total', header: 'Total' }, + ], + enforceFieldSecurity: true, + }, + (node) => ( + + {node} + + ), + ); + + const cells = await paintedCells(); + + // THE LIVE CONTROL, in the same render: the resolvable, allowed column + // still paints, so "the value is gone" cannot be satisfied by a fold that + // dropped everything. + expect(cells).toEqual(expect.arrayContaining(['Fix the pump', 'Replace filter'])); + + // The unresolvable entry does not reach the screen. + expect(cells).not.toContain('SECRET-42'); + expect(cells).not.toContain('SECRET-43'); + }); + it('CENSUS — `RelatedList` runs its OWN field-security filter, and that one reads `accessorKey`', async () => { // Falsifies the brief's assumption that the block's fold is the only // security gate on this path. `RelatedList.filterFLS` resolves a column as From 9303d069af1d155f2933fe0022eb72eb33dc8406 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 09:34:50 +0000 Subject: [PATCH 5/5] docs(plugin-detail,console): state what objectui#9090 left this fold deciding The fold's comment, the member pin's comment and the console parity ledger's prose all said keeping an unnameable entry was a field-security bypass, full stop. That was measured before objectui#9090 landed. It now has a second gate below it for two of the three legs: `filterFLS` for a declared field that field security denies, and `filterRedacted` for a redacted one, both resolving the `accessorKey || columnIdentity` pair this fold refuses. Re-measured by ablation on the merged base: what this arm alone still decides is a key the permission evaluator has no opinion about, which `checkField` default-allows downstream. All three sites and the changeset now say that. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../8793-related-list-identity-fail-closed.md | 14 ++++++++++++-- .../__tests__/registry-inputs-spec-parity.test.ts | 2 +- ...ecordRelatedListRenderer.columnMembers.test.tsx | 9 +++++++++ .../src/renderers/record-related-list.tsx | 7 +++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.changeset/8793-related-list-identity-fail-closed.md b/.changeset/8793-related-list-identity-fail-closed.md index ee0702d434..df3685489d 100644 --- a/.changeset/8793-related-list-identity-fail-closed.md +++ b/.changeset/8793-related-list-identity-fail-closed.md @@ -18,9 +18,19 @@ An entry the security fold cannot check is now excluded rather than kept. **Behaviour change, deliberately narrowing.** On a related list that switches the filter on, a column whose identity resolves to none of `field` / `name` / -`fieldName` / `key` stops rendering — including one authored purely in the -`accessorKey` spelling, and including the case where the redacted or denied +`fieldName` / `key` is no longer handed down — including one authored purely in +the `accessorKey` spelling, and including the case where the redacted or denied field is some other column entirely. Lists that set neither key are untouched: the fold does not run there and `columns` is still handed down by reference. The protocol-declared spelling for this key is a field-name string (`RecordRelatedListProps.columns`), which resolves and is unaffected. + +**What that moves on screen, measured by ablation on the current base.** Two of +the three legs now have a second gate below this one: `RelatedList.filterFLS` +refuses a declared field that field security denies, and since objectui#9090 +`RelatedList.filterRedacted` refuses a redacted one — both resolving the same +`accessorKey || columnIdentity` pair this fold refuses, so both already stopped +such a column from painting. The leg this repair still moves on its own is a key +the permission evaluator has no opinion about, one the child object never +declares: `checkField` default-allows it downstream, and the fold is the only +thing that can refuse it. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index b93c13bade..a4e5dabc6f 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2375,7 +2375,7 @@ const MEMBER_PINS: Record = { }, 'record:related_list.columns': { file: 'packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx', - pins: 'THE `page:header.actions` HOLE ON THIS KEY, asserted as the gap it is: the registration declares `of: \'string\'` and the block folds FIVE member spellings — a bare string, the spec-canonical `{ field }`, the legacy `{ name }` / `{ fieldName }`, and `{ key }`, a tail fallback that is this block\'s alone (`columnIdentity` REFUSES it, asserted next to it, which is the whole content of "tail"). Canonical-first is proven both ways on one mixed `{ field, name }` member, so a fold reading either key alone fails. Two rows carry the sharp edge: a member whose identity does not resolve is DROPPED — the fold fails closed (objectui#8793), because an entry it cannot name is an entry it cannot check, while `RelatedList` renders that entry anyway as `accessorKey || columnIdentity` (`accessorKey` is the instance, excluded from `columnIdentity` on purpose), so keeping it was a field-security bypass; the same row pins that the drop is by unresolvability rather than by matching the redacted name, and that an UNFILTERED list still hands the member down untouched — and a mixed set must come back SHORTER and in order, which is the non-vacuity a single-member array cannot give. Every positive carries its control in the same call (the member survives when a DIFFERENT field is redacted), and the instrument itself — `redactFields`, a renderer-only key on neither the spec nor `inputs` — is asserted to be undeclared so the file cannot be read as licensing it. The end-to-end half (an object member reaching the screen with VALUES) is objectui#5022\'s file, which drives `RelatedList` directly and never runs this fold (objectui#8071).', + pins: 'THE `page:header.actions` HOLE ON THIS KEY, asserted as the gap it is: the registration declares `of: \'string\'` and the block folds FIVE member spellings — a bare string, the spec-canonical `{ field }`, the legacy `{ name }` / `{ fieldName }`, and `{ key }`, a tail fallback that is this block\'s alone (`columnIdentity` REFUSES it, asserted next to it, which is the whole content of "tail"). Canonical-first is proven both ways on one mixed `{ field, name }` member, so a fold reading either key alone fails. Two rows carry the sharp edge: a member whose identity does not resolve is DROPPED — the fold fails closed (objectui#8793), because an entry it cannot name is an entry it cannot check, while `RelatedList` renders that entry anyway as `accessorKey || columnIdentity` (`accessorKey` is the instance, excluded from `columnIdentity` on purpose), so keeping it was a field-security bypass — ⚠️ two of the three legs of that bypass now have a SECOND gate downstream, `filterFLS` for a declared field that field security denies and, since objectui#9090, `filterRedacted` for a redacted one, both resolving the very `accessorKey || columnIdentity` pair this fold refuses, so what THIS fold alone still decides is a key the permission evaluator has no opinion about (one the child object never declares), measured by ablation on the merged base and pinned as THE RESIDUAL LEG in `RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx`; the same row pins that the drop is by unresolvability rather than by matching the redacted name, and that an UNFILTERED list still hands the member down untouched — and a mixed set must come back SHORTER and in order, which is the non-vacuity a single-member array cannot give. Every positive carries its control in the same call (the member survives when a DIFFERENT field is redacted), and the instrument itself — `redactFields`, a renderer-only key on neither the spec nor `inputs` — is asserted to be undeclared so the file cannot be read as licensing it. The end-to-end half (an object member reaching the screen with VALUES) is objectui#5022\'s file, which drives `RelatedList` directly and never runs this fold (objectui#8071).', }, 'record:related_list.dataSource': { file: 'packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.elementDataSource.test.tsx', diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx index dd12ca38cc..d414987600 100644 --- a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.columnMembers.test.tsx @@ -180,6 +180,15 @@ describe('record:related_list — the `columns` MEMBER shape the renderer reads // bypass, not a tolerance. `accessorKey` is the concrete instance: the table // LIBRARY's own key, excluded from `columnIdentity` on purpose // (objectui#3104). Until objectui#8793 this row pinned the opposite. + // + // ⚠️ Two of that bypass's three legs are now caught downstream as well and + // this row is the only place that still sees THIS one: `filterFLS` refuses a + // declared field field-security denies, and since objectui#9090 + // `filterRedacted` refuses a redacted one — both resolving the same + // `accessorKey || columnIdentity` pair. What the fold alone still decides is + // a key the permission evaluator has no opinion about, pinned over rendered + // cells as THE RESIDUAL LEG in + // `RecordRelatedListRenderer.unresolvedIdentityFailClosed-8793.test.tsx`. expect(columnIdentity({ accessorKey: 'status' })).toBeUndefined(); expect(await columnsAfterFold([{ accessorKey: 'status' }], ['status'])).toEqual([]); diff --git a/packages/plugin-detail/src/renderers/record-related-list.tsx b/packages/plugin-detail/src/renderers/record-related-list.tsx index b66fe1f8fe..bbbbea9a1c 100644 --- a/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -199,6 +199,13 @@ const RecordRelatedListBody: React.FC = ({ // painted its real values through the table's own key. An entry the // security fold cannot check is an entry it must not pass. // + // Since objectui#9090 that example has a second gate below it: the block + // now hands `redactFields` DOWN and `RelatedList` filters by the same + // `accessorKey || columnIdentity` pair, as `filterFLS` beside it always + // did for a declared field FLS denies. What this arm alone still decides + // is a key the permission evaluator has no opinion about — one the child + // object never declares, which `checkField` default-ALLOWS downstream. + // // Scoped to the filtering path only: with neither key set this whole // branch is skipped and `columns` is handed down by reference, so an // ordinary related list renders exactly what it always did.