fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites - #7229

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate
Sep 1, 2026
Merged

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites#7229
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7215

What an unauthorised principal could observe before, and cannot after

Before. A view could name a lookup / master_detail / user / tree column that the
current principal is denied read on, and that field name went out in $expand. The client
was therefore asking the server to resolve the relation and return the related record,
not merely to return a bare foreign key. objectui#6898 had already stopped the client asking
for the denied field in $select; the same field could still be asked for in $expand, which
is the larger of the two requests.

On the ListView path it was also asking for the field in $select again. Not a second
defect — the measured reach of this one. That builder gates its columns
(rawCols.filter(c => perms.checkField(...))) and then adds the expand roots back
unconditionally (for (const e of expandFields) required.add(e)), on the recorded ground that
those roots are "known-valid because buildExpandFields() derived them from the object
schema". Valid, yes; readable, never asked. So the denied column walked back into $select
through the union. Measured, pre-fix, in the harness: $select came out as
['id', 'subject', 'account', 'secret_account'] with secret_account denied.

After. Neither projection names a denied relation. $expand carries only relations the
principal can read; the $select re-entry on the ListView path is closed with it.

Reachability — stated narrowly

  • Reachable in a real configuration, on the client-request side. No constructed schema is
    needed: the fetch effect reads the authored schema.columns directly, not the FLS-filtered
    render columns, so an ordinary authored view with a denied lookup column is enough. The
    sharpest case needs no column list at all — with no columns, buildExpandFields
    expands every declared relation of the object, denied ones included. That is the default
    shape, not an edge case.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for
    a mechanism I read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] for every unreadable key, and objectql's expand path writes the
    resolved record back under that same key (record[fieldName] = recordMap.get(...) in
    engine.ts), so 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 (objectstack#7626). Same grading objectui#6898 was given, for the
    same reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use. The value of the
    change is that the invariant stops resting on every future backend having enforced it.

The fix, and one deviation from the card's suggested route

Both sites, because objectui#7179 measured that they are independent paths and a one-site fix
ships with a green suite and a still-open surface:

  • packages/plugin-grid/src/ObjectGrid.tsx — the grid's own fetch
  • packages/plugin-list/src/ListView.tsx — the expandFields memo

The card asked for the gate on the column list feeding buildExpandFields. It is on that
helper's OUTPUT instead
, and the reason is measured, not stylistic — both failure modes are
pinned as tests:

  1. buildExpandFields reads an empty column list as "no column restriction"
    (columns.length > 0 guards its intersection) and falls back to expanding every declared
    relation. Gating the input would therefore widen a view whose only relational column is
    denied, from that one expansion to all of them.
  2. The no-columns case passes undefined — there is no input to gate, and it is the case that
    expands the most.

Gating the output also satisfies the ordering the card called mandatory — intersect against the
object's declared fields first, ask checkField only about survivors — structurally
rather than by convention, because buildExpandFields returns a subset of the declared
reference-bearing fields. Every name the gate judges is declared by construction, so the
"checkField answers false for an undeclared key" trap cannot be reached and a derived /
host-joined column is never judged. buildExpandFields itself is unchanged, as ruled.

One structural note in ListView: const perms = usePermissions() moved above the
expandFields memo. useMemo runs its callback during the render that declares it, so a memo
reading perms from below would hit the temporal dead zone and throw
Cannot access 'perms' before initialization — a crash, not a stale value.

Relation roots, not leaves

buildExpandFields returns top-level field names of the object being fetched, never dotted
paths, so the gate asks checkField(object, root, 'read') — exactly the right question. A
denied leaf on the related object is a different object's FLS and is not answerable at
this call site; it is not in scope here and is not silently claimed to be handled.

Tests — reproduced first, as the card required

New, both mirroring projectionFls-6898.test.tsx:

  • packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx — 8 pins
  • packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx — 10 pins

Pre-fix, on 5015fcf52: grid 5 failed | 3 passed (8), list 7 failed | 3 passed (10).
The leak is real at both sites.

Each file carries the live controls, not just the reds: a permitted lookup still expands; an
undeclared derived column is untouched and does not take the expansion down with it; an
unanswered permission policy filters nothing; the input-gating widening trap is pinned;
master_detail is pinned beside lookup; and the grouping / kanban-binding routes into the
expand list take the same gate.

Ablation — the two-site property demonstrated, not asserted

Predicted before running; every count landed as predicted. Each leg mutates one site, proves
the mutation reached disk by replacement-marker count and blob hash, runs both files,
then restores via git checkout HEAD -- ABSOLUTE_PATH and proves the restore by state
(git diff HEAD, git diff --cached, git status --short all empty, blobs back to the HEAD
hashes), with the restore trapped on EXIT INT TERM. The root vitest config aliases
@object-ui/* to each package's src and both files import their subject relatively, so no
build stands between the edit and the run.

LegMutationgrid filelist file
AObjectGrid gate removed5 failed / 3 passed10 passed / 0 failed
BListView gate removed8 passed / 0 failed7 failed / 3 passed

Removing either site's gate leaves the other site's tests fully green. That is the property
objectui#7179 warned about, shown rather than claimed.

Suites, at the commit being shipped

All runs below are at 74f9521ac (this branch's head, after merging origin/main — both
in-flight PRs touching these files landed mid-task and are merged in, not rebased over).

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ from the repo root —
    170 files passed, 1766 tests passed, including projectionFls-6898.test.tsx and both
    groupingProjection-7179 files as green-before-and-after controls.
  • pnpm type-check81/81 tasks successful. Measured rather than assumed that this covers
    the new tests: each package's tsconfig.json excludes __tests__, but type-check chains
    tsc -p tsconfig.test.json, and --listFiles shows both new files in that program (1 hit
    each).
  • npx eslint packages/plugin-grid packages/plugin-list --format json219 files, 0 errors
    (1236 pre-existing warnings). Per-file before/after on the two edited sources is identical:
    ObjectGrid.tsx 0 errors / 211 warnings, ListView.tsx 0 errors / 182 warnings, both on
    origin/main and here — this change adds no lint finding.
  • node scripts/check-changeset-presence.mjs, pnpm check:control-bytes,
    pnpm check:vi-mock-specifiers, pnpm check:vi-mock-inherit — all green.

Declared narrowing. The repo-wide pnpm test and turbo run lint are left to CI. eslint was
run over both affected packages in full rather than the whole repo; the config
(eslint.config.js) sets no parserOptions.project / projectService, so linting is not
type-aware and a file's verdict depends only on its own contents plus the shared config —
neither of which this diff moves for any untouched file. Vitest was narrowed by path to the two
packages the diff touches, which is this repo's documented way to narrow (root cwd, path
filter, no --).

A changeset is included; both packages are published and their behaviour changes.

Generated by Claude Code


Generated by Claude Code

…th sites
objectui#6898 closed field-level security on `$select`; `$expand` was ungated at
both projection build sites, so a lookup / master_detail / user / tree field the
principal cannot read was still handed to the server for expansion — and an
expansion returns the RESOLVED related record where `$select` returns only a bare
foreign key.
Reproduced first as failing tests at both sites. On the `ListView` path the same
gap also reopened `$select`: that builder gates its columns and then adds the
expand roots back unconditionally, so the denied field re-entered the projection
through the union rather than through its own filter.
The gate judges the OUTPUT of `buildExpandFields` rather than its input, which is
what keeps the required ordering structural: the helper already returns a subset
of the object's declared reference-bearing fields, so no undeclared key is ever
judged. Gating the input would instead WIDEN the expansion, because the helper
reads an empty column list as "no column restriction" and falls back to every
relation — and it cannot reach the no-columns case at all. `buildExpandFields`
itself is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-C2Nqdo6R.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.77KB40.75KB
plugin-grid (index.js)208.87KB56.58KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.55KB27.68KB
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)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. The ZONE 1.1 deviation is CONFIRMED: option A, as shipped

Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM. Clean against origin/main231d1b93c (merge-tree exit 0), changeset present, 5 files.

My ruling was wrong; yours is right, and it is pinned rather than argued

I ruled the gate onto the input column list. You put it on buildExpandFields's output and declared the deviation. Confirmed, on your two measured reasons:

  • buildExpandFields reads an empty column list as "no column restriction" and falls back to every declared relation ⇒ gating the input would widen a view whose only relational column is denied, from one expansion to all of them. My ruling would have made the leak bigger in exactly the case it was meant to close.
  • The no-columns case passes undefinedthere is no input to gate. And that is the default shape, not an edge case.

The output gate also satisfies ZONE 1.2's mandatory ordering structurally rather than by convention: the helper already returns a subset of the object's declared reference-bearing fields, so every name judged is declared and the "checkField answers false for an undeclared key" trap is unreachable. That is better than the ordering I asked for, because it cannot be got wrong by a later edit.

Both failure modes of the input-side form are pinned as tests, so B and C are falsifiable claims rather than my preference against yours. That is what made this a five-minute confirmation instead of an argument.

Second ruling of mine overturned today, both by measurement.

⭐ The finding that matters most is the one beyond the card

the projection gates the columns and then adds these roots back unconditionallyfor (const e of expandFields) required.add(e), on the ground that they are "known-valid because buildExpandFields() derived them from the object schema". Valid, yes; READABLE, never asked.

So a denied lookup column walked straight back into $select through the expand union, and objectui#6898's gate was being defeated here by the expand roots rather than by its own filter. Measured pre-fix: $select came out ['id','subject','account','secret_account'].

#6898 was believed closed and had a live bypass. That is not a second defect, it is the measured reach of this one, and gating at the single output point closes both halves. Worth stating plainly in the landing note, because anyone auditing FLS would have read #6898 as settled.

The ZONE 2 falsification was a crash, not a nuance

const perms = usePermissions() sat below the expandFields memo, and useMemo runs its callback during the declaring render — so reading perms from there is a temporal-dead-zone throw, not a stale value.

I flagged "is checkField in scope at both call sites?" as an assumption to check. It was not, and the failure mode was worse than the one I imagined. The declaration moved above the memo with the reason recorded in-comment as load-bearing rather than cosmetic — which is what stops someone re-tidying the imports and reintroducing a crash.

The severity statement is exactly right

I asked for a precise, narrow claim rather than an alarming one, and got a mechanism read rather than assumed:

FieldMasker.maskRecord does delete result[field] and objectql's engine writes the expanded record back under that same key, so one statement removes both; the expansion sub-read itself takes the referenced object's full CRUD+RLS+FLS treatment (objectstack#7626).

⇒ Against ObjectStack's own server this is defence-in-depth, not a live disclosure; it is load-bearing for a non-enforcing backend. Same p2 grading as #6898, for a stated reason. And the client-request side is reachable in a real configuration, because the fetch effect reads the authored schema.columns, not the FLS-filtered render columns.

Also correctly disposed of: the "denied leaf under a permitted root" question is not expressible herebuildExpandFields returns top-level roots, never dotted paths, so checkField(object, root, 'read') is exactly the right question and a denied leaf on the related object is that object's FLS.

Method

Red first at 5015fcf52 — grid 5 failed / 3 passed, list 7 failed / 3 passed — before any fix, which is what makes the green afterwards mean something.

Ablation legs A and B: removing either site's gate leaves the other site's tests fully green. The two-site property demonstrated, not asserted — the same property that made #7179's one-site fix a trap. And the whole ablation was run twice, before and after merging origin/main (both #7220 and #7226 landed mid-task and touch these files), with identical counts.

The no-rebuild leg is declared load-bearing rather than skipped: the root vitest config aliases @object-ui/* to each package's src and both test files import their subject relatively, so no dist can go stale. An empty git hash-object was treated as failure rather than "nothing to compare" — the right instinct, and one that would have caught a mutation that never reached disk.

Armed.

PM follow-up

Your out-of-scope finding could not be filed because the dedupe search hit the rate limit and you correctly did not file blind. I am filing it — the same ungated $expand at five more build sites, with the calendar / gantt / record-detail no-columns shape as the sharpest.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 15:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67dadd6Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7215-expand-fls-gate branch September 1, 2026 16:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned

2 participants

@os-warren@claude
, '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

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites - #7229

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate
Sep 1, 2026
Merged

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites#7229
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7215

What an unauthorised principal could observe before, and cannot after

Before. A view could name a lookup / master_detail / user / tree column that the
current principal is denied read on, and that field name went out in $expand. The client
was therefore asking the server to resolve the relation and return the related record,
not merely to return a bare foreign key. objectui#6898 had already stopped the client asking
for the denied field in $select; the same field could still be asked for in $expand, which
is the larger of the two requests.

On the ListView path it was also asking for the field in $select again. Not a second
defect — the measured reach of this one. That builder gates its columns
(rawCols.filter(c => perms.checkField(...))) and then adds the expand roots back
unconditionally (for (const e of expandFields) required.add(e)), on the recorded ground that
those roots are "known-valid because buildExpandFields() derived them from the object
schema". Valid, yes; readable, never asked. So the denied column walked back into $select
through the union. Measured, pre-fix, in the harness: $select came out as
['id', 'subject', 'account', 'secret_account'] with secret_account denied.

After. Neither projection names a denied relation. $expand carries only relations the
principal can read; the $select re-entry on the ListView path is closed with it.

Reachability — stated narrowly

  • Reachable in a real configuration, on the client-request side. No constructed schema is
    needed: the fetch effect reads the authored schema.columns directly, not the FLS-filtered
    render columns, so an ordinary authored view with a denied lookup column is enough. The
    sharpest case needs no column list at all — with no columns, buildExpandFields
    expands every declared relation of the object, denied ones included. That is the default
    shape, not an edge case.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for
    a mechanism I read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] for every unreadable key, and objectql's expand path writes the
    resolved record back under that same key (record[fieldName] = recordMap.get(...) in
    engine.ts), so 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 (objectstack#7626). Same grading objectui#6898 was given, for the
    same reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use. The value of the
    change is that the invariant stops resting on every future backend having enforced it.

The fix, and one deviation from the card's suggested route

Both sites, because objectui#7179 measured that they are independent paths and a one-site fix
ships with a green suite and a still-open surface:

  • packages/plugin-grid/src/ObjectGrid.tsx — the grid's own fetch
  • packages/plugin-list/src/ListView.tsx — the expandFields memo

The card asked for the gate on the column list feeding buildExpandFields. It is on that
helper's OUTPUT instead
, and the reason is measured, not stylistic — both failure modes are
pinned as tests:

  1. buildExpandFields reads an empty column list as "no column restriction"
    (columns.length > 0 guards its intersection) and falls back to expanding every declared
    relation. Gating the input would therefore widen a view whose only relational column is
    denied, from that one expansion to all of them.
  2. The no-columns case passes undefined — there is no input to gate, and it is the case that
    expands the most.

Gating the output also satisfies the ordering the card called mandatory — intersect against the
object's declared fields first, ask checkField only about survivors — structurally
rather than by convention, because buildExpandFields returns a subset of the declared
reference-bearing fields. Every name the gate judges is declared by construction, so the
"checkField answers false for an undeclared key" trap cannot be reached and a derived /
host-joined column is never judged. buildExpandFields itself is unchanged, as ruled.

One structural note in ListView: const perms = usePermissions() moved above the
expandFields memo. useMemo runs its callback during the render that declares it, so a memo
reading perms from below would hit the temporal dead zone and throw
Cannot access 'perms' before initialization — a crash, not a stale value.

Relation roots, not leaves

buildExpandFields returns top-level field names of the object being fetched, never dotted
paths, so the gate asks checkField(object, root, 'read') — exactly the right question. A
denied leaf on the related object is a different object's FLS and is not answerable at
this call site; it is not in scope here and is not silently claimed to be handled.

Tests — reproduced first, as the card required

New, both mirroring projectionFls-6898.test.tsx:

  • packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx — 8 pins
  • packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx — 10 pins

Pre-fix, on 5015fcf52: grid 5 failed | 3 passed (8), list 7 failed | 3 passed (10).
The leak is real at both sites.

Each file carries the live controls, not just the reds: a permitted lookup still expands; an
undeclared derived column is untouched and does not take the expansion down with it; an
unanswered permission policy filters nothing; the input-gating widening trap is pinned;
master_detail is pinned beside lookup; and the grouping / kanban-binding routes into the
expand list take the same gate.

Ablation — the two-site property demonstrated, not asserted

Predicted before running; every count landed as predicted. Each leg mutates one site, proves
the mutation reached disk by replacement-marker count and blob hash, runs both files,
then restores via git checkout HEAD -- ABSOLUTE_PATH and proves the restore by state
(git diff HEAD, git diff --cached, git status --short all empty, blobs back to the HEAD
hashes), with the restore trapped on EXIT INT TERM. The root vitest config aliases
@object-ui/* to each package's src and both files import their subject relatively, so no
build stands between the edit and the run.

LegMutationgrid filelist file
AObjectGrid gate removed5 failed / 3 passed10 passed / 0 failed
BListView gate removed8 passed / 0 failed7 failed / 3 passed

Removing either site's gate leaves the other site's tests fully green. That is the property
objectui#7179 warned about, shown rather than claimed.

Suites, at the commit being shipped

All runs below are at 74f9521ac (this branch's head, after merging origin/main — both
in-flight PRs touching these files landed mid-task and are merged in, not rebased over).

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ from the repo root —
    170 files passed, 1766 tests passed, including projectionFls-6898.test.tsx and both
    groupingProjection-7179 files as green-before-and-after controls.
  • pnpm type-check81/81 tasks successful. Measured rather than assumed that this covers
    the new tests: each package's tsconfig.json excludes __tests__, but type-check chains
    tsc -p tsconfig.test.json, and --listFiles shows both new files in that program (1 hit
    each).
  • npx eslint packages/plugin-grid packages/plugin-list --format json219 files, 0 errors
    (1236 pre-existing warnings). Per-file before/after on the two edited sources is identical:
    ObjectGrid.tsx 0 errors / 211 warnings, ListView.tsx 0 errors / 182 warnings, both on
    origin/main and here — this change adds no lint finding.
  • node scripts/check-changeset-presence.mjs, pnpm check:control-bytes,
    pnpm check:vi-mock-specifiers, pnpm check:vi-mock-inherit — all green.

Declared narrowing. The repo-wide pnpm test and turbo run lint are left to CI. eslint was
run over both affected packages in full rather than the whole repo; the config
(eslint.config.js) sets no parserOptions.project / projectService, so linting is not
type-aware and a file's verdict depends only on its own contents plus the shared config —
neither of which this diff moves for any untouched file. Vitest was narrowed by path to the two
packages the diff touches, which is this repo's documented way to narrow (root cwd, path
filter, no --).

A changeset is included; both packages are published and their behaviour changes.

Generated by Claude Code


Generated by Claude Code

…th sites
objectui#6898 closed field-level security on `$select`; `$expand` was ungated at
both projection build sites, so a lookup / master_detail / user / tree field the
principal cannot read was still handed to the server for expansion — and an
expansion returns the RESOLVED related record where `$select` returns only a bare
foreign key.
Reproduced first as failing tests at both sites. On the `ListView` path the same
gap also reopened `$select`: that builder gates its columns and then adds the
expand roots back unconditionally, so the denied field re-entered the projection
through the union rather than through its own filter.
The gate judges the OUTPUT of `buildExpandFields` rather than its input, which is
what keeps the required ordering structural: the helper already returns a subset
of the object's declared reference-bearing fields, so no undeclared key is ever
judged. Gating the input would instead WIDEN the expansion, because the helper
reads an empty column list as "no column restriction" and falls back to every
relation — and it cannot reach the no-columns case at all. `buildExpandFields`
itself is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-C2Nqdo6R.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.77KB40.75KB
plugin-grid (index.js)208.87KB56.58KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.55KB27.68KB
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)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. The ZONE 1.1 deviation is CONFIRMED: option A, as shipped

Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM. Clean against origin/main231d1b93c (merge-tree exit 0), changeset present, 5 files.

My ruling was wrong; yours is right, and it is pinned rather than argued

I ruled the gate onto the input column list. You put it on buildExpandFields's output and declared the deviation. Confirmed, on your two measured reasons:

  • buildExpandFields reads an empty column list as "no column restriction" and falls back to every declared relation ⇒ gating the input would widen a view whose only relational column is denied, from one expansion to all of them. My ruling would have made the leak bigger in exactly the case it was meant to close.
  • The no-columns case passes undefinedthere is no input to gate. And that is the default shape, not an edge case.

The output gate also satisfies ZONE 1.2's mandatory ordering structurally rather than by convention: the helper already returns a subset of the object's declared reference-bearing fields, so every name judged is declared and the "checkField answers false for an undeclared key" trap is unreachable. That is better than the ordering I asked for, because it cannot be got wrong by a later edit.

Both failure modes of the input-side form are pinned as tests, so B and C are falsifiable claims rather than my preference against yours. That is what made this a five-minute confirmation instead of an argument.

Second ruling of mine overturned today, both by measurement.

⭐ The finding that matters most is the one beyond the card

the projection gates the columns and then adds these roots back unconditionallyfor (const e of expandFields) required.add(e), on the ground that they are "known-valid because buildExpandFields() derived them from the object schema". Valid, yes; READABLE, never asked.

So a denied lookup column walked straight back into $select through the expand union, and objectui#6898's gate was being defeated here by the expand roots rather than by its own filter. Measured pre-fix: $select came out ['id','subject','account','secret_account'].

#6898 was believed closed and had a live bypass. That is not a second defect, it is the measured reach of this one, and gating at the single output point closes both halves. Worth stating plainly in the landing note, because anyone auditing FLS would have read #6898 as settled.

The ZONE 2 falsification was a crash, not a nuance

const perms = usePermissions() sat below the expandFields memo, and useMemo runs its callback during the declaring render — so reading perms from there is a temporal-dead-zone throw, not a stale value.

I flagged "is checkField in scope at both call sites?" as an assumption to check. It was not, and the failure mode was worse than the one I imagined. The declaration moved above the memo with the reason recorded in-comment as load-bearing rather than cosmetic — which is what stops someone re-tidying the imports and reintroducing a crash.

The severity statement is exactly right

I asked for a precise, narrow claim rather than an alarming one, and got a mechanism read rather than assumed:

FieldMasker.maskRecord does delete result[field] and objectql's engine writes the expanded record back under that same key, so one statement removes both; the expansion sub-read itself takes the referenced object's full CRUD+RLS+FLS treatment (objectstack#7626).

⇒ Against ObjectStack's own server this is defence-in-depth, not a live disclosure; it is load-bearing for a non-enforcing backend. Same p2 grading as #6898, for a stated reason. And the client-request side is reachable in a real configuration, because the fetch effect reads the authored schema.columns, not the FLS-filtered render columns.

Also correctly disposed of: the "denied leaf under a permitted root" question is not expressible herebuildExpandFields returns top-level roots, never dotted paths, so checkField(object, root, 'read') is exactly the right question and a denied leaf on the related object is that object's FLS.

Method

Red first at 5015fcf52 — grid 5 failed / 3 passed, list 7 failed / 3 passed — before any fix, which is what makes the green afterwards mean something.

Ablation legs A and B: removing either site's gate leaves the other site's tests fully green. The two-site property demonstrated, not asserted — the same property that made #7179's one-site fix a trap. And the whole ablation was run twice, before and after merging origin/main (both #7220 and #7226 landed mid-task and touch these files), with identical counts.

The no-rebuild leg is declared load-bearing rather than skipped: the root vitest config aliases @object-ui/* to each package's src and both test files import their subject relatively, so no dist can go stale. An empty git hash-object was treated as failure rather than "nothing to compare" — the right instinct, and one that would have caught a mutation that never reached disk.

Armed.

PM follow-up

Your out-of-scope finding could not be filed because the dedupe search hit the rate limit and you correctly did not file blind. I am filing it — the same ungated $expand at five more build sites, with the calendar / gantt / record-detail no-columns shape as the sharpest.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 15:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67dadd6Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7215-expand-fls-gate branch September 1, 2026 16:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned

2 participants

@os-warren@claude
, '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

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites - #7229

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate
Sep 1, 2026
Merged

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites#7229
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7215

What an unauthorised principal could observe before, and cannot after

Before. A view could name a lookup / master_detail / user / tree column that the
current principal is denied read on, and that field name went out in $expand. The client
was therefore asking the server to resolve the relation and return the related record,
not merely to return a bare foreign key. objectui#6898 had already stopped the client asking
for the denied field in $select; the same field could still be asked for in $expand, which
is the larger of the two requests.

On the ListView path it was also asking for the field in $select again. Not a second
defect — the measured reach of this one. That builder gates its columns
(rawCols.filter(c => perms.checkField(...))) and then adds the expand roots back
unconditionally (for (const e of expandFields) required.add(e)), on the recorded ground that
those roots are "known-valid because buildExpandFields() derived them from the object
schema". Valid, yes; readable, never asked. So the denied column walked back into $select
through the union. Measured, pre-fix, in the harness: $select came out as
['id', 'subject', 'account', 'secret_account'] with secret_account denied.

After. Neither projection names a denied relation. $expand carries only relations the
principal can read; the $select re-entry on the ListView path is closed with it.

Reachability — stated narrowly

  • Reachable in a real configuration, on the client-request side. No constructed schema is
    needed: the fetch effect reads the authored schema.columns directly, not the FLS-filtered
    render columns, so an ordinary authored view with a denied lookup column is enough. The
    sharpest case needs no column list at all — with no columns, buildExpandFields
    expands every declared relation of the object, denied ones included. That is the default
    shape, not an edge case.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for
    a mechanism I read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] for every unreadable key, and objectql's expand path writes the
    resolved record back under that same key (record[fieldName] = recordMap.get(...) in
    engine.ts), so 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 (objectstack#7626). Same grading objectui#6898 was given, for the
    same reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use. The value of the
    change is that the invariant stops resting on every future backend having enforced it.

The fix, and one deviation from the card's suggested route

Both sites, because objectui#7179 measured that they are independent paths and a one-site fix
ships with a green suite and a still-open surface:

  • packages/plugin-grid/src/ObjectGrid.tsx — the grid's own fetch
  • packages/plugin-list/src/ListView.tsx — the expandFields memo

The card asked for the gate on the column list feeding buildExpandFields. It is on that
helper's OUTPUT instead
, and the reason is measured, not stylistic — both failure modes are
pinned as tests:

  1. buildExpandFields reads an empty column list as "no column restriction"
    (columns.length > 0 guards its intersection) and falls back to expanding every declared
    relation. Gating the input would therefore widen a view whose only relational column is
    denied, from that one expansion to all of them.
  2. The no-columns case passes undefined — there is no input to gate, and it is the case that
    expands the most.

Gating the output also satisfies the ordering the card called mandatory — intersect against the
object's declared fields first, ask checkField only about survivors — structurally
rather than by convention, because buildExpandFields returns a subset of the declared
reference-bearing fields. Every name the gate judges is declared by construction, so the
"checkField answers false for an undeclared key" trap cannot be reached and a derived /
host-joined column is never judged. buildExpandFields itself is unchanged, as ruled.

One structural note in ListView: const perms = usePermissions() moved above the
expandFields memo. useMemo runs its callback during the render that declares it, so a memo
reading perms from below would hit the temporal dead zone and throw
Cannot access 'perms' before initialization — a crash, not a stale value.

Relation roots, not leaves

buildExpandFields returns top-level field names of the object being fetched, never dotted
paths, so the gate asks checkField(object, root, 'read') — exactly the right question. A
denied leaf on the related object is a different object's FLS and is not answerable at
this call site; it is not in scope here and is not silently claimed to be handled.

Tests — reproduced first, as the card required

New, both mirroring projectionFls-6898.test.tsx:

  • packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx — 8 pins
  • packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx — 10 pins

Pre-fix, on 5015fcf52: grid 5 failed | 3 passed (8), list 7 failed | 3 passed (10).
The leak is real at both sites.

Each file carries the live controls, not just the reds: a permitted lookup still expands; an
undeclared derived column is untouched and does not take the expansion down with it; an
unanswered permission policy filters nothing; the input-gating widening trap is pinned;
master_detail is pinned beside lookup; and the grouping / kanban-binding routes into the
expand list take the same gate.

Ablation — the two-site property demonstrated, not asserted

Predicted before running; every count landed as predicted. Each leg mutates one site, proves
the mutation reached disk by replacement-marker count and blob hash, runs both files,
then restores via git checkout HEAD -- ABSOLUTE_PATH and proves the restore by state
(git diff HEAD, git diff --cached, git status --short all empty, blobs back to the HEAD
hashes), with the restore trapped on EXIT INT TERM. The root vitest config aliases
@object-ui/* to each package's src and both files import their subject relatively, so no
build stands between the edit and the run.

LegMutationgrid filelist file
AObjectGrid gate removed5 failed / 3 passed10 passed / 0 failed
BListView gate removed8 passed / 0 failed7 failed / 3 passed

Removing either site's gate leaves the other site's tests fully green. That is the property
objectui#7179 warned about, shown rather than claimed.

Suites, at the commit being shipped

All runs below are at 74f9521ac (this branch's head, after merging origin/main — both
in-flight PRs touching these files landed mid-task and are merged in, not rebased over).

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ from the repo root —
    170 files passed, 1766 tests passed, including projectionFls-6898.test.tsx and both
    groupingProjection-7179 files as green-before-and-after controls.
  • pnpm type-check81/81 tasks successful. Measured rather than assumed that this covers
    the new tests: each package's tsconfig.json excludes __tests__, but type-check chains
    tsc -p tsconfig.test.json, and --listFiles shows both new files in that program (1 hit
    each).
  • npx eslint packages/plugin-grid packages/plugin-list --format json219 files, 0 errors
    (1236 pre-existing warnings). Per-file before/after on the two edited sources is identical:
    ObjectGrid.tsx 0 errors / 211 warnings, ListView.tsx 0 errors / 182 warnings, both on
    origin/main and here — this change adds no lint finding.
  • node scripts/check-changeset-presence.mjs, pnpm check:control-bytes,
    pnpm check:vi-mock-specifiers, pnpm check:vi-mock-inherit — all green.

Declared narrowing. The repo-wide pnpm test and turbo run lint are left to CI. eslint was
run over both affected packages in full rather than the whole repo; the config
(eslint.config.js) sets no parserOptions.project / projectService, so linting is not
type-aware and a file's verdict depends only on its own contents plus the shared config —
neither of which this diff moves for any untouched file. Vitest was narrowed by path to the two
packages the diff touches, which is this repo's documented way to narrow (root cwd, path
filter, no --).

A changeset is included; both packages are published and their behaviour changes.

Generated by Claude Code


Generated by Claude Code

…th sites
objectui#6898 closed field-level security on `$select`; `$expand` was ungated at
both projection build sites, so a lookup / master_detail / user / tree field the
principal cannot read was still handed to the server for expansion — and an
expansion returns the RESOLVED related record where `$select` returns only a bare
foreign key.
Reproduced first as failing tests at both sites. On the `ListView` path the same
gap also reopened `$select`: that builder gates its columns and then adds the
expand roots back unconditionally, so the denied field re-entered the projection
through the union rather than through its own filter.
The gate judges the OUTPUT of `buildExpandFields` rather than its input, which is
what keeps the required ordering structural: the helper already returns a subset
of the object's declared reference-bearing fields, so no undeclared key is ever
judged. Gating the input would instead WIDEN the expansion, because the helper
reads an empty column list as "no column restriction" and falls back to every
relation — and it cannot reach the no-columns case at all. `buildExpandFields`
itself is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-C2Nqdo6R.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.77KB40.75KB
plugin-grid (index.js)208.87KB56.58KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.55KB27.68KB
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)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. The ZONE 1.1 deviation is CONFIRMED: option A, as shipped

Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM. Clean against origin/main231d1b93c (merge-tree exit 0), changeset present, 5 files.

My ruling was wrong; yours is right, and it is pinned rather than argued

I ruled the gate onto the input column list. You put it on buildExpandFields's output and declared the deviation. Confirmed, on your two measured reasons:

  • buildExpandFields reads an empty column list as "no column restriction" and falls back to every declared relation ⇒ gating the input would widen a view whose only relational column is denied, from one expansion to all of them. My ruling would have made the leak bigger in exactly the case it was meant to close.
  • The no-columns case passes undefinedthere is no input to gate. And that is the default shape, not an edge case.

The output gate also satisfies ZONE 1.2's mandatory ordering structurally rather than by convention: the helper already returns a subset of the object's declared reference-bearing fields, so every name judged is declared and the "checkField answers false for an undeclared key" trap is unreachable. That is better than the ordering I asked for, because it cannot be got wrong by a later edit.

Both failure modes of the input-side form are pinned as tests, so B and C are falsifiable claims rather than my preference against yours. That is what made this a five-minute confirmation instead of an argument.

Second ruling of mine overturned today, both by measurement.

⭐ The finding that matters most is the one beyond the card

the projection gates the columns and then adds these roots back unconditionallyfor (const e of expandFields) required.add(e), on the ground that they are "known-valid because buildExpandFields() derived them from the object schema". Valid, yes; READABLE, never asked.

So a denied lookup column walked straight back into $select through the expand union, and objectui#6898's gate was being defeated here by the expand roots rather than by its own filter. Measured pre-fix: $select came out ['id','subject','account','secret_account'].

#6898 was believed closed and had a live bypass. That is not a second defect, it is the measured reach of this one, and gating at the single output point closes both halves. Worth stating plainly in the landing note, because anyone auditing FLS would have read #6898 as settled.

The ZONE 2 falsification was a crash, not a nuance

const perms = usePermissions() sat below the expandFields memo, and useMemo runs its callback during the declaring render — so reading perms from there is a temporal-dead-zone throw, not a stale value.

I flagged "is checkField in scope at both call sites?" as an assumption to check. It was not, and the failure mode was worse than the one I imagined. The declaration moved above the memo with the reason recorded in-comment as load-bearing rather than cosmetic — which is what stops someone re-tidying the imports and reintroducing a crash.

The severity statement is exactly right

I asked for a precise, narrow claim rather than an alarming one, and got a mechanism read rather than assumed:

FieldMasker.maskRecord does delete result[field] and objectql's engine writes the expanded record back under that same key, so one statement removes both; the expansion sub-read itself takes the referenced object's full CRUD+RLS+FLS treatment (objectstack#7626).

⇒ Against ObjectStack's own server this is defence-in-depth, not a live disclosure; it is load-bearing for a non-enforcing backend. Same p2 grading as #6898, for a stated reason. And the client-request side is reachable in a real configuration, because the fetch effect reads the authored schema.columns, not the FLS-filtered render columns.

Also correctly disposed of: the "denied leaf under a permitted root" question is not expressible herebuildExpandFields returns top-level roots, never dotted paths, so checkField(object, root, 'read') is exactly the right question and a denied leaf on the related object is that object's FLS.

Method

Red first at 5015fcf52 — grid 5 failed / 3 passed, list 7 failed / 3 passed — before any fix, which is what makes the green afterwards mean something.

Ablation legs A and B: removing either site's gate leaves the other site's tests fully green. The two-site property demonstrated, not asserted — the same property that made #7179's one-site fix a trap. And the whole ablation was run twice, before and after merging origin/main (both #7220 and #7226 landed mid-task and touch these files), with identical counts.

The no-rebuild leg is declared load-bearing rather than skipped: the root vitest config aliases @object-ui/* to each package's src and both test files import their subject relatively, so no dist can go stale. An empty git hash-object was treated as failure rather than "nothing to compare" — the right instinct, and one that would have caught a mutation that never reached disk.

Armed.

PM follow-up

Your out-of-scope finding could not be filed because the dedupe search hit the rate limit and you correctly did not file blind. I am filing it — the same ungated $expand at five more build sites, with the calendar / gantt / record-detail no-columns shape as the sharpest.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 15:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67dadd6Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7215-expand-fls-gate branch September 1, 2026 16:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned

2 participants

@os-warren@claude
, '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

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites - #7229

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate
Sep 1, 2026
Merged

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites#7229
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7215

What an unauthorised principal could observe before, and cannot after

Before. A view could name a lookup / master_detail / user / tree column that the
current principal is denied read on, and that field name went out in $expand. The client
was therefore asking the server to resolve the relation and return the related record,
not merely to return a bare foreign key. objectui#6898 had already stopped the client asking
for the denied field in $select; the same field could still be asked for in $expand, which
is the larger of the two requests.

On the ListView path it was also asking for the field in $select again. Not a second
defect — the measured reach of this one. That builder gates its columns
(rawCols.filter(c => perms.checkField(...))) and then adds the expand roots back
unconditionally (for (const e of expandFields) required.add(e)), on the recorded ground that
those roots are "known-valid because buildExpandFields() derived them from the object
schema". Valid, yes; readable, never asked. So the denied column walked back into $select
through the union. Measured, pre-fix, in the harness: $select came out as
['id', 'subject', 'account', 'secret_account'] with secret_account denied.

After. Neither projection names a denied relation. $expand carries only relations the
principal can read; the $select re-entry on the ListView path is closed with it.

Reachability — stated narrowly

  • Reachable in a real configuration, on the client-request side. No constructed schema is
    needed: the fetch effect reads the authored schema.columns directly, not the FLS-filtered
    render columns, so an ordinary authored view with a denied lookup column is enough. The
    sharpest case needs no column list at all — with no columns, buildExpandFields
    expands every declared relation of the object, denied ones included. That is the default
    shape, not an edge case.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for
    a mechanism I read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] for every unreadable key, and objectql's expand path writes the
    resolved record back under that same key (record[fieldName] = recordMap.get(...) in
    engine.ts), so 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 (objectstack#7626). Same grading objectui#6898 was given, for the
    same reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use. The value of the
    change is that the invariant stops resting on every future backend having enforced it.

The fix, and one deviation from the card's suggested route

Both sites, because objectui#7179 measured that they are independent paths and a one-site fix
ships with a green suite and a still-open surface:

  • packages/plugin-grid/src/ObjectGrid.tsx — the grid's own fetch
  • packages/plugin-list/src/ListView.tsx — the expandFields memo

The card asked for the gate on the column list feeding buildExpandFields. It is on that
helper's OUTPUT instead
, and the reason is measured, not stylistic — both failure modes are
pinned as tests:

  1. buildExpandFields reads an empty column list as "no column restriction"
    (columns.length > 0 guards its intersection) and falls back to expanding every declared
    relation. Gating the input would therefore widen a view whose only relational column is
    denied, from that one expansion to all of them.
  2. The no-columns case passes undefined — there is no input to gate, and it is the case that
    expands the most.

Gating the output also satisfies the ordering the card called mandatory — intersect against the
object's declared fields first, ask checkField only about survivors — structurally
rather than by convention, because buildExpandFields returns a subset of the declared
reference-bearing fields. Every name the gate judges is declared by construction, so the
"checkField answers false for an undeclared key" trap cannot be reached and a derived /
host-joined column is never judged. buildExpandFields itself is unchanged, as ruled.

One structural note in ListView: const perms = usePermissions() moved above the
expandFields memo. useMemo runs its callback during the render that declares it, so a memo
reading perms from below would hit the temporal dead zone and throw
Cannot access 'perms' before initialization — a crash, not a stale value.

Relation roots, not leaves

buildExpandFields returns top-level field names of the object being fetched, never dotted
paths, so the gate asks checkField(object, root, 'read') — exactly the right question. A
denied leaf on the related object is a different object's FLS and is not answerable at
this call site; it is not in scope here and is not silently claimed to be handled.

Tests — reproduced first, as the card required

New, both mirroring projectionFls-6898.test.tsx:

  • packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx — 8 pins
  • packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx — 10 pins

Pre-fix, on 5015fcf52: grid 5 failed | 3 passed (8), list 7 failed | 3 passed (10).
The leak is real at both sites.

Each file carries the live controls, not just the reds: a permitted lookup still expands; an
undeclared derived column is untouched and does not take the expansion down with it; an
unanswered permission policy filters nothing; the input-gating widening trap is pinned;
master_detail is pinned beside lookup; and the grouping / kanban-binding routes into the
expand list take the same gate.

Ablation — the two-site property demonstrated, not asserted

Predicted before running; every count landed as predicted. Each leg mutates one site, proves
the mutation reached disk by replacement-marker count and blob hash, runs both files,
then restores via git checkout HEAD -- ABSOLUTE_PATH and proves the restore by state
(git diff HEAD, git diff --cached, git status --short all empty, blobs back to the HEAD
hashes), with the restore trapped on EXIT INT TERM. The root vitest config aliases
@object-ui/* to each package's src and both files import their subject relatively, so no
build stands between the edit and the run.

LegMutationgrid filelist file
AObjectGrid gate removed5 failed / 3 passed10 passed / 0 failed
BListView gate removed8 passed / 0 failed7 failed / 3 passed

Removing either site's gate leaves the other site's tests fully green. That is the property
objectui#7179 warned about, shown rather than claimed.

Suites, at the commit being shipped

All runs below are at 74f9521ac (this branch's head, after merging origin/main — both
in-flight PRs touching these files landed mid-task and are merged in, not rebased over).

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ from the repo root —
    170 files passed, 1766 tests passed, including projectionFls-6898.test.tsx and both
    groupingProjection-7179 files as green-before-and-after controls.
  • pnpm type-check81/81 tasks successful. Measured rather than assumed that this covers
    the new tests: each package's tsconfig.json excludes __tests__, but type-check chains
    tsc -p tsconfig.test.json, and --listFiles shows both new files in that program (1 hit
    each).
  • npx eslint packages/plugin-grid packages/plugin-list --format json219 files, 0 errors
    (1236 pre-existing warnings). Per-file before/after on the two edited sources is identical:
    ObjectGrid.tsx 0 errors / 211 warnings, ListView.tsx 0 errors / 182 warnings, both on
    origin/main and here — this change adds no lint finding.
  • node scripts/check-changeset-presence.mjs, pnpm check:control-bytes,
    pnpm check:vi-mock-specifiers, pnpm check:vi-mock-inherit — all green.

Declared narrowing. The repo-wide pnpm test and turbo run lint are left to CI. eslint was
run over both affected packages in full rather than the whole repo; the config
(eslint.config.js) sets no parserOptions.project / projectService, so linting is not
type-aware and a file's verdict depends only on its own contents plus the shared config —
neither of which this diff moves for any untouched file. Vitest was narrowed by path to the two
packages the diff touches, which is this repo's documented way to narrow (root cwd, path
filter, no --).

A changeset is included; both packages are published and their behaviour changes.

Generated by Claude Code


Generated by Claude Code

…th sites
objectui#6898 closed field-level security on `$select`; `$expand` was ungated at
both projection build sites, so a lookup / master_detail / user / tree field the
principal cannot read was still handed to the server for expansion — and an
expansion returns the RESOLVED related record where `$select` returns only a bare
foreign key.
Reproduced first as failing tests at both sites. On the `ListView` path the same
gap also reopened `$select`: that builder gates its columns and then adds the
expand roots back unconditionally, so the denied field re-entered the projection
through the union rather than through its own filter.
The gate judges the OUTPUT of `buildExpandFields` rather than its input, which is
what keeps the required ordering structural: the helper already returns a subset
of the object's declared reference-bearing fields, so no undeclared key is ever
judged. Gating the input would instead WIDEN the expansion, because the helper
reads an empty column list as "no column restriction" and falls back to every
relation — and it cannot reach the no-columns case at all. `buildExpandFields`
itself is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-C2Nqdo6R.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.77KB40.75KB
plugin-grid (index.js)208.87KB56.58KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.55KB27.68KB
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)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. The ZONE 1.1 deviation is CONFIRMED: option A, as shipped

Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM. Clean against origin/main231d1b93c (merge-tree exit 0), changeset present, 5 files.

My ruling was wrong; yours is right, and it is pinned rather than argued

I ruled the gate onto the input column list. You put it on buildExpandFields's output and declared the deviation. Confirmed, on your two measured reasons:

  • buildExpandFields reads an empty column list as "no column restriction" and falls back to every declared relation ⇒ gating the input would widen a view whose only relational column is denied, from one expansion to all of them. My ruling would have made the leak bigger in exactly the case it was meant to close.
  • The no-columns case passes undefinedthere is no input to gate. And that is the default shape, not an edge case.

The output gate also satisfies ZONE 1.2's mandatory ordering structurally rather than by convention: the helper already returns a subset of the object's declared reference-bearing fields, so every name judged is declared and the "checkField answers false for an undeclared key" trap is unreachable. That is better than the ordering I asked for, because it cannot be got wrong by a later edit.

Both failure modes of the input-side form are pinned as tests, so B and C are falsifiable claims rather than my preference against yours. That is what made this a five-minute confirmation instead of an argument.

Second ruling of mine overturned today, both by measurement.

⭐ The finding that matters most is the one beyond the card

the projection gates the columns and then adds these roots back unconditionallyfor (const e of expandFields) required.add(e), on the ground that they are "known-valid because buildExpandFields() derived them from the object schema". Valid, yes; READABLE, never asked.

So a denied lookup column walked straight back into $select through the expand union, and objectui#6898's gate was being defeated here by the expand roots rather than by its own filter. Measured pre-fix: $select came out ['id','subject','account','secret_account'].

#6898 was believed closed and had a live bypass. That is not a second defect, it is the measured reach of this one, and gating at the single output point closes both halves. Worth stating plainly in the landing note, because anyone auditing FLS would have read #6898 as settled.

The ZONE 2 falsification was a crash, not a nuance

const perms = usePermissions() sat below the expandFields memo, and useMemo runs its callback during the declaring render — so reading perms from there is a temporal-dead-zone throw, not a stale value.

I flagged "is checkField in scope at both call sites?" as an assumption to check. It was not, and the failure mode was worse than the one I imagined. The declaration moved above the memo with the reason recorded in-comment as load-bearing rather than cosmetic — which is what stops someone re-tidying the imports and reintroducing a crash.

The severity statement is exactly right

I asked for a precise, narrow claim rather than an alarming one, and got a mechanism read rather than assumed:

FieldMasker.maskRecord does delete result[field] and objectql's engine writes the expanded record back under that same key, so one statement removes both; the expansion sub-read itself takes the referenced object's full CRUD+RLS+FLS treatment (objectstack#7626).

⇒ Against ObjectStack's own server this is defence-in-depth, not a live disclosure; it is load-bearing for a non-enforcing backend. Same p2 grading as #6898, for a stated reason. And the client-request side is reachable in a real configuration, because the fetch effect reads the authored schema.columns, not the FLS-filtered render columns.

Also correctly disposed of: the "denied leaf under a permitted root" question is not expressible herebuildExpandFields returns top-level roots, never dotted paths, so checkField(object, root, 'read') is exactly the right question and a denied leaf on the related object is that object's FLS.

Method

Red first at 5015fcf52 — grid 5 failed / 3 passed, list 7 failed / 3 passed — before any fix, which is what makes the green afterwards mean something.

Ablation legs A and B: removing either site's gate leaves the other site's tests fully green. The two-site property demonstrated, not asserted — the same property that made #7179's one-site fix a trap. And the whole ablation was run twice, before and after merging origin/main (both #7220 and #7226 landed mid-task and touch these files), with identical counts.

The no-rebuild leg is declared load-bearing rather than skipped: the root vitest config aliases @object-ui/* to each package's src and both test files import their subject relatively, so no dist can go stale. An empty git hash-object was treated as failure rather than "nothing to compare" — the right instinct, and one that would have caught a mutation that never reached disk.

Armed.

PM follow-up

Your out-of-scope finding could not be filed because the dedupe search hit the rate limit and you correctly did not file blind. I am filing it — the same ungated $expand at five more build sites, with the calendar / gantt / record-detail no-columns shape as the sharpest.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 15:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67dadd6Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7215-expand-fls-gate branch September 1, 2026 16:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned

2 participants

@os-warren@claude
, '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

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites - #7229

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate
Sep 1, 2026
Merged

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites#7229
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7215

What an unauthorised principal could observe before, and cannot after

Before. A view could name a lookup / master_detail / user / tree column that the
current principal is denied read on, and that field name went out in $expand. The client
was therefore asking the server to resolve the relation and return the related record,
not merely to return a bare foreign key. objectui#6898 had already stopped the client asking
for the denied field in $select; the same field could still be asked for in $expand, which
is the larger of the two requests.

On the ListView path it was also asking for the field in $select again. Not a second
defect — the measured reach of this one. That builder gates its columns
(rawCols.filter(c => perms.checkField(...))) and then adds the expand roots back
unconditionally (for (const e of expandFields) required.add(e)), on the recorded ground that
those roots are "known-valid because buildExpandFields() derived them from the object
schema". Valid, yes; readable, never asked. So the denied column walked back into $select
through the union. Measured, pre-fix, in the harness: $select came out as
['id', 'subject', 'account', 'secret_account'] with secret_account denied.

After. Neither projection names a denied relation. $expand carries only relations the
principal can read; the $select re-entry on the ListView path is closed with it.

Reachability — stated narrowly

  • Reachable in a real configuration, on the client-request side. No constructed schema is
    needed: the fetch effect reads the authored schema.columns directly, not the FLS-filtered
    render columns, so an ordinary authored view with a denied lookup column is enough. The
    sharpest case needs no column list at all — with no columns, buildExpandFields
    expands every declared relation of the object, denied ones included. That is the default
    shape, not an edge case.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for
    a mechanism I read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] for every unreadable key, and objectql's expand path writes the
    resolved record back under that same key (record[fieldName] = recordMap.get(...) in
    engine.ts), so 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 (objectstack#7626). Same grading objectui#6898 was given, for the
    same reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use. The value of the
    change is that the invariant stops resting on every future backend having enforced it.

The fix, and one deviation from the card's suggested route

Both sites, because objectui#7179 measured that they are independent paths and a one-site fix
ships with a green suite and a still-open surface:

  • packages/plugin-grid/src/ObjectGrid.tsx — the grid's own fetch
  • packages/plugin-list/src/ListView.tsx — the expandFields memo

The card asked for the gate on the column list feeding buildExpandFields. It is on that
helper's OUTPUT instead
, and the reason is measured, not stylistic — both failure modes are
pinned as tests:

  1. buildExpandFields reads an empty column list as "no column restriction"
    (columns.length > 0 guards its intersection) and falls back to expanding every declared
    relation. Gating the input would therefore widen a view whose only relational column is
    denied, from that one expansion to all of them.
  2. The no-columns case passes undefined — there is no input to gate, and it is the case that
    expands the most.

Gating the output also satisfies the ordering the card called mandatory — intersect against the
object's declared fields first, ask checkField only about survivors — structurally
rather than by convention, because buildExpandFields returns a subset of the declared
reference-bearing fields. Every name the gate judges is declared by construction, so the
"checkField answers false for an undeclared key" trap cannot be reached and a derived /
host-joined column is never judged. buildExpandFields itself is unchanged, as ruled.

One structural note in ListView: const perms = usePermissions() moved above the
expandFields memo. useMemo runs its callback during the render that declares it, so a memo
reading perms from below would hit the temporal dead zone and throw
Cannot access 'perms' before initialization — a crash, not a stale value.

Relation roots, not leaves

buildExpandFields returns top-level field names of the object being fetched, never dotted
paths, so the gate asks checkField(object, root, 'read') — exactly the right question. A
denied leaf on the related object is a different object's FLS and is not answerable at
this call site; it is not in scope here and is not silently claimed to be handled.

Tests — reproduced first, as the card required

New, both mirroring projectionFls-6898.test.tsx:

  • packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx — 8 pins
  • packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx — 10 pins

Pre-fix, on 5015fcf52: grid 5 failed | 3 passed (8), list 7 failed | 3 passed (10).
The leak is real at both sites.

Each file carries the live controls, not just the reds: a permitted lookup still expands; an
undeclared derived column is untouched and does not take the expansion down with it; an
unanswered permission policy filters nothing; the input-gating widening trap is pinned;
master_detail is pinned beside lookup; and the grouping / kanban-binding routes into the
expand list take the same gate.

Ablation — the two-site property demonstrated, not asserted

Predicted before running; every count landed as predicted. Each leg mutates one site, proves
the mutation reached disk by replacement-marker count and blob hash, runs both files,
then restores via git checkout HEAD -- ABSOLUTE_PATH and proves the restore by state
(git diff HEAD, git diff --cached, git status --short all empty, blobs back to the HEAD
hashes), with the restore trapped on EXIT INT TERM. The root vitest config aliases
@object-ui/* to each package's src and both files import their subject relatively, so no
build stands between the edit and the run.

LegMutationgrid filelist file
AObjectGrid gate removed5 failed / 3 passed10 passed / 0 failed
BListView gate removed8 passed / 0 failed7 failed / 3 passed

Removing either site's gate leaves the other site's tests fully green. That is the property
objectui#7179 warned about, shown rather than claimed.

Suites, at the commit being shipped

All runs below are at 74f9521ac (this branch's head, after merging origin/main — both
in-flight PRs touching these files landed mid-task and are merged in, not rebased over).

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ from the repo root —
    170 files passed, 1766 tests passed, including projectionFls-6898.test.tsx and both
    groupingProjection-7179 files as green-before-and-after controls.
  • pnpm type-check81/81 tasks successful. Measured rather than assumed that this covers
    the new tests: each package's tsconfig.json excludes __tests__, but type-check chains
    tsc -p tsconfig.test.json, and --listFiles shows both new files in that program (1 hit
    each).
  • npx eslint packages/plugin-grid packages/plugin-list --format json219 files, 0 errors
    (1236 pre-existing warnings). Per-file before/after on the two edited sources is identical:
    ObjectGrid.tsx 0 errors / 211 warnings, ListView.tsx 0 errors / 182 warnings, both on
    origin/main and here — this change adds no lint finding.
  • node scripts/check-changeset-presence.mjs, pnpm check:control-bytes,
    pnpm check:vi-mock-specifiers, pnpm check:vi-mock-inherit — all green.

Declared narrowing. The repo-wide pnpm test and turbo run lint are left to CI. eslint was
run over both affected packages in full rather than the whole repo; the config
(eslint.config.js) sets no parserOptions.project / projectService, so linting is not
type-aware and a file's verdict depends only on its own contents plus the shared config —
neither of which this diff moves for any untouched file. Vitest was narrowed by path to the two
packages the diff touches, which is this repo's documented way to narrow (root cwd, path
filter, no --).

A changeset is included; both packages are published and their behaviour changes.

Generated by Claude Code


Generated by Claude Code

…th sites
objectui#6898 closed field-level security on `$select`; `$expand` was ungated at
both projection build sites, so a lookup / master_detail / user / tree field the
principal cannot read was still handed to the server for expansion — and an
expansion returns the RESOLVED related record where `$select` returns only a bare
foreign key.
Reproduced first as failing tests at both sites. On the `ListView` path the same
gap also reopened `$select`: that builder gates its columns and then adds the
expand roots back unconditionally, so the denied field re-entered the projection
through the union rather than through its own filter.
The gate judges the OUTPUT of `buildExpandFields` rather than its input, which is
what keeps the required ordering structural: the helper already returns a subset
of the object's declared reference-bearing fields, so no undeclared key is ever
judged. Gating the input would instead WIDEN the expansion, because the helper
reads an empty column list as "no column restriction" and falls back to every
relation — and it cannot reach the no-columns case at all. `buildExpandFields`
itself is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-C2Nqdo6R.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.77KB40.75KB
plugin-grid (index.js)208.87KB56.58KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.55KB27.68KB
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)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. The ZONE 1.1 deviation is CONFIRMED: option A, as shipped

Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM. Clean against origin/main231d1b93c (merge-tree exit 0), changeset present, 5 files.

My ruling was wrong; yours is right, and it is pinned rather than argued

I ruled the gate onto the input column list. You put it on buildExpandFields's output and declared the deviation. Confirmed, on your two measured reasons:

  • buildExpandFields reads an empty column list as "no column restriction" and falls back to every declared relation ⇒ gating the input would widen a view whose only relational column is denied, from one expansion to all of them. My ruling would have made the leak bigger in exactly the case it was meant to close.
  • The no-columns case passes undefinedthere is no input to gate. And that is the default shape, not an edge case.

The output gate also satisfies ZONE 1.2's mandatory ordering structurally rather than by convention: the helper already returns a subset of the object's declared reference-bearing fields, so every name judged is declared and the "checkField answers false for an undeclared key" trap is unreachable. That is better than the ordering I asked for, because it cannot be got wrong by a later edit.

Both failure modes of the input-side form are pinned as tests, so B and C are falsifiable claims rather than my preference against yours. That is what made this a five-minute confirmation instead of an argument.

Second ruling of mine overturned today, both by measurement.

⭐ The finding that matters most is the one beyond the card

the projection gates the columns and then adds these roots back unconditionallyfor (const e of expandFields) required.add(e), on the ground that they are "known-valid because buildExpandFields() derived them from the object schema". Valid, yes; READABLE, never asked.

So a denied lookup column walked straight back into $select through the expand union, and objectui#6898's gate was being defeated here by the expand roots rather than by its own filter. Measured pre-fix: $select came out ['id','subject','account','secret_account'].

#6898 was believed closed and had a live bypass. That is not a second defect, it is the measured reach of this one, and gating at the single output point closes both halves. Worth stating plainly in the landing note, because anyone auditing FLS would have read #6898 as settled.

The ZONE 2 falsification was a crash, not a nuance

const perms = usePermissions() sat below the expandFields memo, and useMemo runs its callback during the declaring render — so reading perms from there is a temporal-dead-zone throw, not a stale value.

I flagged "is checkField in scope at both call sites?" as an assumption to check. It was not, and the failure mode was worse than the one I imagined. The declaration moved above the memo with the reason recorded in-comment as load-bearing rather than cosmetic — which is what stops someone re-tidying the imports and reintroducing a crash.

The severity statement is exactly right

I asked for a precise, narrow claim rather than an alarming one, and got a mechanism read rather than assumed:

FieldMasker.maskRecord does delete result[field] and objectql's engine writes the expanded record back under that same key, so one statement removes both; the expansion sub-read itself takes the referenced object's full CRUD+RLS+FLS treatment (objectstack#7626).

⇒ Against ObjectStack's own server this is defence-in-depth, not a live disclosure; it is load-bearing for a non-enforcing backend. Same p2 grading as #6898, for a stated reason. And the client-request side is reachable in a real configuration, because the fetch effect reads the authored schema.columns, not the FLS-filtered render columns.

Also correctly disposed of: the "denied leaf under a permitted root" question is not expressible herebuildExpandFields returns top-level roots, never dotted paths, so checkField(object, root, 'read') is exactly the right question and a denied leaf on the related object is that object's FLS.

Method

Red first at 5015fcf52 — grid 5 failed / 3 passed, list 7 failed / 3 passed — before any fix, which is what makes the green afterwards mean something.

Ablation legs A and B: removing either site's gate leaves the other site's tests fully green. The two-site property demonstrated, not asserted — the same property that made #7179's one-site fix a trap. And the whole ablation was run twice, before and after merging origin/main (both #7220 and #7226 landed mid-task and touch these files), with identical counts.

The no-rebuild leg is declared load-bearing rather than skipped: the root vitest config aliases @object-ui/* to each package's src and both test files import their subject relatively, so no dist can go stale. An empty git hash-object was treated as failure rather than "nothing to compare" — the right instinct, and one that would have caught a mutation that never reached disk.

Armed.

PM follow-up

Your out-of-scope finding could not be filed because the dedupe search hit the rate limit and you correctly did not file blind. I am filing it — the same ungated $expand at five more build sites, with the calendar / gantt / record-detail no-columns shape as the sharpest.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 15:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67dadd6Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7215-expand-fls-gate branch September 1, 2026 16:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned

2 participants

@os-warren@claude
, '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

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites - #7229

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate
Sep 1, 2026
Merged

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites#7229
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7215

What an unauthorised principal could observe before, and cannot after

Before. A view could name a lookup / master_detail / user / tree column that the
current principal is denied read on, and that field name went out in $expand. The client
was therefore asking the server to resolve the relation and return the related record,
not merely to return a bare foreign key. objectui#6898 had already stopped the client asking
for the denied field in $select; the same field could still be asked for in $expand, which
is the larger of the two requests.

On the ListView path it was also asking for the field in $select again. Not a second
defect — the measured reach of this one. That builder gates its columns
(rawCols.filter(c => perms.checkField(...))) and then adds the expand roots back
unconditionally (for (const e of expandFields) required.add(e)), on the recorded ground that
those roots are "known-valid because buildExpandFields() derived them from the object
schema". Valid, yes; readable, never asked. So the denied column walked back into $select
through the union. Measured, pre-fix, in the harness: $select came out as
['id', 'subject', 'account', 'secret_account'] with secret_account denied.

After. Neither projection names a denied relation. $expand carries only relations the
principal can read; the $select re-entry on the ListView path is closed with it.

Reachability — stated narrowly

  • Reachable in a real configuration, on the client-request side. No constructed schema is
    needed: the fetch effect reads the authored schema.columns directly, not the FLS-filtered
    render columns, so an ordinary authored view with a denied lookup column is enough. The
    sharpest case needs no column list at all — with no columns, buildExpandFields
    expands every declared relation of the object, denied ones included. That is the default
    shape, not an edge case.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for
    a mechanism I read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] for every unreadable key, and objectql's expand path writes the
    resolved record back under that same key (record[fieldName] = recordMap.get(...) in
    engine.ts), so 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 (objectstack#7626). Same grading objectui#6898 was given, for the
    same reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use. The value of the
    change is that the invariant stops resting on every future backend having enforced it.

The fix, and one deviation from the card's suggested route

Both sites, because objectui#7179 measured that they are independent paths and a one-site fix
ships with a green suite and a still-open surface:

  • packages/plugin-grid/src/ObjectGrid.tsx — the grid's own fetch
  • packages/plugin-list/src/ListView.tsx — the expandFields memo

The card asked for the gate on the column list feeding buildExpandFields. It is on that
helper's OUTPUT instead
, and the reason is measured, not stylistic — both failure modes are
pinned as tests:

  1. buildExpandFields reads an empty column list as "no column restriction"
    (columns.length > 0 guards its intersection) and falls back to expanding every declared
    relation. Gating the input would therefore widen a view whose only relational column is
    denied, from that one expansion to all of them.
  2. The no-columns case passes undefined — there is no input to gate, and it is the case that
    expands the most.

Gating the output also satisfies the ordering the card called mandatory — intersect against the
object's declared fields first, ask checkField only about survivors — structurally
rather than by convention, because buildExpandFields returns a subset of the declared
reference-bearing fields. Every name the gate judges is declared by construction, so the
"checkField answers false for an undeclared key" trap cannot be reached and a derived /
host-joined column is never judged. buildExpandFields itself is unchanged, as ruled.

One structural note in ListView: const perms = usePermissions() moved above the
expandFields memo. useMemo runs its callback during the render that declares it, so a memo
reading perms from below would hit the temporal dead zone and throw
Cannot access 'perms' before initialization — a crash, not a stale value.

Relation roots, not leaves

buildExpandFields returns top-level field names of the object being fetched, never dotted
paths, so the gate asks checkField(object, root, 'read') — exactly the right question. A
denied leaf on the related object is a different object's FLS and is not answerable at
this call site; it is not in scope here and is not silently claimed to be handled.

Tests — reproduced first, as the card required

New, both mirroring projectionFls-6898.test.tsx:

  • packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx — 8 pins
  • packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx — 10 pins

Pre-fix, on 5015fcf52: grid 5 failed | 3 passed (8), list 7 failed | 3 passed (10).
The leak is real at both sites.

Each file carries the live controls, not just the reds: a permitted lookup still expands; an
undeclared derived column is untouched and does not take the expansion down with it; an
unanswered permission policy filters nothing; the input-gating widening trap is pinned;
master_detail is pinned beside lookup; and the grouping / kanban-binding routes into the
expand list take the same gate.

Ablation — the two-site property demonstrated, not asserted

Predicted before running; every count landed as predicted. Each leg mutates one site, proves
the mutation reached disk by replacement-marker count and blob hash, runs both files,
then restores via git checkout HEAD -- ABSOLUTE_PATH and proves the restore by state
(git diff HEAD, git diff --cached, git status --short all empty, blobs back to the HEAD
hashes), with the restore trapped on EXIT INT TERM. The root vitest config aliases
@object-ui/* to each package's src and both files import their subject relatively, so no
build stands between the edit and the run.

LegMutationgrid filelist file
AObjectGrid gate removed5 failed / 3 passed10 passed / 0 failed
BListView gate removed8 passed / 0 failed7 failed / 3 passed

Removing either site's gate leaves the other site's tests fully green. That is the property
objectui#7179 warned about, shown rather than claimed.

Suites, at the commit being shipped

All runs below are at 74f9521ac (this branch's head, after merging origin/main — both
in-flight PRs touching these files landed mid-task and are merged in, not rebased over).

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ from the repo root —
    170 files passed, 1766 tests passed, including projectionFls-6898.test.tsx and both
    groupingProjection-7179 files as green-before-and-after controls.
  • pnpm type-check81/81 tasks successful. Measured rather than assumed that this covers
    the new tests: each package's tsconfig.json excludes __tests__, but type-check chains
    tsc -p tsconfig.test.json, and --listFiles shows both new files in that program (1 hit
    each).
  • npx eslint packages/plugin-grid packages/plugin-list --format json219 files, 0 errors
    (1236 pre-existing warnings). Per-file before/after on the two edited sources is identical:
    ObjectGrid.tsx 0 errors / 211 warnings, ListView.tsx 0 errors / 182 warnings, both on
    origin/main and here — this change adds no lint finding.
  • node scripts/check-changeset-presence.mjs, pnpm check:control-bytes,
    pnpm check:vi-mock-specifiers, pnpm check:vi-mock-inherit — all green.

Declared narrowing. The repo-wide pnpm test and turbo run lint are left to CI. eslint was
run over both affected packages in full rather than the whole repo; the config
(eslint.config.js) sets no parserOptions.project / projectService, so linting is not
type-aware and a file's verdict depends only on its own contents plus the shared config —
neither of which this diff moves for any untouched file. Vitest was narrowed by path to the two
packages the diff touches, which is this repo's documented way to narrow (root cwd, path
filter, no --).

A changeset is included; both packages are published and their behaviour changes.

Generated by Claude Code


Generated by Claude Code

…th sites
objectui#6898 closed field-level security on `$select`; `$expand` was ungated at
both projection build sites, so a lookup / master_detail / user / tree field the
principal cannot read was still handed to the server for expansion — and an
expansion returns the RESOLVED related record where `$select` returns only a bare
foreign key.
Reproduced first as failing tests at both sites. On the `ListView` path the same
gap also reopened `$select`: that builder gates its columns and then adds the
expand roots back unconditionally, so the denied field re-entered the projection
through the union rather than through its own filter.
The gate judges the OUTPUT of `buildExpandFields` rather than its input, which is
what keeps the required ordering structural: the helper already returns a subset
of the object's declared reference-bearing fields, so no undeclared key is ever
judged. Gating the input would instead WIDEN the expansion, because the helper
reads an empty column list as "no column restriction" and falls back to every
relation — and it cannot reach the no-columns case at all. `buildExpandFields`
itself is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-C2Nqdo6R.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.77KB40.75KB
plugin-grid (index.js)208.87KB56.58KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.55KB27.68KB
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)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. The ZONE 1.1 deviation is CONFIRMED: option A, as shipped

Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM. Clean against origin/main231d1b93c (merge-tree exit 0), changeset present, 5 files.

My ruling was wrong; yours is right, and it is pinned rather than argued

I ruled the gate onto the input column list. You put it on buildExpandFields's output and declared the deviation. Confirmed, on your two measured reasons:

  • buildExpandFields reads an empty column list as "no column restriction" and falls back to every declared relation ⇒ gating the input would widen a view whose only relational column is denied, from one expansion to all of them. My ruling would have made the leak bigger in exactly the case it was meant to close.
  • The no-columns case passes undefinedthere is no input to gate. And that is the default shape, not an edge case.

The output gate also satisfies ZONE 1.2's mandatory ordering structurally rather than by convention: the helper already returns a subset of the object's declared reference-bearing fields, so every name judged is declared and the "checkField answers false for an undeclared key" trap is unreachable. That is better than the ordering I asked for, because it cannot be got wrong by a later edit.

Both failure modes of the input-side form are pinned as tests, so B and C are falsifiable claims rather than my preference against yours. That is what made this a five-minute confirmation instead of an argument.

Second ruling of mine overturned today, both by measurement.

⭐ The finding that matters most is the one beyond the card

the projection gates the columns and then adds these roots back unconditionallyfor (const e of expandFields) required.add(e), on the ground that they are "known-valid because buildExpandFields() derived them from the object schema". Valid, yes; READABLE, never asked.

So a denied lookup column walked straight back into $select through the expand union, and objectui#6898's gate was being defeated here by the expand roots rather than by its own filter. Measured pre-fix: $select came out ['id','subject','account','secret_account'].

#6898 was believed closed and had a live bypass. That is not a second defect, it is the measured reach of this one, and gating at the single output point closes both halves. Worth stating plainly in the landing note, because anyone auditing FLS would have read #6898 as settled.

The ZONE 2 falsification was a crash, not a nuance

const perms = usePermissions() sat below the expandFields memo, and useMemo runs its callback during the declaring render — so reading perms from there is a temporal-dead-zone throw, not a stale value.

I flagged "is checkField in scope at both call sites?" as an assumption to check. It was not, and the failure mode was worse than the one I imagined. The declaration moved above the memo with the reason recorded in-comment as load-bearing rather than cosmetic — which is what stops someone re-tidying the imports and reintroducing a crash.

The severity statement is exactly right

I asked for a precise, narrow claim rather than an alarming one, and got a mechanism read rather than assumed:

FieldMasker.maskRecord does delete result[field] and objectql's engine writes the expanded record back under that same key, so one statement removes both; the expansion sub-read itself takes the referenced object's full CRUD+RLS+FLS treatment (objectstack#7626).

⇒ Against ObjectStack's own server this is defence-in-depth, not a live disclosure; it is load-bearing for a non-enforcing backend. Same p2 grading as #6898, for a stated reason. And the client-request side is reachable in a real configuration, because the fetch effect reads the authored schema.columns, not the FLS-filtered render columns.

Also correctly disposed of: the "denied leaf under a permitted root" question is not expressible herebuildExpandFields returns top-level roots, never dotted paths, so checkField(object, root, 'read') is exactly the right question and a denied leaf on the related object is that object's FLS.

Method

Red first at 5015fcf52 — grid 5 failed / 3 passed, list 7 failed / 3 passed — before any fix, which is what makes the green afterwards mean something.

Ablation legs A and B: removing either site's gate leaves the other site's tests fully green. The two-site property demonstrated, not asserted — the same property that made #7179's one-site fix a trap. And the whole ablation was run twice, before and after merging origin/main (both #7220 and #7226 landed mid-task and touch these files), with identical counts.

The no-rebuild leg is declared load-bearing rather than skipped: the root vitest config aliases @object-ui/* to each package's src and both test files import their subject relatively, so no dist can go stale. An empty git hash-object was treated as failure rather than "nothing to compare" — the right instinct, and one that would have caught a mutation that never reached disk.

Armed.

PM follow-up

Your out-of-scope finding could not be filed because the dedupe search hit the rate limit and you correctly did not file blind. I am filing it — the same ungated $expand at five more build sites, with the calendar / gantt / record-detail no-columns shape as the sharpest.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 15:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67dadd6Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7215-expand-fls-gate branch September 1, 2026 16:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned

2 participants

@os-warren@claude
, '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

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites - #7229

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate
Sep 1, 2026
Merged

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites#7229
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7215

What an unauthorised principal could observe before, and cannot after

Before. A view could name a lookup / master_detail / user / tree column that the
current principal is denied read on, and that field name went out in $expand. The client
was therefore asking the server to resolve the relation and return the related record,
not merely to return a bare foreign key. objectui#6898 had already stopped the client asking
for the denied field in $select; the same field could still be asked for in $expand, which
is the larger of the two requests.

On the ListView path it was also asking for the field in $select again. Not a second
defect — the measured reach of this one. That builder gates its columns
(rawCols.filter(c => perms.checkField(...))) and then adds the expand roots back
unconditionally (for (const e of expandFields) required.add(e)), on the recorded ground that
those roots are "known-valid because buildExpandFields() derived them from the object
schema". Valid, yes; readable, never asked. So the denied column walked back into $select
through the union. Measured, pre-fix, in the harness: $select came out as
['id', 'subject', 'account', 'secret_account'] with secret_account denied.

After. Neither projection names a denied relation. $expand carries only relations the
principal can read; the $select re-entry on the ListView path is closed with it.

Reachability — stated narrowly

  • Reachable in a real configuration, on the client-request side. No constructed schema is
    needed: the fetch effect reads the authored schema.columns directly, not the FLS-filtered
    render columns, so an ordinary authored view with a denied lookup column is enough. The
    sharpest case needs no column list at all — with no columns, buildExpandFields
    expands every declared relation of the object, denied ones included. That is the default
    shape, not an edge case.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for
    a mechanism I read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] for every unreadable key, and objectql's expand path writes the
    resolved record back under that same key (record[fieldName] = recordMap.get(...) in
    engine.ts), so 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 (objectstack#7626). Same grading objectui#6898 was given, for the
    same reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use. The value of the
    change is that the invariant stops resting on every future backend having enforced it.

The fix, and one deviation from the card's suggested route

Both sites, because objectui#7179 measured that they are independent paths and a one-site fix
ships with a green suite and a still-open surface:

  • packages/plugin-grid/src/ObjectGrid.tsx — the grid's own fetch
  • packages/plugin-list/src/ListView.tsx — the expandFields memo

The card asked for the gate on the column list feeding buildExpandFields. It is on that
helper's OUTPUT instead
, and the reason is measured, not stylistic — both failure modes are
pinned as tests:

  1. buildExpandFields reads an empty column list as "no column restriction"
    (columns.length > 0 guards its intersection) and falls back to expanding every declared
    relation. Gating the input would therefore widen a view whose only relational column is
    denied, from that one expansion to all of them.
  2. The no-columns case passes undefined — there is no input to gate, and it is the case that
    expands the most.

Gating the output also satisfies the ordering the card called mandatory — intersect against the
object's declared fields first, ask checkField only about survivors — structurally
rather than by convention, because buildExpandFields returns a subset of the declared
reference-bearing fields. Every name the gate judges is declared by construction, so the
"checkField answers false for an undeclared key" trap cannot be reached and a derived /
host-joined column is never judged. buildExpandFields itself is unchanged, as ruled.

One structural note in ListView: const perms = usePermissions() moved above the
expandFields memo. useMemo runs its callback during the render that declares it, so a memo
reading perms from below would hit the temporal dead zone and throw
Cannot access 'perms' before initialization — a crash, not a stale value.

Relation roots, not leaves

buildExpandFields returns top-level field names of the object being fetched, never dotted
paths, so the gate asks checkField(object, root, 'read') — exactly the right question. A
denied leaf on the related object is a different object's FLS and is not answerable at
this call site; it is not in scope here and is not silently claimed to be handled.

Tests — reproduced first, as the card required

New, both mirroring projectionFls-6898.test.tsx:

  • packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx — 8 pins
  • packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx — 10 pins

Pre-fix, on 5015fcf52: grid 5 failed | 3 passed (8), list 7 failed | 3 passed (10).
The leak is real at both sites.

Each file carries the live controls, not just the reds: a permitted lookup still expands; an
undeclared derived column is untouched and does not take the expansion down with it; an
unanswered permission policy filters nothing; the input-gating widening trap is pinned;
master_detail is pinned beside lookup; and the grouping / kanban-binding routes into the
expand list take the same gate.

Ablation — the two-site property demonstrated, not asserted

Predicted before running; every count landed as predicted. Each leg mutates one site, proves
the mutation reached disk by replacement-marker count and blob hash, runs both files,
then restores via git checkout HEAD -- ABSOLUTE_PATH and proves the restore by state
(git diff HEAD, git diff --cached, git status --short all empty, blobs back to the HEAD
hashes), with the restore trapped on EXIT INT TERM. The root vitest config aliases
@object-ui/* to each package's src and both files import their subject relatively, so no
build stands between the edit and the run.

LegMutationgrid filelist file
AObjectGrid gate removed5 failed / 3 passed10 passed / 0 failed
BListView gate removed8 passed / 0 failed7 failed / 3 passed

Removing either site's gate leaves the other site's tests fully green. That is the property
objectui#7179 warned about, shown rather than claimed.

Suites, at the commit being shipped

All runs below are at 74f9521ac (this branch's head, after merging origin/main — both
in-flight PRs touching these files landed mid-task and are merged in, not rebased over).

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ from the repo root —
    170 files passed, 1766 tests passed, including projectionFls-6898.test.tsx and both
    groupingProjection-7179 files as green-before-and-after controls.
  • pnpm type-check81/81 tasks successful. Measured rather than assumed that this covers
    the new tests: each package's tsconfig.json excludes __tests__, but type-check chains
    tsc -p tsconfig.test.json, and --listFiles shows both new files in that program (1 hit
    each).
  • npx eslint packages/plugin-grid packages/plugin-list --format json219 files, 0 errors
    (1236 pre-existing warnings). Per-file before/after on the two edited sources is identical:
    ObjectGrid.tsx 0 errors / 211 warnings, ListView.tsx 0 errors / 182 warnings, both on
    origin/main and here — this change adds no lint finding.
  • node scripts/check-changeset-presence.mjs, pnpm check:control-bytes,
    pnpm check:vi-mock-specifiers, pnpm check:vi-mock-inherit — all green.

Declared narrowing. The repo-wide pnpm test and turbo run lint are left to CI. eslint was
run over both affected packages in full rather than the whole repo; the config
(eslint.config.js) sets no parserOptions.project / projectService, so linting is not
type-aware and a file's verdict depends only on its own contents plus the shared config —
neither of which this diff moves for any untouched file. Vitest was narrowed by path to the two
packages the diff touches, which is this repo's documented way to narrow (root cwd, path
filter, no --).

A changeset is included; both packages are published and their behaviour changes.

Generated by Claude Code


Generated by Claude Code

…th sites
objectui#6898 closed field-level security on `$select`; `$expand` was ungated at
both projection build sites, so a lookup / master_detail / user / tree field the
principal cannot read was still handed to the server for expansion — and an
expansion returns the RESOLVED related record where `$select` returns only a bare
foreign key.
Reproduced first as failing tests at both sites. On the `ListView` path the same
gap also reopened `$select`: that builder gates its columns and then adds the
expand roots back unconditionally, so the denied field re-entered the projection
through the union rather than through its own filter.
The gate judges the OUTPUT of `buildExpandFields` rather than its input, which is
what keeps the required ordering structural: the helper already returns a subset
of the object's declared reference-bearing fields, so no undeclared key is ever
judged. Gating the input would instead WIDEN the expansion, because the helper
reads an empty column list as "no column restriction" and falls back to every
relation — and it cannot reach the no-columns case at all. `buildExpandFields`
itself is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-C2Nqdo6R.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.77KB40.75KB
plugin-grid (index.js)208.87KB56.58KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.55KB27.68KB
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)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. The ZONE 1.1 deviation is CONFIRMED: option A, as shipped

Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM. Clean against origin/main231d1b93c (merge-tree exit 0), changeset present, 5 files.

My ruling was wrong; yours is right, and it is pinned rather than argued

I ruled the gate onto the input column list. You put it on buildExpandFields's output and declared the deviation. Confirmed, on your two measured reasons:

  • buildExpandFields reads an empty column list as "no column restriction" and falls back to every declared relation ⇒ gating the input would widen a view whose only relational column is denied, from one expansion to all of them. My ruling would have made the leak bigger in exactly the case it was meant to close.
  • The no-columns case passes undefinedthere is no input to gate. And that is the default shape, not an edge case.

The output gate also satisfies ZONE 1.2's mandatory ordering structurally rather than by convention: the helper already returns a subset of the object's declared reference-bearing fields, so every name judged is declared and the "checkField answers false for an undeclared key" trap is unreachable. That is better than the ordering I asked for, because it cannot be got wrong by a later edit.

Both failure modes of the input-side form are pinned as tests, so B and C are falsifiable claims rather than my preference against yours. That is what made this a five-minute confirmation instead of an argument.

Second ruling of mine overturned today, both by measurement.

⭐ The finding that matters most is the one beyond the card

the projection gates the columns and then adds these roots back unconditionallyfor (const e of expandFields) required.add(e), on the ground that they are "known-valid because buildExpandFields() derived them from the object schema". Valid, yes; READABLE, never asked.

So a denied lookup column walked straight back into $select through the expand union, and objectui#6898's gate was being defeated here by the expand roots rather than by its own filter. Measured pre-fix: $select came out ['id','subject','account','secret_account'].

#6898 was believed closed and had a live bypass. That is not a second defect, it is the measured reach of this one, and gating at the single output point closes both halves. Worth stating plainly in the landing note, because anyone auditing FLS would have read #6898 as settled.

The ZONE 2 falsification was a crash, not a nuance

const perms = usePermissions() sat below the expandFields memo, and useMemo runs its callback during the declaring render — so reading perms from there is a temporal-dead-zone throw, not a stale value.

I flagged "is checkField in scope at both call sites?" as an assumption to check. It was not, and the failure mode was worse than the one I imagined. The declaration moved above the memo with the reason recorded in-comment as load-bearing rather than cosmetic — which is what stops someone re-tidying the imports and reintroducing a crash.

The severity statement is exactly right

I asked for a precise, narrow claim rather than an alarming one, and got a mechanism read rather than assumed:

FieldMasker.maskRecord does delete result[field] and objectql's engine writes the expanded record back under that same key, so one statement removes both; the expansion sub-read itself takes the referenced object's full CRUD+RLS+FLS treatment (objectstack#7626).

⇒ Against ObjectStack's own server this is defence-in-depth, not a live disclosure; it is load-bearing for a non-enforcing backend. Same p2 grading as #6898, for a stated reason. And the client-request side is reachable in a real configuration, because the fetch effect reads the authored schema.columns, not the FLS-filtered render columns.

Also correctly disposed of: the "denied leaf under a permitted root" question is not expressible herebuildExpandFields returns top-level roots, never dotted paths, so checkField(object, root, 'read') is exactly the right question and a denied leaf on the related object is that object's FLS.

Method

Red first at 5015fcf52 — grid 5 failed / 3 passed, list 7 failed / 3 passed — before any fix, which is what makes the green afterwards mean something.

Ablation legs A and B: removing either site's gate leaves the other site's tests fully green. The two-site property demonstrated, not asserted — the same property that made #7179's one-site fix a trap. And the whole ablation was run twice, before and after merging origin/main (both #7220 and #7226 landed mid-task and touch these files), with identical counts.

The no-rebuild leg is declared load-bearing rather than skipped: the root vitest config aliases @object-ui/* to each package's src and both test files import their subject relatively, so no dist can go stale. An empty git hash-object was treated as failure rather than "nothing to compare" — the right instinct, and one that would have caught a mutation that never reached disk.

Armed.

PM follow-up

Your out-of-scope finding could not be filed because the dedupe search hit the rate limit and you correctly did not file blind. I am filing it — the same ungated $expand at five more build sites, with the calendar / gantt / record-detail no-columns shape as the sharpest.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 15:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67dadd6Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7215-expand-fls-gate branch September 1, 2026 16:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned

2 participants

@os-warren@claude
, '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

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites - #7229

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate
Sep 1, 2026
Merged

fix(plugin-grid,plugin-list): FLS-gate the $expand projection at both build sites#7229
os-warren merged 2 commits into
mainfrom
claude/issue-7215-expand-fls-gate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7215

What an unauthorised principal could observe before, and cannot after

Before. A view could name a lookup / master_detail / user / tree column that the
current principal is denied read on, and that field name went out in $expand. The client
was therefore asking the server to resolve the relation and return the related record,
not merely to return a bare foreign key. objectui#6898 had already stopped the client asking
for the denied field in $select; the same field could still be asked for in $expand, which
is the larger of the two requests.

On the ListView path it was also asking for the field in $select again. Not a second
defect — the measured reach of this one. That builder gates its columns
(rawCols.filter(c => perms.checkField(...))) and then adds the expand roots back
unconditionally (for (const e of expandFields) required.add(e)), on the recorded ground that
those roots are "known-valid because buildExpandFields() derived them from the object
schema". Valid, yes; readable, never asked. So the denied column walked back into $select
through the union. Measured, pre-fix, in the harness: $select came out as
['id', 'subject', 'account', 'secret_account'] with secret_account denied.

After. Neither projection names a denied relation. $expand carries only relations the
principal can read; the $select re-entry on the ListView path is closed with it.

Reachability — stated narrowly

  • Reachable in a real configuration, on the client-request side. No constructed schema is
    needed: the fetch effect reads the authored schema.columns directly, not the FLS-filtered
    render columns, so an ordinary authored view with a denied lookup column is enough. The
    sharpest case needs no column list at all — with no columns, buildExpandFields
    expands every declared relation of the object, denied ones included. That is the default
    shape, not an edge case.
  • Against ObjectStack's own server this is defence-in-depth, not a live disclosure, and for
    a mechanism I read rather than assumed: plugin-security's FieldMasker.maskRecord does
    delete result[field] for every unreadable key, and objectql's expand path writes the
    resolved record back under that same key (record[fieldName] = recordMap.get(...) in
    engine.ts), so 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 (objectstack#7626). Same grading objectui#6898 was given, for the
    same reason, and it becomes load-bearing for any backend that does not strip.
  • Not established: that any deployed non-enforcing backend is in use. The value of the
    change is that the invariant stops resting on every future backend having enforced it.

The fix, and one deviation from the card's suggested route

Both sites, because objectui#7179 measured that they are independent paths and a one-site fix
ships with a green suite and a still-open surface:

  • packages/plugin-grid/src/ObjectGrid.tsx — the grid's own fetch
  • packages/plugin-list/src/ListView.tsx — the expandFields memo

The card asked for the gate on the column list feeding buildExpandFields. It is on that
helper's OUTPUT instead
, and the reason is measured, not stylistic — both failure modes are
pinned as tests:

  1. buildExpandFields reads an empty column list as "no column restriction"
    (columns.length > 0 guards its intersection) and falls back to expanding every declared
    relation. Gating the input would therefore widen a view whose only relational column is
    denied, from that one expansion to all of them.
  2. The no-columns case passes undefined — there is no input to gate, and it is the case that
    expands the most.

Gating the output also satisfies the ordering the card called mandatory — intersect against the
object's declared fields first, ask checkField only about survivors — structurally
rather than by convention, because buildExpandFields returns a subset of the declared
reference-bearing fields. Every name the gate judges is declared by construction, so the
"checkField answers false for an undeclared key" trap cannot be reached and a derived /
host-joined column is never judged. buildExpandFields itself is unchanged, as ruled.

One structural note in ListView: const perms = usePermissions() moved above the
expandFields memo. useMemo runs its callback during the render that declares it, so a memo
reading perms from below would hit the temporal dead zone and throw
Cannot access 'perms' before initialization — a crash, not a stale value.

Relation roots, not leaves

buildExpandFields returns top-level field names of the object being fetched, never dotted
paths, so the gate asks checkField(object, root, 'read') — exactly the right question. A
denied leaf on the related object is a different object's FLS and is not answerable at
this call site; it is not in scope here and is not silently claimed to be handled.

Tests — reproduced first, as the card required

New, both mirroring projectionFls-6898.test.tsx:

  • packages/plugin-grid/src/__tests__/expandFls-7215.test.tsx — 8 pins
  • packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx — 10 pins

Pre-fix, on 5015fcf52: grid 5 failed | 3 passed (8), list 7 failed | 3 passed (10).
The leak is real at both sites.

Each file carries the live controls, not just the reds: a permitted lookup still expands; an
undeclared derived column is untouched and does not take the expansion down with it; an
unanswered permission policy filters nothing; the input-gating widening trap is pinned;
master_detail is pinned beside lookup; and the grouping / kanban-binding routes into the
expand list take the same gate.

Ablation — the two-site property demonstrated, not asserted

Predicted before running; every count landed as predicted. Each leg mutates one site, proves
the mutation reached disk by replacement-marker count and blob hash, runs both files,
then restores via git checkout HEAD -- ABSOLUTE_PATH and proves the restore by state
(git diff HEAD, git diff --cached, git status --short all empty, blobs back to the HEAD
hashes), with the restore trapped on EXIT INT TERM. The root vitest config aliases
@object-ui/* to each package's src and both files import their subject relatively, so no
build stands between the edit and the run.

LegMutationgrid filelist file
AObjectGrid gate removed5 failed / 3 passed10 passed / 0 failed
BListView gate removed8 passed / 0 failed7 failed / 3 passed

Removing either site's gate leaves the other site's tests fully green. That is the property
objectui#7179 warned about, shown rather than claimed.

Suites, at the commit being shipped

All runs below are at 74f9521ac (this branch's head, after merging origin/main — both
in-flight PRs touching these files landed mid-task and are merged in, not rebased over).

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ from the repo root —
    170 files passed, 1766 tests passed, including projectionFls-6898.test.tsx and both
    groupingProjection-7179 files as green-before-and-after controls.
  • pnpm type-check81/81 tasks successful. Measured rather than assumed that this covers
    the new tests: each package's tsconfig.json excludes __tests__, but type-check chains
    tsc -p tsconfig.test.json, and --listFiles shows both new files in that program (1 hit
    each).
  • npx eslint packages/plugin-grid packages/plugin-list --format json219 files, 0 errors
    (1236 pre-existing warnings). Per-file before/after on the two edited sources is identical:
    ObjectGrid.tsx 0 errors / 211 warnings, ListView.tsx 0 errors / 182 warnings, both on
    origin/main and here — this change adds no lint finding.
  • node scripts/check-changeset-presence.mjs, pnpm check:control-bytes,
    pnpm check:vi-mock-specifiers, pnpm check:vi-mock-inherit — all green.

Declared narrowing. The repo-wide pnpm test and turbo run lint are left to CI. eslint was
run over both affected packages in full rather than the whole repo; the config
(eslint.config.js) sets no parserOptions.project / projectService, so linting is not
type-aware and a file's verdict depends only on its own contents plus the shared config —
neither of which this diff moves for any untouched file. Vitest was narrowed by path to the two
packages the diff touches, which is this repo's documented way to narrow (root cwd, path
filter, no --).

A changeset is included; both packages are published and their behaviour changes.

Generated by Claude Code


Generated by Claude Code

…th sites
objectui#6898 closed field-level security on `$select`; `$expand` was ungated at
both projection build sites, so a lookup / master_detail / user / tree field the
principal cannot read was still handed to the server for expansion — and an
expansion returns the RESOLVED related record where `$select` returns only a bare
foreign key.
Reproduced first as failing tests at both sites. On the `ListView` path the same
gap also reopened `$select`: that builder gates its columns and then adds the
expand roots back unconditionally, so the denied field re-entered the projection
through the union rather than through its own filter.
The gate judges the OUTPUT of `buildExpandFields` rather than its input, which is
what keeps the required ordering structural: the helper already returns a subset
of the object's declared reference-bearing fields, so no undeclared key is ever
judged. Gating the input would instead WIDEN the expansion, because the helper
reads an empty column list as "no column restriction" and falls back to every
relation — and it cannot reach the no-columns case at all. `buildExpandFields`
itself is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-C2Nqdo6R.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
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.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.77KB40.75KB
plugin-grid (index.js)208.87KB56.58KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.55KB27.68KB
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)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. The ZONE 1.1 deviation is CONFIRMED: option A, as shipped

Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM. Clean against origin/main231d1b93c (merge-tree exit 0), changeset present, 5 files.

My ruling was wrong; yours is right, and it is pinned rather than argued

I ruled the gate onto the input column list. You put it on buildExpandFields's output and declared the deviation. Confirmed, on your two measured reasons:

  • buildExpandFields reads an empty column list as "no column restriction" and falls back to every declared relation ⇒ gating the input would widen a view whose only relational column is denied, from one expansion to all of them. My ruling would have made the leak bigger in exactly the case it was meant to close.
  • The no-columns case passes undefinedthere is no input to gate. And that is the default shape, not an edge case.

The output gate also satisfies ZONE 1.2's mandatory ordering structurally rather than by convention: the helper already returns a subset of the object's declared reference-bearing fields, so every name judged is declared and the "checkField answers false for an undeclared key" trap is unreachable. That is better than the ordering I asked for, because it cannot be got wrong by a later edit.

Both failure modes of the input-side form are pinned as tests, so B and C are falsifiable claims rather than my preference against yours. That is what made this a five-minute confirmation instead of an argument.

Second ruling of mine overturned today, both by measurement.

⭐ The finding that matters most is the one beyond the card

the projection gates the columns and then adds these roots back unconditionallyfor (const e of expandFields) required.add(e), on the ground that they are "known-valid because buildExpandFields() derived them from the object schema". Valid, yes; READABLE, never asked.

So a denied lookup column walked straight back into $select through the expand union, and objectui#6898's gate was being defeated here by the expand roots rather than by its own filter. Measured pre-fix: $select came out ['id','subject','account','secret_account'].

#6898 was believed closed and had a live bypass. That is not a second defect, it is the measured reach of this one, and gating at the single output point closes both halves. Worth stating plainly in the landing note, because anyone auditing FLS would have read #6898 as settled.

The ZONE 2 falsification was a crash, not a nuance

const perms = usePermissions() sat below the expandFields memo, and useMemo runs its callback during the declaring render — so reading perms from there is a temporal-dead-zone throw, not a stale value.

I flagged "is checkField in scope at both call sites?" as an assumption to check. It was not, and the failure mode was worse than the one I imagined. The declaration moved above the memo with the reason recorded in-comment as load-bearing rather than cosmetic — which is what stops someone re-tidying the imports and reintroducing a crash.

The severity statement is exactly right

I asked for a precise, narrow claim rather than an alarming one, and got a mechanism read rather than assumed:

FieldMasker.maskRecord does delete result[field] and objectql's engine writes the expanded record back under that same key, so one statement removes both; the expansion sub-read itself takes the referenced object's full CRUD+RLS+FLS treatment (objectstack#7626).

⇒ Against ObjectStack's own server this is defence-in-depth, not a live disclosure; it is load-bearing for a non-enforcing backend. Same p2 grading as #6898, for a stated reason. And the client-request side is reachable in a real configuration, because the fetch effect reads the authored schema.columns, not the FLS-filtered render columns.

Also correctly disposed of: the "denied leaf under a permitted root" question is not expressible herebuildExpandFields returns top-level roots, never dotted paths, so checkField(object, root, 'read') is exactly the right question and a denied leaf on the related object is that object's FLS.

Method

Red first at 5015fcf52 — grid 5 failed / 3 passed, list 7 failed / 3 passed — before any fix, which is what makes the green afterwards mean something.

Ablation legs A and B: removing either site's gate leaves the other site's tests fully green. The two-site property demonstrated, not asserted — the same property that made #7179's one-site fix a trap. And the whole ablation was run twice, before and after merging origin/main (both #7220 and #7226 landed mid-task and touch these files), with identical counts.

The no-rebuild leg is declared load-bearing rather than skipped: the root vitest config aliases @object-ui/* to each package's src and both test files import their subject relatively, so no dist can go stale. An empty git hash-object was treated as failure rather than "nothing to compare" — the right instinct, and one that would have caught a mutation that never reached disk.

Armed.

PM follow-up

Your out-of-scope finding could not be filed because the dedupe search hit the rate limit and you correctly did not file blind. I am filing it — the same ungated $expand at five more build sites, with the calendar / gantt / record-detail no-columns shape as the sharpest.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 15:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67dadd6Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7215-expand-fls-gate branch September 1, 2026 16:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$expand carries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned

2 participants

@os-warren@claude