diff --git a/.changeset/6799-authored-columns-fls.md b/.changeset/6799-authored-columns-fls.md new file mode 100644 index 000000000..70aae7bab --- /dev/null +++ b/.changeset/6799-authored-columns-fls.md @@ -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`. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 012b1acbd..0356ce42d 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -1959,6 +1959,56 @@ export const ObjectGrid: React.FC = ({ 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: @@ -1991,6 +2041,7 @@ export const ObjectGrid: React.FC = ({ // `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 @@ -2203,6 +2254,7 @@ export const ObjectGrid: React.FC = ({ // 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; diff --git a/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx b/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx new file mode 100644 index 000000000..9149dede9 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/authoredColumnsFls-6799.test.tsx @@ -0,0 +1,448 @@ +/** + * 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#6799 — field-level security on `generateColumns()`'s AUTHORED + * `columns` path, the third and last of its three default paths. + * + * ## The defect + * + * `generateColumns()` has three default paths. After objectui#6723 two of them + * re-applied FLS and the authored `columns` path did not: + * + * `schema.columns` authored -> authored path -> FLS SKIPPED + * host passes `data`, `fields` -> inline-data path -> FLS (objectui#6723) + * neither -> object-schema -> FLS + * + * This path is the MOST REACHABLE of the three, which is what separates it from + * objectui#6723: the inline-data path needs the host to hand rows down, while + * the authored path runs whether the grid fetches its own rows or not. Every + * case below therefore lets the GRID fetch (no `data` prop) except the one that + * pins the host-fed shape explicitly. + * + * Maintainer ruling 2026-08-30: take the same defence-in-depth fix as + * objectui#6723, three paths consistent. + * + * ## The limit is the point, not an optimisation + * + * Only keys the OBJECT DECLARES are judged. Host-joined and derived keys pass + * through untouched. A `ListColumn` carries `label` / `link` / `action` / + * `prefix` / `width`, and a column whose `field` the object does not declare is + * a legitimate authored derived column — judging it would silently delete + * authored work, which the ruling refuses by name. PIN 3 and PIN 3b are that + * boundary, and a fix that passed PIN 2 by dropping everything fails them. + * + * ## Which key is judged — never a bare string + * + * The ruling is explicit: for the `ListColumn[]` arm the judged key is read + * through `columnIdentity` / `resolvesToDataColumn`, not off a bare string. + * `columnIdentity` accepts all three authored shapes (`'stage'`, + * `{ field: 'stage' }`, and the legacy `{ name: 'stage' }`), which is why one + * predicate serves both arms. PIN 5 pins the legacy spelling specifically: a + * bare-string read would see no identity there and wave the column through. + * + * ## Why the stub `checkField` is an ALLOWLIST + * + * Inherited verbatim from `inlineDataFls-6723.test.tsx`, for the same reason: + * `PermissionProvider` (role-based) answers `true` for a field no policy + * mentions, so under it a derived key survives whether or not the guard judges + * it — the limit would be untestable and PIN 3 green in both worlds for the + * wrong reason. The stub models a server that ENUMERATES readable fields. The + * real provider still gets a case of its own (WIRING), so nothing here rests + * solely on an imitation. + * + * ## ABLATION — see the PR body for the recorded run. + * + * `vitest.config.mts` aliases every `@object-ui/*` specifier to that package's + * `src`, and this file imports `../ObjectGrid` relatively, so no build step + * stands between the edit and the run — the ablation reads source directly. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +/** + * Stable stub identity: `ObjectGrid` carries `perms` in `useCallback` / + * `useMemo` dependency arrays, so a fresh object per call would churn those + * memos on every render. + */ +const { permsStub, state } = vi.hoisted(() => { + const state: { + /** Has `/me/permissions` answered yet? `false` = defer, filter nothing. */ + isLoaded: boolean; + /** Fields this principal may READ. Anything absent is denied. */ + readable: string[]; + /** Bypass the stub and run the REAL provider-backed hook instead. */ + useRealProvider: boolean; + } = { isLoaded: true, readable: [], useRealProvider: false }; + return { + state, + permsStub: { + get isLoaded() { return state.isLoaded; }, + checkField: (_object: string, field: string, action: string) => + action === 'read' ? state.readable.includes(field) : true, + check: () => ({ allowed: true }), + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + userId: null, + systemPermissions: undefined, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }, + }; +}); + +// The real module stays reachable so the WIRING case below exercises the ACTUAL +// `PermissionProvider` gate rather than a hand-written imitation of it. +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePermissions: () => { + const real = actual.usePermissions(); + return state.useRealProvider ? real : (permsStub as any); + }, + }; +}); + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; +import { PermissionProvider } from '@object-ui/permissions'; +import type { ObjectPermissionConfig, RoleDefinition } from '@object-ui/types'; + +registerAllFields(); + +beforeAll(() => { + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = vi.fn(() => false) as any; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +beforeEach(() => { + state.isLoaded = true; + state.readable = []; + state.useRealProvider = false; +}); +afterEach(() => cleanup()); + +const OBJECT = 'opportunity'; + +/** + * `salary` is the field under test: DECLARED by the object and denied to this + * principal. `computed_score` is deliberately NOT declared — it is the + * host-joined / derived key the limit protects. + */ +const OPPORTUNITY_SCHEMA = { + name: OBJECT, + label: 'Opportunity', + fields: { + name: { type: 'text', label: 'Opportunity Name' }, + amount: { type: 'currency', label: 'Amount', currency: 'USD' }, + salary: { type: 'number', label: 'Salary' }, + }, +}; + +/** + * Rows as the grid's own fetch returns them — INCLUDING a payload for the + * denied field, which is what makes PIN 2 about the renderer: the value is in + * memory and must still not reach the screen. + */ +const ROWS = [ + { id: 'o-1', name: 'Acme expansion', amount: 42000, salary: 120000, computed_score: 'A+' }, +]; + +function makeDataSource(overrides: Record = {}) { + return { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + getObjectSchema: vi.fn(async () => OPPORTUNITY_SCHEMA), + ...overrides, + } as any; +} + +/** + * The DATA columns' header labels, in render order. Two kinds of furniture are + * dropped: cells with no header text (selection checkbox, row-action kebab) and + * the row-index column, whose header is a literal `#`. + */ +function dataHeaders(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('thead th')) + .map((th) => (th.textContent ?? '').trim()) + .filter((text) => text.length > 0 && text !== '#'); +} + +/** + * No `data` prop: the GRID fetches. That is the reachability claim this card + * turns on, so it is the default shape here rather than an extra case. + */ +function renderAuthoredGrid( + schemaOverrides: Record = {}, + dataSource?: any, + wrap?: (el: React.ReactElement) => React.ReactElement, +) { + const ds = dataSource ?? makeDataSource(); + const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaOverrides }; + const inner = ( + + + + ); + const utils = render(wrap ? wrap(inner) : inner); + return { ...utils, ds }; +} + +describe('ObjectGrid — FLS on the authored `columns` path (#6799)', () => { + /* ------------------------------------------------------------------ * + * The security pins. Both arms, because both are in the ruling. * + * ------------------------------------------------------------------ */ + + it('PIN 1 — ListColumn[]: a declared field the principal CAN read renders its column', async () => { + state.readable = ['name', 'amount']; + const { container, ds } = renderAuthoredGrid({ + columns: [{ field: 'name' }, { field: 'amount' }], + }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Amount'])); + + // The positive half: a guard that dropped everything would also satisfy + // PIN 2, so the readable columns have to be pinned present. + expect(screen.getByText('Acme expansion')).toBeInTheDocument(); + }); + + it('PIN 2 — ListColumn[]: a declared field the principal CANNOT read does not render', async () => { + state.readable = ['name']; + const { container, ds } = renderAuthoredGrid({ + columns: [{ field: 'name' }, { field: 'salary', label: 'Salary' }], + }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name'])); + + // The absence half, twice over: not the header, and not the VALUE the fetch + // already put in the row. `120000` is `salary` on the only row. + expect(dataHeaders(container)).not.toContain('Salary'); + expect(screen.queryByText('120000')).toBeNull(); + }); + + it('PIN 2b — string[]: a declared field the principal CANNOT read does not render', async () => { + state.readable = ['name']; + const { container, ds } = renderAuthoredGrid({ columns: ['name', 'salary'] }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name'])); + expect(screen.queryByText('120000')).toBeNull(); + }); + + it('PIN 2c — the host-fed shape reaches the same gate', async () => { + // `if (cols)` is judged before the inline-data path, so an authored + // projection wins even when a host hands rows down. Same defect, other + // door — and the door objectui#6723 did NOT close. + state.readable = ['name']; + const ds = makeDataSource(); + const { container } = render( + + + , + ); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => expect(dataHeaders(container)).toEqual(['Opportunity Name'])); + expect(screen.queryByText('120000')).toBeNull(); + }); + + /* ------------------------------------------------------------------ * + * Boundaries — green in BOTH worlds. Controls, not restatements. * + * ------------------------------------------------------------------ */ + + it('PIN 3 — a key the object does not declare survives (host-joined / derived)', async () => { + // `computed_score` is not an object field and the allowlist does not name + // it, so a guard that judged undeclared keys would delete this authored + // column. That drop is the failure the ruling refuses by name. + state.readable = ['name']; + const { container, ds } = renderAuthoredGrid({ + columns: [{ field: 'name' }, { field: 'computed_score', label: 'Score' }], + }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Score'])); + expect(screen.getByText('A+')).toBeInTheDocument(); + }); + + it('PIN 3b — an INHERITED name is not mistaken for a declared field', async () => { + // `objectSchema.fields.constructor` resolves through the prototype chain, + // so a truthiness read (`fields?.[name]`) would call it declared, ask the + // allowlist about it, and drop a derived column named `constructor`. + state.readable = ['name']; + const ds = makeDataSource({ + find: vi.fn(async () => ({ + data: [{ ...ROWS[0], constructor: 'derived-value' }], + total: 1, + })), + }); + const { container } = renderAuthoredGrid({ columns: ['name', 'constructor'] }, ds); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => expect(dataHeaders(container)).toContain('Constructor')); + }); + + it('PIN 4 — a derived column keeps its authored furniture, not just its slot', async () => { + // The over-eager-filter failure has a quieter form than deletion: keeping + // the column but losing what a `ListColumn` carries. `label` is the visible + // one, so it is the one pinned. + state.readable = ['name']; + const { container, ds } = renderAuthoredGrid({ + columns: [ + { field: 'name' }, + { field: 'computed_score', label: 'Fit Score', width: 120 }, + ], + }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Fit Score'])); + }); + + it('PIN 5 — the legacy `{ name }` identity spelling is judged too', async () => { + // `columnIdentity` folds `field` / `name` / `fieldName`. A gate that read a + // bare `col.field` would find no identity here and wave a DENIED declared + // field straight through — the exact failure the ruling forbids by naming + // `columnIdentity` as the reader. + state.readable = ['name']; + const { container, ds } = renderAuthoredGrid({ + columns: [{ field: 'name' }, { name: 'salary', field: 'salary' }], + }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => expect(dataHeaders(container)).toEqual(['Opportunity Name'])); + expect(screen.queryByText('120000')).toBeNull(); + }); + + it('CONTROL — permissions not loaded yet: nothing is filtered', async () => { + // `/me/permissions` has not answered. The other two paths defer in exactly + // this case (`perms?.isLoaded &&`), and so must this one: a grid that + // blanked its columns while perms were in flight would be a worse defect + // than the one being fixed. + state.isLoaded = false; + state.readable = []; + const { container, ds } = renderAuthoredGrid({ columns: ['name', 'salary'] }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Salary'])); + }); + + it('CONTROL — no `objectName`: an object-less grid is untouched', async () => { + // No object behind the columns means no policy to apply. `checkField` needs + // an object to answer about, and the other two gates read + // `schema.objectName` for the same reason. + state.readable = []; + const ds = makeDataSource(); + const { container } = render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + expect(dataHeaders(container)).toEqual(['Name', 'Salary']); + }); + + it('CONTROL — object schema still in flight: authored columns are untouched', async () => { + // `objectSchema` is `null`, so NOTHING is declared yet and every key is a + // derived key as far as this gate can tell. Deferring here is the same + // fail-open the declared-key limit already implies; blanking a grid while + // its schema loads would be the worse defect. + state.readable = []; + const pending = new Promise(() => { /* never resolves */ }); + const ds = makeDataSource({ getObjectSchema: vi.fn(() => pending) }); + // Rows come from the HOST here. A grid that fetches its own rows waits on + // the object schema and would sit on a spinner forever, pinning nothing. + const { container } = render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + expect(dataHeaders(container)).toEqual(['Name', 'Salary']); + }); + + it('CONTROL — hidden columns stay dropped and unresolvable ones stay dropped', async () => { + // `resolvesToDataColumn` runs before the field gate and must keep owning + // its own decisions. A gate bolted on ahead of it would resurrect a hidden + // column or a mis-spelled one. + state.readable = ['name', 'amount']; + const { container, ds } = renderAuthoredGrid({ + columns: [ + { field: 'name' }, + { field: 'amount', hidden: true }, + { accessorKey: 'salary' }, + ], + }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => expect(dataHeaders(container)).toEqual(['Opportunity Name'])); + }); + + it('WIRING — the REAL PermissionProvider gate drops the denied authored column', async () => { + // Everything above runs against the stub. This case runs the actual + // `@object-ui/permissions` provider end to end, so the fix is pinned to the + // real `checkField` and not only to the shape of a double. + state.useRealProvider = true; + const roles: RoleDefinition[] = [{ name: 'restricted', label: 'Restricted' }]; + const permissions: ObjectPermissionConfig[] = [ + { + object: OBJECT, + roles: { + restricted: { + actions: ['read'], + fieldPermissions: [{ field: 'salary', read: false, write: false }], + }, + }, + }, + ]; + + const { container, ds } = renderAuthoredGrid( + { columns: [{ field: 'name' }, { field: 'salary' }] }, + undefined, + (el) => ( + + {el} + + ), + ); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => expect(dataHeaders(container)).toEqual(['Opportunity Name'])); + expect(screen.queryByText('120000')).toBeNull(); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index ec9552009..7bf9f454e 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -2110,9 +2110,21 @@ export const ListView = React.forwardRef(({ * 2. The author declared some and the gates above removed them all — FLS * denied every one, or every one is hidden. That case must KEEP sending * the empty projection. Falling through to the grid's defaults there - * would show fields the author never asked for, and `ObjectGrid` - * re-applies FLS only on the DERIVED path, not on the explicit-columns - * one — so widening here would be a widening past the field gate. + * would show fields the author never asked for. + * + * ⭐ THE REASON CHANGED; THE PREDICATE DID NOT (objectui#6799). This + * clause used to end "…and `ObjectGrid` re-applies FLS only on the + * DERIVED path, not on the explicit-columns one — so widening here would + * be a widening past the field gate." That is no longer true. As of + * objectui#6799 the grid re-applies FLS on ALL THREE of its default + * paths, the authored `columns` one included, so a fall-through here no + * longer escapes the field gate. The predicate stays exactly as it is + * for the half of the sentence that never depended on the grid: an empty + * projection is the AUTHOR's projection after filtering, and handing the + * grid "unauthored" would replace it with the object's default columns — + * fields the author never declared. FLS-checked now, but still not what + * was authored. AUTHORING INTENT is what this predicate protects, and + * that was always the load-bearing half. * * Hence the question is about the AUTHORED value and never about what * survived filtering. diff --git a/packages/plugin-list/src/__tests__/ListView.unauthoredColumnProjection-6598.test.tsx b/packages/plugin-list/src/__tests__/ListView.unauthoredColumnProjection-6598.test.tsx index 01732e542..1de59fddd 100644 --- a/packages/plugin-list/src/__tests__/ListView.unauthoredColumnProjection-6598.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.unauthoredColumnProjection-6598.test.tsx @@ -30,12 +30,18 @@ * `effectiveFields` is `[]` for two reasons that must NOT be handed down the * same way, and only one of them is "unauthored". When the author DID declare * columns and the field gate removed every one of them, the empty projection is - * the answer and has to survive: `ObjectGrid` re-applies FLS on its DERIVED - * column path only, never on the explicit-columns path, so falling through to - * the derivation there would put fields on screen that the author never asked - * for and the principal may not read. That is why the predicate reads the - * AUTHORED value and never what survived filtering — and why it is pinned next - * to the case it would otherwise be "simplified" into. + * the answer and has to survive: falling through to the derivation there would + * put fields on screen that the author never asked for. That is why the + * predicate reads the AUTHORED value and never what survived filtering — and + * why it is pinned next to the case it would otherwise be "simplified" into. + * + * ⭐ The reason is NOT "the grid would not re-check". It used to be: before + * objectui#6799 `ObjectGrid` re-applied FLS on its derived path only, so a + * fall-through here escaped the field gate outright. objectui#6799 closed that + * — all three of the grid's default paths re-check now — and this pin is + * deliberately UNCHANGED, because what it protects is AUTHORING INTENT. The + * object's default columns are not the author's projection whether or not they + * are FLS-checked on the way out. * * plugin-grid is not a dependency of plugin-list (avoids a cycle), so — as in * `ListView.findParamsHandoff.test.tsx` — a stub `object-grid` records what @@ -177,8 +183,10 @@ describe('ListView → object-grid: the unauthored column projection (#6598)', ( // NOT `undefined`. The author declared a projection; every column of it was // denied. Handing the grid "unauthored" here would run its derivation and - // put the object's other fields on screen — a widening past the field gate, - // which the explicit-columns path in ObjectGrid does not re-check. + // put the object's OTHER fields on screen — fields the author never + // declared. (Before objectui#6799 that was ALSO a widening past the field + // gate, because the grid's explicit-columns path did not re-check. It does + // now; the assertion is unchanged, because authoring intent is the reason.) expect(gridSchema()).toBeTruthy(); expect(gridSchema().columns).toEqual([]); expect(gridSchema().fields).toEqual([]);