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
36 changes: 36 additions & 0 deletions .changeset/8793-related-list-identity-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@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` 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.
Original file line number Diff line number Diff line change
Expand Up @@ -2375,7 +2375,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
},
'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 — ⚠️ 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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -166,19 +171,38 @@ 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.
//
// ⚠️ 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([
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' },
]);
});
Expand Down
Loading
Loading