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
66 changes: 66 additions & 0 deletions .changeset/6799-authored-columns-fls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
'@object-ui/plugin-grid': patch
---

`ObjectGrid` re-applies field-level security on its authored `columns` path too,
so all three of `generateColumns()`'s default paths now go through the field
gate (objectui#6799, maintainer ruling 2026-08-30).

objectui#6723 closed the inline-data path and left this one. It was the worst of
the three to leave, because it is the **most reachable**: the inline-data path
needs a host to hand rows down, while the authored `columns` path runs whether
the grid fetches its own rows or not.

| path | reached when | FLS re-applied |
| --- | --- | --- |
| authored `columns` (`ListColumn[]` and `string[]` arms) | `schema.columns` present and non-empty | **no, until now** |
| inline-data | host passes `data` and `fields` is declared | yes (objectui#6723) |
| object-schema | everything else | yes |

Both arms now filter through `perms.checkField(objectName, fieldName, 'read')`
when `perms.isLoaded && schema.objectName` — the same gate and the same deferral
condition the other two paths use.

**What a consumer will feel.** A grid that composes `ObjectGrid` directly with
an authored `columns` projection will now render *fewer* columns for a principal
whose field policy denies them: a column naming a declared field the user may
not read disappears, where it previously rendered with its values. If your host
already filters its projection through `checkField` before forwarding — as
`ListView` does — nothing changes at all; this is a measured no-op on that path.
Hosts that did **not** filter first will see the difference, and that is the
point of the change rather than a side effect of it.

⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing
rather than an optimisation.** Host-joined and derived columns pass through
untouched. It matters more here than on the inline-data path: a `ListColumn`
carries `label` / `link` / `action` / `prefix` / `width`, so a column whose
`field` the object does not declare is not a mistake but a legitimate authored
derived column, and dropping it would destroy authoring work. A field policy
that enumerates readable fields answers "no" for a key it has never heard of, so
judging derived keys would silently delete them. Declaration is read with
`hasOwnProperty`, so an inherited name (`constructor`) is not mistaken for a
declared field.

**The judged key is read through `columnIdentity`, never off a bare string.** It
folds the three authored identity spellings — `'salary'`, `{ field: 'salary' }`
and the legacy `{ name: 'salary' }` — which is why one predicate serves both
arms. A gate reading `col.field` directly would find no identity on the legacy
spelling and wave a denied declared field straight through.
`resolvesToDataColumn` still owns its own decisions and runs first, so the gate
narrows what survives and never resurrects a hidden or unresolvable column.

**Defence in depth, not a reachable exploit through `ListView`.** Measured in
this repo: three shipped compositions reach this path without filtering first —
`ObjectView`, and the designer's `ObjectManager` and `FieldDesigner` — plus two
dev/demo harnesses. `ListView` filters its own `effectiveFields` through the
same gate before forwarding, and that redundancy is the point: the invariant
must not rest on every future host having read the docs.

objectui#6598's `hasAuthoredColumns` predicate is unchanged and its rationale is
rewritten in the same change: it used to rest on "the grid would not re-check",
which is no longer true, and it now rests on the half that never depended on the
grid — an empty projection is the author's projection after filtering, and the
object's default columns are not what was authored whether or not they are
FLS-checked on the way out.

Pinned in `packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx`.
52 changes: 52 additions & 0 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1959,6 +1959,56 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
const cols = normalizeColumns(schemaColumns);

if (cols) {
// FLS on the AUTHORED `columns` path (objectui#6799 — maintainer ruling
// 2026-08-30, inheriting objectui#6723's 2026-08-29 reasoning verbatim).
//
// This was the LAST of `generateColumns()`'s three default paths that did
// not re-apply field-level security. The object-schema path always did;
// the inline-data path does as of objectui#6723. Leaving this one out was
// the worst of the three to leave, because it is the MOST REACHABLE:
// objectui#6723's path needs a host to hand rows down, while this one runs
// whether the grid fetches its own rows or not. Three paths of one
// function, two checking and one not, is a bypass around the field gate
// rather than an inconsistency.
//
// ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION — and it bites harder
// here than on the inline-data path. Only keys the OBJECT DECLARES are
// judged; everything else passes through untouched. A `ListColumn` carries
// `label` / `link` / `action` / `prefix` / `width`, so a column whose
// `field` the object does not declare is not a mistake — it is a
// legitimate authored derived or host-joined column (`computed_score`, a
// flattened `account.name`), and deleting it would destroy authoring work.
// `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. `hasOwnProperty` rather than a
// truthiness read so an inherited name (`constructor`, `toString`) cannot
// be mistaken for a declared field.
//
// ⛔ THE JUDGED KEY IS READ THROUGH `columnIdentity`, NEVER OFF A BARE
// STRING (the ruling says so by name). `columnIdentity` folds the three
// authored identity spellings — `'salary'`, `{ field: 'salary' }` and the
// legacy `{ name: 'salary' }` — which is why ONE predicate serves both
// arms below. A gate reading `col.field` directly would find no identity
// on the legacy spelling and wave a denied declared field straight
// through. `resolvesToDataColumn` keeps owning its own decisions and runs
// first: this gate narrows what survives, it never resurrects a hidden or
// unresolvable column.
//
// Redundant through `ListView`, which filters its own `effectiveFields`
// through this same gate before forwarding them as `columns` — and that
// redundancy IS the point: the invariant must not rest on every future
// host having read the docs. Measured in-repo hosts that do NOT filter
// first: `ObjectView`, `ObjectManager`, `FieldDesigner`. Pinned in
// `authoredColumnsFls-6799.test.tsx`.
const passesFieldGate = (entry: unknown): boolean => {
if (!perms?.isLoaded || !schema.objectName) return true;
const fieldName = columnIdentity(entry);
// No readable identity ⇒ nothing to ask the policy about.
if (!fieldName) return true;
// Undeclared ⇒ host-joined / derived ⇒ not this gate's business.
if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true;
return perms.checkField(schema.objectName, fieldName, 'read');
};
// ObjectStack's DECLARED column spelling is the only one read
// (objectui#5068). `ObjectGridSchema.columns` is `string[] | ListColumn[]`,
// and `ListColumnSchema` in `@objectstack/spec/ui` is a STRICT object:
Expand Down Expand Up @@ -1991,6 +2041,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `col?.field && typeof col.field === 'string' && !col.hidden`.
return (cols as ListColumn[])
.filter((col) => resolvesToDataColumn(col))
.filter((col) => passesFieldGate(col))
.map((col, colIndex) => {
// Fall back to the SCHEMA FIELD's label before prettifying the machine
// name — otherwise a column declared as bare { field } shows an English
Expand Down Expand Up @@ -2203,6 +2254,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// String array format - enrich with objectDef field metadata for type-aware rendering
return (cols as string[])
.filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
.filter((fieldName) => passesFieldGate(fieldName))
.map((fieldName, colIndex) => {
const fieldDef = objectSchema?.fields?.[fieldName];
const rawFieldLabel = fieldDef?.label;
Expand Down
Loading
Loading