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
54 changes: 54 additions & 0 deletions .changeset/6723-inline-data-fls.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
---
'@object-ui/plugin-grid': patch
---

`ObjectGrid` re-applies field-level security on its inline-data column path too,
so whether an object-bound grid re-checks FLS no longer depends on who fetched
the rows (objectui#6723, maintainer ruling 2026-08-29).

`generateColumns()` re-applied FLS at exactly one place — the object-schema
path. The inline-data path, taken when a host hands rows down as `data` **and**
the author declared a `fields` projection, had no equivalent check. Both paths
serve object-bound grids, so the same object with the same authored projection
did or did not go through the field gate purely according to provenance:

| rows from | `fields` declared | path taken | FLS re-applied |
| --- | --- | --- | --- |
| grid fetches | no | object-schema | yes |
| grid fetches | yes | object-schema | yes |
| host passes `data` | no | object-schema (since objectui#6677) | yes |
| host passes `data` | yes | inline-data | **no, until now** |

The inline-data path now filters each column through
`perms.checkField(objectName, fieldName, 'read')` when `perms.isLoaded &&
schema.objectName`, the same gate and the same deferral condition the
object-schema path has always used.

⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
rather than an optimisation.** Host-joined and derived keys pass through
untouched, because keeping them is this path's whole reason to exist — the
object-schema path drops them outright (`if (!field) return;`). A field policy
that enumerates readable fields answers "no" for a key it has never heard of, so
judging derived keys would silently drop them, which is the failure the issue's
own analysis warned about. Declaration is read with `hasOwnProperty`, so an
inherited name (`constructor`) is not mistaken for a declared field.

**Defence in depth, not a reachable exploit through the shipped hosts.**
`ListView` — the dominant host — already filters its own `effectiveFields`
through this same gate before forwarding, and that redundancy is the point: the
invariant must not rest on every future host having read the docs. The exposure
this closes is a direct
`<ObjectGrid schema={{ objectName, fields }} data={rows} />` composition, or a
future host that forwards an authored projection unfiltered.

Deliberately unchanged, and refused by name in the ruling: the two paths' other
differences stay as they are — the schema path's `resolveFieldLabel` (i18n) vs
the inline path's local humanisation, and the schema path's drop of names the
object does not declare. Converging those is a separate decision.

Pinned in `packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx` (a
readable declared field renders; an unreadable declared field does not, even
with host data for it; a derived key is unaffected; plus the perms-not-loaded,
no-`objectName` and schema-in-flight boundaries and a case through the real
`PermissionProvider`) and, as a measured no-op on the `ListView` path, in
`packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx`.
37 changes: 36 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2299,7 +2299,42 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
if (hasInlineData && !rowKeysWouldOutrankSchemaPolicy) {
const inlineData = dataConfig?.provider === 'value' ? dataConfig.items as any[] : [];
if (inlineData.length > 0) {
const fieldsToShow = schemaFields || Object.keys(inlineData[0]);
// FLS on the inline-data path (objectui#6723 — maintainer ruling
// 2026-08-29: the NARROW defence-in-depth fix, not a convergence).
//
// The object-schema path below re-applies field-level security to the
// columns it derives; this path did not. So whether an object-bound
// grid re-checked FLS depended on WHO FETCHED THE ROWS: same object,
// same authored projection, rows the grid fetched went through the
// gate and rows a host handed down did not. That is the asymmetry, and
// a security invariant may not be decided by the data's provenance.
//
// ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION. Only keys the
// OBJECT DECLARES are judged; everything else passes through
// untouched. A host may legitimately join or derive columns
// (`computed_score`, a flattened `account.name`), and keeping those is
// this path's whole reason to exist — the object-schema path drops
// them outright (`if (!field) return;`). Judging an undeclared key
// would silently drop derived columns, which is the failure
// objectui#6723's own analysis warned about and which the ruling
// refuses by name. `checkField` answers `false` for a field the
// policy has never heard of, so asking it about a derived key is not
// a stricter reading of the same rule — it is a different, wrong one.
//
// Redundant through `ListView`, which filters its own `effectiveFields`
// through this same gate before forwarding (its source says so), and
// that redundancy IS the point: the invariant must not rest on every
// future host having read the docs. Pinned as a byte-for-byte no-op on
// that path in `inlineDataFls-6723.test.tsx`.
const fieldsToShow = (schemaFields || Object.keys(inlineData[0])).filter((fieldName) => {
if (!perms?.isLoaded || !schema.objectName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
// `hasOwnProperty` rather than a truthiness read so an inherited
// name (`constructor`, `toString`) cannot be mistaken for a declared
// field and dropped.
if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
});
return fieldsToShow.map((fieldName) => {
const fieldDef = objectSchema?.fields?.[fieldName];
// Annotated for the same reason as paths A and B (objectui#6004).
Expand Down
Loading
Loading