From e1ec85b0c54137c87cd827c86177d64fbcde8213 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 14:48:39 +0000 Subject: [PATCH 1/2] fix(plugin-grid): re-apply field-level security on the inline-data column path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectGrid.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, so whether an object-bound grid re-checked FLS depended on who fetched the rows. Same object, same authored projection, two answers. 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 already used. Only keys the OBJECT DECLARES are judged; host-joined and derived keys pass through untouched. That limit is load-bearing rather than an optimisation: a policy that enumerates readable fields answers "no" for a key it never heard of, so judging derived keys would silently drop them. Declaration is read with `hasOwnProperty` so an inherited name is not mistaken for a declared field. Deliberately unchanged (refused by name in the maintainer ruling of 2026-08-29): the two paths' label handling, the schema path's drop of undeclared names, and everything on the `ListView` host side. Part of #6723 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .changeset/6723-inline-data-fls.md | 54 +++ packages/plugin-grid/src/ObjectGrid.tsx | 37 +- .../src/__tests__/inlineDataFls-6723.test.tsx | 343 ++++++++++++++++++ .../ListView.inlineFlsNoop-6723.test.tsx | 185 ++++++++++ vitest.config.mts | 6 + 5 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 .changeset/6723-inline-data-fls.md create mode 100644 packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx diff --git a/.changeset/6723-inline-data-fls.md b/.changeset/6723-inline-data-fls.md new file mode 100644 index 000000000..248b793ee --- /dev/null +++ b/.changeset/6723-inline-data-fls.md @@ -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 +`` 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`. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 0988ddca0..2b55805e4 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2299,7 +2299,42 @@ export const ObjectGrid: React.FC = ({ 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). diff --git a/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx new file mode 100644 index 000000000..74d0b5e6f --- /dev/null +++ b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx @@ -0,0 +1,343 @@ +/** + * 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#6723 — field-level security on `generateColumns()`'s INLINE-DATA + * path, and the limit that makes it safe. + * + * ## The defect + * + * `generateColumns()` re-applied FLS at exactly one place: the object-schema + * path (`if (perms?.isLoaded && schema.objectName && !perms.checkField(...)) + * return;`). 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 re-check FLS purely according to WHO FETCHED THE + * ROWS: + * + * grid fetches, `fields` declared -> object-schema path -> FLS re-applied + * host passes `data`, `fields` -> inline-data path -> FLS SKIPPED + * + * A security invariant may not be decided by the data's provenance. Maintainer + * ruling 2026-08-29: take the NARROW defence-in-depth fix. + * + * ## The limit is the point, not an optimisation + * + * Only keys the OBJECT DECLARES are judged. 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;`). Judging + * them would silently drop derived columns — the failure the issue's own + * analysis warned about, and the one the ruling refuses by name. + * + * ## Why the stub `checkField` is an ALLOWLIST + * + * `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 above would be untestable, and PIN 3 would be green in both + * worlds for the wrong reason. The stub therefore models the shape a server + * that ENUMERATES readable fields produces: deny anything not listed. That is + * the only policy shape under which the limit is load-bearing, so it is the one + * the limit is pinned against. The real provider still gets a case of its own + * (WIRING below), so nothing here rests solely on an imitation. + * + * ## RED-FIRST — measured on the merge-base (d06059f24), this file only + * + * 1 red / 8 green. The red is PIN 2, and it is the whole fix: + * + * x PIN 2 — a declared field the principal cannot read is NOT rendered + * -> AssertionError: expected [ 'Opportunity Name', 'Salary' ] to deeply + * equal [ 'Opportunity Name' ] + * + * PIN 1, PIN 3 and the five controls are green on both sides by construction — + * they are the boundaries the guard must not cross, not restatements of it. + * Reported in both directions in the PR body. + */ +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. Same hoisted-state shape as + * `inlineEditPermissionGate.test.tsx`. + */ +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. The +// real hook is invoked on every render so hook order is stable whichever branch +// is returned. +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 exactly as a host hands them down — INCLUDING a payload for the denied + * field. That is what makes PIN 2 about the grid and not about the fetch: the + * value is right there in memory and must still not reach the screen. + */ +const HOST_ROWS = [ + { id: 'o-1', name: 'Acme expansion', amount: 42000, salary: 120000, computed_score: 'A+' }, +]; + +function makeDataSource(overrides: Record = {}) { + return { + // A host owns the fetch, so the grid must never call this. + find: vi.fn(async () => ({ data: [], total: 0 })), + 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 !== '#'); +} + +function renderHostFedGrid( + 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 inline-data path (#6723)', () => { + it('PIN 1 — a declared field the principal CAN read renders its column', async () => { + state.readable = ['name', 'amount']; + const { container, ds } = renderHostFedGrid({ fields: ['name', '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(); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('PIN 2 — a declared field the principal CANNOT read does not render, even with host data for it', async () => { + state.readable = ['name']; + const { container, ds } = renderHostFedGrid({ fields: ['name', '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 host + // already put in the payload. `120000` is `salary` on the only row. + expect(dataHeaders(container)).not.toContain('Salary'); + expect(screen.queryByText('120000')).toBeNull(); + }); + + it('PIN 3 — a key the object does not declare is unaffected by the gate', async () => { + // `computed_score` is not an object field, and the allowlist does not name + // it, so a guard that judged undeclared keys would drop it here. That drop + // is the failure mode the ruling refuses by name. + state.readable = ['name']; + const { container, ds } = renderHostFedGrid({ fields: ['name', 'computed_score'] }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Computed 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`. The + // guard reads `hasOwnProperty` for exactly this reason. + state.readable = ['name']; + const rows = [{ ...HOST_ROWS[0], constructor: 'derived-value' }]; + const ds = makeDataSource(); + const { container } = render( + + + , + ); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => expect(dataHeaders(container)).toContain('Constructor')); + }); + + /* ------------------------------------------------------------------ * + * Boundaries — green in BOTH worlds. Controls, not restatements. * + * ------------------------------------------------------------------ */ + + it('CONTROL — permissions not loaded yet: nothing is filtered', async () => { + // `/me/permissions` has not answered. The existing object-schema gate defers + // 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 } = renderHostFedGrid({ fields: ['name', 'salary'] }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Salary'])); + }); + + it('CONTROL — no `objectName`: a pure inline-data grid is untouched', async () => { + // No object behind the data means no policy to apply. `checkField` needs an + // object to answer about, and the existing gate reads `schema.objectName` + // for the same reason. + state.readable = []; + const { container } = render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + expect(dataHeaders(container)).toEqual(['Name', 'Salary']); + }); + + it('CONTROL — object schema still in flight: the row-key fallback is untouched', async () => { + // `objectSchema` is `null`, so NOTHING is declared yet and every key is a + // derived key as far as this gate can tell. First paint keeps the row keys + // rather than blanking (the boundary #6677 pinned), and the gate must not + // move that. + state.readable = []; + const pending = new Promise(() => { /* never resolves */ }); + const ds = makeDataSource({ getObjectSchema: vi.fn(() => pending) }); + const { container } = renderHostFedGrid({}, ds); + + await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + expect(dataHeaders(container)).toEqual(['Id', 'Name', 'Amount', 'Salary', 'Computed score']); + }); + + it('WIRING — the REAL PermissionProvider gate drops the denied declared 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 } = renderHostFedGrid({ fields: ['name', '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/__tests__/ListView.inlineFlsNoop-6723.test.tsx b/packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx new file mode 100644 index 000000000..9cd3043de --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx @@ -0,0 +1,185 @@ +/** + * 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#6723, PIN 4 — the grid's new inline-data FLS gate is a NO-OP through + * `ListView`, and that is measured here rather than assumed. + * + * ## Why this pin exists at all + * + * `ListView` filters its own `effectiveFields` through `checkField` before + * forwarding, so the grid-side gate #6723 adds is redundant on this path. The + * ruling keeps both ("redundancy is depth"), which makes "redundant" a claim + * about behaviour that has to hold: adding a second gate must not change one + * pixel of what this host renders. + * + * ## Why it cannot fire here — the mechanism, pinned by the assertions + * + * `ListView` reaches the grid two ways, and neither one arrives at the + * inline-data branch carrying a declared-but-denied key: + * + * - AUTHORED columns: it forwards `columns: effectiveFields` (already + * FLS-filtered) alongside the rows, and `generateColumns()` returns from + * the `normalizeColumns(schemaColumns)` branch before the inline-data + * branch is reached. Pinned below by exact header equality with the + * filtered projection. + * - UNAUTHORED: it forwards `fields: undefined, columns: undefined` + * (objectui#6598), so `rowKeysWouldOutrankSchemaPolicy` is true once the + * schema has loaded and the grid falls through to the OBJECT-SCHEMA path — + * the one that has always re-applied FLS. Pinned below by the absence of + * `Id`: `id` is `hidden: true`, which the object-schema policy drops and + * the inline-data path (row keys) would keep. That absence is what makes + * this case a mechanism assertion and not just a header count. + * + * ## Reading this file under ablation + * + * Every case here is green with the #6723 guard present AND with it removed — + * that identity IS pin 4. The CONTROL case is what keeps the two denials above + * from being vacuous: with no permission policy mounted, `Amount` renders in + * both shapes, so the assertions measure the field gate rather than a render + * that never produced the column. + * + * The grid here is the REAL `@object-ui/plugin-grid` renderer, not a stub: this + * file is listed in `heavyDomTests` (vitest.config.mts), whose setup imports + * plugin-grid for its side-effect registration — the same route + * `ListView.crossPageSelectAll.test.tsx` and the two #6598 files take. A stub + * grid cannot observe a no-op inside the grid. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, SchemaRendererProvider } from '@object-ui/react'; +import { PermissionProvider } from '@object-ui/permissions'; +import { ListView } from '../ListView'; +import type { ListViewSchema, ObjectPermissionConfig, RoleDefinition } from '@object-ui/types'; + +const OBJECT = 'opportunity'; + +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(() => { vi.clearAllMocks(); }); +afterEach(() => { cleanup(); }); + +/** + * `id` is `hidden: true` on purpose: the object-schema default-columns policy + * drops it and the inline-data row-key derivation keeps it, so its presence or + * absence tells the two grid paths apart from the outside. + */ +const OPPORTUNITY_FIELDS = { + id: { type: 'text', label: 'Id', hidden: true }, + name: { type: 'text', label: 'Opportunity Name' }, + amount: { type: 'currency', label: 'Amount' }, +}; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ + data: [{ id: 'o-1', name: 'Acme expansion', amount: 1000 }], + total: 1, + hasMore: false, + })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: async (name: string) => ({ name, label: 'Opportunity', fields: OPPORTUNITY_FIELDS }), + } as any; +} + +const listSchema = (over: Record = {}): ListViewSchema => + ({ type: 'list-view', objectName: OBJECT, ...over }) as unknown as ListViewSchema; + +/** Denies `amount` — a field the object DECLARES — to the current principal. */ +const ROLES: RoleDefinition[] = [{ name: 'restricted', label: 'Restricted' }]; +const DENY_AMOUNT: ObjectPermissionConfig[] = [ + { + object: OBJECT, + roles: { + restricted: { + actions: ['read'], + fieldPermissions: [{ field: 'amount', read: false, write: false }], + }, + }, + }, +]; + +function renderList(schema: ListViewSchema, permissions?: ObjectPermissionConfig[]) { + const ds = makeDataSource(); + const inner = ( + + + + + + ); + const utils = render( + permissions + ? ( + + {inner} + + ) + : inner, + ); + return { ...utils, ds }; +} + +/** Data column headers in render order — furniture and the `#` index dropped. */ +function dataHeaders(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('thead th')) + .map((th) => (th.textContent ?? '').trim()) + .filter((text) => text.length > 0 && text !== '#'); +} + +describe('ListView-hosted grid — the #6723 inline-data FLS gate is a no-op here', () => { + it('AUTHORED columns: the host pre-filtered, and the grid renders exactly what it was handed', async () => { + const { container } = renderList(listSchema({ columns: ['name', 'amount'] }), DENY_AMOUNT); + + await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + // `effectiveFields` already dropped `amount`, so the grid never sees it. + await waitFor(() => expect(dataHeaders(container)).toEqual(['Opportunity Name'])); + expect(screen.queryByText('1,000.00')).toBeNull(); + }); + + it('UNAUTHORED: the grid takes the OBJECT-SCHEMA path, whose own FLS gate is the one that answers', async () => { + const { container } = renderList(listSchema(), DENY_AMOUNT); + + await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + const headers = await waitFor(() => { + const h = dataHeaders(container); + expect(h.length).toBeGreaterThan(0); + return h; + }); + + expect(headers).toContain('Opportunity Name'); + // The pre-existing object-schema gate dropped it. + expect(headers).not.toContain('Amount'); + // MECHANISM: `id` is `hidden`, so the object-schema policy excludes it while + // the inline-data row-key derivation would include it. Its absence is what + // shows the inline branch (the one #6723 touches) did not run here. + expect(headers).not.toContain('Id'); + }); + + it('CONTROL: with no permission policy mounted, `Amount` renders in both shapes', async () => { + // Without this the two denials above would also pass on a grid that failed + // to render the column for some unrelated reason. + const authored = renderList(listSchema({ columns: ['name', 'amount'] })); + await waitFor(() => expect(dataHeaders(authored.container)).toEqual(['Opportunity Name', 'Amount'])); + cleanup(); + + const unauthored = renderList(listSchema()); + await waitFor(() => expect(dataHeaders(unauthored.container)).toContain('Amount')); + expect(dataHeaders(unauthored.container)).toContain('Opportunity Name'); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 5c1a76e06..96383af6b 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -139,6 +139,12 @@ const heavyDomTests = [ // all eight at once, and a stub grid stands in for one side of the // ListView/ObjectGrid disagreement it has to observe. 'packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx', + // objectui#6723 — PIN 4: the grid's new inline-data FLS gate must be a + // no-op through this host, which pre-filters its own fields. "No-op" + // is a claim about the REAL grid's rendered headers, so a stub grid + // (what the sibling handoff pins register) cannot observe it. Same + // reason and same route as the three entries above. + 'packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx', ]; export default defineConfig({ From 7159718f0da9e9129cc0959fb4b77d397de7d5ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 14:53:56 +0000 Subject: [PATCH 2/2] test(plugin-grid): record the measured ablation in the #6723 pin's header The header predicted one red. The measured ablation (this file restored to d06059f24, guard removed) produced two: PIN 2 and WIRING, the same assertion asked once of the stub and once of the real `PermissionProvider`. The six that did not move are named as such, since "did not move" is the half that says the guard is narrow. Also records why no rebuild stands between the edit and the run here: `vitest.config.mts` aliases every `@object-ui/*` specifier to that package's `src`, and the pin imports `../ObjectGrid` relatively. Part of #6723 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../src/__tests__/inlineDataFls-6723.test.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx index 74d0b5e6f..0612ede75 100644 --- a/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx +++ b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx @@ -45,17 +45,27 @@ * the limit is pinned against. The real provider still gets a case of its own * (WIRING below), so nothing here rests solely on an imitation. * - * ## RED-FIRST — measured on the merge-base (d06059f24), this file only + * ## ABLATION — guard removed (this file restored to d06059f24), this file only * - * 1 red / 8 green. The red is PIN 2, and it is the whole fix: + * 2 red / 6 green. Both reds are the same assertion asked twice, once of the + * stub and once of the real provider — which is what says the fix is wired to + * the actual gate and not only to the shape of a double: * * x PIN 2 — a declared field the principal cannot read is NOT rendered * -> AssertionError: expected [ 'Opportunity Name', 'Salary' ] to deeply * equal [ 'Opportunity Name' ] + * x WIRING — the REAL PermissionProvider gate drops the denied column + * -> AssertionError: expected [ 'Opportunity Name', 'Salary' ] to deeply + * equal [ 'Opportunity Name' ] + * + * PIN 1, PIN 3, PIN 3b and the three controls DID NOT MOVE, which is the half + * that says the guard is narrow: they are the boundaries it must not cross, not + * restatements of it. Restoring the guard returns the file to 8 green. * - * PIN 1, PIN 3 and the five controls are green on both sides by construction — - * they are the boundaries the guard must not cross, not restatements of it. - * Reported in both directions in the PR body. + * `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, and + * the mutation was confirmed on disk by blob hash before the run. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor, cleanup } from '@testing-library/react';