From 2f35833befe62b0d442c3dc4f9541532a6a35b85 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:10:45 +0000 Subject: [PATCH] fix(plugin-grid,plugin-list): FLS-gate the `$expand` projection at both sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#6898 closed field-level security on `$select`; `$expand` was ungated at both projection build sites, so a lookup / master_detail / user / tree field the principal cannot read was still handed to the server for expansion — and an expansion returns the RESOLVED related record where `$select` returns only a bare foreign key. Reproduced first as failing tests at both sites. On the `ListView` path the same gap also reopened `$select`: that builder gates its columns and then adds the expand roots back unconditionally, so the denied field re-entered the projection through the union rather than through its own filter. The gate judges the OUTPUT of `buildExpandFields` rather than its input, which is what keeps the required ordering structural: the helper already returns a subset of the object's declared reference-bearing fields, so no undeclared key is ever judged. Gating the input would instead WIDEN the expansion, because the helper reads an empty column list as "no column restriction" and falls back to every relation — and it cannot reach the no-columns case at all. `buildExpandFields` itself is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- .changeset/7215-expand-fls-gate.md | 32 +++ packages/plugin-grid/src/ObjectGrid.tsx | 48 +++- .../src/__tests__/expandFls-7215.test.tsx | 238 ++++++++++++++++++ packages/plugin-list/src/ListView.tsx | 64 ++++- .../ListView.expandFls-7215.test.tsx | 237 +++++++++++++++++ 5 files changed, 607 insertions(+), 12 deletions(-) create mode 100644 .changeset/7215-expand-fls-gate.md create mode 100644 packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx diff --git a/.changeset/7215-expand-fls-gate.md b/.changeset/7215-expand-fls-gate.md new file mode 100644 index 000000000..d962648ae --- /dev/null +++ b/.changeset/7215-expand-fls-gate.md @@ -0,0 +1,32 @@ +--- +'@object-ui/plugin-grid': patch +'@object-ui/plugin-list': patch +--- + +FLS-gate the `$expand` projection at both build sites (objectui#7215). + +objectui#6898 closed field-level security on `$select`. `$expand` was left ungated at +both projection sites — `ObjectGrid`'s own fetch and `ListView`'s `expandFields` memo — +so a `lookup` / `master_detail` / `user` / `tree` field the current principal cannot +read was still handed to the server for expansion. `$select` on a denied lookup asks for +its bare foreign key; `$expand` on the same field asks the server to resolve it and +return the related record, so the larger of the two disclosures was the ungated one. + +**Reproduced before it was fixed**, as failing tests at both sites, and the same leak +reaches further on the `ListView` path: that builder's `$select` gate drops the denied +column and then adds the expand roots back unconditionally, so the denied field walked +back into `$select` as well. Gating the expansion closes both halves. + +**Grading, measured rather than assumed.** Against ObjectStack's own server this is +defence-in-depth, exactly as objectui#6898 is: `plugin-security`'s +`FieldMasker.maskRecord` deletes every unreadable key from each returned row, and +objectql's expand path writes the resolved record back under that same key, so one +statement removes the expanded object and the bare id alike; the expansion sub-read is +itself gated (`__expandRead` takes the referenced object's full CRUD + RLS + FLS +treatment). It is load-bearing for any backend that does not strip. + +**Nothing a permitted view did stops working.** The gate judges the OUTPUT of +`buildExpandFields`, which is already a subset of the object's declared +reference-bearing fields, so the "`checkField` answers false for an undeclared key" +trap cannot be reached and derived / host-joined columns are untouched. An unanswered +permission policy filters nothing. `buildExpandFields` itself is unchanged. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 0a3f9248c..08a5275de 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -1815,11 +1815,57 @@ export const ObjectGrid: React.FC = ({ // and the grouping fields are covered by that superset. Passing an // array here unconditionally would NARROW that case to the grouping // fields alone. + // + // [objectui#7215] FIELD-LEVEL SECURITY ON `$expand` — the half + // objectui#6898 left open. That card gated `$select`, which asks for + // a denied lookup's BARE FOREIGN KEY; `$expand` asks the server to + // RESOLVE the same field and hand back the related record, so the + // larger of the two disclosures was the ungated one. + // + // Graded the same way #6898 was, and by measurement rather than + // assumption: against ObjectStack this is defence-in-depth, because + // `plugin-security`'s `FieldMasker.maskRecord` does `delete + // result[field]` on every unreadable key and objectql's expand path + // writes the resolved record back under THAT SAME KEY + // (`record[fieldName] = recordMap.get(...)`), so one statement + // deletes the expanded object and the bare id alike. It is + // load-bearing for a backend that does not strip. + // + // ⭐ THE GATE GOES ON THE OUTPUT, NOT ON THE COLUMN LIST, and both + // reasons are measured (`__tests__/expandFls-7215.test.tsx` pins + // each): + // + // - `buildExpandFields` reads an EMPTY column list as "no column + // restriction" and falls back to every declared relation, so + // filtering its INPUT would WIDEN a view whose only relational + // column is denied from that one field to all of them; + // - the no-columns case passes `undefined`, so it has no input to + // gate at all — and it is the case that expands the most. + // + // Gating the output also satisfies, structurally, the ordering the + // `$select` gate above spells out by hand (intersect with the + // DECLARED fields first, ask `checkField` only about survivors): + // `buildExpandFields` returns a subset of the object's declared + // reference-bearing fields, so every name judged here is declared by + // construction and the "`checkField` answers false for an undeclared + // key" trap is unreachable. That is why this gate is SHORTER than + // `passesProjectionGate` rather than a copy of it — no undeclared-key + // arm, and no identity read, because these are resolved root names + // rather than column entries. + // + // Deferral is the same as every other gate on this path: an + // unanswered policy filters nothing, and the effect re-runs on + // `perms.isLoaded`, so the expansion is rebuilt the moment the answer + // arrives. + const expandReadable = (fieldName: string): boolean => { + if (!perms?.isLoaded || !objectName) return true; + return perms.checkField(objectName, fieldName, 'read'); + }; const expandColumns = schemaColumns ?? schemaFields; const expand = buildExpandFields( resolvedSchema?.fields, expandColumns ? [...(expandColumns as any[]), ...groupingFieldRefs] : undefined, - ); + ).filter(expandReadable); if (expand.length > 0) { params.$expand = expand; } diff --git a/packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx b/packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx new file mode 100644 index 000000000..f541c49e1 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx @@ -0,0 +1,238 @@ +/** + * 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#7215 — field-level security on the `$expand` PROJECTION. + * + * ## The half objectui#6898 did not close + * + * objectui#6898 gated `$select`. `$expand` was left ungated at both projection + * sites: `buildExpandFields` was handed the RAW column list, so a `lookup` / + * `master_detail` / `user` / `tree` column the principal cannot read was still + * expanded. `$select` on a denied lookup asks for a bare foreign key; `$expand` + * on the same field asks the server to RESOLVE it and return the related + * record — the larger of the two disclosures was the ungated one. + * + * ## Grading, measured rather than assumed — same as objectui#6898 + * + * Against ObjectStack's own server this is defence-in-depth, not a live leak, + * and for the same mechanism the #6898 comment records: `plugin-security`'s + * read middleware runs `FieldMasker.maskResults`, whose `maskRecord` does + * `delete result[field]` on every unreadable key, and objectql's expand path + * writes the resolved record back under THAT SAME KEY + * (`record[fieldName] = recordMap.get(...)` in `engine.ts`), so the expanded + * object is deleted by the same statement that deletes the bare id. The + * expansion sub-read is itself gated (`__expandRead` takes the referenced + * object's full CRUD + RLS + FLS treatment since objectstack#7626), so nothing + * is disclosed on that path either. It is load-bearing for a backend that does + * not strip — the same argument objectui#6723 / #6799 / #6898 accepted. + * + * ## Why the gate goes on the OUTPUT of `buildExpandFields`, not its input + * + * The card's suggested route was to filter the COLUMN LIST before it reaches + * `buildExpandFields`. Measured here, that route is unsound in two directions, + * and both are pinned below: + * + * - `buildExpandFields` reads an EMPTY column list as "no column restriction" + * (`columns.length > 0` guards the intersection) and falls back to expanding + * EVERY declared relation. So a view whose only relational column is denied + * would have its input gated down to `[]` and its `$expand` WIDENED from the + * one denied field to all of them — PIN 6. + * - a view that declares no columns at all passes `undefined` and never had an + * input to gate — PIN 5. + * + * Gating the output satisfies the ordering requirement the card states + * (intersect against the object's DECLARED fields first, ask `checkField` only + * about survivors) structurally rather than by convention: `buildExpandFields` + * returns a subset of the declared reference-bearing fields, so every name the + * gate judges is declared by construction and the "`checkField` answers false + * for an undeclared key" trap cannot be reached — PIN 4. + * + * ## Why the stub `checkField` is an ALLOWLIST + * + * Inherited from `projectionFls-6898.test.tsx` for its reason: + * `PermissionProvider` answers `true` for a field no policy mentions, so under + * it the undeclared-key limit would be green in both worlds for the wrong + * reason. The stub models a server that ENUMERATES readable fields. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import React from 'react'; + +/** Stable stub identity — `ObjectGrid` carries `perms` in memo dep arrays. */ +const { permsStub, state } = vi.hoisted(() => { + const state: { isLoaded: boolean; readable: string[] } = { + isLoaded: true, + readable: [], + }; + 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, + }, + }; +}); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, usePermissions: () => permsStub as any }; +}); + +import { ObjectGrid } from '../ObjectGrid'; +import { ActionProvider } from '@object-ui/react'; + +const OBJECT = 'opportunity'; + +/** + * Two relations of DIFFERENT declared types, so the pins cover the family + * rather than one spelling: `account` is the readable `lookup` control and + * `owner_dept` the denied `master_detail`. `secret_account` is the denied + * `lookup` — the field under test. `computed_score` is deliberately NOT + * declared: the derived / host-joined key the ordering limit protects. + */ +const OBJECT_FIELDS = { + name: { type: 'text', label: 'Name' }, + stage: { type: 'select', label: 'Stage' }, + account: { type: 'lookup', reference: 'accounts', label: 'Account' }, + secret_account: { type: 'lookup', reference: 'accounts', label: 'Secret Account' }, + owner_dept: { type: 'master_detail', reference: 'departments', label: 'Dept' }, +}; + +const makeDataSource = () => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })), +}); + +/** Render a grid and return the `$expand` it actually asked the server for. */ +const expandFor = async (schemaExtra: Record): Promise => { + const ds = makeDataSource(); + const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaExtra }; + render( + + + , + ); + await vi.waitFor(() => expect(ds.find).toHaveBeenCalled()); + return (ds.find.mock.calls.at(-1)?.[1]?.$expand ?? []) as string[]; +}; + +beforeEach(() => { + vi.clearAllMocks(); + state.isLoaded = true; + state.readable = []; +}); +afterEach(() => cleanup()); + +describe('ObjectGrid — `$expand` is FLS-gated (objectui#7215)', () => { + // ── PIN 1: the defect itself ──────────────────────────────────────────── + it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => { + state.readable = ['name', 'account', 'id']; + const expand = await expandFor({ columns: ['name', 'account', 'secret_account'] }); + expect( + expand, + 'objectui#6898 closed `$select` on this same field; `$expand` asks the server to ' + + 'RESOLVE it and hand back the related record, which is the larger disclosure', + ).not.toContain('secret_account'); + }); + + // ── PIN 2: the live control — the gate narrows, it never empties ──────── + it('still expands a lookup the principal CAN read', async () => { + state.readable = ['name', 'account', 'id']; + const expand = await expandFor({ columns: ['name', 'account', 'secret_account'] }); + expect( + expand, + 'a gate that killed all expansion would turn every related cell into a bare id — ' + + 'the "8UY9zHWBfjYjYor4 instead of Initech Solutions" failure this codebase already records', + ).toContain('account'); + }); + + // ── PIN 3: `master_detail`, not only `lookup` ─────────────────────────── + it('gates a denied `master_detail` root too, not only `lookup`', async () => { + state.readable = ['name', 'account', 'id']; + const expand = await expandFor({ columns: ['name', 'account', 'owner_dept'] }); + expect(expand).not.toContain('owner_dept'); + expect(expand).toContain('account'); + }); + + // ── PIN 4: THE ORDERING LIMIT — an undeclared column is not judged ────── + it('leaves an UNDECLARED (derived / host-joined) column alone and keeps expanding', async () => { + state.readable = ['name', 'account', 'id']; + const expand = await expandFor({ columns: ['name', 'computed_score', 'account'] }); + expect( + expand, + '`checkField` answers false for a key no policy mentions, so a gate applied in the ' + + 'wrong order would drop the derived column and, with it, the whole expansion', + ).toEqual(['account']); + }); + + // ── PIN 5: reachable with NO column list at all ───────────────────────── + it('gates the no-columns case, where every declared relation is expanded', async () => { + state.readable = ['name', 'account', 'id']; + const expand = await expandFor({}); + expect( + expand, + 'with no `columns` the helper expands EVERY declared relation, so there is no input ' + + 'list to gate — this is the case an input-side filter cannot reach at all', + ).toEqual(['account']); + }); + + // ── PIN 6: the trap — gating the INPUT to empty WIDENS the expansion ──── + it('does not WIDEN to every relation when the only relational column is denied', async () => { + state.readable = ['name', 'id']; + const expand = await expandFor({ columns: ['name', 'secret_account'] }); + expect( + expand, + '`buildExpandFields` reads an empty column list as "no column restriction" and falls ' + + 'back to every declared relation, so a gate applied to its INPUT turns one denied ' + + 'expansion into all of them', + ).toEqual([]); + }); + + // ── PIN 7: the grouping augmentation rides the same gate ─────────────── + it('gates a denied relation reached through `grouping.fields[]`', async () => { + state.readable = ['name', 'account', 'id']; + const expand = await expandFor({ + columns: ['name', 'account'], + grouping: { fields: [{ field: 'secret_account', order: 'asc', collapsed: false }] }, + }); + expect( + expand, + 'objectui#7179 unions the grouping fields into the expand column list; that union is ' + + 'reached by the same principal and takes the same gate', + ).not.toContain('secret_account'); + expect(expand).toContain('account'); + }); + + // ── PIN 8: deferral — an unanswered policy filters nothing ───────────── + it('filters NOTHING while `/me/permissions` has not answered', async () => { + state.isLoaded = false; + state.readable = []; + const expand = await expandFor({ columns: ['name', 'account', 'secret_account'] }); + expect( + expand, + 'never filter on an unanswered policy — the no-provider default is `isLoaded: false` ' + + 'forever, and a grid with no PermissionProvider must keep expanding', + ).toEqual(expect.arrayContaining(['account', 'secret_account'])); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 55f0c698c..3cd9a841c 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -1427,6 +1427,17 @@ export const ListView = React.forwardRef(({ return () => { isMounted = false; }; }, [schema.objectName, dataSource]); + // Permissions context — must be read before the `$expand` memo below AND + // before the data-fetch effect, so both can FLS-gate what they ask the server + // for (preventing it from returning denied fields). Also feeds the column-list + // gate further down the file. + // + // ⚠️ The position is load-bearing, not cosmetic: `useMemo` runs its callback + // DURING the render that declares it, so a memo above this line that read + // `perms` would hit the temporal dead zone and throw + // `Cannot access 'perms' before initialization` — not a stale value, a crash. + const perms = usePermissions(); + // Auto-compute $expand fields from objectDef (lookup / master_detail). // // Important: include not only the user-declared `schema.columns` (table @@ -1485,13 +1496,48 @@ export const ListView = React.forwardRef(({ // ("list view shows 'Initech Solutions' but kanban used to show // '8UY9zHWBfjYjYor4'"). Better than one `(empty)` bucket, still wrong. // - // Unguarded is safe HERE and only here: `buildExpandFields` returns a - // subset of the object's declared reference-bearing fields, so a grouping - // field the object does not have — or has as a non-relation — is dropped - // structurally. The `$select` half below needs a real gate, and takes one. + // Unguarded AGAINST UNKNOWN KEYS is safe here, and here only: + // `buildExpandFields` returns a subset of the object's declared + // reference-bearing fields, so a grouping field the object does not have — + // or has as a non-relation — is dropped structurally. The `$select` half + // below needs a real gate for that, and takes one. It is NOT unguarded + // against FLS: that gate is on this helper's OUTPUT, below (objectui#7215), + // where every route into the expand list — columns, view bindings and this + // grouping union alike — passes through it exactly once. for (const f of collectGroupingFieldRefs(groupingConfig)) collected.add(f); const augmented = collected.size > 0 ? Array.from(collected) : undefined; - return buildExpandFields(objectDef?.fields, augmented); + const expandable = buildExpandFields(objectDef?.fields, augmented); + // [objectui#7215] FIELD-LEVEL SECURITY ON `$expand`, the half objectui#6898 + // left open on both projection sites. `$select` on a denied lookup asks for + // its BARE FOREIGN KEY; `$expand` asks the server to RESOLVE it and return + // the related record, so the larger disclosure was the ungated one. + // + // ON THIS SITE IT ALSO REOPENED `$select`, which is not a second defect but + // the measured reach of this one: the projection below gates the columns + // (`rawCols.filter(c => perms.checkField(...))`) and then adds these roots + // back unconditionally — `for (const e of expandFields) required.add(e)`, + // on the ground that they are "known-valid because `buildExpandFields()` + // derived them from the object schema". Valid, yes; READABLE, never asked. + // A denied lookup column walked straight back through that union, so + // objectui#6898's gate was being defeated here by the expand roots rather + // than by its own filter. Gating at this single point closes both halves. + // + // ⭐ THE GATE GOES ON THE OUTPUT, NOT ON `augmented`, for two measured + // reasons (`__tests__/ListView.expandFls-7215.test.tsx` pins both): + // `buildExpandFields` reads an EMPTY column list as "no column restriction" + // and falls back to every declared relation, so gating its INPUT would + // WIDEN a view whose collected columns are all denied from one expansion to + // all of them; and the no-columns case passes `undefined`, which has no + // input to gate at all. Gating the output also gives the required ordering + // structurally — this helper returns a subset of the object's DECLARED + // reference-bearing fields, so every name judged here is declared and the + // "`checkField` answers false for an undeclared key" trap is unreachable. + // + // An unanswered policy filters nothing, exactly as the `$select` gate + // defers; `perms` is in the dep list so the expansion is rebuilt the moment + // the answer arrives, and the fetch effect already depends on `perms` too. + if (!perms?.isLoaded || !schema.objectName) return expandable; + return expandable.filter((f) => perms.checkField(schema.objectName!, f, 'read')); }, [ objectDef?.fields, groupingConfig, @@ -1502,14 +1548,10 @@ export const ListView = React.forwardRef(({ (schema as any).timeline, (schema as any).gantt, (schema as any).options, + perms, + schema.objectName, ]); - // Permissions context — must be read before the data-fetch effect so - // the effect can FLS-gate the `$select` projection (preventing the - // server from returning denied fields). Also feeds the column-list - // gate further down the file. - const perms = usePermissions(); - // A gantt view whose `data` names the api provider is fed by a composite // endpoint that ObjectGantt resolves itself (resolveDataSource → // ApiDataSource, reads AND write-backs). ListView must neither fetch diff --git a/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx b/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx new file mode 100644 index 000000000..cfe24c76d --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx @@ -0,0 +1,237 @@ +/** + * 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#7215 — field-level security on `$expand`, on the SECOND projection + * site. + * + * `plugin-grid`'s `expandFls-7215.test.tsx` pins the first. The two exist + * separately for the reason objectui#7179 measured: `ObjectGrid` builds its own + * query when it fetches for itself, `ListView` builds one when it fetches and + * hands the rows down — independent code paths, and a fix landing on one of + * them ships with a green suite and a still-open hole on the other mounting + * path. The ablation in the PR body demonstrates that property rather than + * asserting it. + * + * ## This site leaks the denied field into `$select` as well + * + * Not a second card — the same one, measured. This builder's `$select` IS + * FLS-gated (`rawCols.filter(c => perms.checkField(...))`), and then adds the + * expand roots back unconditionally: `for (const e of expandFields) + * required.add(e)`, on the stated ground that those roots are "known-valid + * because `buildExpandFields()` derived them from the object schema". Valid, + * yes; READABLE, never asked. So a denied lookup column walked back through + * that union into `$select`, defeating objectui#6898's gate on this path. + * Gating `expandFields` closes both halves at once — PIN 3. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; + +/** Stable stub identity — the memo and the fetch effect carry `perms` in deps. */ +const { permsStub, state } = vi.hoisted(() => { + const state: { isLoaded: boolean; readable: string[] } = { + isLoaded: true, + readable: [], + }; + 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, + }, + }; +}); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, usePermissions: () => permsStub as any }; +}); + +import { ListView } from '../ListView'; +import { SchemaRendererProvider } from '@object-ui/react'; + +const OBJECT = 'duly_task'; + +/** + * `account` is the readable `lookup` control, `secret_account` the denied one, + * `owner_dept` a denied `master_detail` so the pins cover the family rather + * than one spelling. `computed_score` is deliberately NOT declared. + */ +const objectDef = { + name: OBJECT, + label: 'Task', + fields: { + id: { name: 'id', type: 'text' }, + subject: { name: 'subject', type: 'text', label: 'Subject' }, + account: { name: 'account', type: 'lookup', reference: 'accounts', label: 'Account' }, + secret_account: { name: 'secret_account', type: 'lookup', reference: 'accounts', label: 'Secret' }, + owner_dept: { name: 'owner_dept', type: 'master_detail', reference: 'departments', label: 'Dept' }, + }, +}; + +const makeDataSource = () => + ({ + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => objectDef), + }) as any; + +/** Mount a list view and return the params it actually asked the server for. */ +async function paramsFor(schemaExtra: Record) { + const dataSource = makeDataSource(); + const schema: any = { + type: 'list-view', + objectName: OBJECT, + viewType: 'grid', + ...schemaExtra, + }; + render( + + + , + ); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + const call = dataSource.find.mock.calls.at(-1)?.[1] ?? {}; + return { + select: (call.$select ?? []) as string[], + expand: (call.$expand ?? []) as string[], + }; +} + +const expandFor = async (schemaExtra: Record) => + (await paramsFor(schemaExtra)).expand; + +beforeEach(() => { + vi.clearAllMocks(); + state.isLoaded = true; + state.readable = []; +}); +afterEach(() => cleanup()); + +describe('ListView — `$expand` is FLS-gated (objectui#7215)', () => { + // ── PIN 1: the defect itself, on the second build site ───────────────── + it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => { + state.readable = ['subject', 'account', 'id']; + const expand = await expandFor({ columns: ['subject', 'account', 'secret_account'] }); + expect( + expand, + 'the two projection builders are independent paths — closing only the grid leaves this ' + + 'one open on every mounting that fetches through ListView', + ).not.toContain('secret_account'); + }); + + // ── PIN 2: the live control ──────────────────────────────────────────── + it('still expands a lookup the principal CAN read', async () => { + state.readable = ['subject', 'account', 'id']; + const expand = await expandFor({ columns: ['subject', 'account', 'secret_account'] }); + expect(expand).toContain('account'); + }); + + // ── PIN 3: the denied field walked back into `$select` too ───────────── + it('stops the denied field re-entering `$select` through the expand-roots union', async () => { + state.readable = ['subject', 'account', 'id']; + const { select } = await paramsFor({ columns: ['subject', 'account', 'secret_account'] }); + expect( + select, + 'the `$select` gate drops the denied column and then `for (const e of expandFields) ' + + 'required.add(e)` puts it straight back — objectui#6898 is defeated on this path ' + + 'by the ungated expand roots, not by its own filter', + ).not.toContain('secret_account'); + expect(select).toContain('account'); + }); + + // ── PIN 4: `master_detail`, not only `lookup` ────────────────────────── + it('gates a denied `master_detail` root too', async () => { + state.readable = ['subject', 'account', 'id']; + const expand = await expandFor({ columns: ['subject', 'account', 'owner_dept'] }); + expect(expand).not.toContain('owner_dept'); + expect(expand).toContain('account'); + }); + + // ── PIN 5: THE ORDERING LIMIT ────────────────────────────────────────── + it('leaves an UNDECLARED (derived / host-joined) column alone and keeps expanding', async () => { + state.readable = ['subject', 'account', 'id']; + const expand = await expandFor({ columns: ['subject', 'computed_score', 'account'] }); + expect( + expand, + '`checkField` answers false for a key no policy mentions; a gate in the wrong order ' + + 'would judge the derived column and take the expansion down with it', + ).toEqual(['account']); + }); + + // ── PIN 6: reachable with no relational column named at all ──────────── + it('gates the no-columns case, where every declared relation is expanded', async () => { + state.readable = ['subject', 'account', 'id']; + const expand = await expandFor({}); + expect(expand).toEqual(['account']); + }); + + // ── PIN 7: the trap — gating the INPUT to empty WIDENS the expansion ─── + it('does not WIDEN to every relation when every collected column is denied', async () => { + state.readable = ['subject', 'id']; + const expand = await expandFor({ columns: ['secret_account'] }); + expect( + expand, + '`buildExpandFields` reads an empty column list as "no column restriction", so a gate ' + + 'applied to its INPUT turns one denied expansion into every relation the object has', + ).toEqual([]); + }); + + // ── PIN 8: the view bindings reach the same helper ───────────────────── + it('gates a denied relation reached through a kanban card binding', async () => { + state.readable = ['subject', 'account', 'id']; + const expand = await expandFor({ + columns: ['subject', 'account'], + kanban: { groupByField: 'secret_account' }, + }); + expect( + expand, + 'this memo collects the alternate views\' field bindings as well as `columns`; every ' + + 'route into the expand list is the same principal asking for the same record', + ).not.toContain('secret_account'); + expect(expand).toContain('account'); + }); + + // ── PIN 9: the grouping augmentation rides the same gate ─────────────── + it('gates a denied relation reached through `grouping.fields[]`', async () => { + state.readable = ['subject', 'account', 'id']; + const expand = await expandFor({ + columns: ['subject', 'account'], + grouping: { fields: [{ field: 'secret_account', order: 'asc', collapsed: false }] }, + }); + expect(expand).not.toContain('secret_account'); + expect(expand).toContain('account'); + }); + + // ── PIN 10: deferral — an unanswered policy filters nothing ──────────── + it('filters NOTHING while `/me/permissions` has not answered', async () => { + state.isLoaded = false; + state.readable = []; + const expand = await expandFor({ columns: ['subject', 'account', 'secret_account'] }); + expect( + expand, + 'never filter on an unanswered policy — a list with no PermissionProvider must keep ' + + 'expanding, or every related cell degrades to a bare id', + ).toEqual(expect.arrayContaining(['account', 'secret_account'])); + }); +});