From 7600df62eec03ad16ebb6f8cd1eaf39a09ad5535 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 01:25:41 +0000 Subject: [PATCH 1/2] fix(plugin-calendar,plugin-gantt,plugin-detail,plugin-dashboard,app-shell): FLS-gate `$expand` at the five remaining build sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#7215 / PR #7229 FLS-gated the `$expand` projection at the two sites in its scope. `buildExpandFields` (and `computeLookupExpand`, the dashboard's own whitelist) are reached from more places; this closes the five the card names. Three of them — calendar, gantt and the record page — pass NO column list, so the helper falls back to every declared relation on the object, denied ones included: the maximal ask, by default rather than by configuration. DetailView was INPUT-gated, which is the route #7229 measured as unsound: an emptied column list reads as "no restriction" and WIDENS the request. The gate is on the helper's OUTPUT at every site, copied from #7229 rather than re-derived, so the "checkField answers false for an undeclared key" trap stays structurally unreachable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .../RecordDetailView.expandFls-7230.test.tsx | 279 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 52 +++- packages/plugin-calendar/package.json | 1 + .../plugin-calendar/src/ObjectCalendar.tsx | 51 +++- .../ObjectCalendar.expandFls-7230.test.tsx | 227 ++++++++++++++ packages/plugin-dashboard/package.json | 1 + .../plugin-dashboard/src/ObjectDataTable.tsx | 42 ++- .../ObjectDataTable.expandFls-7230.test.tsx | 228 ++++++++++++++ packages/plugin-detail/src/DetailView.tsx | 47 ++- .../DetailView.expandFls-7230.test.tsx | 259 ++++++++++++++++ packages/plugin-gantt/package.json | 1 + .../src/ObjectGantt.expandFls-7230.test.tsx | 206 +++++++++++++ packages/plugin-gantt/src/ObjectGantt.tsx | 38 ++- pnpm-lock.yaml | 9 + 14 files changed, 1429 insertions(+), 12 deletions(-) create mode 100644 packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx create mode 100644 packages/plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx create mode 100644 packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx diff --git a/packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx b/packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx new file mode 100644 index 0000000000..1a3e86b15e --- /dev/null +++ b/packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx @@ -0,0 +1,279 @@ +/** + * 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#7230 — field-level security on the record page's `$expand`. + * + * ## The site + * + * `RecordDetailView` loads the record backing an assigned or synthesized page + * with + * + * const expandFields = buildExpandFields(objectDef?.fields); + * + * — **no column list**. `buildExpandFields` reads an absent column list as "no + * column restriction" and falls back to **every declared relation on the + * object**, denied ones included. So every record page in the console asks the + * server to resolve the object's full relation set, by default rather than by + * configuration. objectui#7215 / PR #7229 gated the two projection sites in its + * scope; this call site was outside it. + * + * ## Grading — the same reading #7215 recorded, not a stronger claim + * + * Against ObjectStack's own server this is defence-in-depth, not a live + * disclosure: `plugin-security`'s `FieldMasker.maskRecord` does + * `delete result[field]` on every unreadable key and objectql writes the + * expanded record back under THAT SAME KEY, so one statement removes the + * expanded object and the bare id alike; the expansion sub-read takes the + * referenced object's full CRUD + RLS + FLS treatment (objectstack#7626). It is + * load-bearing for a backend that does not strip, and the client-request side + * is real either way. + * + * ## The gate is on the OUTPUT — copied from #7229 + * + * There is no input to gate (the call passes `undefined`), and the output + * contains only DECLARED reference-bearing fields, so the "`checkField` answers + * false for an undeclared key" trap is structurally unreachable. + * + * ⚠️ One structural note that is load-bearing rather than cosmetic: this + * component read `usePermissions()` ~670 lines BELOW this effect. The effect's + * dependency array is evaluated DURING render, so listing `perms` there while + * the binding was still declared below would throw + * `Cannot access 'perms' before initialization` — a crash, not a stale value. + * The hook call moved above the effect; the later destructure now reads that + * one value instead of calling the hook again. Same lesson PR #7229 recorded + * for `ListView`'s memo. + * + * The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`: the + * real provider answers `true` for any field no policy mentions. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; + +/** Stable stub identity — `perms` rides the record-load effect's dependency list. */ +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 }; +}); + +vi.mock('@object-ui/auth', () => ({ + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); +vi.mock('@object-ui/collaboration', () => ({ + useRecordPresence: () => ({ viewers: [], others: [] }), + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); +// Orthogonal chrome — this file observes the record query's parameters only. +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { RecordDetailView } from './RecordDetailView'; + +const OBJECT = 'os_7230_opportunity'; +const REC = 'rec-1'; + +/** + * `account` is the readable `lookup` control, `owner_dept` the denied + * `master_detail`, `secret_account` the denied `lookup` under test. + */ +const objectDef = { + name: OBJECT, + label: 'Opportunity', + managedBy: 'platform', + highlightFields: ['name'], + fields: { + id: { label: 'Id', type: 'text' }, + name: { label: 'Name', type: 'text' }, + stage: { label: 'Stage', type: 'text' }, + account: { label: 'Account', type: 'lookup', reference_to: 'accounts' }, + secret_account: { label: 'Secret Account', type: 'lookup', reference_to: 'accounts' }, + owner_dept: { label: 'Dept', type: 'master_detail', reference_to: 'departments' }, + }, +}; + +const RECORD = { id: REC, name: 'Big deal', stage: 'new' }; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })), + findOne: vi.fn(async (_name: string, id: string) => ({ ...RECORD, id })), + create: vi.fn(async (_o: string, row: any) => row), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: async (name: string) => ({ name, fields: objectDef.fields }), + } as Record; +} + +function makeMetadata() { + const pages: any[] = []; + return { + objects: [], pages, loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: async () => pages, getItem: async () => null, + getItemsByType: () => pages, + } as any; +} + +/** + * Mount the record page as a tenant with NO assigned page gets it (the metadata + * context carries none, so the page is synthesized) and hand back the `$expand` + * of the record query. + * + * The `waitFor` targets a real recorded `findOne` for THIS object, so a page + * that stopped loading its record times out rather than reading as an empty + * expansion. + */ +async function expandFor(): Promise { + const ds = makeDataSource(); + render( + + + {}} + objectNameOverride={OBJECT} + recordIdOverride={REC} + embedded + /> + + , + ); + await waitFor(() => + expect(ds.findOne.mock.calls.some((c: any[]) => c[0] === OBJECT)).toBe(true)); + const call = ds.findOne.mock.calls.filter((c: any[]) => c[0] === OBJECT).at(-1); + return (call?.[2]?.$expand ?? []) as string[]; +} + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + state.isLoaded = true; + state.readable = []; + vi.spyOn(console, 'error').mockImplementation(() => {}); + // Unrelated chrome (approvals, favourites, row-level verdicts) reaches for + // the platform API; happy-dom would resolve those relative URLs to a real + // socket, which the repo's network-escape guard fails the file for + // (objectui#6640). Serve them from a double — none of it is what this file + // observes. + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ allowed: true, data: [] }), + text: async () => '{}', + })) as never); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('RecordDetailView — `$expand` is FLS-gated (objectui#7230)', () => { + // ── PIN 1: the defect itself ──────────────────────────────────────────── + it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => { + state.readable = ['id', 'name', 'stage', 'account']; + const expand = await expandFor(); + expect( + expand, + 'with no column list the record page expands EVERY declared relation, so a denied ' + + 'lookup is asked for on every record page by default', + ).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 = ['id', 'name', 'stage', 'account']; + const expand = await expandFor(); + expect( + expand, + 'the page subtitle interpolation and the `record:*` renderers depend on the expanded ' + + 'display name; a gate that emptied the expansion would show raw ids instead', + ).toContain('account'); + }); + + // ── PIN 3: `master_detail`, not only `lookup` ─────────────────────────── + it('gates a denied `master_detail` root too, not only `lookup`', async () => { + state.readable = ['id', 'name', 'stage', 'account']; + const expand = await expandFor(); + expect(expand).not.toContain('owner_dept'); + expect(expand).toContain('account'); + }); + + // ── PIN 4: the whole set, asserted exactly ───────────────────────────── + it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => { + state.readable = ['id', 'name', 'stage', 'account']; + const expand = await expandFor(); + expect( + expand.slice().sort(), + 'an absence assertion alone would also pass if the expansion had gone empty', + ).toEqual(['account']); + }); + + // ── PIN 5: every relation denied → no `$expand` at all ───────────────── + it('omits `$expand` entirely when every declared relation is denied', async () => { + state.readable = ['id', 'name', 'stage']; + const expand = await expandFor(); + expect(expand).toEqual([]); + }); + + // ── PIN 6: 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(); + expect( + expand, + 'never filter on an unanswered policy — the no-provider default is `isLoaded: false` ' + + 'forever, and a console with no PermissionProvider must keep expanding', + ).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept'])); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 8f589f1867..275122064c 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -389,6 +389,20 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri 'idle' | 'loading' | 'loaded' | 'missing' >('idle'); + // Permissions context. + // + // ⚠️ [objectui#7230] THE POSITION IS LOAD-BEARING, not cosmetic. This used to + // be a `usePermissions()` call ~670 lines below, next to the header's + // Edit/Delete gates. The record-load effect immediately after this line now + // FLS-gates its `$expand`, and an effect's DEPENDENCY ARRAY is evaluated + // DURING render — so listing `perms` there while the binding was still + // declared below would hit the temporal dead zone and throw + // `Cannot access 'perms' before initialization`: a crash, not a stale value. + // The hook moved up; the site below destructures THIS value instead of + // calling the hook a second time, so the hook order is unchanged in shape. + // Same structural note PR #7229 recorded for `ListView`'s memo. + const perms = usePermissions(); + useEffect(() => { let cancelled = false; if (!effectivePage || !pureRecordId || !objectName || !dataSource?.findOne) { @@ -399,7 +413,39 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // Expand lookup/master_detail fields so the page receives display // names (e.g. account.name) rather than raw foreign-key IDs. The // page subtitle interpolation and record:* renderers depend on this. - const expandFields = buildExpandFields(objectDef?.fields); + // + // [objectui#7230] FIELD-LEVEL SECURITY ON `$expand`, the gate + // objectui#7215 / PR #7229 put on the two projection sites in its scope. + // `$select` on a denied lookup asks the server for a bare foreign key; + // `$expand` asks it to RESOLVE the relation and hand back the related + // record — the larger of the two requests. + // + // ⚠️ NO COLUMN LIST IS PASSED HERE, which makes this the sharpest of the + // family: `buildExpandFields` reads an absent column list as "no column + // restriction" and falls back to EVERY declared relation on the object, + // denied ones included. Every record page in the console therefore asked + // for the object's full relation set by default, not by configuration. + // + // Graded as objectui#7215 graded it, 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, so one statement removes the expanded object and the bare id + // alike; the expansion sub-read itself takes the referenced object's full + // CRUD + RLS + FLS treatment (objectstack#7626). It is load-bearing for a + // backend that does not strip. + // + // ⭐ THE GATE IS ON THE HELPER'S OUTPUT. There is no input to gate on this + // site, and the output holds only DECLARED reference-bearing fields, so the + // "`checkField` answers false for an undeclared key" trap is structurally + // unreachable and a derived / host-joined key is never judged. An + // unanswered policy filters nothing; `perms` is in this effect's dependency + // list, so the record is re-read the moment the answer arrives. Pinned in + // `RecordDetailView.expandFls-7230.test.tsx`. + const expandable = buildExpandFields(objectDef?.fields); + const expandFields = !perms?.isLoaded + ? expandable + : expandable.filter((f) => perms.checkField(objectName, f, 'read')); const params = expandFields.length > 0 ? { $expand: expandFields } : undefined; const loadRecord = () => { setPageRecordStatus('loading'); @@ -441,7 +487,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }; // #2269: recordInvalidationNonce re-runs this fetch in place whenever the // record (or its object) is invalidated on the bus. - }, [effectivePage, objectName, pureRecordId, dataSource, objectDef, recordInvalidationNonce]); + }, [effectivePage, objectName, pureRecordId, dataSource, objectDef, recordInvalidationNonce, perms]); // Derive a human-readable record title from the loaded `pageRecord` so // favourites (record:*) and the breadcrumb show e.g. "Acme Corporation" @@ -1076,7 +1122,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // are still loading (`isLoaded === false`, e.g. no PermissionProvider in a // standalone embed) the gate stays open — fail-open is safe because the // server enforces data access regardless; this is purely a UI/DX filter. - const { can: canOnObject, isLoaded: permissionsLoaded, getObjectApiOperations, systemPermissions } = usePermissions(); + const { can: canOnObject, isLoaded: permissionsLoaded, getObjectApiOperations, systemPermissions } = perms; // [#3546] Server-resolved effective API operation set for this object // (`/me/permissions` `apiOperations`). Threaded as the 2nd arg into // `resolveRecordHeaderActionGates` for the detail header's Edit/Delete and diff --git a/packages/plugin-calendar/package.json b/packages/plugin-calendar/package.json index b2b8d67e1e..2bedaea496 100644 --- a/packages/plugin-calendar/package.json +++ b/packages/plugin-calendar/package.json @@ -36,6 +36,7 @@ "@object-ui/fields": "workspace:*", "@object-ui/i18n": "workspace:*", "@object-ui/mobile": "workspace:*", + "@object-ui/permissions": "workspace:*", "@object-ui/plugin-detail": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 459c85d2e7..499b6c9329 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -34,6 +34,7 @@ import { declaredUserMessage, } from '@object-ui/react'; import { RecordDetailDrawer, deriveRecordPageHref } from '@object-ui/plugin-detail'; +import { usePermissions } from '@object-ui/permissions'; import { useIsMobile, Dialog, @@ -285,6 +286,13 @@ export const ObjectCalendar: React.FC = ({ const objectSchemaReady = schemaResolution !== null && schemaResolution.key === schemaKey; const objectSchema = objectSchemaReady ? schemaResolution.def : null; + // Permissions context, read here rather than inside the fetch effect below: + // an effect's DEPENDENCY ARRAY is evaluated during render, so `perms` has to + // be a binding that already exists by the time this component's render + // reaches that effect (objectui#7230, same structural note PR #7229 recorded + // for `ListView`'s memo). + const perms = usePermissions(); + // Sync external data/loading changes from parent (e.g. ObjectView re-fetches after filter change) useEffect(() => { if (hasExternalData) { @@ -348,7 +356,46 @@ export const ObjectCalendar: React.FC = ({ // calendar whose object declares relations queries WITH its // expansion the first time. `objectSchema` is `null` here only // when there was nothing to resolve it from. - const expand = buildExpandFields(objectSchema?.fields); + // + // [objectui#7230] FIELD-LEVEL SECURITY ON `$expand` — the same gate + // objectui#7215 / PR #7229 put on the two projection sites in its + // scope, brought to this one. `$select` on a denied lookup asks the + // server for a bare foreign key; `$expand` asks it to RESOLVE the + // relation and return the related record, which is the larger of the + // two requests. + // + // ⚠️ THIS SITE PASSES NO COLUMN LIST, which makes it the sharp one: + // `buildExpandFields` reads an absent column list as "no column + // restriction" and falls back to EVERY declared relation on the + // object, denied ones included. A standalone calendar therefore asks + // for the maximum possible set by default, not by configuration. + // + // Graded as objectui#7215 graded it, 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, so + // one statement removes the expanded object and the bare id alike; + // the expansion sub-read itself takes the referenced object's full + // CRUD + RLS + FLS treatment (objectstack#7626). It is load-bearing + // for a backend that does not strip. + // + // ⭐ THE GATE IS ON THE HELPER'S OUTPUT, and on this site the + // alternative is not merely unsound but unreachable — the call passes + // `undefined`, so there is no input to gate. Gating the output also + // gives the required ordering structurally: `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 cannot be + // reached. Pinned in `__tests__/ObjectCalendar.expandFls-7230.test.tsx`. + // + // Deferral matches every other gate on this path: an unanswered + // policy filters nothing, and `perms` is in this effect's dependency + // list, so the expansion is rebuilt the moment the answer arrives. + const expandable = buildExpandFields(objectSchema?.fields); + const expand = !perms?.isLoaded + ? expandable + : expandable.filter((f) => perms.checkField(objectName, f, 'read')); const result = await dataSource.find(objectName, { $filter: schema.filter, $orderby: convertSortToQueryParams(schema.sort), @@ -378,7 +425,7 @@ export const ObjectCalendar: React.FC = ({ fetchData(); return () => { isMounted = false; }; }, [hasExternalData, dataProvider, schemaObjectName, dataItems, dataSource, hasInlineData, - schema.filter, schema.sort, refreshKey, objectSchemaReady, objectSchema]); + schema.filter, schema.sort, refreshKey, objectSchemaReady, objectSchema, perms]); // Fetch object schema for field metadata. // diff --git a/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx new file mode 100644 index 0000000000..80b625b1ad --- /dev/null +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandFls-7230.test.tsx @@ -0,0 +1,227 @@ +/** + * 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#7230 — field-level security on `ObjectCalendar`'s `$expand`. + * + * ## The site, and why it is the sharp one + * + * objectui#7215 / PR #7229 gated `$expand` at the two PROJECTION sites it + * owned (`ObjectGrid`, `ListView`). `buildExpandFields` is called from more + * places than that, and this is one of them. It passes **no column list at + * all**: + * + * const expand = buildExpandFields(objectSchema?.fields); + * + * `buildExpandFields` reads an absent column list as "no column restriction" + * and falls back to **every declared relation on the object**, denied ones + * included. So a standalone `object-calendar` does not merely fail to filter a + * column list — it has none, and therefore asks the server to resolve the + * maximum possible set of relations by default. That is the ordinary shape of + * this surface, not a corner of it. + * + * ## Grading — defence-in-depth, stated the same way #7215 stated it + * + * Against ObjectStack's own server this is not a live disclosure: + * `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, so one statement removes the expanded object and + * the bare id alike; the expansion sub-read itself takes the referenced + * object's full CRUD + RLS + FLS treatment (objectstack#7626). It is + * load-bearing for a backend that does not strip, and the client-request side + * is real regardless: this component asks the server to resolve relations the + * current principal cannot read. + * + * ## The gate goes on the OUTPUT of `buildExpandFields` — copied, not re-derived + * + * PR #7229 settled the shape and pinned both halves of the reasoning. On THIS + * site the input-gating route is not merely unsound, it is unreachable: the + * call passes `undefined`, so there is no input to gate. Gating the output also + * makes the "`checkField` answers false for an undeclared key" trap + * structurally unreachable, because `buildExpandFields` returns a subset of the + * object's DECLARED reference-bearing fields — every name the gate judges is + * declared by construction. + * + * ## Why the stub `checkField` is an ALLOWLIST + * + * Inherited from `expandFls-7215.test.tsx` for its reason: the real + * `PermissionProvider` answers `true` for a field no policy mentions, so a + * denial has to be modelled by a stub that ENUMERATES readable fields — the + * shape a server reporting field permissions actually has. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; + +/** Stable stub identity — `ObjectCalendar` carries `perms` in an effect dep list. */ +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 }; +}); + +// The calendar grid itself is orthogonal to what this file observes (the query +// parameters), and rendering it pulls a month of DOM per case. +vi.mock('../CalendarView', () => ({ + CalendarView: () =>
, +})); + +import { ObjectCalendar } from '../ObjectCalendar'; + +const OBJECT = 'visit'; + +/** + * Two relations of DIFFERENT declared types so the pins cover the family + * rather than one spelling: `account` is the readable `lookup` control, + * `owner_dept` the denied `master_detail`, `secret_account` the denied + * `lookup` under test. `starts_at` / `name` are the non-relations the helper + * drops structurally. + */ +const VISIT_FIELDS: Record = { + name: { type: 'text', label: 'Name' }, + starts_at: { type: 'datetime', label: 'Start' }, + account: { type: 'lookup', reference_to: 'account', label: 'Account' }, + secret_account: { type: 'lookup', reference_to: 'account', label: 'Secret Account' }, + owner_dept: { type: 'master_detail', reference_to: 'department', label: 'Dept' }, +}; + +const today = new Date(); +const IN_MONTH = new Date(today.getFullYear(), today.getMonth(), 8, 9, 0, 0, 0); +const ROW = { id: 'v1', name: 'Site visit', starts_at: IN_MONTH.toISOString() }; + +function makeAdapter() { + return { + find: vi.fn(async () => ({ value: [ROW] })), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: VISIT_FIELDS })), + } as Record; +} + +/** + * Render a standalone calendar and hand back the `$expand` it actually asked + * the server for. The `waitFor` targets a real recorded call, so "the calendar + * stopped fetching" times out rather than reading as an empty expansion. + */ +async function expandFor(): Promise { + const ds = makeAdapter(); + render( + , + ); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + // The schema settles a tick after mount and re-opens the gate; wait for the + // query issued with the schema in hand rather than reading the first call. + await waitFor(() => + expect(ds.getObjectSchema).toHaveBeenCalled()); + await waitFor(() => { + const last = ds.find.mock.calls.at(-1)?.[1] ?? {}; + expect(Object.prototype.hasOwnProperty.call(last, '$filter')).toBe(true); + }); + return (ds.find.mock.calls.at(-1)?.[1]?.$expand ?? []) as string[]; +} + +beforeEach(() => { + vi.clearAllMocks(); + state.isLoaded = true; + state.readable = []; + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('ObjectCalendar — `$expand` is FLS-gated (objectui#7230)', () => { + // ── PIN 1: the defect itself ──────────────────────────────────────────── + it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => { + state.readable = ['id', 'name', 'starts_at', 'account']; + const expand = await expandFor(); + expect( + expand, + 'with no column list this component expands EVERY declared relation, so a denied ' + + 'lookup is asked for by default rather than by configuration', + ).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 = ['id', 'name', 'starts_at', 'account']; + const expand = await expandFor(); + expect( + expand, + 'a gate that killed all expansion would paint raw foreign-key ids in every ' + + 'related field — the failure objectui#6453 fixed on this very component', + ).toContain('account'); + }); + + // ── PIN 3: `master_detail`, not only `lookup` ─────────────────────────── + it('gates a denied `master_detail` root too, not only `lookup`', async () => { + state.readable = ['id', 'name', 'starts_at', 'account']; + const expand = await expandFor(); + expect(expand).not.toContain('owner_dept'); + expect(expand).toContain('account'); + }); + + // ── PIN 4: the whole no-column-list set, asserted exactly ─────────────── + it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => { + state.readable = ['id', 'name', 'starts_at', 'account']; + const expand = await expandFor(); + expect( + expand.slice().sort(), + 'an absence assertion alone would also pass if the expansion had gone empty', + ).toEqual(['account']); + }); + + // ── PIN 5: every relation denied → no `$expand` at all, not a widened one ─ + it('omits `$expand` entirely when every declared relation is denied', async () => { + state.readable = ['id', 'name', 'starts_at']; + const expand = await expandFor(); + expect(expand).toEqual([]); + }); + + // ── PIN 6: 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(); + expect( + expand, + 'never filter on an unanswered policy — the no-provider default is `isLoaded: false` ' + + 'forever, and a calendar with no PermissionProvider must keep expanding', + ).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept'])); + }); +}); diff --git a/packages/plugin-dashboard/package.json b/packages/plugin-dashboard/package.json index f61e5e5c64..1eab9eb4bd 100644 --- a/packages/plugin-dashboard/package.json +++ b/packages/plugin-dashboard/package.json @@ -28,6 +28,7 @@ "@object-ui/core": "workspace:*", "@object-ui/fields": "workspace:*", "@object-ui/i18n": "workspace:*", + "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", "clsx": "^2.1.1", diff --git a/packages/plugin-dashboard/src/ObjectDataTable.tsx b/packages/plugin-dashboard/src/ObjectDataTable.tsx index 629f33e943..c9341a2cc9 100644 --- a/packages/plugin-dashboard/src/ObjectDataTable.tsx +++ b/packages/plugin-dashboard/src/ObjectDataTable.tsx @@ -18,6 +18,7 @@ import type { ObjectDataTableSchema, TableColumn } from '@object-ui/types'; import { normalizeTableColumnType } from '@object-ui/types'; import { Skeleton, RefreshIndicator, cn } from '@object-ui/components'; import { useSafeFieldLabel, useObjectTranslation, useLocalization, useDisplayLocale } from '@object-ui/i18n'; +import { usePermissions } from '@object-ui/permissions'; import { resolveFilterPlaceholders, humanizeFieldKey } from './utils'; import type { FieldMeta } from './recordFields'; import { @@ -651,6 +652,11 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo // cannot be called from inside it. const filterScope = useFilterScope(); + // Permissions context, read at component level: an effect's DEPENDENCY ARRAY + // is evaluated during render, so `perms` has to be a binding that already + // exists by the time render reaches the fetch effect below (objectui#7230). + const perms = usePermissions(); + useEffect(() => { let isMounted = true; @@ -672,7 +678,39 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo // If we know the schema, ask the server to expand lookup columns so // cells can render the related record's display name instead of a // bare FK id. Adapters that don't understand `$expand` ignore it. - const expand = computeLookupExpand(schema, objectSchema); + // + // [objectui#7230] FIELD-LEVEL SECURITY ON `$expand`, the gate + // objectui#7215 / PR #7229 put on the two projection sites in its + // scope. This widget does not call `buildExpandFields` — it builds + // its own whitelist in `computeLookupExpand` — but that helper has + // the same two-arm shape and therefore the same two exposures: the + // explicit-`columns` arm expanded a denied relation the author named, + // and the auto-derive arm (`cols.length > 0` false — the drill-down + // drawer, and any widget naming no columns) expands EVERY lookup-type + // field the object schema declares, denied ones included. + // + // ⭐ THE GATE IS ON THE OUTPUT, for the same structural reason it is + // everywhere else in this family: `computeLookupExpand` resolves both + // arms through `fieldsByName` — the object schema's own field map — + // so every name it returns is DECLARED by construction, and the + // "`checkField` answers false for an undeclared key" trap cannot be + // reached. Gating the INPUT would be unsound here too: `cols.length > + // 0` reads an emptied column list as "no restriction" and widens to + // every relation. Pinned in + // `__tests__/ObjectDataTable.expandFls-7230.test.tsx`. + // + // Graded as objectui#7215 graded it: defence-in-depth against + // ObjectStack's own server (`FieldMasker.maskRecord` deletes the very + // key objectql writes the expansion back under; the sub-read takes + // the referenced object's full CRUD + RLS + FLS, objectstack#7626), + // and load-bearing for a backend that does not strip. + // + // An unanswered policy filters nothing; `perms` is in this effect's + // dependency list, so the expansion is rebuilt when the answer lands. + const expandable = computeLookupExpand(schema, objectSchema); + const expand = !perms?.isLoaded + ? expandable + : expandable.filter((f) => perms.checkField(schema.objectName!, f, 'read')); const params: any = { $filter: resolveFilterPlaceholders(schema.filter, filterScope) }; if (expand.length) params.$expand = expand; const results = await dataSource.find(schema.objectName, params); @@ -703,7 +741,7 @@ export const ObjectDataTable: React.FC = ({ schema, dataSo } return () => { isMounted = false; }; - }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, objectSchema, filterScope]); + }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, objectSchema, filterScope, perms]); // Fetch object schema for column-header translation and select-option cell labels. useEffect(() => { diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx new file mode 100644 index 0000000000..cd44589cb0 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.expandFls-7230.test.tsx @@ -0,0 +1,228 @@ +/** + * 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#7230 — field-level security on `ObjectDataTable`'s `$expand`. + * + * ## The site, and why it needs its own file + * + * This is the one of the five that does NOT call `buildExpandFields`. It builds + * its own `$expand` whitelist in `computeLookupExpand`, and that helper has the + * SAME two-arm shape and therefore the same two exposures: + * + * - an explicit `columns` list → expand the relations the author named; + * - `cols.length > 0` false (auto-derive mode — the drill-down drawer, and + * any widget that names no columns) → expand EVERY lookup-type field the + * object schema declares. + * + * Neither arm asks whether the principal may READ the relation, so the second + * arm asks the server to resolve every declared relation on the object, denied + * ones included. + * + * ## The gate goes on the OUTPUT, for the same structural reason + * + * `computeLookupExpand` resolves both arms through `fieldsByName` — the object + * schema's own field map — so every name it returns is DECLARED by + * construction, exactly as `buildExpandFields`'s is. Gating its output + * therefore inherits PR #7229's property unchanged: the "`checkField` answers + * false for an undeclared key" trap is unreachable, and a derived / + * host-joined column is never judged. Gating the INPUT would be unsound here + * for the same measured reason as everywhere else — `cols.length > 0` reads an + * emptied column list as "no restriction" and widens to every relation. + * + * ## Grading + * + * Defence-in-depth against ObjectStack's own server, as objectui#7215 recorded + * (`FieldMasker.maskRecord` deletes the key the expansion is written back + * under; the sub-read takes the referenced object's own CRUD + RLS + FLS, + * objectstack#7626), and load-bearing for a backend that does not strip. + * + * The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; + +/** Stable stub identity — `perms` rides the fetch effect's dependency list. */ +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 { ObjectDataTable } from '../ObjectDataTable'; + +const OBJECT = 'opportunity'; + +/** + * `account` is the readable `lookup` control, `owner_dept` the denied + * `master_detail`, `secret_account` the denied `lookup` under test. + * `computed_score` is never declared here — the derived key the ordering limit + * protects. + */ +const OBJECT_FIELDS: Record = { + name: { type: 'text', label: 'Name' }, + amount: { type: 'currency', label: 'Amount' }, + 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 ROWS = [{ id: 'o1', name: 'Acme', amount: 10 }]; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: ROWS })), + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })), + } as Record; +} + +/** + * Render the widget and hand back the `$expand` it actually asked the server + * for. The `waitFor` targets a recorded `find`, so a widget that stopped + * fetching times out rather than reading as an empty expansion. + */ +async function expandFor(schemaExtra: Record): Promise { + const ds = makeDataSource(); + render( + , + ); + // ⚠️ Wait for the SCHEMA-DEPENDENT query, not the first one. `objectSchema` + // is component state and sits in the fetch effect's dependency list, so the + // widget issues one `find` before the schema lands (necessarily carrying no + // `$expand` — `computeLookupExpand` returns `[]` without a field map) and + // re-issues it after. Reading the first call would report "no expansion" for + // every case and turn this whole file green for the wrong reason. + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + await waitFor(() => expect(ds.find.mock.calls.length).toBeGreaterThan(1)); + return (ds.find.mock.calls.at(-1)?.[1]?.$expand ?? []) as string[]; +} + +const AUTHORED_COLUMNS = { + columns: [ + { field: 'name', label: 'Name' }, + { field: 'account', label: 'Account' }, + { field: 'secret_account', label: 'Secret Account' }, + { field: 'owner_dept', label: 'Dept' }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + state.isLoaded = true; + state.readable = []; + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('ObjectDataTable — `$expand` is FLS-gated (objectui#7230)', () => { + describe('the explicit-columns arm', () => { + // ── PIN 1: the defect ──────────────────────────────────────────────── + it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => { + state.readable = ['id', 'name', 'amount', 'account']; + const expand = await expandFor(AUTHORED_COLUMNS); + expect(expand).not.toContain('secret_account'); + }); + + // ── PIN 2: `master_detail`, not only `lookup` ─────────────────────── + it('gates a denied `master_detail` column too', async () => { + state.readable = ['id', 'name', 'amount', 'account']; + const expand = await expandFor(AUTHORED_COLUMNS); + expect(expand).not.toContain('owner_dept'); + }); + + // ── PIN 3: the live control ───────────────────────────────────────── + it('still expands a lookup the principal CAN read', async () => { + state.readable = ['id', 'name', 'amount', 'account']; + const expand = await expandFor(AUTHORED_COLUMNS); + expect( + expand, + 'a gate that killed all expansion would render a bare foreign-key id in every ' + + 'lookup cell — the failure `computeLookupExpand` exists to prevent', + ).toEqual(['account']); + }); + }); + + describe('the auto-derive arm — no columns, every declared relation expanded', () => { + // ── PIN 4: the sharp case ─────────────────────────────────────────── + it('gates the no-columns case, where every lookup-type field is expanded', async () => { + state.readable = ['id', 'name', 'amount', 'account']; + const expand = await expandFor({}); + expect( + expand, + 'with no `columns` the whitelist arm falls through to every declared relation, so ' + + 'there is no input list to gate — the case an input-side filter cannot reach', + ).toEqual(['account']); + }); + + // ── PIN 5: every relation denied → nothing expanded, not everything ── + it('sends no `$expand` when every declared relation is denied', async () => { + state.readable = ['id', 'name', 'amount']; + const expand = await expandFor({}); + expect(expand).toEqual([]); + }); + }); + + describe('limits', () => { + // ── PIN 6: THE ORDERING LIMIT — an undeclared column is not judged ─── + it('leaves an UNDECLARED (derived / host-joined) column alone and keeps expanding', async () => { + state.readable = ['id', 'name', 'amount', 'account']; + const expand = await expandFor({ + columns: [ + { field: 'name', label: 'Name' }, + { field: 'computed_score', label: 'Score' }, + { field: 'account', label: 'Account' }, + ], + }); + expect( + expand, + '`computeLookupExpand` resolves both arms through the object schema’s own field ' + + 'map, so a derived column is absent from its OUTPUT and is never judged', + ).toEqual(['account']); + }); + + // ── PIN 7: 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(AUTHORED_COLUMNS); + expect( + expand, + 'never filter on an unanswered policy — the no-provider default is `isLoaded: false` ' + + 'forever, and a dashboard table with no PermissionProvider must keep expanding', + ).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept'])); + }); + }); +}); diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx index d10f659449..49028b9836 100644 --- a/packages/plugin-detail/src/DetailView.tsx +++ b/packages/plugin-detail/src/DetailView.tsx @@ -522,8 +522,49 @@ export const DetailView: React.FC = ({ if (!isMounted) return; setObjectSchema(resolvedSchema); - // Compute $expand from objectSchema - const expandFields = buildExpandFields(resolvedSchema?.fields, allFields); + // Compute $expand from objectSchema. + // + // [objectui#7230] FIELD-LEVEL SECURITY ON `$expand`, the gate + // objectui#7215 / PR #7229 put on the two projection sites in its + // scope. `$select` on a denied lookup asks for a bare foreign key; + // `$expand` asks the server to RESOLVE the relation and return the + // related record — the larger of the two requests. + // + // ⚠️ THIS SITE WAS ALREADY INPUT-GATED, AND THAT IS THE DEFECT, not the + // fix. `allFields` is collected from `schema`, which is `gatedSchema` — + // already FLS-filtered field by field above. Filtering the INPUT is + // precisely the route PR #7229 measured as unsound, and here is what it + // costs: `buildExpandFields` reads an EMPTY column list as "no column + // restriction" and falls back to EVERY declared relation on the object. + // So a detail view whose authored fields are ALL denied had its column + // list gated down to `[]` and its `$expand` WIDENED from the relations + // it asked for to every relation the object declares — the principal + // who may read least asking for the most. The same widening is reached + // with no authored field list at all, where the input filter has + // nothing to remove and the expansion is maximal from the start. + // + // ⭐ SO THE GATE GOES ON THE HELPER'S OUTPUT. The input filter above + // stays — it is load-bearing for the RENDER half — but it is no longer + // what decides the projection. Gating the output also gives the + // required ordering structurally: `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 cannot be reached — a derived / + // host-joined column is never judged. Both halves are pinned in + // `__tests__/DetailView.expandFls-7230.test.tsx`. + // + // Graded as objectui#7215 graded it: defence-in-depth against + // ObjectStack's own server (`FieldMasker.maskRecord` deletes the very + // key objectql writes the expansion back under; the sub-read takes the + // referenced object's full CRUD + RLS + FLS, objectstack#7626), and + // load-bearing for a backend that does not strip. + // + // An unanswered policy filters nothing, exactly as `gatedSchema` above + // defers; `perms` is in this effect's dependency list. + const expandable = buildExpandFields(resolvedSchema?.fields, allFields); + const expandFields = !perms?.isLoaded + ? expandable + : expandable.filter((f) => perms.checkField(objectName, f, 'read')); const params = expandFields.length > 0 ? { $expand: expandFields } : undefined; const findOnePromise = params @@ -586,7 +627,7 @@ export const DetailView: React.FC = ({ } return () => { isMounted = false; }; - }, [schema.api, schema.resourceId, schema.objectName, dataSource, schema.sections, schema.fields, reloadTick, invalidationNonce]); + }, [schema.api, schema.resourceId, schema.objectName, dataSource, schema.sections, schema.fields, reloadTick, invalidationNonce, perms]); const handleBack = React.useCallback(() => { if (onBack) { diff --git a/packages/plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx b/packages/plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx new file mode 100644 index 0000000000..cc20cfb4a2 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/DetailView.expandFls-7230.test.tsx @@ -0,0 +1,259 @@ +/** + * 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#7230 — field-level security on `DetailView`'s `$expand`. + * + * ## This site is DIFFERENT from the other four, and the difference is measured + * + * The card lists this call as "passes a column list, ungated". Measured on + * `main`, that is half right and the other half is what makes it interesting: + * the list it passes is `allFields`, collected from `schema.sections` / + * `schema.fields` — and `schema` here is `gatedSchema`, which this component + * ALREADY FLS-filters field by field (`DetailView.tsx`, the `gatedSchema` memo, + * whose comment even names "$expand build" among the downstream uses it means + * to protect). + * + * So this site is not ungated. It is **INPUT-gated** — precisely the route PR + * #7229 measured as unsound and rejected. The consequence is not "a denied + * lookup slips through in the ordinary case"; it is worse and narrower: + * + * `buildExpandFields` reads an EMPTY column list as "no column restriction" + * and falls back to EVERY declared relation on the object. + * + * ⇒ Filtering the input therefore WIDENS the request in exactly the case where + * the principal may read least. A detail view whose authored fields are all + * denied has its column list gated down to `[]` and its `$expand` widened from + * "the relations it asked for" to "every relation the object declares", + * denied ones included. The same widening is reached with no authored field + * list at all (a synthesized/auto-derived detail view), where the input filter + * has nothing to remove and the expansion is maximal from the start. + * + * That is why the pins below are split into two groups, and why the split is + * stated rather than hidden: PIN 1 and PIN 2 are GREEN in both directions — + * they pin the property the input filter already delivers and this change must + * not lose — while PIN 3 and PIN 4 are the RED ones that carry this card. + * A green pin proves nothing on its own; naming which pins discriminate is the + * discipline `RecordDetailView.sectionHeadingsRenderPath-6190.test.tsx` records. + * + * ## The fix is the same OUTPUT gate as everywhere else + * + * Moving the gate to `buildExpandFields`'s output closes the widening without + * removing the input filter (which is load-bearing for the RENDER half), and it + * makes the "`checkField` answers false for an undeclared key" trap + * structurally unreachable — the helper returns only DECLARED reference-bearing + * fields. + * + * The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`: the real + * provider answers `true` for any field no policy mentions. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; + +/** Stable stub identity — `perms` rides the fetch effect's dependency list. */ +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 { DetailView } from '../DetailView'; + +const OBJECT = 'opportunity'; + +/** + * `account` is the readable `lookup` control, `owner_dept` the denied + * `master_detail`, `secret_account` the denied `lookup` under test. + * `computed_score` is deliberately NOT declared anywhere below — it is the + * derived / host-joined key the ordering limit protects. + */ +const OBJECT_FIELDS: Record = { + name: { type: 'text', label: 'Name' }, + stage: { type: 'select', label: 'Stage' }, + account: { type: 'lookup', reference_to: 'accounts', label: 'Account' }, + secret_account: { type: 'lookup', reference_to: 'accounts', label: 'Secret Account' }, + owner_dept: { type: 'master_detail', reference_to: 'departments', label: 'Dept' }, +}; + +const RECORD = { id: 'o1', name: 'Big deal' }; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [] })), + findOne: vi.fn(async () => RECORD), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: OBJECT_FIELDS })), + } as Record; +} + +/** + * Render a fetching detail view and hand back the `$expand` it asked for. + * The `waitFor` targets a real recorded `findOne`, so a component that stopped + * fetching times out instead of reading as an empty expansion. + */ +async function expandFor(schemaExtra: Record): Promise { + const ds = makeDataSource(); + render( + , + ); + await waitFor(() => expect(ds.findOne).toHaveBeenCalled()); + return (ds.findOne.mock.calls.at(-1)?.[2]?.$expand ?? []) as string[]; +} + +const AUTHORED = { + sections: [ + { + title: 'Basics', + fields: [ + { name: 'name', label: 'Name' }, + { name: 'account', label: 'Account' }, + { name: 'secret_account', label: 'Secret Account' }, + { name: 'owner_dept', label: 'Dept' }, + ], + }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + state.isLoaded = true; + state.readable = []; + vi.spyOn(console, 'error').mockImplementation(() => {}); + // `useRecordEditable` probes `/api/v1/security/explain` for the ROW-level + // verdict whenever the object-level check passes (the stub above allows it). + // happy-dom resolves that relative URL to a real socket, which the repo's + // network-escape guard fails the file for (objectui#6640) — so serve it from + // a double. Its answer is orthogonal to `$expand`: this file observes the + // query parameters, not the edit affordance. + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ allowed: true }), + text: async () => '{"allowed":true}', + })) as never); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('DetailView — `$expand` is FLS-gated on the helper OUTPUT (objectui#7230)', () => { + describe('already delivered by the INPUT filter — green in both directions, and must stay so', () => { + // ── PIN 1 ──────────────────────────────────────────────────────────── + it('does not expand a denied lookup that the authored field list names', async () => { + state.readable = ['id', 'name', 'account']; + const expand = await expandFor(AUTHORED); + expect(expand).not.toContain('secret_account'); + expect(expand).not.toContain('owner_dept'); + }); + + // ── PIN 2: the live control — the gate narrows, it never empties ───── + it('still expands a lookup the principal CAN read', async () => { + state.readable = ['id', 'name', 'account']; + const expand = await expandFor(AUTHORED); + expect( + expand, + 'a gate that killed all expansion would show a bare foreign-key id where the ' + + 'related record’s display name belongs', + ).toEqual(['account']); + }); + }); + + describe('the widening this card exists to close — RED before the output gate', () => { + // ── PIN 3: input-gating to EMPTY widens to every relation ──────────── + it('does NOT widen to every declared relation when every authored field is denied', async () => { + state.readable = ['id']; + const expand = await expandFor(AUTHORED); + expect( + expand, + '`buildExpandFields` reads an empty column list as "no column restriction" and ' + + 'falls back to every declared relation, so filtering its INPUT turns the most ' + + 'restricted principal into the one that asks for the most', + ).toEqual([]); + }); + + // ── PIN 4: no authored field list at all ──────────────────────────── + it('gates a detail view that declares NO fields, where the expansion is maximal', async () => { + state.readable = ['id', 'name', 'account']; + const expand = await expandFor({}); + expect( + expand, + 'with no `sections`/`fields` the input filter has nothing to remove and the helper ' + + 'expands every declared relation — the case an input-side gate cannot reach', + ).toEqual(['account']); + }); + }); + + describe('limits', () => { + // ── PIN 5: THE ORDERING LIMIT — an undeclared column is not judged ─── + it('leaves an UNDECLARED (derived / host-joined) field alone and keeps expanding', async () => { + state.readable = ['id', 'name', 'account']; + const expand = await expandFor({ + sections: [{ + title: 'Basics', + fields: [ + { name: 'name', label: 'Name' }, + { name: 'computed_score', label: 'Score' }, + { name: 'account', label: 'Account' }, + ], + }], + }); + expect( + expand, + '`checkField` answers false for a key no policy mentions; the gate is on the ' + + 'helper’s OUTPUT, which contains only DECLARED reference-bearing fields, so a ' + + 'derived column is never judged and cannot take the expansion down with it', + ).toEqual(['account']); + }); + + // ── PIN 6: 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(AUTHORED); + expect( + expand, + 'never filter on an unanswered policy — the no-provider default is `isLoaded: false` ' + + 'forever, and a detail view with no PermissionProvider must keep expanding', + ).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept'])); + }); + }); +}); diff --git a/packages/plugin-gantt/package.json b/packages/plugin-gantt/package.json index 6ef13f936b..2d153b570c 100644 --- a/packages/plugin-gantt/package.json +++ b/packages/plugin-gantt/package.json @@ -35,6 +35,7 @@ "@object-ui/core": "workspace:*", "@object-ui/fields": "workspace:*", "@object-ui/i18n": "workspace:*", + "@object-ui/permissions": "workspace:*", "@object-ui/plugin-detail": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", diff --git a/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx b/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx new file mode 100644 index 0000000000..14765d50b9 --- /dev/null +++ b/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx @@ -0,0 +1,206 @@ +/** + * 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#7230 — field-level security on `ObjectGantt`'s `$expand`. + * + * ## The site + * + * objectui#7215 / PR #7229 gated the two PROJECTION sites in its scope + * (`ObjectGrid`, `ListView`). This is one of the call sites it did not reach, + * and like the calendar's it passes **no column list**: + * + * const expand = buildExpandFields(objectSchema?.fields); + * + * `buildExpandFields` reads an absent column list as "no column restriction" + * and falls back to **every declared relation on the object**, denied ones + * included — so this is the maximal ask, issued by default rather than by + * configuration. + * + * ## Grading — the same defence-in-depth reading #7215 recorded + * + * `FieldMasker.maskRecord` deletes every unreadable key and objectql writes the + * expanded record back under that same key, so ObjectStack's own server strips + * both the expansion and the bare id in one statement; the expansion sub-read + * takes the referenced object's full CRUD + RLS + FLS treatment + * (objectstack#7626). The value is that the invariant stops resting on every + * future backend having enforced it — and the client-request side is real + * either way. + * + * ## The gate is on the OUTPUT — copied from #7229, not re-derived + * + * There is no input to gate here (the call passes `undefined`), and gating the + * output makes the "`checkField` answers false for an undeclared key" trap + * structurally unreachable: every name judged is a DECLARED reference-bearing + * field, because that is all the helper returns. + * + * The stub `checkField` is an ALLOWLIST for the reason `expandFls-7215.test.tsx` + * records: the real provider answers `true` for a field no policy mentions, so + * a denial can only be modelled by enumerating what is readable. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; + +/** Stable stub identity — `perms` rides `reload`'s dependency list. */ +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 }; +}); + +/** The chart itself is orthogonal to the query parameters this file observes. */ +vi.mock('./GanttView', () => ({ + GanttView: ({ tasks }: any) =>
, +})); + +import { ObjectGantt } from './ObjectGantt'; + +const TASKS = [ + { id: '1', name: 'Alpha', start: '2024-01-01', end: '2024-01-05' }, + { id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10' }, +]; + +/** + * Two relations of DIFFERENT declared types, so the pins cover the family: + * `project` is the readable `lookup` control, `owner_dept` the denied + * `master_detail`, `secret_project` the denied `lookup` under test. + */ +const TASK_FIELDS: Record = { + name: { type: 'text' }, + start: { type: 'date' }, + end: { type: 'date' }, + project: { type: 'lookup', reference_to: 'projects' }, + secret_project: { type: 'lookup', reference_to: 'projects' }, + owner_dept: { type: 'master_detail', reference_to: 'departments' }, +}; + +const GANTT_SCHEMA = { + type: 'gantt', + objectName: 'task', + startDateField: 'start', + endDateField: 'end', + titleField: 'name', +} as any; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: TASKS })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: TASK_FIELDS }), + } as any; +} + +/** + * Mount and hand back the `$expand` of the SCHEMA-DEPENDENT query. The + * `waitFor` targets a `find('task', …)` issued after `getObjectSchema` settled, + * so "the gantt stopped fetching" times out rather than reading as an empty + * expansion — the ghost-assertion guard this family of pins requires. + */ +async function expandFor(): Promise { + const ds = makeDataSource(); + render(); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + await waitFor(() => expect(ds.find.mock.calls.length).toBeGreaterThan(1)); + return (ds.find.mock.calls.at(-1)?.[1]?.$expand ?? []) as string[]; +} + +beforeEach(() => { + vi.clearAllMocks(); + state.isLoaded = true; + state.readable = []; + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('ObjectGantt — `$expand` is FLS-gated (objectui#7230)', () => { + // ── PIN 1: the defect itself ──────────────────────────────────────────── + it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => { + state.readable = ['id', 'name', 'start', 'end', 'project']; + const expand = await expandFor(); + expect( + expand, + 'with no column list this component expands EVERY declared relation, so a denied ' + + 'lookup is asked for by default rather than by configuration', + ).not.toContain('secret_project'); + }); + + // ── PIN 2: the live control — the gate narrows, it never empties ──────── + it('still expands a lookup the principal CAN read', async () => { + state.readable = ['id', 'name', 'start', 'end', 'project']; + const expand = await expandFor(); + expect( + expand, + 'a gate that killed all expansion would paint raw foreign-key ids in every bar', + ).toContain('project'); + }); + + // ── PIN 3: `master_detail`, not only `lookup` ─────────────────────────── + it('gates a denied `master_detail` root too, not only `lookup`', async () => { + state.readable = ['id', 'name', 'start', 'end', 'project']; + const expand = await expandFor(); + expect(expand).not.toContain('owner_dept'); + expect(expand).toContain('project'); + }); + + // ── PIN 4: the whole set, asserted exactly ───────────────────────────── + it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => { + state.readable = ['id', 'name', 'start', 'end', 'project']; + const expand = await expandFor(); + expect( + expand.slice().sort(), + 'an absence assertion alone would also pass if the expansion had gone empty', + ).toEqual(['project']); + }); + + // ── PIN 5: every relation denied → no widened expansion ──────────────── + it('sends no `$expand` when every declared relation is denied', async () => { + state.readable = ['id', 'name', 'start', 'end']; + const expand = await expandFor(); + expect(expand).toEqual([]); + }); + + // ── PIN 6: 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(); + expect( + expand, + 'never filter on an unanswered policy — the no-provider default is `isLoaded: false` ' + + 'forever, and a gantt with no PermissionProvider must keep expanding', + ).toEqual(expect.arrayContaining(['project', 'secret_project', 'owner_dept'])); + }); +}); diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 15d6753abb..5e5c8214a0 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -34,6 +34,7 @@ import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui import { useNavigationOverlay, SchemaRendererContext } from '@object-ui/react'; import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; import { RecordDetailDrawer, deriveRecordPageHref } from '@object-ui/plugin-detail'; +import { usePermissions } from '@object-ui/permissions'; import { AlertDialog, AlertDialogAction, @@ -639,6 +640,12 @@ export const ObjectGantt: React.FC = ({ const resource = dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName ?? ''; + // Permissions context, read here rather than inside `reload` below: a + // `useCallback`'s DEPENDENCY ARRAY is evaluated during render, so `perms` has + // to be a binding that already exists by the time render reaches it + // (objectui#7230, the structural note PR #7229 recorded for `ListView`). + const perms = usePermissions(); + // Load (and re-load) data through the resolved adapter. `silent: true` // re-reads the source WITHOUT flipping `loading`, so GanttView stays mounted // and keeps its scroll/collapse state — used by the write-readback below and @@ -672,7 +679,34 @@ export const ObjectGantt: React.FC = ({ // 'object' → context adapter, 'api' → ApiDataSource (both resolved above). // Auto-inject $expand for lookup/master_detail fields when a schema is // available; api adapters return an empty field map, so expand stays off. - const expand = buildExpandFields(objectSchema?.fields); + // + // [objectui#7230] FIELD-LEVEL SECURITY ON `$expand`, the gate + // objectui#7215 / PR #7229 put on the two projection sites in its scope. + // `$select` on a denied lookup asks for a bare foreign key; `$expand` + // asks the server to RESOLVE the relation and return the related record. + // + // ⚠️ NO COLUMN LIST IS PASSED HERE, which is what makes this site sharp: + // `buildExpandFields` reads an absent column list as "no column + // restriction" and falls back to EVERY declared relation on the object, + // denied ones included — the maximal ask, by default rather than by + // configuration. + // + // Graded as objectui#7215 graded it: defence-in-depth against + // ObjectStack's own server (`FieldMasker.maskRecord` deletes the very key + // objectql writes the expansion back under, and the sub-read takes the + // referenced object's full CRUD + RLS + FLS — objectstack#7626), and + // load-bearing for a backend that does not strip. + // + // ⭐ THE GATE IS ON THE OUTPUT. There is no input to gate on this site, + // and the output contains only DECLARED reference-bearing fields, so the + // "`checkField` answers false for an undeclared key" trap is structurally + // unreachable. An unanswered policy filters nothing; `perms` is in this + // callback's dependency list, so the expansion is rebuilt the moment the + // answer arrives. Pinned in `ObjectGantt.expandFls-7230.test.tsx`. + const expandable = buildExpandFields(objectSchema?.fields); + const expand = !perms?.isLoaded || !resource + ? expandable + : expandable.filter((f) => perms.checkField(resource, f, 'read')); const result = await effectiveDataSource.find(resource, { $filter: schema.filter, $orderby: convertSortToQueryParams(schema.sort), @@ -707,7 +741,7 @@ export const ObjectGantt: React.FC = ({ } } // eslint-disable-next-line react-hooks/exhaustive-deps -- (rest as any).data intentionally untracked, matching the original effect - }, [effectiveDataSource, resource, hasInlineData, dataProvider, dataItems, schema.filter, schema.sort, objectSchema]); + }, [effectiveDataSource, resource, hasInlineData, dataProvider, dataItems, schema.filter, schema.sort, objectSchema, perms]); useEffect(() => { reload(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74a17dd4c0..901b6c5df7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1552,6 +1552,9 @@ importers: '@object-ui/mobile': specifier: workspace:* version: link:../mobile + '@object-ui/permissions': + specifier: workspace:* + version: link:../permissions '@object-ui/plugin-detail': specifier: workspace:* version: link:../plugin-detail @@ -1762,6 +1765,9 @@ importers: '@object-ui/i18n': specifier: workspace:* version: link:../i18n + '@object-ui/permissions': + specifier: workspace:* + version: link:../permissions '@object-ui/react': specifier: workspace:* version: link:../react @@ -2076,6 +2082,9 @@ importers: '@object-ui/i18n': specifier: workspace:* version: link:../i18n + '@object-ui/permissions': + specifier: workspace:* + version: link:../permissions '@object-ui/plugin-detail': specifier: workspace:* version: link:../plugin-detail From 96a284fe235b5429d8d9e3b1ba68a957c987f111 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 01:58:24 +0000 Subject: [PATCH 2/2] chore(changeset): declare the `$expand` FLS gate at the five remaining build sites Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .changeset/7230-expand-fls-gate-five-sites.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .changeset/7230-expand-fls-gate-five-sites.md diff --git a/.changeset/7230-expand-fls-gate-five-sites.md b/.changeset/7230-expand-fls-gate-five-sites.md new file mode 100644 index 0000000000..17fe56f95b --- /dev/null +++ b/.changeset/7230-expand-fls-gate-five-sites.md @@ -0,0 +1,45 @@ +--- +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-gantt': patch +'@object-ui/plugin-detail': patch +'@object-ui/plugin-dashboard': patch +'@object-ui/app-shell': patch +--- + +FLS-gate the `$expand` projection at the five remaining build sites (objectui#7230). + +objectui#7215 / PR #7229 gated `$expand` at the two projection sites in its scope +(`ObjectGrid`, `ListView`). The helper is reached from more places than that. This +closes the five that were left: `ObjectCalendar`, `ObjectGantt`, `RecordDetailView`, +`DetailView`, and `ObjectDataTable` (which builds its own whitelist in +`computeLookupExpand` rather than calling `buildExpandFields`). + +**Three of them pass no column list at all**, which makes them the sharp ones: +`buildExpandFields` reads an absent column list as "no column restriction" and falls +back to **every declared relation on the object**, denied ones included. So a standalone +calendar, a gantt, and every record page in the console asked the server to resolve the +object's full relation set by default rather than by configuration. + +**`DetailView` was input-gated, and that is the defect rather than the fix.** Its column +list is already FLS-filtered field by field, which is exactly the route PR #7229 measured +as unsound: an emptied column list reads as "no column restriction", so a detail view +whose authored fields are all denied had its `$expand` **widened** from the relations it +asked for to every relation the object declares. The principal who may read least was +asking for the most. + +**Reproduced before it was fixed**, as a failing test per site. + +**Grading, measured rather than assumed.** Against ObjectStack's own server this is +defence-in-depth, exactly as objectui#6898 and #7215 are: `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, and the +client-request side is real regardless. + +**Nothing a permitted view did stops working.** The gate judges each helper's OUTPUT, +which contains only 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. Neither +`buildExpandFields` nor `computeLookupExpand` is changed.