Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/7215-expand-fls-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
48 changes: 47 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1824,11 +1824,57 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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;
}
Expand Down
238 changes: 238 additions & 0 deletions packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<typeof import('@object-ui/permissions')>();
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<string, unknown>): Promise<string[]> => {
const ds = makeDataSource();
const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaExtra };
render(
<ActionProvider>
<ObjectGrid schema={schema} dataSource={ds as never} />
</ActionProvider>,
);
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']));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/7215-expand-fls-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
48 changes: 47 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1824,11 +1824,57 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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;
}
Expand Down
238 changes: 238 additions & 0 deletions packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<typeof import('@object-ui/permissions')>();
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<string, unknown>): Promise<string[]> => {
const ds = makeDataSource();
const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaExtra };
render(
<ActionProvider>
<ObjectGrid schema={schema} dataSource={ds as never} />
</ActionProvider>,
);
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']));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/7215-expand-fls-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
48 changes: 47 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1824,11 +1824,57 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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;
}
Expand Down
238 changes: 238 additions & 0 deletions packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<typeof import('@object-ui/permissions')>();
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<string, unknown>): Promise<string[]> => {
const ds = makeDataSource();
const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaExtra };
render(
<ActionProvider>
<ObjectGrid schema={schema} dataSource={ds as never} />
</ActionProvider>,
);
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']));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/7215-expand-fls-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
48 changes: 47 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1824,11 +1824,57 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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;
}
Expand Down
238 changes: 238 additions & 0 deletions packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<typeof import('@object-ui/permissions')>();
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<string, unknown>): Promise<string[]> => {
const ds = makeDataSource();
const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaExtra };
render(
<ActionProvider>
<ObjectGrid schema={schema} dataSource={ds as never} />
</ActionProvider>,
);
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']));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/7215-expand-fls-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
48 changes: 47 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1824,11 +1824,57 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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;
}
Expand Down
238 changes: 238 additions & 0 deletions packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<typeof import('@object-ui/permissions')>();
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<string, unknown>): Promise<string[]> => {
const ds = makeDataSource();
const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaExtra };
render(
<ActionProvider>
<ObjectGrid schema={schema} dataSource={ds as never} />
</ActionProvider>,
);
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']));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/7215-expand-fls-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
48 changes: 47 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1824,11 +1824,57 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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;
}
Expand Down
238 changes: 238 additions & 0 deletions packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<typeof import('@object-ui/permissions')>();
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<string, unknown>): Promise<string[]> => {
const ds = makeDataSource();
const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaExtra };
render(
<ActionProvider>
<ObjectGrid schema={schema} dataSource={ds as never} />
</ActionProvider>,
);
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']));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/7215-expand-fls-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
48 changes: 47 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1824,11 +1824,57 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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;
}
Expand Down
238 changes: 238 additions & 0 deletions packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<typeof import('@object-ui/permissions')>();
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<string, unknown>): Promise<string[]> => {
const ds = makeDataSource();
const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaExtra };
render(
<ActionProvider>
<ObjectGrid schema={schema} dataSource={ds as never} />
</ActionProvider>,
);
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']));
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/7215-expand-fls-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
48 changes: 47 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1824,11 +1824,57 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// 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;
}
Expand Down
238 changes: 238 additions & 0 deletions packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<typeof import('@object-ui/permissions')>();
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<string, unknown>): Promise<string[]> => {
const ds = makeDataSource();
const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaExtra };
render(
<ActionProvider>
<ObjectGrid schema={schema} dataSource={ds as never} />
</ActionProvider>,
);
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']));
});
});
Loading
Loading