Skip to content

fix(plugin-grid): re-apply field-level security on ObjectGrid's inline-data column path - #6800

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6723-inline-fls
Aug 30, 2026
Merged

fix(plugin-grid): re-apply field-level security on ObjectGrid's inline-data column path#6800
os-sam merged 2 commits into
mainfrom
claude/issue-6723-inline-fls

Conversation

@claude

@claudeclaudeBot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes#6723

Narrow defence-in-depth fix, implementing the maintainer ruling of 2026-08-29
(quoted verbatim on the card). ObjectGrid.generateColumns() re-applied
field-level security at exactly one place — the object-schema path. The
inline-data path, taken when a host hands rows down as dataand the author
declared a fields projection, had no equivalent check, so whether an
object-bound grid re-checked FLS depended on who fetched the rows.

What changed

One predicate, in packages/plugin-grid/src/ObjectGrid.tsx:

constfieldsToShow=(schemaFields||Object.keys(inlineData[0])).filter((fieldName)=>{if(!perms?.isLoaded||!schema.objectName)returntrue;if(!Object.prototype.hasOwnProperty.call(objectSchema?.fields??{},fieldName))returntrue;returnperms.checkField(schema.objectName,fieldName,'read');});

Same gate and same deferral condition (perms.isLoaded && schema.objectName)
the object-schema path has always used.

Only keys the object DECLARES are judged. That limit is the ruling's
load-bearing clause, not an optimisation: host-joined and derived keys pass
through untouched, because keeping them is this path's whole reason to exist
(the object-schema path drops them outright with if (!field) return;). A field
policy that enumerates readable fields answers "no" for a key it has never heard
of, so judging derived keys would silently drop them — the exact failure the
card's own analysis warned about. Declaration is read with hasOwnProperty, so
an inherited name (constructor, toString) is not mistaken for a declared
field.

Refused by name in the ruling, and not done here: merging the two paths'
label handling (resolveFieldLabel i18n vs the inline path's local
humanisation), the undeclared-key drop, and anything on the ListView host
side.

Premise verification

The card's premise holds on my base, d06059f24 (same ref the dispatch named).
git grep -n checkField -- packages/plugin-grid/src/ObjectGrid.tsx returns
exactly two lines — one comment and one call — and the call is inside the
object-schema path's fieldsToShow.forEach. generateColumns opens at line
1827; the call was at 2400. The inline-data path did not filter.

Acceptance pins, and the ablation in both directions

packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx (8 cases) and
packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx (3
cases). Ablation method: this branch's only edit to ObjectGrid.tsx is the
guard, so the mutation is git checkout d06059f24 -- ObjectGrid.tsx, confirmed
on disk by blob hash before the run (0988ddca0 = base blob, not 2b55805e4 =
head blob) and restored under a trap ... EXIT INT TERM; the restore was proved
by an empty git diff HEAD plus a disk hash equal to the HEAD blob, not by an
exit code. No rebuild stands between the edit and the run: vitest.config.mts
aliases every @object-ui/* specifier to that package's src, and the pin
imports ../ObjectGrid relatively.

pinwith guardguard removedmoved?
1 — a declared field the principal CAN read rendersgreengreenno
2 — a declared field the principal CANNOT read does NOT render, with host data for itgreenredyes
3 — a non-object derived key is unaffectedgreengreenno
3b — an inherited name is not mistaken for a declared fieldgreengreenno
CONTROL — perms not loaded: nothing filteredgreengreenno
CONTROL — no objectName: nothing filteredgreengreenno
CONTROL — schema in flight: row-key fallback untouchedgreengreenno
WIRING — the REAL PermissionProvider drops the denied columngreenredyes
4 — ListView, all three casesgreengreenno

Ablated run: Tests 2 failed | 9 passed (11). Both reds are the same assertion
asked twice — once of the stub, once of the real provider — and both read
AssertionError: expected [ 'Opportunity Name', 'Salary' ] to deeply equal [ 'Opportunity Name' ]. Restored: Test Files 2 passed (2) / Tests 11 passed (11).

So the guard is not decorative: pin 2 moves, and it moves through the real
PermissionProvider and not only against a double.

Why the stub's checkField is an allowlist

PermissionProvider answers true for a field no policy mentions, so under it
a derived key survives whether or not the guard judges it — pin 3 would be green
in both worlds for the wrong reason and the limit would be untestable. The stub
models the shape a server that ENUMERATES readable fields produces: deny
anything not listed. That is the only policy shape under which the limit is
load-bearing, so it is the one the limit is pinned against.

Pin 4 is measured, not assumed

ListView.inlineFlsNoop-6723.test.tsx mounts the REAL ListView over the REAL
ObjectGrid (registered in heavyDomTests, the same route
ListView.crossPageSelectAll.test.tsx and the two #6598 files take) with a real
PermissionProvider denying a declared field, and asserts the rendered headers
in both handoff shapes. It is green identically with and without the guard —
that identity is the no-op.

The mechanism behind the no-op is pinned too, not just the outcome. ListView
reaches the grid two ways and neither arrives at the inline-data branch carrying
a declared-but-denied key: with authored columns it forwards the already
FLS-filtered columns and the normalizeColumns branch returns first (pinned by
exact header equality); unauthored it forwards fields: undefined, columns: undefined (#6598), so rowKeysWouldOutrankSchemaPolicy sends the grid to the
object-schema path (pinned by the ABSENCE of Id, which is hidden: true
the object-schema policy drops it and the row-key derivation would keep it). A
CONTROL with no policy mounted keeps the two denials from being vacuous.

Verification

All of the below on 7159718f0, the branch head, re-run after the final commit.

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/
    Test Files 151 passed (151) / Tests 1613 passed (1613)
  • pnpm exec vitest run scripts/ (this diff edits vitest.config.mts, which
    several scripts/__tests__ helpers read) → Test Files 88 passed (88) /
    Tests 2447 passed (2447)
  • pnpm --filter @object-ui/plugin-grid type-check and the same for
    plugin-list → both clean. Each package's type-check is
    tsc --noEmit && tsc -p tsconfig.test.json, and --listFiles confirms both
    new test files are in that program (1 hit each), so this is a measurement of
    the tests and not merely of src.
  • Gates, each quoted from its own verdict line, not from a piped exit code:
    check-control-bytes: OK (scanned 5647 tracked text file(s)) ·
    check-vi-mock-specifiers: OK (491 carry a mock) ·
    check-changeset-presence: 3 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s) · check-changeset-fixed: OK ·
    check-changeset-no-major: No changeset declares a major bump ·
    check-phantom-dependencies: Every in-scope import is declared ·
    check-side-effects-array: OK
  • Lint: pnpm --filter @object-ui/plugin-grid lint,
    pnpm --filter @object-ui/plugin-list lint, pnpm lint:root0 errors
    in all three (683 / 442 / 28 pre-existing warnings; eslint exits 0). Declared
    narrowing: this covers every file this PR touches AND every file in both
    packages it touches — 134 and 65 files judged respectively, counted from
    eslint --format json. eslint.config.js sets no project /
    projectService, so type-aware linting is off and nothing in this diff can
    move the verdict on a file it does not contain.

Out of scope, filed separately

After this lands, the authored columns path (normalizeColumns(schemaColumns),
both arms) is the one remaining generateColumns() path with no FLS re-check —
the same defect class, reachable without the host supplying rows at all. The
ruling scoped this card to the inline-data path, so it is not touched here.
Filed as #6799 with the evidence and the two reasons it is a decision rather
than a mechanical follow-up.


Generated by Claude Code

…lumn path
`ObjectGrid.generateColumns()` re-applied FLS at exactly one place, the
object-schema path. The inline-data path — taken when a host hands rows
down as `data` AND the author declared a `fields` projection — had no
equivalent check, so whether an object-bound grid re-checked FLS depended
on who fetched the rows. Same object, same authored projection, two
answers.
The inline-data path now filters each column through
`perms.checkField(objectName, fieldName, 'read')` when
`perms.isLoaded && schema.objectName`, the same gate and the same
deferral condition the object-schema path already used.
Only keys the OBJECT DECLARES are judged; host-joined and derived keys
pass through untouched. That limit is load-bearing rather than an
optimisation: a policy that enumerates readable fields answers "no" for a
key it never heard of, so judging derived keys would silently drop them.
Declaration is read with `hasOwnProperty` so an inherited name is not
mistaken for a declared field.
Deliberately unchanged (refused by name in the maintainer ruling of
2026-08-29): the two paths' label handling, the schema path's drop of
undeclared names, and everything on the `ListView` host side.
Part of #6723
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
…ader
The header predicted one red. The measured ablation (this file restored to
d06059f, guard removed) produced two: PIN 2 and WIRING, the same
assertion asked once of the stub and once of the real
`PermissionProvider`. The six that did not move are named as such, since
"did not move" is the half that says the guard is narrow.
Also records why no rebuild stands between the edit and the run here:
`vitest.config.mts` aliases every `@object-ui/*` specifier to that
package's `src`, and the pin imports `../ObjectGrid` relatively.
Part of #6723
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.1 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-CyJPZKVy.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.75KB116.33KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.72KB54.58KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.40KB1.20KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sales
os-sales marked this pull request as ready for review August 29, 2026 22:15
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 9bd08feAug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6723-inline-fls branch August 30, 2026 02:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sam@claude