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
45 changes: 45 additions & 0 deletions .changeset/7230-expand-fls-gate-five-sites.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/app-shell': patch
---

FLS-gate the `$expand` projection at the five remaining build sites (objectui#7230).

objectui#7215 / PR #7229 gated `$expand` at the two projection sites in its scope
(`ObjectGrid`, `ListView`). The helper is reached from more places than that. This
closes the five that were left: `ObjectCalendar`, `ObjectGantt`, `RecordDetailView`,
`DetailView`, and `ObjectDataTable` (which builds its own whitelist in
`computeLookupExpand` rather than calling `buildExpandFields`).

**Three of them pass no column list at all**, which makes them the sharp ones:
`buildExpandFields` reads an absent column list as "no column restriction" and falls
back to **every declared relation on the object**, denied ones included. So a standalone
calendar, a gantt, and every record page in the console asked the server to resolve the
object's full relation set by default rather than by configuration.

**`DetailView` was input-gated, and that is the defect rather than the fix.** Its column
list is already FLS-filtered field by field, which is exactly the route PR #7229 measured
as unsound: an emptied column list reads as "no column restriction", so a detail view
whose authored fields are all denied had its `$expand` **widened** from the relations it
asked for to every relation the object declares. The principal who may read least was
asking for the most.

**Reproduced before it was fixed**, as a failing test per site.

**Grading, measured rather than assumed.** Against ObjectStack's own server this is
defence-in-depth, exactly as objectui#6898 and #7215 are: `plugin-security`'s
`FieldMasker.maskRecord` deletes every unreadable key from each returned row and
objectql's expand path writes the resolved record back under that same key, so one
statement removes the expanded object and the bare id alike; the expansion sub-read is
itself gated (`__expandRead` takes the referenced object's full CRUD + RLS + FLS
treatment). It is load-bearing for any backend that does not strip, and the
client-request side is real regardless.

**Nothing a permitted view did stops working.** The gate judges each helper's OUTPUT,
which contains only the object's declared reference-bearing fields, so the "`checkField`
answers false for an undeclared key" trap cannot be reached and derived / host-joined
columns are untouched. An unanswered permission policy filters nothing. Neither
`buildExpandFields` nor `computeLookupExpand` is changed.
279 changes: 279 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7230 — field-level security on the record page's `$expand`.
*
* ## The site
*
* `RecordDetailView` loads the record backing an assigned or synthesized page
* with
*
* const expandFields = buildExpandFields(objectDef?.fields);
*
* — **no column list**. `buildExpandFields` reads an absent column list as "no
* column restriction" and falls back to **every declared relation on the
* object**, denied ones included. So every record page in the console asks the
* server to resolve the object's full relation set, by default rather than by
* configuration. objectui#7215 / PR #7229 gated the two projection sites in its
* scope; this call site was outside it.
*
* ## Grading — the same reading #7215 recorded, not a stronger claim
*
* Against ObjectStack's own server this is defence-in-depth, not a live
* disclosure: `plugin-security`'s `FieldMasker.maskRecord` does
* `delete result[field]` on every unreadable key and objectql writes the
* expanded record back under THAT SAME KEY, so one statement removes the
* expanded object and the bare id alike; the expansion sub-read takes the
* referenced object's full CRUD + RLS + FLS treatment (objectstack#7626). It is
* load-bearing for a backend that does not strip, and the client-request side
* is real either way.
*
* ## The gate is on the OUTPUT — copied from #7229
*
* There is no input to gate (the call passes `undefined`), and the output
* contains only DECLARED reference-bearing fields, so the "`checkField` answers
* false for an undeclared key" trap is structurally unreachable.
*
* ⚠️ One structural note that is load-bearing rather than cosmetic: this
* component read `usePermissions()` ~670 lines BELOW this effect. The effect's
* dependency array is evaluated DURING render, so listing `perms` there while
* the binding was still declared below would throw
* `Cannot access 'perms' before initialization` — a crash, not a stale value.
* The hook call moved above the effect; the later destructure now reads that
* one value instead of calling the hook again. Same lesson PR #7229 recorded
* for `ListView`'s memo.
*
* The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`: the
* real provider answers `true` for any field no policy mentions.
*/
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';

/** Stable stub identity — `perms` rides the record-load effect's dependency list. */
const { permsStub, state } = vi.hoisted(() => {
const state: { isLoaded: boolean; readable: string[] } = { isLoaded: true, readable: [] };
return {
state,
permsStub: {
get isLoaded() { return state.isLoaded; },
checkField: (_object: string, field: string, action: string) =>
action === 'read' ? state.readable.includes(field) : true,
check: () => ({ allowed: true }),
getFieldPermissions: () => [],
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
userId: null,
systemPermissions: undefined,
hasCapabilities: () => true,
can: () => true,
cannot: () => false,
},
};
});

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
return { ...actual, usePermissions: () => permsStub as any };
});

vi.mock('@object-ui/auth', () => ({
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));
vi.mock('@object-ui/collaboration', () => ({
useRecordPresence: () => ({ viewers: [], others: [] }),
PresenceAvatars: () => null,
}));
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), info: vi.fn(),
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
}),
}));
// Orthogonal chrome — this file observes the record query's parameters only.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';

const OBJECT = 'os_7230_opportunity';
const REC = 'rec-1';

/**
* `account` is the readable `lookup` control, `owner_dept` the denied
* `master_detail`, `secret_account` the denied `lookup` under test.
*/
const objectDef = {
name: OBJECT,
label: 'Opportunity',
managedBy: 'platform',
highlightFields: ['name'],
fields: {
id: { label: 'Id', type: 'text' },
name: { label: 'Name', type: 'text' },
stage: { label: 'Stage', type: 'text' },
account: { label: 'Account', type: 'lookup', reference_to: 'accounts' },
secret_account: { label: 'Secret Account', type: 'lookup', reference_to: 'accounts' },
owner_dept: { label: 'Dept', type: 'master_detail', reference_to: 'departments' },
},
};

const RECORD = { id: REC, name: 'Big deal', stage: 'new' };

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })),
findOne: vi.fn(async (_name: string, id: string) => ({ ...RECORD, id })),
create: vi.fn(async (_o: string, row: any) => row),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
getObjectSchema: async (name: string) => ({ name, fields: objectDef.fields }),
} as Record<string, any>;
}

function makeMetadata() {
const pages: any[] = [];
return {
objects: [], pages, loading: false, error: null,
refresh: async () => {}, invalidate: () => {},
ensureType: async () => pages, getItem: async () => null,
getItemsByType: () => pages,
} as any;
}

/**
* Mount the record page as a tenant with NO assigned page gets it (the metadata
* context carries none, so the page is synthesized) and hand back the `$expand`
* of the record query.
*
* The `waitFor` targets a real recorded `findOne` for THIS object, so a page
* that stopped loading its record times out rather than reading as an empty
* expansion.
*/
async function expandFor(): Promise<string[]> {
const ds = makeDataSource();
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT}/${REC}`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={ds as never}
objects={[objectDef] as never}
onEdit={() => {}}
objectNameOverride={OBJECT}
recordIdOverride={REC}
embedded
/>
</MetadataCtx.Provider>
</MemoryRouter>,
);
await waitFor(() =>
expect(ds.findOne.mock.calls.some((c: any[]) => c[0] === OBJECT)).toBe(true));
const call = ds.findOne.mock.calls.filter((c: any[]) => c[0] === OBJECT).at(-1);
return (call?.[2]?.$expand ?? []) as string[];
}

beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

beforeEach(() => {
cleanup();
vi.clearAllMocks();
state.isLoaded = true;
state.readable = [];
vi.spyOn(console, 'error').mockImplementation(() => {});
// Unrelated chrome (approvals, favourites, row-level verdicts) reaches for
// the platform API; happy-dom would resolve those relative URLs to a real
// socket, which the repo's network-escape guard fails the file for
// (objectui#6640). Serve them from a double — none of it is what this file
// observes.
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ allowed: true, data: [] }),
text: async () => '{}',
})) as never);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('RecordDetailView — `$expand` is FLS-gated (objectui#7230)', () => {
// ── PIN 1: the defect itself ────────────────────────────────────────────
it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'with no column list the record page expands EVERY declared relation, so a denied '
+ 'lookup is asked for on every record page by default',
).not.toContain('secret_account');
});

// ── PIN 2: the live control — the gate narrows, it never empties ────────
it('still expands a lookup the principal CAN read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'the page subtitle interpolation and the `record:*` renderers depend on the expanded '
+ 'display name; a gate that emptied the expansion would show raw ids instead',
).toContain('account');
});

// ── PIN 3: `master_detail`, not only `lookup` ───────────────────────────
it('gates a denied `master_detail` root too, not only `lookup`', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(expand).not.toContain('owner_dept');
expect(expand).toContain('account');
});

// ── PIN 4: the whole set, asserted exactly ─────────────────────────────
it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand.slice().sort(),
'an absence assertion alone would also pass if the expansion had gone empty',
).toEqual(['account']);
});

// ── PIN 5: every relation denied → no `$expand` at all ─────────────────
it('omits `$expand` entirely when every declared relation is denied', async () => {
state.readable = ['id', 'name', 'stage'];
const expand = await expandFor();
expect(expand).toEqual([]);
});

// ── PIN 6: deferral — an unanswered policy filters nothing ─────────────
it('filters NOTHING while `/me/permissions` has not answered', async () => {
state.isLoaded = false;
state.readable = [];
const expand = await expandFor();
expect(
expand,
'never filter on an unanswered policy — the no-provider default is `isLoaded: false` '
+ 'forever, and a console with no PermissionProvider must keep expanding',
).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept']));
});
});
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
45 changes: 45 additions & 0 deletions .changeset/7230-expand-fls-gate-five-sites.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/app-shell': patch
---

FLS-gate the `$expand` projection at the five remaining build sites (objectui#7230).

objectui#7215 / PR #7229 gated `$expand` at the two projection sites in its scope
(`ObjectGrid`, `ListView`). The helper is reached from more places than that. This
closes the five that were left: `ObjectCalendar`, `ObjectGantt`, `RecordDetailView`,
`DetailView`, and `ObjectDataTable` (which builds its own whitelist in
`computeLookupExpand` rather than calling `buildExpandFields`).

**Three of them pass no column list at all**, which makes them the sharp ones:
`buildExpandFields` reads an absent column list as "no column restriction" and falls
back to **every declared relation on the object**, denied ones included. So a standalone
calendar, a gantt, and every record page in the console asked the server to resolve the
object's full relation set by default rather than by configuration.

**`DetailView` was input-gated, and that is the defect rather than the fix.** Its column
list is already FLS-filtered field by field, which is exactly the route PR #7229 measured
as unsound: an emptied column list reads as "no column restriction", so a detail view
whose authored fields are all denied had its `$expand` **widened** from the relations it
asked for to every relation the object declares. The principal who may read least was
asking for the most.

**Reproduced before it was fixed**, as a failing test per site.

**Grading, measured rather than assumed.** Against ObjectStack's own server this is
defence-in-depth, exactly as objectui#6898 and #7215 are: `plugin-security`'s
`FieldMasker.maskRecord` deletes every unreadable key from each returned row and
objectql's expand path writes the resolved record back under that same key, so one
statement removes the expanded object and the bare id alike; the expansion sub-read is
itself gated (`__expandRead` takes the referenced object's full CRUD + RLS + FLS
treatment). It is load-bearing for any backend that does not strip, and the
client-request side is real regardless.

**Nothing a permitted view did stops working.** The gate judges each helper's OUTPUT,
which contains only the object's declared reference-bearing fields, so the "`checkField`
answers false for an undeclared key" trap cannot be reached and derived / host-joined
columns are untouched. An unanswered permission policy filters nothing. Neither
`buildExpandFields` nor `computeLookupExpand` is changed.
279 changes: 279 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7230 — field-level security on the record page's `$expand`.
*
* ## The site
*
* `RecordDetailView` loads the record backing an assigned or synthesized page
* with
*
* const expandFields = buildExpandFields(objectDef?.fields);
*
* — **no column list**. `buildExpandFields` reads an absent column list as "no
* column restriction" and falls back to **every declared relation on the
* object**, denied ones included. So every record page in the console asks the
* server to resolve the object's full relation set, by default rather than by
* configuration. objectui#7215 / PR #7229 gated the two projection sites in its
* scope; this call site was outside it.
*
* ## Grading — the same reading #7215 recorded, not a stronger claim
*
* Against ObjectStack's own server this is defence-in-depth, not a live
* disclosure: `plugin-security`'s `FieldMasker.maskRecord` does
* `delete result[field]` on every unreadable key and objectql writes the
* expanded record back under THAT SAME KEY, so one statement removes the
* expanded object and the bare id alike; the expansion sub-read takes the
* referenced object's full CRUD + RLS + FLS treatment (objectstack#7626). It is
* load-bearing for a backend that does not strip, and the client-request side
* is real either way.
*
* ## The gate is on the OUTPUT — copied from #7229
*
* There is no input to gate (the call passes `undefined`), and the output
* contains only DECLARED reference-bearing fields, so the "`checkField` answers
* false for an undeclared key" trap is structurally unreachable.
*
* ⚠️ One structural note that is load-bearing rather than cosmetic: this
* component read `usePermissions()` ~670 lines BELOW this effect. The effect's
* dependency array is evaluated DURING render, so listing `perms` there while
* the binding was still declared below would throw
* `Cannot access 'perms' before initialization` — a crash, not a stale value.
* The hook call moved above the effect; the later destructure now reads that
* one value instead of calling the hook again. Same lesson PR #7229 recorded
* for `ListView`'s memo.
*
* The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`: the
* real provider answers `true` for any field no policy mentions.
*/
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';

/** Stable stub identity — `perms` rides the record-load effect's dependency list. */
const { permsStub, state } = vi.hoisted(() => {
const state: { isLoaded: boolean; readable: string[] } = { isLoaded: true, readable: [] };
return {
state,
permsStub: {
get isLoaded() { return state.isLoaded; },
checkField: (_object: string, field: string, action: string) =>
action === 'read' ? state.readable.includes(field) : true,
check: () => ({ allowed: true }),
getFieldPermissions: () => [],
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
userId: null,
systemPermissions: undefined,
hasCapabilities: () => true,
can: () => true,
cannot: () => false,
},
};
});

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
return { ...actual, usePermissions: () => permsStub as any };
});

vi.mock('@object-ui/auth', () => ({
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));
vi.mock('@object-ui/collaboration', () => ({
useRecordPresence: () => ({ viewers: [], others: [] }),
PresenceAvatars: () => null,
}));
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), info: vi.fn(),
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
}),
}));
// Orthogonal chrome — this file observes the record query's parameters only.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';

const OBJECT = 'os_7230_opportunity';
const REC = 'rec-1';

/**
* `account` is the readable `lookup` control, `owner_dept` the denied
* `master_detail`, `secret_account` the denied `lookup` under test.
*/
const objectDef = {
name: OBJECT,
label: 'Opportunity',
managedBy: 'platform',
highlightFields: ['name'],
fields: {
id: { label: 'Id', type: 'text' },
name: { label: 'Name', type: 'text' },
stage: { label: 'Stage', type: 'text' },
account: { label: 'Account', type: 'lookup', reference_to: 'accounts' },
secret_account: { label: 'Secret Account', type: 'lookup', reference_to: 'accounts' },
owner_dept: { label: 'Dept', type: 'master_detail', reference_to: 'departments' },
},
};

const RECORD = { id: REC, name: 'Big deal', stage: 'new' };

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })),
findOne: vi.fn(async (_name: string, id: string) => ({ ...RECORD, id })),
create: vi.fn(async (_o: string, row: any) => row),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
getObjectSchema: async (name: string) => ({ name, fields: objectDef.fields }),
} as Record<string, any>;
}

function makeMetadata() {
const pages: any[] = [];
return {
objects: [], pages, loading: false, error: null,
refresh: async () => {}, invalidate: () => {},
ensureType: async () => pages, getItem: async () => null,
getItemsByType: () => pages,
} as any;
}

/**
* Mount the record page as a tenant with NO assigned page gets it (the metadata
* context carries none, so the page is synthesized) and hand back the `$expand`
* of the record query.
*
* The `waitFor` targets a real recorded `findOne` for THIS object, so a page
* that stopped loading its record times out rather than reading as an empty
* expansion.
*/
async function expandFor(): Promise<string[]> {
const ds = makeDataSource();
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT}/${REC}`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={ds as never}
objects={[objectDef] as never}
onEdit={() => {}}
objectNameOverride={OBJECT}
recordIdOverride={REC}
embedded
/>
</MetadataCtx.Provider>
</MemoryRouter>,
);
await waitFor(() =>
expect(ds.findOne.mock.calls.some((c: any[]) => c[0] === OBJECT)).toBe(true));
const call = ds.findOne.mock.calls.filter((c: any[]) => c[0] === OBJECT).at(-1);
return (call?.[2]?.$expand ?? []) as string[];
}

beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

beforeEach(() => {
cleanup();
vi.clearAllMocks();
state.isLoaded = true;
state.readable = [];
vi.spyOn(console, 'error').mockImplementation(() => {});
// Unrelated chrome (approvals, favourites, row-level verdicts) reaches for
// the platform API; happy-dom would resolve those relative URLs to a real
// socket, which the repo's network-escape guard fails the file for
// (objectui#6640). Serve them from a double — none of it is what this file
// observes.
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ allowed: true, data: [] }),
text: async () => '{}',
})) as never);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('RecordDetailView — `$expand` is FLS-gated (objectui#7230)', () => {
// ── PIN 1: the defect itself ────────────────────────────────────────────
it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'with no column list the record page expands EVERY declared relation, so a denied '
+ 'lookup is asked for on every record page by default',
).not.toContain('secret_account');
});

// ── PIN 2: the live control — the gate narrows, it never empties ────────
it('still expands a lookup the principal CAN read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'the page subtitle interpolation and the `record:*` renderers depend on the expanded '
+ 'display name; a gate that emptied the expansion would show raw ids instead',
).toContain('account');
});

// ── PIN 3: `master_detail`, not only `lookup` ───────────────────────────
it('gates a denied `master_detail` root too, not only `lookup`', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(expand).not.toContain('owner_dept');
expect(expand).toContain('account');
});

// ── PIN 4: the whole set, asserted exactly ─────────────────────────────
it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand.slice().sort(),
'an absence assertion alone would also pass if the expansion had gone empty',
).toEqual(['account']);
});

// ── PIN 5: every relation denied → no `$expand` at all ─────────────────
it('omits `$expand` entirely when every declared relation is denied', async () => {
state.readable = ['id', 'name', 'stage'];
const expand = await expandFor();
expect(expand).toEqual([]);
});

// ── PIN 6: deferral — an unanswered policy filters nothing ─────────────
it('filters NOTHING while `/me/permissions` has not answered', async () => {
state.isLoaded = false;
state.readable = [];
const expand = await expandFor();
expect(
expand,
'never filter on an unanswered policy — the no-provider default is `isLoaded: false` '
+ 'forever, and a console with no PermissionProvider must keep expanding',
).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept']));
});
});
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
45 changes: 45 additions & 0 deletions .changeset/7230-expand-fls-gate-five-sites.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/app-shell': patch
---

FLS-gate the `$expand` projection at the five remaining build sites (objectui#7230).

objectui#7215 / PR #7229 gated `$expand` at the two projection sites in its scope
(`ObjectGrid`, `ListView`). The helper is reached from more places than that. This
closes the five that were left: `ObjectCalendar`, `ObjectGantt`, `RecordDetailView`,
`DetailView`, and `ObjectDataTable` (which builds its own whitelist in
`computeLookupExpand` rather than calling `buildExpandFields`).

**Three of them pass no column list at all**, which makes them the sharp ones:
`buildExpandFields` reads an absent column list as "no column restriction" and falls
back to **every declared relation on the object**, denied ones included. So a standalone
calendar, a gantt, and every record page in the console asked the server to resolve the
object's full relation set by default rather than by configuration.

**`DetailView` was input-gated, and that is the defect rather than the fix.** Its column
list is already FLS-filtered field by field, which is exactly the route PR #7229 measured
as unsound: an emptied column list reads as "no column restriction", so a detail view
whose authored fields are all denied had its `$expand` **widened** from the relations it
asked for to every relation the object declares. The principal who may read least was
asking for the most.

**Reproduced before it was fixed**, as a failing test per site.

**Grading, measured rather than assumed.** Against ObjectStack's own server this is
defence-in-depth, exactly as objectui#6898 and #7215 are: `plugin-security`'s
`FieldMasker.maskRecord` deletes every unreadable key from each returned row and
objectql's expand path writes the resolved record back under that same key, so one
statement removes the expanded object and the bare id alike; the expansion sub-read is
itself gated (`__expandRead` takes the referenced object's full CRUD + RLS + FLS
treatment). It is load-bearing for any backend that does not strip, and the
client-request side is real regardless.

**Nothing a permitted view did stops working.** The gate judges each helper's OUTPUT,
which contains only the object's declared reference-bearing fields, so the "`checkField`
answers false for an undeclared key" trap cannot be reached and derived / host-joined
columns are untouched. An unanswered permission policy filters nothing. Neither
`buildExpandFields` nor `computeLookupExpand` is changed.
279 changes: 279 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7230 — field-level security on the record page's `$expand`.
*
* ## The site
*
* `RecordDetailView` loads the record backing an assigned or synthesized page
* with
*
* const expandFields = buildExpandFields(objectDef?.fields);
*
* — **no column list**. `buildExpandFields` reads an absent column list as "no
* column restriction" and falls back to **every declared relation on the
* object**, denied ones included. So every record page in the console asks the
* server to resolve the object's full relation set, by default rather than by
* configuration. objectui#7215 / PR #7229 gated the two projection sites in its
* scope; this call site was outside it.
*
* ## Grading — the same reading #7215 recorded, not a stronger claim
*
* Against ObjectStack's own server this is defence-in-depth, not a live
* disclosure: `plugin-security`'s `FieldMasker.maskRecord` does
* `delete result[field]` on every unreadable key and objectql writes the
* expanded record back under THAT SAME KEY, so one statement removes the
* expanded object and the bare id alike; the expansion sub-read takes the
* referenced object's full CRUD + RLS + FLS treatment (objectstack#7626). It is
* load-bearing for a backend that does not strip, and the client-request side
* is real either way.
*
* ## The gate is on the OUTPUT — copied from #7229
*
* There is no input to gate (the call passes `undefined`), and the output
* contains only DECLARED reference-bearing fields, so the "`checkField` answers
* false for an undeclared key" trap is structurally unreachable.
*
* ⚠️ One structural note that is load-bearing rather than cosmetic: this
* component read `usePermissions()` ~670 lines BELOW this effect. The effect's
* dependency array is evaluated DURING render, so listing `perms` there while
* the binding was still declared below would throw
* `Cannot access 'perms' before initialization` — a crash, not a stale value.
* The hook call moved above the effect; the later destructure now reads that
* one value instead of calling the hook again. Same lesson PR #7229 recorded
* for `ListView`'s memo.
*
* The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`: the
* real provider answers `true` for any field no policy mentions.
*/
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';

/** Stable stub identity — `perms` rides the record-load effect's dependency list. */
const { permsStub, state } = vi.hoisted(() => {
const state: { isLoaded: boolean; readable: string[] } = { isLoaded: true, readable: [] };
return {
state,
permsStub: {
get isLoaded() { return state.isLoaded; },
checkField: (_object: string, field: string, action: string) =>
action === 'read' ? state.readable.includes(field) : true,
check: () => ({ allowed: true }),
getFieldPermissions: () => [],
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
userId: null,
systemPermissions: undefined,
hasCapabilities: () => true,
can: () => true,
cannot: () => false,
},
};
});

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
return { ...actual, usePermissions: () => permsStub as any };
});

vi.mock('@object-ui/auth', () => ({
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));
vi.mock('@object-ui/collaboration', () => ({
useRecordPresence: () => ({ viewers: [], others: [] }),
PresenceAvatars: () => null,
}));
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), info: vi.fn(),
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
}),
}));
// Orthogonal chrome — this file observes the record query's parameters only.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';

const OBJECT = 'os_7230_opportunity';
const REC = 'rec-1';

/**
* `account` is the readable `lookup` control, `owner_dept` the denied
* `master_detail`, `secret_account` the denied `lookup` under test.
*/
const objectDef = {
name: OBJECT,
label: 'Opportunity',
managedBy: 'platform',
highlightFields: ['name'],
fields: {
id: { label: 'Id', type: 'text' },
name: { label: 'Name', type: 'text' },
stage: { label: 'Stage', type: 'text' },
account: { label: 'Account', type: 'lookup', reference_to: 'accounts' },
secret_account: { label: 'Secret Account', type: 'lookup', reference_to: 'accounts' },
owner_dept: { label: 'Dept', type: 'master_detail', reference_to: 'departments' },
},
};

const RECORD = { id: REC, name: 'Big deal', stage: 'new' };

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })),
findOne: vi.fn(async (_name: string, id: string) => ({ ...RECORD, id })),
create: vi.fn(async (_o: string, row: any) => row),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
getObjectSchema: async (name: string) => ({ name, fields: objectDef.fields }),
} as Record<string, any>;
}

function makeMetadata() {
const pages: any[] = [];
return {
objects: [], pages, loading: false, error: null,
refresh: async () => {}, invalidate: () => {},
ensureType: async () => pages, getItem: async () => null,
getItemsByType: () => pages,
} as any;
}

/**
* Mount the record page as a tenant with NO assigned page gets it (the metadata
* context carries none, so the page is synthesized) and hand back the `$expand`
* of the record query.
*
* The `waitFor` targets a real recorded `findOne` for THIS object, so a page
* that stopped loading its record times out rather than reading as an empty
* expansion.
*/
async function expandFor(): Promise<string[]> {
const ds = makeDataSource();
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT}/${REC}`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={ds as never}
objects={[objectDef] as never}
onEdit={() => {}}
objectNameOverride={OBJECT}
recordIdOverride={REC}
embedded
/>
</MetadataCtx.Provider>
</MemoryRouter>,
);
await waitFor(() =>
expect(ds.findOne.mock.calls.some((c: any[]) => c[0] === OBJECT)).toBe(true));
const call = ds.findOne.mock.calls.filter((c: any[]) => c[0] === OBJECT).at(-1);
return (call?.[2]?.$expand ?? []) as string[];
}

beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

beforeEach(() => {
cleanup();
vi.clearAllMocks();
state.isLoaded = true;
state.readable = [];
vi.spyOn(console, 'error').mockImplementation(() => {});
// Unrelated chrome (approvals, favourites, row-level verdicts) reaches for
// the platform API; happy-dom would resolve those relative URLs to a real
// socket, which the repo's network-escape guard fails the file for
// (objectui#6640). Serve them from a double — none of it is what this file
// observes.
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ allowed: true, data: [] }),
text: async () => '{}',
})) as never);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('RecordDetailView — `$expand` is FLS-gated (objectui#7230)', () => {
// ── PIN 1: the defect itself ────────────────────────────────────────────
it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'with no column list the record page expands EVERY declared relation, so a denied '
+ 'lookup is asked for on every record page by default',
).not.toContain('secret_account');
});

// ── PIN 2: the live control — the gate narrows, it never empties ────────
it('still expands a lookup the principal CAN read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'the page subtitle interpolation and the `record:*` renderers depend on the expanded '
+ 'display name; a gate that emptied the expansion would show raw ids instead',
).toContain('account');
});

// ── PIN 3: `master_detail`, not only `lookup` ───────────────────────────
it('gates a denied `master_detail` root too, not only `lookup`', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(expand).not.toContain('owner_dept');
expect(expand).toContain('account');
});

// ── PIN 4: the whole set, asserted exactly ─────────────────────────────
it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand.slice().sort(),
'an absence assertion alone would also pass if the expansion had gone empty',
).toEqual(['account']);
});

// ── PIN 5: every relation denied → no `$expand` at all ─────────────────
it('omits `$expand` entirely when every declared relation is denied', async () => {
state.readable = ['id', 'name', 'stage'];
const expand = await expandFor();
expect(expand).toEqual([]);
});

// ── PIN 6: deferral — an unanswered policy filters nothing ─────────────
it('filters NOTHING while `/me/permissions` has not answered', async () => {
state.isLoaded = false;
state.readable = [];
const expand = await expandFor();
expect(
expand,
'never filter on an unanswered policy — the no-provider default is `isLoaded: false` '
+ 'forever, and a console with no PermissionProvider must keep expanding',
).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept']));
});
});
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
45 changes: 45 additions & 0 deletions .changeset/7230-expand-fls-gate-five-sites.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/app-shell': patch
---

FLS-gate the `$expand` projection at the five remaining build sites (objectui#7230).

objectui#7215 / PR #7229 gated `$expand` at the two projection sites in its scope
(`ObjectGrid`, `ListView`). The helper is reached from more places than that. This
closes the five that were left: `ObjectCalendar`, `ObjectGantt`, `RecordDetailView`,
`DetailView`, and `ObjectDataTable` (which builds its own whitelist in
`computeLookupExpand` rather than calling `buildExpandFields`).

**Three of them pass no column list at all**, which makes them the sharp ones:
`buildExpandFields` reads an absent column list as "no column restriction" and falls
back to **every declared relation on the object**, denied ones included. So a standalone
calendar, a gantt, and every record page in the console asked the server to resolve the
object's full relation set by default rather than by configuration.

**`DetailView` was input-gated, and that is the defect rather than the fix.** Its column
list is already FLS-filtered field by field, which is exactly the route PR #7229 measured
as unsound: an emptied column list reads as "no column restriction", so a detail view
whose authored fields are all denied had its `$expand` **widened** from the relations it
asked for to every relation the object declares. The principal who may read least was
asking for the most.

**Reproduced before it was fixed**, as a failing test per site.

**Grading, measured rather than assumed.** Against ObjectStack's own server this is
defence-in-depth, exactly as objectui#6898 and #7215 are: `plugin-security`'s
`FieldMasker.maskRecord` deletes every unreadable key from each returned row and
objectql's expand path writes the resolved record back under that same key, so one
statement removes the expanded object and the bare id alike; the expansion sub-read is
itself gated (`__expandRead` takes the referenced object's full CRUD + RLS + FLS
treatment). It is load-bearing for any backend that does not strip, and the
client-request side is real regardless.

**Nothing a permitted view did stops working.** The gate judges each helper's OUTPUT,
which contains only the object's declared reference-bearing fields, so the "`checkField`
answers false for an undeclared key" trap cannot be reached and derived / host-joined
columns are untouched. An unanswered permission policy filters nothing. Neither
`buildExpandFields` nor `computeLookupExpand` is changed.
279 changes: 279 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7230 — field-level security on the record page's `$expand`.
*
* ## The site
*
* `RecordDetailView` loads the record backing an assigned or synthesized page
* with
*
* const expandFields = buildExpandFields(objectDef?.fields);
*
* — **no column list**. `buildExpandFields` reads an absent column list as "no
* column restriction" and falls back to **every declared relation on the
* object**, denied ones included. So every record page in the console asks the
* server to resolve the object's full relation set, by default rather than by
* configuration. objectui#7215 / PR #7229 gated the two projection sites in its
* scope; this call site was outside it.
*
* ## Grading — the same reading #7215 recorded, not a stronger claim
*
* Against ObjectStack's own server this is defence-in-depth, not a live
* disclosure: `plugin-security`'s `FieldMasker.maskRecord` does
* `delete result[field]` on every unreadable key and objectql writes the
* expanded record back under THAT SAME KEY, so one statement removes the
* expanded object and the bare id alike; the expansion sub-read takes the
* referenced object's full CRUD + RLS + FLS treatment (objectstack#7626). It is
* load-bearing for a backend that does not strip, and the client-request side
* is real either way.
*
* ## The gate is on the OUTPUT — copied from #7229
*
* There is no input to gate (the call passes `undefined`), and the output
* contains only DECLARED reference-bearing fields, so the "`checkField` answers
* false for an undeclared key" trap is structurally unreachable.
*
* ⚠️ One structural note that is load-bearing rather than cosmetic: this
* component read `usePermissions()` ~670 lines BELOW this effect. The effect's
* dependency array is evaluated DURING render, so listing `perms` there while
* the binding was still declared below would throw
* `Cannot access 'perms' before initialization` — a crash, not a stale value.
* The hook call moved above the effect; the later destructure now reads that
* one value instead of calling the hook again. Same lesson PR #7229 recorded
* for `ListView`'s memo.
*
* The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`: the
* real provider answers `true` for any field no policy mentions.
*/
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';

/** Stable stub identity — `perms` rides the record-load effect's dependency list. */
const { permsStub, state } = vi.hoisted(() => {
const state: { isLoaded: boolean; readable: string[] } = { isLoaded: true, readable: [] };
return {
state,
permsStub: {
get isLoaded() { return state.isLoaded; },
checkField: (_object: string, field: string, action: string) =>
action === 'read' ? state.readable.includes(field) : true,
check: () => ({ allowed: true }),
getFieldPermissions: () => [],
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
userId: null,
systemPermissions: undefined,
hasCapabilities: () => true,
can: () => true,
cannot: () => false,
},
};
});

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
return { ...actual, usePermissions: () => permsStub as any };
});

vi.mock('@object-ui/auth', () => ({
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));
vi.mock('@object-ui/collaboration', () => ({
useRecordPresence: () => ({ viewers: [], others: [] }),
PresenceAvatars: () => null,
}));
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), info: vi.fn(),
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
}),
}));
// Orthogonal chrome — this file observes the record query's parameters only.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';

const OBJECT = 'os_7230_opportunity';
const REC = 'rec-1';

/**
* `account` is the readable `lookup` control, `owner_dept` the denied
* `master_detail`, `secret_account` the denied `lookup` under test.
*/
const objectDef = {
name: OBJECT,
label: 'Opportunity',
managedBy: 'platform',
highlightFields: ['name'],
fields: {
id: { label: 'Id', type: 'text' },
name: { label: 'Name', type: 'text' },
stage: { label: 'Stage', type: 'text' },
account: { label: 'Account', type: 'lookup', reference_to: 'accounts' },
secret_account: { label: 'Secret Account', type: 'lookup', reference_to: 'accounts' },
owner_dept: { label: 'Dept', type: 'master_detail', reference_to: 'departments' },
},
};

const RECORD = { id: REC, name: 'Big deal', stage: 'new' };

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })),
findOne: vi.fn(async (_name: string, id: string) => ({ ...RECORD, id })),
create: vi.fn(async (_o: string, row: any) => row),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
getObjectSchema: async (name: string) => ({ name, fields: objectDef.fields }),
} as Record<string, any>;
}

function makeMetadata() {
const pages: any[] = [];
return {
objects: [], pages, loading: false, error: null,
refresh: async () => {}, invalidate: () => {},
ensureType: async () => pages, getItem: async () => null,
getItemsByType: () => pages,
} as any;
}

/**
* Mount the record page as a tenant with NO assigned page gets it (the metadata
* context carries none, so the page is synthesized) and hand back the `$expand`
* of the record query.
*
* The `waitFor` targets a real recorded `findOne` for THIS object, so a page
* that stopped loading its record times out rather than reading as an empty
* expansion.
*/
async function expandFor(): Promise<string[]> {
const ds = makeDataSource();
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT}/${REC}`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={ds as never}
objects={[objectDef] as never}
onEdit={() => {}}
objectNameOverride={OBJECT}
recordIdOverride={REC}
embedded
/>
</MetadataCtx.Provider>
</MemoryRouter>,
);
await waitFor(() =>
expect(ds.findOne.mock.calls.some((c: any[]) => c[0] === OBJECT)).toBe(true));
const call = ds.findOne.mock.calls.filter((c: any[]) => c[0] === OBJECT).at(-1);
return (call?.[2]?.$expand ?? []) as string[];
}

beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

beforeEach(() => {
cleanup();
vi.clearAllMocks();
state.isLoaded = true;
state.readable = [];
vi.spyOn(console, 'error').mockImplementation(() => {});
// Unrelated chrome (approvals, favourites, row-level verdicts) reaches for
// the platform API; happy-dom would resolve those relative URLs to a real
// socket, which the repo's network-escape guard fails the file for
// (objectui#6640). Serve them from a double — none of it is what this file
// observes.
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ allowed: true, data: [] }),
text: async () => '{}',
})) as never);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('RecordDetailView — `$expand` is FLS-gated (objectui#7230)', () => {
// ── PIN 1: the defect itself ────────────────────────────────────────────
it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'with no column list the record page expands EVERY declared relation, so a denied '
+ 'lookup is asked for on every record page by default',
).not.toContain('secret_account');
});

// ── PIN 2: the live control — the gate narrows, it never empties ────────
it('still expands a lookup the principal CAN read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'the page subtitle interpolation and the `record:*` renderers depend on the expanded '
+ 'display name; a gate that emptied the expansion would show raw ids instead',
).toContain('account');
});

// ── PIN 3: `master_detail`, not only `lookup` ───────────────────────────
it('gates a denied `master_detail` root too, not only `lookup`', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(expand).not.toContain('owner_dept');
expect(expand).toContain('account');
});

// ── PIN 4: the whole set, asserted exactly ─────────────────────────────
it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand.slice().sort(),
'an absence assertion alone would also pass if the expansion had gone empty',
).toEqual(['account']);
});

// ── PIN 5: every relation denied → no `$expand` at all ─────────────────
it('omits `$expand` entirely when every declared relation is denied', async () => {
state.readable = ['id', 'name', 'stage'];
const expand = await expandFor();
expect(expand).toEqual([]);
});

// ── PIN 6: deferral — an unanswered policy filters nothing ─────────────
it('filters NOTHING while `/me/permissions` has not answered', async () => {
state.isLoaded = false;
state.readable = [];
const expand = await expandFor();
expect(
expand,
'never filter on an unanswered policy — the no-provider default is `isLoaded: false` '
+ 'forever, and a console with no PermissionProvider must keep expanding',
).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept']));
});
});
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
45 changes: 45 additions & 0 deletions .changeset/7230-expand-fls-gate-five-sites.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/app-shell': patch
---

FLS-gate the `$expand` projection at the five remaining build sites (objectui#7230).

objectui#7215 / PR #7229 gated `$expand` at the two projection sites in its scope
(`ObjectGrid`, `ListView`). The helper is reached from more places than that. This
closes the five that were left: `ObjectCalendar`, `ObjectGantt`, `RecordDetailView`,
`DetailView`, and `ObjectDataTable` (which builds its own whitelist in
`computeLookupExpand` rather than calling `buildExpandFields`).

**Three of them pass no column list at all**, which makes them the sharp ones:
`buildExpandFields` reads an absent column list as "no column restriction" and falls
back to **every declared relation on the object**, denied ones included. So a standalone
calendar, a gantt, and every record page in the console asked the server to resolve the
object's full relation set by default rather than by configuration.

**`DetailView` was input-gated, and that is the defect rather than the fix.** Its column
list is already FLS-filtered field by field, which is exactly the route PR #7229 measured
as unsound: an emptied column list reads as "no column restriction", so a detail view
whose authored fields are all denied had its `$expand` **widened** from the relations it
asked for to every relation the object declares. The principal who may read least was
asking for the most.

**Reproduced before it was fixed**, as a failing test per site.

**Grading, measured rather than assumed.** Against ObjectStack's own server this is
defence-in-depth, exactly as objectui#6898 and #7215 are: `plugin-security`'s
`FieldMasker.maskRecord` deletes every unreadable key from each returned row and
objectql's expand path writes the resolved record back under that same key, so one
statement removes the expanded object and the bare id alike; the expansion sub-read is
itself gated (`__expandRead` takes the referenced object's full CRUD + RLS + FLS
treatment). It is load-bearing for any backend that does not strip, and the
client-request side is real regardless.

**Nothing a permitted view did stops working.** The gate judges each helper's OUTPUT,
which contains only the object's declared reference-bearing fields, so the "`checkField`
answers false for an undeclared key" trap cannot be reached and derived / host-joined
columns are untouched. An unanswered permission policy filters nothing. Neither
`buildExpandFields` nor `computeLookupExpand` is changed.
279 changes: 279 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7230 — field-level security on the record page's `$expand`.
*
* ## The site
*
* `RecordDetailView` loads the record backing an assigned or synthesized page
* with
*
* const expandFields = buildExpandFields(objectDef?.fields);
*
* — **no column list**. `buildExpandFields` reads an absent column list as "no
* column restriction" and falls back to **every declared relation on the
* object**, denied ones included. So every record page in the console asks the
* server to resolve the object's full relation set, by default rather than by
* configuration. objectui#7215 / PR #7229 gated the two projection sites in its
* scope; this call site was outside it.
*
* ## Grading — the same reading #7215 recorded, not a stronger claim
*
* Against ObjectStack's own server this is defence-in-depth, not a live
* disclosure: `plugin-security`'s `FieldMasker.maskRecord` does
* `delete result[field]` on every unreadable key and objectql writes the
* expanded record back under THAT SAME KEY, so one statement removes the
* expanded object and the bare id alike; the expansion sub-read takes the
* referenced object's full CRUD + RLS + FLS treatment (objectstack#7626). It is
* load-bearing for a backend that does not strip, and the client-request side
* is real either way.
*
* ## The gate is on the OUTPUT — copied from #7229
*
* There is no input to gate (the call passes `undefined`), and the output
* contains only DECLARED reference-bearing fields, so the "`checkField` answers
* false for an undeclared key" trap is structurally unreachable.
*
* ⚠️ One structural note that is load-bearing rather than cosmetic: this
* component read `usePermissions()` ~670 lines BELOW this effect. The effect's
* dependency array is evaluated DURING render, so listing `perms` there while
* the binding was still declared below would throw
* `Cannot access 'perms' before initialization` — a crash, not a stale value.
* The hook call moved above the effect; the later destructure now reads that
* one value instead of calling the hook again. Same lesson PR #7229 recorded
* for `ListView`'s memo.
*
* The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`: the
* real provider answers `true` for any field no policy mentions.
*/
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';

/** Stable stub identity — `perms` rides the record-load effect's dependency list. */
const { permsStub, state } = vi.hoisted(() => {
const state: { isLoaded: boolean; readable: string[] } = { isLoaded: true, readable: [] };
return {
state,
permsStub: {
get isLoaded() { return state.isLoaded; },
checkField: (_object: string, field: string, action: string) =>
action === 'read' ? state.readable.includes(field) : true,
check: () => ({ allowed: true }),
getFieldPermissions: () => [],
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
userId: null,
systemPermissions: undefined,
hasCapabilities: () => true,
can: () => true,
cannot: () => false,
},
};
});

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
return { ...actual, usePermissions: () => permsStub as any };
});

vi.mock('@object-ui/auth', () => ({
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));
vi.mock('@object-ui/collaboration', () => ({
useRecordPresence: () => ({ viewers: [], others: [] }),
PresenceAvatars: () => null,
}));
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), info: vi.fn(),
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
}),
}));
// Orthogonal chrome — this file observes the record query's parameters only.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';

const OBJECT = 'os_7230_opportunity';
const REC = 'rec-1';

/**
* `account` is the readable `lookup` control, `owner_dept` the denied
* `master_detail`, `secret_account` the denied `lookup` under test.
*/
const objectDef = {
name: OBJECT,
label: 'Opportunity',
managedBy: 'platform',
highlightFields: ['name'],
fields: {
id: { label: 'Id', type: 'text' },
name: { label: 'Name', type: 'text' },
stage: { label: 'Stage', type: 'text' },
account: { label: 'Account', type: 'lookup', reference_to: 'accounts' },
secret_account: { label: 'Secret Account', type: 'lookup', reference_to: 'accounts' },
owner_dept: { label: 'Dept', type: 'master_detail', reference_to: 'departments' },
},
};

const RECORD = { id: REC, name: 'Big deal', stage: 'new' };

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })),
findOne: vi.fn(async (_name: string, id: string) => ({ ...RECORD, id })),
create: vi.fn(async (_o: string, row: any) => row),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
getObjectSchema: async (name: string) => ({ name, fields: objectDef.fields }),
} as Record<string, any>;
}

function makeMetadata() {
const pages: any[] = [];
return {
objects: [], pages, loading: false, error: null,
refresh: async () => {}, invalidate: () => {},
ensureType: async () => pages, getItem: async () => null,
getItemsByType: () => pages,
} as any;
}

/**
* Mount the record page as a tenant with NO assigned page gets it (the metadata
* context carries none, so the page is synthesized) and hand back the `$expand`
* of the record query.
*
* The `waitFor` targets a real recorded `findOne` for THIS object, so a page
* that stopped loading its record times out rather than reading as an empty
* expansion.
*/
async function expandFor(): Promise<string[]> {
const ds = makeDataSource();
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT}/${REC}`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={ds as never}
objects={[objectDef] as never}
onEdit={() => {}}
objectNameOverride={OBJECT}
recordIdOverride={REC}
embedded
/>
</MetadataCtx.Provider>
</MemoryRouter>,
);
await waitFor(() =>
expect(ds.findOne.mock.calls.some((c: any[]) => c[0] === OBJECT)).toBe(true));
const call = ds.findOne.mock.calls.filter((c: any[]) => c[0] === OBJECT).at(-1);
return (call?.[2]?.$expand ?? []) as string[];
}

beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

beforeEach(() => {
cleanup();
vi.clearAllMocks();
state.isLoaded = true;
state.readable = [];
vi.spyOn(console, 'error').mockImplementation(() => {});
// Unrelated chrome (approvals, favourites, row-level verdicts) reaches for
// the platform API; happy-dom would resolve those relative URLs to a real
// socket, which the repo's network-escape guard fails the file for
// (objectui#6640). Serve them from a double — none of it is what this file
// observes.
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ allowed: true, data: [] }),
text: async () => '{}',
})) as never);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('RecordDetailView — `$expand` is FLS-gated (objectui#7230)', () => {
// ── PIN 1: the defect itself ────────────────────────────────────────────
it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'with no column list the record page expands EVERY declared relation, so a denied '
+ 'lookup is asked for on every record page by default',
).not.toContain('secret_account');
});

// ── PIN 2: the live control — the gate narrows, it never empties ────────
it('still expands a lookup the principal CAN read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'the page subtitle interpolation and the `record:*` renderers depend on the expanded '
+ 'display name; a gate that emptied the expansion would show raw ids instead',
).toContain('account');
});

// ── PIN 3: `master_detail`, not only `lookup` ───────────────────────────
it('gates a denied `master_detail` root too, not only `lookup`', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(expand).not.toContain('owner_dept');
expect(expand).toContain('account');
});

// ── PIN 4: the whole set, asserted exactly ─────────────────────────────
it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand.slice().sort(),
'an absence assertion alone would also pass if the expansion had gone empty',
).toEqual(['account']);
});

// ── PIN 5: every relation denied → no `$expand` at all ─────────────────
it('omits `$expand` entirely when every declared relation is denied', async () => {
state.readable = ['id', 'name', 'stage'];
const expand = await expandFor();
expect(expand).toEqual([]);
});

// ── PIN 6: deferral — an unanswered policy filters nothing ─────────────
it('filters NOTHING while `/me/permissions` has not answered', async () => {
state.isLoaded = false;
state.readable = [];
const expand = await expandFor();
expect(
expand,
'never filter on an unanswered policy — the no-provider default is `isLoaded: false` '
+ 'forever, and a console with no PermissionProvider must keep expanding',
).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept']));
});
});
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
45 changes: 45 additions & 0 deletions .changeset/7230-expand-fls-gate-five-sites.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/app-shell': patch
---

FLS-gate the `$expand` projection at the five remaining build sites (objectui#7230).

objectui#7215 / PR #7229 gated `$expand` at the two projection sites in its scope
(`ObjectGrid`, `ListView`). The helper is reached from more places than that. This
closes the five that were left: `ObjectCalendar`, `ObjectGantt`, `RecordDetailView`,
`DetailView`, and `ObjectDataTable` (which builds its own whitelist in
`computeLookupExpand` rather than calling `buildExpandFields`).

**Three of them pass no column list at all**, which makes them the sharp ones:
`buildExpandFields` reads an absent column list as "no column restriction" and falls
back to **every declared relation on the object**, denied ones included. So a standalone
calendar, a gantt, and every record page in the console asked the server to resolve the
object's full relation set by default rather than by configuration.

**`DetailView` was input-gated, and that is the defect rather than the fix.** Its column
list is already FLS-filtered field by field, which is exactly the route PR #7229 measured
as unsound: an emptied column list reads as "no column restriction", so a detail view
whose authored fields are all denied had its `$expand` **widened** from the relations it
asked for to every relation the object declares. The principal who may read least was
asking for the most.

**Reproduced before it was fixed**, as a failing test per site.

**Grading, measured rather than assumed.** Against ObjectStack's own server this is
defence-in-depth, exactly as objectui#6898 and #7215 are: `plugin-security`'s
`FieldMasker.maskRecord` deletes every unreadable key from each returned row and
objectql's expand path writes the resolved record back under that same key, so one
statement removes the expanded object and the bare id alike; the expansion sub-read is
itself gated (`__expandRead` takes the referenced object's full CRUD + RLS + FLS
treatment). It is load-bearing for any backend that does not strip, and the
client-request side is real regardless.

**Nothing a permitted view did stops working.** The gate judges each helper's OUTPUT,
which contains only the object's declared reference-bearing fields, so the "`checkField`
answers false for an undeclared key" trap cannot be reached and derived / host-joined
columns are untouched. An unanswered permission policy filters nothing. Neither
`buildExpandFields` nor `computeLookupExpand` is changed.
279 changes: 279 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7230 — field-level security on the record page's `$expand`.
*
* ## The site
*
* `RecordDetailView` loads the record backing an assigned or synthesized page
* with
*
* const expandFields = buildExpandFields(objectDef?.fields);
*
* — **no column list**. `buildExpandFields` reads an absent column list as "no
* column restriction" and falls back to **every declared relation on the
* object**, denied ones included. So every record page in the console asks the
* server to resolve the object's full relation set, by default rather than by
* configuration. objectui#7215 / PR #7229 gated the two projection sites in its
* scope; this call site was outside it.
*
* ## Grading — the same reading #7215 recorded, not a stronger claim
*
* Against ObjectStack's own server this is defence-in-depth, not a live
* disclosure: `plugin-security`'s `FieldMasker.maskRecord` does
* `delete result[field]` on every unreadable key and objectql writes the
* expanded record back under THAT SAME KEY, so one statement removes the
* expanded object and the bare id alike; the expansion sub-read takes the
* referenced object's full CRUD + RLS + FLS treatment (objectstack#7626). It is
* load-bearing for a backend that does not strip, and the client-request side
* is real either way.
*
* ## The gate is on the OUTPUT — copied from #7229
*
* There is no input to gate (the call passes `undefined`), and the output
* contains only DECLARED reference-bearing fields, so the "`checkField` answers
* false for an undeclared key" trap is structurally unreachable.
*
* ⚠️ One structural note that is load-bearing rather than cosmetic: this
* component read `usePermissions()` ~670 lines BELOW this effect. The effect's
* dependency array is evaluated DURING render, so listing `perms` there while
* the binding was still declared below would throw
* `Cannot access 'perms' before initialization` — a crash, not a stale value.
* The hook call moved above the effect; the later destructure now reads that
* one value instead of calling the hook again. Same lesson PR #7229 recorded
* for `ListView`'s memo.
*
* The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`: the
* real provider answers `true` for any field no policy mentions.
*/
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';

/** Stable stub identity — `perms` rides the record-load effect's dependency list. */
const { permsStub, state } = vi.hoisted(() => {
const state: { isLoaded: boolean; readable: string[] } = { isLoaded: true, readable: [] };
return {
state,
permsStub: {
get isLoaded() { return state.isLoaded; },
checkField: (_object: string, field: string, action: string) =>
action === 'read' ? state.readable.includes(field) : true,
check: () => ({ allowed: true }),
getFieldPermissions: () => [],
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
userId: null,
systemPermissions: undefined,
hasCapabilities: () => true,
can: () => true,
cannot: () => false,
},
};
});

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
return { ...actual, usePermissions: () => permsStub as any };
});

vi.mock('@object-ui/auth', () => ({
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));
vi.mock('@object-ui/collaboration', () => ({
useRecordPresence: () => ({ viewers: [], others: [] }),
PresenceAvatars: () => null,
}));
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), info: vi.fn(),
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
}),
}));
// Orthogonal chrome — this file observes the record query's parameters only.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';

const OBJECT = 'os_7230_opportunity';
const REC = 'rec-1';

/**
* `account` is the readable `lookup` control, `owner_dept` the denied
* `master_detail`, `secret_account` the denied `lookup` under test.
*/
const objectDef = {
name: OBJECT,
label: 'Opportunity',
managedBy: 'platform',
highlightFields: ['name'],
fields: {
id: { label: 'Id', type: 'text' },
name: { label: 'Name', type: 'text' },
stage: { label: 'Stage', type: 'text' },
account: { label: 'Account', type: 'lookup', reference_to: 'accounts' },
secret_account: { label: 'Secret Account', type: 'lookup', reference_to: 'accounts' },
owner_dept: { label: 'Dept', type: 'master_detail', reference_to: 'departments' },
},
};

const RECORD = { id: REC, name: 'Big deal', stage: 'new' };

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })),
findOne: vi.fn(async (_name: string, id: string) => ({ ...RECORD, id })),
create: vi.fn(async (_o: string, row: any) => row),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
getObjectSchema: async (name: string) => ({ name, fields: objectDef.fields }),
} as Record<string, any>;
}

function makeMetadata() {
const pages: any[] = [];
return {
objects: [], pages, loading: false, error: null,
refresh: async () => {}, invalidate: () => {},
ensureType: async () => pages, getItem: async () => null,
getItemsByType: () => pages,
} as any;
}

/**
* Mount the record page as a tenant with NO assigned page gets it (the metadata
* context carries none, so the page is synthesized) and hand back the `$expand`
* of the record query.
*
* The `waitFor` targets a real recorded `findOne` for THIS object, so a page
* that stopped loading its record times out rather than reading as an empty
* expansion.
*/
async function expandFor(): Promise<string[]> {
const ds = makeDataSource();
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT}/${REC}`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={ds as never}
objects={[objectDef] as never}
onEdit={() => {}}
objectNameOverride={OBJECT}
recordIdOverride={REC}
embedded
/>
</MetadataCtx.Provider>
</MemoryRouter>,
);
await waitFor(() =>
expect(ds.findOne.mock.calls.some((c: any[]) => c[0] === OBJECT)).toBe(true));
const call = ds.findOne.mock.calls.filter((c: any[]) => c[0] === OBJECT).at(-1);
return (call?.[2]?.$expand ?? []) as string[];
}

beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

beforeEach(() => {
cleanup();
vi.clearAllMocks();
state.isLoaded = true;
state.readable = [];
vi.spyOn(console, 'error').mockImplementation(() => {});
// Unrelated chrome (approvals, favourites, row-level verdicts) reaches for
// the platform API; happy-dom would resolve those relative URLs to a real
// socket, which the repo's network-escape guard fails the file for
// (objectui#6640). Serve them from a double — none of it is what this file
// observes.
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ allowed: true, data: [] }),
text: async () => '{}',
})) as never);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('RecordDetailView — `$expand` is FLS-gated (objectui#7230)', () => {
// ── PIN 1: the defect itself ────────────────────────────────────────────
it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'with no column list the record page expands EVERY declared relation, so a denied '
+ 'lookup is asked for on every record page by default',
).not.toContain('secret_account');
});

// ── PIN 2: the live control — the gate narrows, it never empties ────────
it('still expands a lookup the principal CAN read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'the page subtitle interpolation and the `record:*` renderers depend on the expanded '
+ 'display name; a gate that emptied the expansion would show raw ids instead',
).toContain('account');
});

// ── PIN 3: `master_detail`, not only `lookup` ───────────────────────────
it('gates a denied `master_detail` root too, not only `lookup`', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(expand).not.toContain('owner_dept');
expect(expand).toContain('account');
});

// ── PIN 4: the whole set, asserted exactly ─────────────────────────────
it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand.slice().sort(),
'an absence assertion alone would also pass if the expansion had gone empty',
).toEqual(['account']);
});

// ── PIN 5: every relation denied → no `$expand` at all ─────────────────
it('omits `$expand` entirely when every declared relation is denied', async () => {
state.readable = ['id', 'name', 'stage'];
const expand = await expandFor();
expect(expand).toEqual([]);
});

// ── PIN 6: deferral — an unanswered policy filters nothing ─────────────
it('filters NOTHING while `/me/permissions` has not answered', async () => {
state.isLoaded = false;
state.readable = [];
const expand = await expandFor();
expect(
expand,
'never filter on an unanswered policy — the no-provider default is `isLoaded: false` '
+ 'forever, and a console with no PermissionProvider must keep expanding',
).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept']));
});
});
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
45 changes: 45 additions & 0 deletions .changeset/7230-expand-fls-gate-five-sites.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/app-shell': patch
---

FLS-gate the `$expand` projection at the five remaining build sites (objectui#7230).

objectui#7215 / PR #7229 gated `$expand` at the two projection sites in its scope
(`ObjectGrid`, `ListView`). The helper is reached from more places than that. This
closes the five that were left: `ObjectCalendar`, `ObjectGantt`, `RecordDetailView`,
`DetailView`, and `ObjectDataTable` (which builds its own whitelist in
`computeLookupExpand` rather than calling `buildExpandFields`).

**Three of them pass no column list at all**, which makes them the sharp ones:
`buildExpandFields` reads an absent column list as "no column restriction" and falls
back to **every declared relation on the object**, denied ones included. So a standalone
calendar, a gantt, and every record page in the console asked the server to resolve the
object's full relation set by default rather than by configuration.

**`DetailView` was input-gated, and that is the defect rather than the fix.** Its column
list is already FLS-filtered field by field, which is exactly the route PR #7229 measured
as unsound: an emptied column list reads as "no column restriction", so a detail view
whose authored fields are all denied had its `$expand` **widened** from the relations it
asked for to every relation the object declares. The principal who may read least was
asking for the most.

**Reproduced before it was fixed**, as a failing test per site.

**Grading, measured rather than assumed.** Against ObjectStack's own server this is
defence-in-depth, exactly as objectui#6898 and #7215 are: `plugin-security`'s
`FieldMasker.maskRecord` deletes every unreadable key from each returned row and
objectql's expand path writes the resolved record back under that same key, so one
statement removes the expanded object and the bare id alike; the expansion sub-read is
itself gated (`__expandRead` takes the referenced object's full CRUD + RLS + FLS
treatment). It is load-bearing for any backend that does not strip, and the
client-request side is real regardless.

**Nothing a permitted view did stops working.** The gate judges each helper's OUTPUT,
which contains only the object's declared reference-bearing fields, so the "`checkField`
answers false for an undeclared key" trap cannot be reached and derived / host-joined
columns are untouched. An unanswered permission policy filters nothing. Neither
`buildExpandFields` nor `computeLookupExpand` is changed.
279 changes: 279 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7230 — field-level security on the record page's `$expand`.
*
* ## The site
*
* `RecordDetailView` loads the record backing an assigned or synthesized page
* with
*
* const expandFields = buildExpandFields(objectDef?.fields);
*
* — **no column list**. `buildExpandFields` reads an absent column list as "no
* column restriction" and falls back to **every declared relation on the
* object**, denied ones included. So every record page in the console asks the
* server to resolve the object's full relation set, by default rather than by
* configuration. objectui#7215 / PR #7229 gated the two projection sites in its
* scope; this call site was outside it.
*
* ## Grading — the same reading #7215 recorded, not a stronger claim
*
* Against ObjectStack's own server this is defence-in-depth, not a live
* disclosure: `plugin-security`'s `FieldMasker.maskRecord` does
* `delete result[field]` on every unreadable key and objectql writes the
* expanded record back under THAT SAME KEY, so one statement removes the
* expanded object and the bare id alike; the expansion sub-read takes the
* referenced object's full CRUD + RLS + FLS treatment (objectstack#7626). It is
* load-bearing for a backend that does not strip, and the client-request side
* is real either way.
*
* ## The gate is on the OUTPUT — copied from #7229
*
* There is no input to gate (the call passes `undefined`), and the output
* contains only DECLARED reference-bearing fields, so the "`checkField` answers
* false for an undeclared key" trap is structurally unreachable.
*
* ⚠️ One structural note that is load-bearing rather than cosmetic: this
* component read `usePermissions()` ~670 lines BELOW this effect. The effect's
* dependency array is evaluated DURING render, so listing `perms` there while
* the binding was still declared below would throw
* `Cannot access 'perms' before initialization` — a crash, not a stale value.
* The hook call moved above the effect; the later destructure now reads that
* one value instead of calling the hook again. Same lesson PR #7229 recorded
* for `ListView`'s memo.
*
* The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`: the
* real provider answers `true` for any field no policy mentions.
*/
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';

/** Stable stub identity — `perms` rides the record-load effect's dependency list. */
const { permsStub, state } = vi.hoisted(() => {
const state: { isLoaded: boolean; readable: string[] } = { isLoaded: true, readable: [] };
return {
state,
permsStub: {
get isLoaded() { return state.isLoaded; },
checkField: (_object: string, field: string, action: string) =>
action === 'read' ? state.readable.includes(field) : true,
check: () => ({ allowed: true }),
getFieldPermissions: () => [],
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
userId: null,
systemPermissions: undefined,
hasCapabilities: () => true,
can: () => true,
cannot: () => false,
},
};
});

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
return { ...actual, usePermissions: () => permsStub as any };
});

vi.mock('@object-ui/auth', () => ({
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));
vi.mock('@object-ui/collaboration', () => ({
useRecordPresence: () => ({ viewers: [], others: [] }),
PresenceAvatars: () => null,
}));
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), info: vi.fn(),
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
}),
}));
// Orthogonal chrome — this file observes the record query's parameters only.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';

const OBJECT = 'os_7230_opportunity';
const REC = 'rec-1';

/**
* `account` is the readable `lookup` control, `owner_dept` the denied
* `master_detail`, `secret_account` the denied `lookup` under test.
*/
const objectDef = {
name: OBJECT,
label: 'Opportunity',
managedBy: 'platform',
highlightFields: ['name'],
fields: {
id: { label: 'Id', type: 'text' },
name: { label: 'Name', type: 'text' },
stage: { label: 'Stage', type: 'text' },
account: { label: 'Account', type: 'lookup', reference_to: 'accounts' },
secret_account: { label: 'Secret Account', type: 'lookup', reference_to: 'accounts' },
owner_dept: { label: 'Dept', type: 'master_detail', reference_to: 'departments' },
},
};

const RECORD = { id: REC, name: 'Big deal', stage: 'new' };

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })),
findOne: vi.fn(async (_name: string, id: string) => ({ ...RECORD, id })),
create: vi.fn(async (_o: string, row: any) => row),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
getObjectSchema: async (name: string) => ({ name, fields: objectDef.fields }),
} as Record<string, any>;
}

function makeMetadata() {
const pages: any[] = [];
return {
objects: [], pages, loading: false, error: null,
refresh: async () => {}, invalidate: () => {},
ensureType: async () => pages, getItem: async () => null,
getItemsByType: () => pages,
} as any;
}

/**
* Mount the record page as a tenant with NO assigned page gets it (the metadata
* context carries none, so the page is synthesized) and hand back the `$expand`
* of the record query.
*
* The `waitFor` targets a real recorded `findOne` for THIS object, so a page
* that stopped loading its record times out rather than reading as an empty
* expansion.
*/
async function expandFor(): Promise<string[]> {
const ds = makeDataSource();
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT}/${REC}`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={ds as never}
objects={[objectDef] as never}
onEdit={() => {}}
objectNameOverride={OBJECT}
recordIdOverride={REC}
embedded
/>
</MetadataCtx.Provider>
</MemoryRouter>,
);
await waitFor(() =>
expect(ds.findOne.mock.calls.some((c: any[]) => c[0] === OBJECT)).toBe(true));
const call = ds.findOne.mock.calls.filter((c: any[]) => c[0] === OBJECT).at(-1);
return (call?.[2]?.$expand ?? []) as string[];
}

beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

beforeEach(() => {
cleanup();
vi.clearAllMocks();
state.isLoaded = true;
state.readable = [];
vi.spyOn(console, 'error').mockImplementation(() => {});
// Unrelated chrome (approvals, favourites, row-level verdicts) reaches for
// the platform API; happy-dom would resolve those relative URLs to a real
// socket, which the repo's network-escape guard fails the file for
// (objectui#6640). Serve them from a double — none of it is what this file
// observes.
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ allowed: true, data: [] }),
text: async () => '{}',
})) as never);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('RecordDetailView — `$expand` is FLS-gated (objectui#7230)', () => {
// ── PIN 1: the defect itself ────────────────────────────────────────────
it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'with no column list the record page expands EVERY declared relation, so a denied '
+ 'lookup is asked for on every record page by default',
).not.toContain('secret_account');
});

// ── PIN 2: the live control — the gate narrows, it never empties ────────
it('still expands a lookup the principal CAN read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'the page subtitle interpolation and the `record:*` renderers depend on the expanded '
+ 'display name; a gate that emptied the expansion would show raw ids instead',
).toContain('account');
});

// ── PIN 3: `master_detail`, not only `lookup` ───────────────────────────
it('gates a denied `master_detail` root too, not only `lookup`', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(expand).not.toContain('owner_dept');
expect(expand).toContain('account');
});

// ── PIN 4: the whole set, asserted exactly ─────────────────────────────
it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand.slice().sort(),
'an absence assertion alone would also pass if the expansion had gone empty',
).toEqual(['account']);
});

// ── PIN 5: every relation denied → no `$expand` at all ─────────────────
it('omits `$expand` entirely when every declared relation is denied', async () => {
state.readable = ['id', 'name', 'stage'];
const expand = await expandFor();
expect(expand).toEqual([]);
});

// ── PIN 6: deferral — an unanswered policy filters nothing ─────────────
it('filters NOTHING while `/me/permissions` has not answered', async () => {
state.isLoaded = false;
state.readable = [];
const expand = await expandFor();
expect(
expand,
'never filter on an unanswered policy — the no-provider default is `isLoaded: false` '
+ 'forever, and a console with no PermissionProvider must keep expanding',
).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept']));
});
});
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
45 changes: 45 additions & 0 deletions .changeset/7230-expand-fls-gate-five-sites.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/app-shell': patch
---

FLS-gate the `$expand` projection at the five remaining build sites (objectui#7230).

objectui#7215 / PR #7229 gated `$expand` at the two projection sites in its scope
(`ObjectGrid`, `ListView`). The helper is reached from more places than that. This
closes the five that were left: `ObjectCalendar`, `ObjectGantt`, `RecordDetailView`,
`DetailView`, and `ObjectDataTable` (which builds its own whitelist in
`computeLookupExpand` rather than calling `buildExpandFields`).

**Three of them pass no column list at all**, which makes them the sharp ones:
`buildExpandFields` reads an absent column list as "no column restriction" and falls
back to **every declared relation on the object**, denied ones included. So a standalone
calendar, a gantt, and every record page in the console asked the server to resolve the
object's full relation set by default rather than by configuration.

**`DetailView` was input-gated, and that is the defect rather than the fix.** Its column
list is already FLS-filtered field by field, which is exactly the route PR #7229 measured
as unsound: an emptied column list reads as "no column restriction", so a detail view
whose authored fields are all denied had its `$expand` **widened** from the relations it
asked for to every relation the object declares. The principal who may read least was
asking for the most.

**Reproduced before it was fixed**, as a failing test per site.

**Grading, measured rather than assumed.** Against ObjectStack's own server this is
defence-in-depth, exactly as objectui#6898 and #7215 are: `plugin-security`'s
`FieldMasker.maskRecord` deletes every unreadable key from each returned row and
objectql's expand path writes the resolved record back under that same key, so one
statement removes the expanded object and the bare id alike; the expansion sub-read is
itself gated (`__expandRead` takes the referenced object's full CRUD + RLS + FLS
treatment). It is load-bearing for any backend that does not strip, and the
client-request side is real regardless.

**Nothing a permitted view did stops working.** The gate judges each helper's OUTPUT,
which contains only the object's declared reference-bearing fields, so the "`checkField`
answers false for an undeclared key" trap cannot be reached and derived / host-joined
columns are untouched. An unanswered permission policy filters nothing. Neither
`buildExpandFields` nor `computeLookupExpand` is changed.
279 changes: 279 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.expandFls-7230.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7230 — field-level security on the record page's `$expand`.
*
* ## The site
*
* `RecordDetailView` loads the record backing an assigned or synthesized page
* with
*
* const expandFields = buildExpandFields(objectDef?.fields);
*
* — **no column list**. `buildExpandFields` reads an absent column list as "no
* column restriction" and falls back to **every declared relation on the
* object**, denied ones included. So every record page in the console asks the
* server to resolve the object's full relation set, by default rather than by
* configuration. objectui#7215 / PR #7229 gated the two projection sites in its
* scope; this call site was outside it.
*
* ## Grading — the same reading #7215 recorded, not a stronger claim
*
* Against ObjectStack's own server this is defence-in-depth, not a live
* disclosure: `plugin-security`'s `FieldMasker.maskRecord` does
* `delete result[field]` on every unreadable key and objectql writes the
* expanded record back under THAT SAME KEY, so one statement removes the
* expanded object and the bare id alike; the expansion sub-read takes the
* referenced object's full CRUD + RLS + FLS treatment (objectstack#7626). It is
* load-bearing for a backend that does not strip, and the client-request side
* is real either way.
*
* ## The gate is on the OUTPUT — copied from #7229
*
* There is no input to gate (the call passes `undefined`), and the output
* contains only DECLARED reference-bearing fields, so the "`checkField` answers
* false for an undeclared key" trap is structurally unreachable.
*
* ⚠️ One structural note that is load-bearing rather than cosmetic: this
* component read `usePermissions()` ~670 lines BELOW this effect. The effect's
* dependency array is evaluated DURING render, so listing `perms` there while
* the binding was still declared below would throw
* `Cannot access 'perms' before initialization` — a crash, not a stale value.
* The hook call moved above the effect; the later destructure now reads that
* one value instead of calling the hook again. Same lesson PR #7229 recorded
* for `ListView`'s memo.
*
* The stub `checkField` is an ALLOWLIST, per `expandFls-7215.test.tsx`: the
* real provider answers `true` for any field no policy mentions.
*/
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';

/** Stable stub identity — `perms` rides the record-load effect's dependency list. */
const { permsStub, state } = vi.hoisted(() => {
const state: { isLoaded: boolean; readable: string[] } = { isLoaded: true, readable: [] };
return {
state,
permsStub: {
get isLoaded() { return state.isLoaded; },
checkField: (_object: string, field: string, action: string) =>
action === 'read' ? state.readable.includes(field) : true,
check: () => ({ allowed: true }),
getFieldPermissions: () => [],
getRowFilter: () => undefined,
getObjectApiOperations: () => undefined,
roles: [],
userId: null,
systemPermissions: undefined,
hasCapabilities: () => true,
can: () => true,
cannot: () => false,
},
};
});

vi.mock('@object-ui/permissions', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
return { ...actual, usePermissions: () => permsStub as any };
});

vi.mock('@object-ui/auth', () => ({
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));
vi.mock('@object-ui/collaboration', () => ({
useRecordPresence: () => ({ viewers: [], others: [] }),
PresenceAvatars: () => null,
}));
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), info: vi.fn(),
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
}),
}));
// Orthogonal chrome — this file observes the record query's parameters only.
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
}));

import { RecordDetailView } from './RecordDetailView';

const OBJECT = 'os_7230_opportunity';
const REC = 'rec-1';

/**
* `account` is the readable `lookup` control, `owner_dept` the denied
* `master_detail`, `secret_account` the denied `lookup` under test.
*/
const objectDef = {
name: OBJECT,
label: 'Opportunity',
managedBy: 'platform',
highlightFields: ['name'],
fields: {
id: { label: 'Id', type: 'text' },
name: { label: 'Name', type: 'text' },
stage: { label: 'Stage', type: 'text' },
account: { label: 'Account', type: 'lookup', reference_to: 'accounts' },
secret_account: { label: 'Secret Account', type: 'lookup', reference_to: 'accounts' },
owner_dept: { label: 'Dept', type: 'master_detail', reference_to: 'departments' },
},
};

const RECORD = { id: REC, name: 'Big deal', stage: 'new' };

function makeDataSource() {
return {
find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })),
findOne: vi.fn(async (_name: string, id: string) => ({ ...RECORD, id })),
create: vi.fn(async (_o: string, row: any) => row),
update: vi.fn(async () => ({})),
delete: vi.fn(async () => ({})),
getObjectSchema: async (name: string) => ({ name, fields: objectDef.fields }),
} as Record<string, any>;
}

function makeMetadata() {
const pages: any[] = [];
return {
objects: [], pages, loading: false, error: null,
refresh: async () => {}, invalidate: () => {},
ensureType: async () => pages, getItem: async () => null,
getItemsByType: () => pages,
} as any;
}

/**
* Mount the record page as a tenant with NO assigned page gets it (the metadata
* context carries none, so the page is synthesized) and hand back the `$expand`
* of the record query.
*
* The `waitFor` targets a real recorded `findOne` for THIS object, so a page
* that stopped loading its record times out rather than reading as an empty
* expansion.
*/
async function expandFor(): Promise<string[]> {
const ds = makeDataSource();
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT}/${REC}`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={ds as never}
objects={[objectDef] as never}
onEdit={() => {}}
objectNameOverride={OBJECT}
recordIdOverride={REC}
embedded
/>
</MetadataCtx.Provider>
</MemoryRouter>,
);
await waitFor(() =>
expect(ds.findOne.mock.calls.some((c: any[]) => c[0] === OBJECT)).toBe(true));
const call = ds.findOne.mock.calls.filter((c: any[]) => c[0] === OBJECT).at(-1);
return (call?.[2]?.$expand ?? []) as string[];
}

beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

beforeEach(() => {
cleanup();
vi.clearAllMocks();
state.isLoaded = true;
state.readable = [];
vi.spyOn(console, 'error').mockImplementation(() => {});
// Unrelated chrome (approvals, favourites, row-level verdicts) reaches for
// the platform API; happy-dom would resolve those relative URLs to a real
// socket, which the repo's network-escape guard fails the file for
// (objectui#6640). Serve them from a double — none of it is what this file
// observes.
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ allowed: true, data: [] }),
text: async () => '{}',
})) as never);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('RecordDetailView — `$expand` is FLS-gated (objectui#7230)', () => {
// ── PIN 1: the defect itself ────────────────────────────────────────────
it('does NOT ask the server to EXPAND a lookup the principal cannot read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'with no column list the record page expands EVERY declared relation, so a denied '
+ 'lookup is asked for on every record page by default',
).not.toContain('secret_account');
});

// ── PIN 2: the live control — the gate narrows, it never empties ────────
it('still expands a lookup the principal CAN read', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand,
'the page subtitle interpolation and the `record:*` renderers depend on the expanded '
+ 'display name; a gate that emptied the expansion would show raw ids instead',
).toContain('account');
});

// ── PIN 3: `master_detail`, not only `lookup` ───────────────────────────
it('gates a denied `master_detail` root too, not only `lookup`', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(expand).not.toContain('owner_dept');
expect(expand).toContain('account');
});

// ── PIN 4: the whole set, asserted exactly ─────────────────────────────
it('sends exactly the readable relations — asserted as a set, not merely by absence', async () => {
state.readable = ['id', 'name', 'stage', 'account'];
const expand = await expandFor();
expect(
expand.slice().sort(),
'an absence assertion alone would also pass if the expansion had gone empty',
).toEqual(['account']);
});

// ── PIN 5: every relation denied → no `$expand` at all ─────────────────
it('omits `$expand` entirely when every declared relation is denied', async () => {
state.readable = ['id', 'name', 'stage'];
const expand = await expandFor();
expect(expand).toEqual([]);
});

// ── PIN 6: deferral — an unanswered policy filters nothing ─────────────
it('filters NOTHING while `/me/permissions` has not answered', async () => {
state.isLoaded = false;
state.readable = [];
const expand = await expandFor();
expect(
expand,
'never filter on an unanswered policy — the no-provider default is `isLoaded: false` '
+ 'forever, and a console with no PermissionProvider must keep expanding',
).toEqual(expect.arrayContaining(['account', 'secret_account', 'owner_dept']));
});
});
Loading
Loading