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 eaab12112..9b3d4fe30 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -1824,11 +1824,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 fc75f4603..02f6e7df2 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'])); + }); +});