fix(plugin-grid): gate the server $select projection on field-level security - #7090

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls
Aug 31, 2026
Merged

fix(plugin-grid): gate the server $select projection on field-level security#7090
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6898

Field-level security on ObjectGrid's server $select projection — the FETCH half of the gap #6799 closed on the RENDER half.

The escalation gate, answered first (this is what the grade rests on)

Triage made one measurement the first mandatory step: does ObjectStack's own REST enforce FLS on the $select projection? Answered by reading the enforcement path in the objectstack sibling checkout, not by assuming the server catches it.

Answer: ENFORCED — but on the RECORD, not on the projection. Branch 1 of the ruling. p2 stands; this PR is defence-in-depth.

Four independent readings, listed weakest to strongest:

  1. plugins/plugin-security/src/security-plugin.ts step 4 (post-next()) runs FieldMasker.maskResults on every find / findOne / insert / update result.

  2. field-masker.ts's maskRecord does delete result[field] for each non-readable field — the key is deleted, not nulled.

  3. predicate-guard.ts says so in terms, and explains why the projection is deliberately left unguarded:

    fields (projection) is intentionally NOT collected — selecting a hidden field is harmless because FieldMasker strips it from the result; only predicates leak.

  4. An executed end-to-end HTTP pin, qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts:

    it('an explicit \select` of the denied field returns the record WITHOUT it (no error, no value)')`

    GET /data/showcase_project/{id}?select=name,{denied} answers 200 with the denied key absent, and the same projection serves an admin the real value.

That is precisely the ruling's first branch — "a server that enforces FLS refuses or omits it — no exposure". ObjectStack omits. So nothing here is load-bearing for ObjectStack, and the code comment at the gate says so, to stop a future reader concluding otherwise. It becomes load-bearing for any backend that does not strip — the same argument the #6723 / #6799 rulings accepted for the render half.

Mandatory question 2 — what else is concatenated into $select, as an enumeration

getSelectFields() (re-derived at L1460, not the card's day-old L1402) composes from six sources, not the two the card named:

#SourceNotes
1schemaFields (the fields prop)authored projection, arm A
2schemaColumns (the columns prop) via columnIdentityauthored projection, arm B
3id, force-added by ensureIdrow navigation / record key
4conditionalFormattingcondition, expression, and the native { field } shapepredicate harvest
5rowActionDefs / bulkActionDefs / object actionsvisible, disabled, and recordIdFieldpredicate harvest
6OBJECT-level userActionsvisibleWhen / disabledWhenpredicate harvest (never the view-level block)

Source 5's recordIdField (objectstack#8018) is the one the card did not name. All of 4–6 pass isProjectableField, which also admits the undeclared platform columns (created_at, owner_id, organization_id, …).

The inference-leak trap — measured, and it is NOT a new card

The ruling flagged that a field the principal cannot read used as a filter may be an inference leak even when the value never returns. Measured: already closed server-side, before this card existed.plugin-security's assertReadableQueryFields (anti filter-oracle, #2251) rejects with 403 PERMISSION_DENIED, naming the offending field, when the caller's own where / orderBy / groupBy / having / aggregations reference an unreadable field — and it deliberately runs against the caller's verbatim AST, before RLS injection, so injected policy filters referencing owner_id are not caught by it. Pinned live in the same dogfood file: filtering and sorting on the denied field each answer 403, while the entitled caller still can. No card filed — the suspected gap does not exist.

The change

perms.checkField(object, field, 'read') now gates the projection, on both authored arms and on the predicate harvest.

Two limits are deliberate, and both are pinned:

  • Only keys the object DECLARES are judged. The card is right that this does not transfer automatically from the render path — on the render path an undeclared key is a derived column, in a $select it is what the host asked the server for, so it had to be re-derived. It lands in the same place for a reason about checkField rather than about drawing: checkField answers false for a field no policy mentions, so judging an undeclared key is not a stricter reading of this rule, it is a different and wrong one — it would strip a host's derived or joined column out of its own query.
  • id survives even a policy that denies it, structurally: every arm composes ensureId(...)after the gate. Keeping the restoration in the composition rather than in a branch means it cannot drift out of one arm. This is the card's decision point 2 — a naive filter breaks navigation rather than closing a hole.

Denied predicate operands are dropped too, and this costs nothing that was working: against ObjectStack the server already deletes that key from every row, so the operand never arrived and the CEL predicate was already faulting No such key and failing closed. Dropping it changes what we ask for, not what we got. Against a non-enforcing backend it turns "the button works, and the denied value sits in memory" into "the button hides" — the correct direction for a predicate gated on a field this principal may not read. Readable operands are untouched, so #3501 does not regress (pinned).

One non-obvious dependency, and why it is load-bearing

The fetch effect now also depends on perms.isLoaded. /me/permissions resolves asynchronously, so on the first render isLoaded is false and the gate correctly defers. Without this dependency nothing would ever rebuild the projection after the policy answered, and the gate would be dead on the only fetch most grids make. Ablation B below is the isolated proof. The boolean rather than perms itself: it flips false→true exactly once, so this costs at most one refetch, where the context object's identity would refetch on every render. PermissionProvider reports true synchronously and the no-provider default stays false forever, so neither pays anything.

Known limit, stated rather than papered over

Under MePermissionsProvider the first request still goes out ungated, in the window before /me/permissions answers; the projection is corrected on the refetch. Closing that window entirely would mean blocking the grid's fetch on permissions, which would hang every host with no PermissionProvider (isLoaded is false there forever). Given the measured server behaviour the residual against ObjectStack is nil.

Verification

All runs at final commit 91c67d2, through the shared verification lock. Exit codes captured before any pipe.

RunResult
vitest run packages/plugin-grid/101 files / 930 tests passed, exit 0
pnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
eslint --no-inline-config on both changed filesexit 0, 0 errors

The suite was first run from the package directory and the repo's own invocation guard refused it (#3378 — a package-cwd run silently executes the console package's 22 files and reports green). Re-run from the repo root as the guard prescribes; the numbers above are from the correct invocation.

Lint narrowing, declared. Repo-wide pnpm lint is CI's run. The narrowing to 2 files is a measurement, not a skip: the population comes from eslint's own config rather than my guess, the count (2) is read from --format json, and type-aware linting is not enabled in eslint.config.js (no parserOptions.project / projectService) — so this diff cannot move the verdict on any file it does not touch. Warning counts are byte-identical to origin/main rule-for-rule (213 → 213, react-hooks/exhaustive-deps unchanged at 10, so the new dependency satisfies the rule rather than suppressing it).

Ablation — the gate can actually see the change

Both legs committed first, so the restore leg had a real reference. Each mutation was proven on disk by blob hash before the suite ran (an editor's exit code is not evidence — a zero-hit replace exits 0), and each restore proven by git diff HEAD empty and blob equality against HEAD.

Ablation A — strip the gate from both authored arms. HEAD blob 1de48053, mutated blob 2c565d99.
4 of 10 red: PIN 1 (denied column still requested), PIN 2 (fields arm), PIN 5 (legacy { name } spelling), PIN 9.

Ablation B — remove only the perms.isLoaded dependency. HEAD blob 1de48053, mutated blob bdac2a69.
exactly 1 of 10 red: PIN 9, "the gate is not dead on the first fetch". The other nine stayed green — which is the point: with a dead gate, nine pins would have reported success.

Restore verified after both: git hash-object = 1de48053 = HEAD, working tree clean.

Scope

packages/plugin-grid/src/ only — ObjectGrid.tsx plus its new test, and the changeset. generateColumns() is untouched: the maintainer's 2026-08-30 ruling deliberately scoped #6799 to that one function.

The export path was checked and needs nothing: it derives its fields from generateColumns(), so it already inherits the #6799 render-half gate.


Generated by Claude Code

…ecurity
ObjectGrid's getSelectFields() built the projection from the authored
columns/fields with no FLS gate, so after the render-half fix the hidden
field was still being requested from the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
`vi.fn(async () => ...)` narrows the mock's arg tuple to `[]`, and every
assertion reads `find.mock.calls.at(-1)?.[1].$select` — the second arg.
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)3149.3 KB3191.4 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-DArX1IDC.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)14.51KB5.35KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)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)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.30KB63.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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-warren
os-warren marked this pull request as ready for review August 31, 2026 17:52
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 1349400Aug 31, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6898-objectgrid-select-fls branch August 31, 2026 20:38
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.

plugin-grid: ObjectGrid builds the server $select from the authored projection with no FLS gate

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): gate the server $select projection on field-level security - #7090

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls
Aug 31, 2026
Merged

fix(plugin-grid): gate the server $select projection on field-level security#7090
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6898

Field-level security on ObjectGrid's server $select projection — the FETCH half of the gap #6799 closed on the RENDER half.

The escalation gate, answered first (this is what the grade rests on)

Triage made one measurement the first mandatory step: does ObjectStack's own REST enforce FLS on the $select projection? Answered by reading the enforcement path in the objectstack sibling checkout, not by assuming the server catches it.

Answer: ENFORCED — but on the RECORD, not on the projection. Branch 1 of the ruling. p2 stands; this PR is defence-in-depth.

Four independent readings, listed weakest to strongest:

  1. plugins/plugin-security/src/security-plugin.ts step 4 (post-next()) runs FieldMasker.maskResults on every find / findOne / insert / update result.

  2. field-masker.ts's maskRecord does delete result[field] for each non-readable field — the key is deleted, not nulled.

  3. predicate-guard.ts says so in terms, and explains why the projection is deliberately left unguarded:

    fields (projection) is intentionally NOT collected — selecting a hidden field is harmless because FieldMasker strips it from the result; only predicates leak.

  4. An executed end-to-end HTTP pin, qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts:

    it('an explicit \select` of the denied field returns the record WITHOUT it (no error, no value)')`

    GET /data/showcase_project/{id}?select=name,{denied} answers 200 with the denied key absent, and the same projection serves an admin the real value.

That is precisely the ruling's first branch — "a server that enforces FLS refuses or omits it — no exposure". ObjectStack omits. So nothing here is load-bearing for ObjectStack, and the code comment at the gate says so, to stop a future reader concluding otherwise. It becomes load-bearing for any backend that does not strip — the same argument the #6723 / #6799 rulings accepted for the render half.

Mandatory question 2 — what else is concatenated into $select, as an enumeration

getSelectFields() (re-derived at L1460, not the card's day-old L1402) composes from six sources, not the two the card named:

#SourceNotes
1schemaFields (the fields prop)authored projection, arm A
2schemaColumns (the columns prop) via columnIdentityauthored projection, arm B
3id, force-added by ensureIdrow navigation / record key
4conditionalFormattingcondition, expression, and the native { field } shapepredicate harvest
5rowActionDefs / bulkActionDefs / object actionsvisible, disabled, and recordIdFieldpredicate harvest
6OBJECT-level userActionsvisibleWhen / disabledWhenpredicate harvest (never the view-level block)

Source 5's recordIdField (objectstack#8018) is the one the card did not name. All of 4–6 pass isProjectableField, which also admits the undeclared platform columns (created_at, owner_id, organization_id, …).

The inference-leak trap — measured, and it is NOT a new card

The ruling flagged that a field the principal cannot read used as a filter may be an inference leak even when the value never returns. Measured: already closed server-side, before this card existed.plugin-security's assertReadableQueryFields (anti filter-oracle, #2251) rejects with 403 PERMISSION_DENIED, naming the offending field, when the caller's own where / orderBy / groupBy / having / aggregations reference an unreadable field — and it deliberately runs against the caller's verbatim AST, before RLS injection, so injected policy filters referencing owner_id are not caught by it. Pinned live in the same dogfood file: filtering and sorting on the denied field each answer 403, while the entitled caller still can. No card filed — the suspected gap does not exist.

The change

perms.checkField(object, field, 'read') now gates the projection, on both authored arms and on the predicate harvest.

Two limits are deliberate, and both are pinned:

  • Only keys the object DECLARES are judged. The card is right that this does not transfer automatically from the render path — on the render path an undeclared key is a derived column, in a $select it is what the host asked the server for, so it had to be re-derived. It lands in the same place for a reason about checkField rather than about drawing: checkField answers false for a field no policy mentions, so judging an undeclared key is not a stricter reading of this rule, it is a different and wrong one — it would strip a host's derived or joined column out of its own query.
  • id survives even a policy that denies it, structurally: every arm composes ensureId(...)after the gate. Keeping the restoration in the composition rather than in a branch means it cannot drift out of one arm. This is the card's decision point 2 — a naive filter breaks navigation rather than closing a hole.

Denied predicate operands are dropped too, and this costs nothing that was working: against ObjectStack the server already deletes that key from every row, so the operand never arrived and the CEL predicate was already faulting No such key and failing closed. Dropping it changes what we ask for, not what we got. Against a non-enforcing backend it turns "the button works, and the denied value sits in memory" into "the button hides" — the correct direction for a predicate gated on a field this principal may not read. Readable operands are untouched, so #3501 does not regress (pinned).

One non-obvious dependency, and why it is load-bearing

The fetch effect now also depends on perms.isLoaded. /me/permissions resolves asynchronously, so on the first render isLoaded is false and the gate correctly defers. Without this dependency nothing would ever rebuild the projection after the policy answered, and the gate would be dead on the only fetch most grids make. Ablation B below is the isolated proof. The boolean rather than perms itself: it flips false→true exactly once, so this costs at most one refetch, where the context object's identity would refetch on every render. PermissionProvider reports true synchronously and the no-provider default stays false forever, so neither pays anything.

Known limit, stated rather than papered over

Under MePermissionsProvider the first request still goes out ungated, in the window before /me/permissions answers; the projection is corrected on the refetch. Closing that window entirely would mean blocking the grid's fetch on permissions, which would hang every host with no PermissionProvider (isLoaded is false there forever). Given the measured server behaviour the residual against ObjectStack is nil.

Verification

All runs at final commit 91c67d2, through the shared verification lock. Exit codes captured before any pipe.

RunResult
vitest run packages/plugin-grid/101 files / 930 tests passed, exit 0
pnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
eslint --no-inline-config on both changed filesexit 0, 0 errors

The suite was first run from the package directory and the repo's own invocation guard refused it (#3378 — a package-cwd run silently executes the console package's 22 files and reports green). Re-run from the repo root as the guard prescribes; the numbers above are from the correct invocation.

Lint narrowing, declared. Repo-wide pnpm lint is CI's run. The narrowing to 2 files is a measurement, not a skip: the population comes from eslint's own config rather than my guess, the count (2) is read from --format json, and type-aware linting is not enabled in eslint.config.js (no parserOptions.project / projectService) — so this diff cannot move the verdict on any file it does not touch. Warning counts are byte-identical to origin/main rule-for-rule (213 → 213, react-hooks/exhaustive-deps unchanged at 10, so the new dependency satisfies the rule rather than suppressing it).

Ablation — the gate can actually see the change

Both legs committed first, so the restore leg had a real reference. Each mutation was proven on disk by blob hash before the suite ran (an editor's exit code is not evidence — a zero-hit replace exits 0), and each restore proven by git diff HEAD empty and blob equality against HEAD.

Ablation A — strip the gate from both authored arms. HEAD blob 1de48053, mutated blob 2c565d99.
4 of 10 red: PIN 1 (denied column still requested), PIN 2 (fields arm), PIN 5 (legacy { name } spelling), PIN 9.

Ablation B — remove only the perms.isLoaded dependency. HEAD blob 1de48053, mutated blob bdac2a69.
exactly 1 of 10 red: PIN 9, "the gate is not dead on the first fetch". The other nine stayed green — which is the point: with a dead gate, nine pins would have reported success.

Restore verified after both: git hash-object = 1de48053 = HEAD, working tree clean.

Scope

packages/plugin-grid/src/ only — ObjectGrid.tsx plus its new test, and the changeset. generateColumns() is untouched: the maintainer's 2026-08-30 ruling deliberately scoped #6799 to that one function.

The export path was checked and needs nothing: it derives its fields from generateColumns(), so it already inherits the #6799 render-half gate.


Generated by Claude Code

…ecurity
ObjectGrid's getSelectFields() built the projection from the authored
columns/fields with no FLS gate, so after the render-half fix the hidden
field was still being requested from the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
`vi.fn(async () => ...)` narrows the mock's arg tuple to `[]`, and every
assertion reads `find.mock.calls.at(-1)?.[1].$select` — the second arg.
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)3149.3 KB3191.4 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-DArX1IDC.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)14.51KB5.35KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)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)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.30KB63.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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-warren
os-warren marked this pull request as ready for review August 31, 2026 17:52
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 1349400Aug 31, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6898-objectgrid-select-fls branch August 31, 2026 20:38
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.

plugin-grid: ObjectGrid builds the server $select from the authored projection with no FLS gate

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): gate the server $select projection on field-level security - #7090

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls
Aug 31, 2026
Merged

fix(plugin-grid): gate the server $select projection on field-level security#7090
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6898

Field-level security on ObjectGrid's server $select projection — the FETCH half of the gap #6799 closed on the RENDER half.

The escalation gate, answered first (this is what the grade rests on)

Triage made one measurement the first mandatory step: does ObjectStack's own REST enforce FLS on the $select projection? Answered by reading the enforcement path in the objectstack sibling checkout, not by assuming the server catches it.

Answer: ENFORCED — but on the RECORD, not on the projection. Branch 1 of the ruling. p2 stands; this PR is defence-in-depth.

Four independent readings, listed weakest to strongest:

  1. plugins/plugin-security/src/security-plugin.ts step 4 (post-next()) runs FieldMasker.maskResults on every find / findOne / insert / update result.

  2. field-masker.ts's maskRecord does delete result[field] for each non-readable field — the key is deleted, not nulled.

  3. predicate-guard.ts says so in terms, and explains why the projection is deliberately left unguarded:

    fields (projection) is intentionally NOT collected — selecting a hidden field is harmless because FieldMasker strips it from the result; only predicates leak.

  4. An executed end-to-end HTTP pin, qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts:

    it('an explicit \select` of the denied field returns the record WITHOUT it (no error, no value)')`

    GET /data/showcase_project/{id}?select=name,{denied} answers 200 with the denied key absent, and the same projection serves an admin the real value.

That is precisely the ruling's first branch — "a server that enforces FLS refuses or omits it — no exposure". ObjectStack omits. So nothing here is load-bearing for ObjectStack, and the code comment at the gate says so, to stop a future reader concluding otherwise. It becomes load-bearing for any backend that does not strip — the same argument the #6723 / #6799 rulings accepted for the render half.

Mandatory question 2 — what else is concatenated into $select, as an enumeration

getSelectFields() (re-derived at L1460, not the card's day-old L1402) composes from six sources, not the two the card named:

#SourceNotes
1schemaFields (the fields prop)authored projection, arm A
2schemaColumns (the columns prop) via columnIdentityauthored projection, arm B
3id, force-added by ensureIdrow navigation / record key
4conditionalFormattingcondition, expression, and the native { field } shapepredicate harvest
5rowActionDefs / bulkActionDefs / object actionsvisible, disabled, and recordIdFieldpredicate harvest
6OBJECT-level userActionsvisibleWhen / disabledWhenpredicate harvest (never the view-level block)

Source 5's recordIdField (objectstack#8018) is the one the card did not name. All of 4–6 pass isProjectableField, which also admits the undeclared platform columns (created_at, owner_id, organization_id, …).

The inference-leak trap — measured, and it is NOT a new card

The ruling flagged that a field the principal cannot read used as a filter may be an inference leak even when the value never returns. Measured: already closed server-side, before this card existed.plugin-security's assertReadableQueryFields (anti filter-oracle, #2251) rejects with 403 PERMISSION_DENIED, naming the offending field, when the caller's own where / orderBy / groupBy / having / aggregations reference an unreadable field — and it deliberately runs against the caller's verbatim AST, before RLS injection, so injected policy filters referencing owner_id are not caught by it. Pinned live in the same dogfood file: filtering and sorting on the denied field each answer 403, while the entitled caller still can. No card filed — the suspected gap does not exist.

The change

perms.checkField(object, field, 'read') now gates the projection, on both authored arms and on the predicate harvest.

Two limits are deliberate, and both are pinned:

  • Only keys the object DECLARES are judged. The card is right that this does not transfer automatically from the render path — on the render path an undeclared key is a derived column, in a $select it is what the host asked the server for, so it had to be re-derived. It lands in the same place for a reason about checkField rather than about drawing: checkField answers false for a field no policy mentions, so judging an undeclared key is not a stricter reading of this rule, it is a different and wrong one — it would strip a host's derived or joined column out of its own query.
  • id survives even a policy that denies it, structurally: every arm composes ensureId(...)after the gate. Keeping the restoration in the composition rather than in a branch means it cannot drift out of one arm. This is the card's decision point 2 — a naive filter breaks navigation rather than closing a hole.

Denied predicate operands are dropped too, and this costs nothing that was working: against ObjectStack the server already deletes that key from every row, so the operand never arrived and the CEL predicate was already faulting No such key and failing closed. Dropping it changes what we ask for, not what we got. Against a non-enforcing backend it turns "the button works, and the denied value sits in memory" into "the button hides" — the correct direction for a predicate gated on a field this principal may not read. Readable operands are untouched, so #3501 does not regress (pinned).

One non-obvious dependency, and why it is load-bearing

The fetch effect now also depends on perms.isLoaded. /me/permissions resolves asynchronously, so on the first render isLoaded is false and the gate correctly defers. Without this dependency nothing would ever rebuild the projection after the policy answered, and the gate would be dead on the only fetch most grids make. Ablation B below is the isolated proof. The boolean rather than perms itself: it flips false→true exactly once, so this costs at most one refetch, where the context object's identity would refetch on every render. PermissionProvider reports true synchronously and the no-provider default stays false forever, so neither pays anything.

Known limit, stated rather than papered over

Under MePermissionsProvider the first request still goes out ungated, in the window before /me/permissions answers; the projection is corrected on the refetch. Closing that window entirely would mean blocking the grid's fetch on permissions, which would hang every host with no PermissionProvider (isLoaded is false there forever). Given the measured server behaviour the residual against ObjectStack is nil.

Verification

All runs at final commit 91c67d2, through the shared verification lock. Exit codes captured before any pipe.

RunResult
vitest run packages/plugin-grid/101 files / 930 tests passed, exit 0
pnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
eslint --no-inline-config on both changed filesexit 0, 0 errors

The suite was first run from the package directory and the repo's own invocation guard refused it (#3378 — a package-cwd run silently executes the console package's 22 files and reports green). Re-run from the repo root as the guard prescribes; the numbers above are from the correct invocation.

Lint narrowing, declared. Repo-wide pnpm lint is CI's run. The narrowing to 2 files is a measurement, not a skip: the population comes from eslint's own config rather than my guess, the count (2) is read from --format json, and type-aware linting is not enabled in eslint.config.js (no parserOptions.project / projectService) — so this diff cannot move the verdict on any file it does not touch. Warning counts are byte-identical to origin/main rule-for-rule (213 → 213, react-hooks/exhaustive-deps unchanged at 10, so the new dependency satisfies the rule rather than suppressing it).

Ablation — the gate can actually see the change

Both legs committed first, so the restore leg had a real reference. Each mutation was proven on disk by blob hash before the suite ran (an editor's exit code is not evidence — a zero-hit replace exits 0), and each restore proven by git diff HEAD empty and blob equality against HEAD.

Ablation A — strip the gate from both authored arms. HEAD blob 1de48053, mutated blob 2c565d99.
4 of 10 red: PIN 1 (denied column still requested), PIN 2 (fields arm), PIN 5 (legacy { name } spelling), PIN 9.

Ablation B — remove only the perms.isLoaded dependency. HEAD blob 1de48053, mutated blob bdac2a69.
exactly 1 of 10 red: PIN 9, "the gate is not dead on the first fetch". The other nine stayed green — which is the point: with a dead gate, nine pins would have reported success.

Restore verified after both: git hash-object = 1de48053 = HEAD, working tree clean.

Scope

packages/plugin-grid/src/ only — ObjectGrid.tsx plus its new test, and the changeset. generateColumns() is untouched: the maintainer's 2026-08-30 ruling deliberately scoped #6799 to that one function.

The export path was checked and needs nothing: it derives its fields from generateColumns(), so it already inherits the #6799 render-half gate.


Generated by Claude Code

…ecurity
ObjectGrid's getSelectFields() built the projection from the authored
columns/fields with no FLS gate, so after the render-half fix the hidden
field was still being requested from the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
`vi.fn(async () => ...)` narrows the mock's arg tuple to `[]`, and every
assertion reads `find.mock.calls.at(-1)?.[1].$select` — the second arg.
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)3149.3 KB3191.4 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-DArX1IDC.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)14.51KB5.35KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)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)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.30KB63.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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-warren
os-warren marked this pull request as ready for review August 31, 2026 17:52
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 1349400Aug 31, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6898-objectgrid-select-fls branch August 31, 2026 20:38
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.

plugin-grid: ObjectGrid builds the server $select from the authored projection with no FLS gate

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): gate the server $select projection on field-level security - #7090

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls
Aug 31, 2026
Merged

fix(plugin-grid): gate the server $select projection on field-level security#7090
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6898

Field-level security on ObjectGrid's server $select projection — the FETCH half of the gap #6799 closed on the RENDER half.

The escalation gate, answered first (this is what the grade rests on)

Triage made one measurement the first mandatory step: does ObjectStack's own REST enforce FLS on the $select projection? Answered by reading the enforcement path in the objectstack sibling checkout, not by assuming the server catches it.

Answer: ENFORCED — but on the RECORD, not on the projection. Branch 1 of the ruling. p2 stands; this PR is defence-in-depth.

Four independent readings, listed weakest to strongest:

  1. plugins/plugin-security/src/security-plugin.ts step 4 (post-next()) runs FieldMasker.maskResults on every find / findOne / insert / update result.

  2. field-masker.ts's maskRecord does delete result[field] for each non-readable field — the key is deleted, not nulled.

  3. predicate-guard.ts says so in terms, and explains why the projection is deliberately left unguarded:

    fields (projection) is intentionally NOT collected — selecting a hidden field is harmless because FieldMasker strips it from the result; only predicates leak.

  4. An executed end-to-end HTTP pin, qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts:

    it('an explicit \select` of the denied field returns the record WITHOUT it (no error, no value)')`

    GET /data/showcase_project/{id}?select=name,{denied} answers 200 with the denied key absent, and the same projection serves an admin the real value.

That is precisely the ruling's first branch — "a server that enforces FLS refuses or omits it — no exposure". ObjectStack omits. So nothing here is load-bearing for ObjectStack, and the code comment at the gate says so, to stop a future reader concluding otherwise. It becomes load-bearing for any backend that does not strip — the same argument the #6723 / #6799 rulings accepted for the render half.

Mandatory question 2 — what else is concatenated into $select, as an enumeration

getSelectFields() (re-derived at L1460, not the card's day-old L1402) composes from six sources, not the two the card named:

#SourceNotes
1schemaFields (the fields prop)authored projection, arm A
2schemaColumns (the columns prop) via columnIdentityauthored projection, arm B
3id, force-added by ensureIdrow navigation / record key
4conditionalFormattingcondition, expression, and the native { field } shapepredicate harvest
5rowActionDefs / bulkActionDefs / object actionsvisible, disabled, and recordIdFieldpredicate harvest
6OBJECT-level userActionsvisibleWhen / disabledWhenpredicate harvest (never the view-level block)

Source 5's recordIdField (objectstack#8018) is the one the card did not name. All of 4–6 pass isProjectableField, which also admits the undeclared platform columns (created_at, owner_id, organization_id, …).

The inference-leak trap — measured, and it is NOT a new card

The ruling flagged that a field the principal cannot read used as a filter may be an inference leak even when the value never returns. Measured: already closed server-side, before this card existed.plugin-security's assertReadableQueryFields (anti filter-oracle, #2251) rejects with 403 PERMISSION_DENIED, naming the offending field, when the caller's own where / orderBy / groupBy / having / aggregations reference an unreadable field — and it deliberately runs against the caller's verbatim AST, before RLS injection, so injected policy filters referencing owner_id are not caught by it. Pinned live in the same dogfood file: filtering and sorting on the denied field each answer 403, while the entitled caller still can. No card filed — the suspected gap does not exist.

The change

perms.checkField(object, field, 'read') now gates the projection, on both authored arms and on the predicate harvest.

Two limits are deliberate, and both are pinned:

  • Only keys the object DECLARES are judged. The card is right that this does not transfer automatically from the render path — on the render path an undeclared key is a derived column, in a $select it is what the host asked the server for, so it had to be re-derived. It lands in the same place for a reason about checkField rather than about drawing: checkField answers false for a field no policy mentions, so judging an undeclared key is not a stricter reading of this rule, it is a different and wrong one — it would strip a host's derived or joined column out of its own query.
  • id survives even a policy that denies it, structurally: every arm composes ensureId(...)after the gate. Keeping the restoration in the composition rather than in a branch means it cannot drift out of one arm. This is the card's decision point 2 — a naive filter breaks navigation rather than closing a hole.

Denied predicate operands are dropped too, and this costs nothing that was working: against ObjectStack the server already deletes that key from every row, so the operand never arrived and the CEL predicate was already faulting No such key and failing closed. Dropping it changes what we ask for, not what we got. Against a non-enforcing backend it turns "the button works, and the denied value sits in memory" into "the button hides" — the correct direction for a predicate gated on a field this principal may not read. Readable operands are untouched, so #3501 does not regress (pinned).

One non-obvious dependency, and why it is load-bearing

The fetch effect now also depends on perms.isLoaded. /me/permissions resolves asynchronously, so on the first render isLoaded is false and the gate correctly defers. Without this dependency nothing would ever rebuild the projection after the policy answered, and the gate would be dead on the only fetch most grids make. Ablation B below is the isolated proof. The boolean rather than perms itself: it flips false→true exactly once, so this costs at most one refetch, where the context object's identity would refetch on every render. PermissionProvider reports true synchronously and the no-provider default stays false forever, so neither pays anything.

Known limit, stated rather than papered over

Under MePermissionsProvider the first request still goes out ungated, in the window before /me/permissions answers; the projection is corrected on the refetch. Closing that window entirely would mean blocking the grid's fetch on permissions, which would hang every host with no PermissionProvider (isLoaded is false there forever). Given the measured server behaviour the residual against ObjectStack is nil.

Verification

All runs at final commit 91c67d2, through the shared verification lock. Exit codes captured before any pipe.

RunResult
vitest run packages/plugin-grid/101 files / 930 tests passed, exit 0
pnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
eslint --no-inline-config on both changed filesexit 0, 0 errors

The suite was first run from the package directory and the repo's own invocation guard refused it (#3378 — a package-cwd run silently executes the console package's 22 files and reports green). Re-run from the repo root as the guard prescribes; the numbers above are from the correct invocation.

Lint narrowing, declared. Repo-wide pnpm lint is CI's run. The narrowing to 2 files is a measurement, not a skip: the population comes from eslint's own config rather than my guess, the count (2) is read from --format json, and type-aware linting is not enabled in eslint.config.js (no parserOptions.project / projectService) — so this diff cannot move the verdict on any file it does not touch. Warning counts are byte-identical to origin/main rule-for-rule (213 → 213, react-hooks/exhaustive-deps unchanged at 10, so the new dependency satisfies the rule rather than suppressing it).

Ablation — the gate can actually see the change

Both legs committed first, so the restore leg had a real reference. Each mutation was proven on disk by blob hash before the suite ran (an editor's exit code is not evidence — a zero-hit replace exits 0), and each restore proven by git diff HEAD empty and blob equality against HEAD.

Ablation A — strip the gate from both authored arms. HEAD blob 1de48053, mutated blob 2c565d99.
4 of 10 red: PIN 1 (denied column still requested), PIN 2 (fields arm), PIN 5 (legacy { name } spelling), PIN 9.

Ablation B — remove only the perms.isLoaded dependency. HEAD blob 1de48053, mutated blob bdac2a69.
exactly 1 of 10 red: PIN 9, "the gate is not dead on the first fetch". The other nine stayed green — which is the point: with a dead gate, nine pins would have reported success.

Restore verified after both: git hash-object = 1de48053 = HEAD, working tree clean.

Scope

packages/plugin-grid/src/ only — ObjectGrid.tsx plus its new test, and the changeset. generateColumns() is untouched: the maintainer's 2026-08-30 ruling deliberately scoped #6799 to that one function.

The export path was checked and needs nothing: it derives its fields from generateColumns(), so it already inherits the #6799 render-half gate.


Generated by Claude Code

…ecurity
ObjectGrid's getSelectFields() built the projection from the authored
columns/fields with no FLS gate, so after the render-half fix the hidden
field was still being requested from the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
`vi.fn(async () => ...)` narrows the mock's arg tuple to `[]`, and every
assertion reads `find.mock.calls.at(-1)?.[1].$select` — the second arg.
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)3149.3 KB3191.4 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-DArX1IDC.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)14.51KB5.35KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)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)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.30KB63.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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-warren
os-warren marked this pull request as ready for review August 31, 2026 17:52
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 1349400Aug 31, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6898-objectgrid-select-fls branch August 31, 2026 20:38
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.

plugin-grid: ObjectGrid builds the server $select from the authored projection with no FLS gate

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): gate the server $select projection on field-level security - #7090

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls
Aug 31, 2026
Merged

fix(plugin-grid): gate the server $select projection on field-level security#7090
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6898

Field-level security on ObjectGrid's server $select projection — the FETCH half of the gap #6799 closed on the RENDER half.

The escalation gate, answered first (this is what the grade rests on)

Triage made one measurement the first mandatory step: does ObjectStack's own REST enforce FLS on the $select projection? Answered by reading the enforcement path in the objectstack sibling checkout, not by assuming the server catches it.

Answer: ENFORCED — but on the RECORD, not on the projection. Branch 1 of the ruling. p2 stands; this PR is defence-in-depth.

Four independent readings, listed weakest to strongest:

  1. plugins/plugin-security/src/security-plugin.ts step 4 (post-next()) runs FieldMasker.maskResults on every find / findOne / insert / update result.

  2. field-masker.ts's maskRecord does delete result[field] for each non-readable field — the key is deleted, not nulled.

  3. predicate-guard.ts says so in terms, and explains why the projection is deliberately left unguarded:

    fields (projection) is intentionally NOT collected — selecting a hidden field is harmless because FieldMasker strips it from the result; only predicates leak.

  4. An executed end-to-end HTTP pin, qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts:

    it('an explicit \select` of the denied field returns the record WITHOUT it (no error, no value)')`

    GET /data/showcase_project/{id}?select=name,{denied} answers 200 with the denied key absent, and the same projection serves an admin the real value.

That is precisely the ruling's first branch — "a server that enforces FLS refuses or omits it — no exposure". ObjectStack omits. So nothing here is load-bearing for ObjectStack, and the code comment at the gate says so, to stop a future reader concluding otherwise. It becomes load-bearing for any backend that does not strip — the same argument the #6723 / #6799 rulings accepted for the render half.

Mandatory question 2 — what else is concatenated into $select, as an enumeration

getSelectFields() (re-derived at L1460, not the card's day-old L1402) composes from six sources, not the two the card named:

#SourceNotes
1schemaFields (the fields prop)authored projection, arm A
2schemaColumns (the columns prop) via columnIdentityauthored projection, arm B
3id, force-added by ensureIdrow navigation / record key
4conditionalFormattingcondition, expression, and the native { field } shapepredicate harvest
5rowActionDefs / bulkActionDefs / object actionsvisible, disabled, and recordIdFieldpredicate harvest
6OBJECT-level userActionsvisibleWhen / disabledWhenpredicate harvest (never the view-level block)

Source 5's recordIdField (objectstack#8018) is the one the card did not name. All of 4–6 pass isProjectableField, which also admits the undeclared platform columns (created_at, owner_id, organization_id, …).

The inference-leak trap — measured, and it is NOT a new card

The ruling flagged that a field the principal cannot read used as a filter may be an inference leak even when the value never returns. Measured: already closed server-side, before this card existed.plugin-security's assertReadableQueryFields (anti filter-oracle, #2251) rejects with 403 PERMISSION_DENIED, naming the offending field, when the caller's own where / orderBy / groupBy / having / aggregations reference an unreadable field — and it deliberately runs against the caller's verbatim AST, before RLS injection, so injected policy filters referencing owner_id are not caught by it. Pinned live in the same dogfood file: filtering and sorting on the denied field each answer 403, while the entitled caller still can. No card filed — the suspected gap does not exist.

The change

perms.checkField(object, field, 'read') now gates the projection, on both authored arms and on the predicate harvest.

Two limits are deliberate, and both are pinned:

  • Only keys the object DECLARES are judged. The card is right that this does not transfer automatically from the render path — on the render path an undeclared key is a derived column, in a $select it is what the host asked the server for, so it had to be re-derived. It lands in the same place for a reason about checkField rather than about drawing: checkField answers false for a field no policy mentions, so judging an undeclared key is not a stricter reading of this rule, it is a different and wrong one — it would strip a host's derived or joined column out of its own query.
  • id survives even a policy that denies it, structurally: every arm composes ensureId(...)after the gate. Keeping the restoration in the composition rather than in a branch means it cannot drift out of one arm. This is the card's decision point 2 — a naive filter breaks navigation rather than closing a hole.

Denied predicate operands are dropped too, and this costs nothing that was working: against ObjectStack the server already deletes that key from every row, so the operand never arrived and the CEL predicate was already faulting No such key and failing closed. Dropping it changes what we ask for, not what we got. Against a non-enforcing backend it turns "the button works, and the denied value sits in memory" into "the button hides" — the correct direction for a predicate gated on a field this principal may not read. Readable operands are untouched, so #3501 does not regress (pinned).

One non-obvious dependency, and why it is load-bearing

The fetch effect now also depends on perms.isLoaded. /me/permissions resolves asynchronously, so on the first render isLoaded is false and the gate correctly defers. Without this dependency nothing would ever rebuild the projection after the policy answered, and the gate would be dead on the only fetch most grids make. Ablation B below is the isolated proof. The boolean rather than perms itself: it flips false→true exactly once, so this costs at most one refetch, where the context object's identity would refetch on every render. PermissionProvider reports true synchronously and the no-provider default stays false forever, so neither pays anything.

Known limit, stated rather than papered over

Under MePermissionsProvider the first request still goes out ungated, in the window before /me/permissions answers; the projection is corrected on the refetch. Closing that window entirely would mean blocking the grid's fetch on permissions, which would hang every host with no PermissionProvider (isLoaded is false there forever). Given the measured server behaviour the residual against ObjectStack is nil.

Verification

All runs at final commit 91c67d2, through the shared verification lock. Exit codes captured before any pipe.

RunResult
vitest run packages/plugin-grid/101 files / 930 tests passed, exit 0
pnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
eslint --no-inline-config on both changed filesexit 0, 0 errors

The suite was first run from the package directory and the repo's own invocation guard refused it (#3378 — a package-cwd run silently executes the console package's 22 files and reports green). Re-run from the repo root as the guard prescribes; the numbers above are from the correct invocation.

Lint narrowing, declared. Repo-wide pnpm lint is CI's run. The narrowing to 2 files is a measurement, not a skip: the population comes from eslint's own config rather than my guess, the count (2) is read from --format json, and type-aware linting is not enabled in eslint.config.js (no parserOptions.project / projectService) — so this diff cannot move the verdict on any file it does not touch. Warning counts are byte-identical to origin/main rule-for-rule (213 → 213, react-hooks/exhaustive-deps unchanged at 10, so the new dependency satisfies the rule rather than suppressing it).

Ablation — the gate can actually see the change

Both legs committed first, so the restore leg had a real reference. Each mutation was proven on disk by blob hash before the suite ran (an editor's exit code is not evidence — a zero-hit replace exits 0), and each restore proven by git diff HEAD empty and blob equality against HEAD.

Ablation A — strip the gate from both authored arms. HEAD blob 1de48053, mutated blob 2c565d99.
4 of 10 red: PIN 1 (denied column still requested), PIN 2 (fields arm), PIN 5 (legacy { name } spelling), PIN 9.

Ablation B — remove only the perms.isLoaded dependency. HEAD blob 1de48053, mutated blob bdac2a69.
exactly 1 of 10 red: PIN 9, "the gate is not dead on the first fetch". The other nine stayed green — which is the point: with a dead gate, nine pins would have reported success.

Restore verified after both: git hash-object = 1de48053 = HEAD, working tree clean.

Scope

packages/plugin-grid/src/ only — ObjectGrid.tsx plus its new test, and the changeset. generateColumns() is untouched: the maintainer's 2026-08-30 ruling deliberately scoped #6799 to that one function.

The export path was checked and needs nothing: it derives its fields from generateColumns(), so it already inherits the #6799 render-half gate.


Generated by Claude Code

…ecurity
ObjectGrid's getSelectFields() built the projection from the authored
columns/fields with no FLS gate, so after the render-half fix the hidden
field was still being requested from the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
`vi.fn(async () => ...)` narrows the mock's arg tuple to `[]`, and every
assertion reads `find.mock.calls.at(-1)?.[1].$select` — the second arg.
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)3149.3 KB3191.4 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-DArX1IDC.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)14.51KB5.35KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)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)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.30KB63.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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-warren
os-warren marked this pull request as ready for review August 31, 2026 17:52
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 1349400Aug 31, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6898-objectgrid-select-fls branch August 31, 2026 20:38
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.

plugin-grid: ObjectGrid builds the server $select from the authored projection with no FLS gate

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): gate the server $select projection on field-level security - #7090

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls
Aug 31, 2026
Merged

fix(plugin-grid): gate the server $select projection on field-level security#7090
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6898

Field-level security on ObjectGrid's server $select projection — the FETCH half of the gap #6799 closed on the RENDER half.

The escalation gate, answered first (this is what the grade rests on)

Triage made one measurement the first mandatory step: does ObjectStack's own REST enforce FLS on the $select projection? Answered by reading the enforcement path in the objectstack sibling checkout, not by assuming the server catches it.

Answer: ENFORCED — but on the RECORD, not on the projection. Branch 1 of the ruling. p2 stands; this PR is defence-in-depth.

Four independent readings, listed weakest to strongest:

  1. plugins/plugin-security/src/security-plugin.ts step 4 (post-next()) runs FieldMasker.maskResults on every find / findOne / insert / update result.

  2. field-masker.ts's maskRecord does delete result[field] for each non-readable field — the key is deleted, not nulled.

  3. predicate-guard.ts says so in terms, and explains why the projection is deliberately left unguarded:

    fields (projection) is intentionally NOT collected — selecting a hidden field is harmless because FieldMasker strips it from the result; only predicates leak.

  4. An executed end-to-end HTTP pin, qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts:

    it('an explicit \select` of the denied field returns the record WITHOUT it (no error, no value)')`

    GET /data/showcase_project/{id}?select=name,{denied} answers 200 with the denied key absent, and the same projection serves an admin the real value.

That is precisely the ruling's first branch — "a server that enforces FLS refuses or omits it — no exposure". ObjectStack omits. So nothing here is load-bearing for ObjectStack, and the code comment at the gate says so, to stop a future reader concluding otherwise. It becomes load-bearing for any backend that does not strip — the same argument the #6723 / #6799 rulings accepted for the render half.

Mandatory question 2 — what else is concatenated into $select, as an enumeration

getSelectFields() (re-derived at L1460, not the card's day-old L1402) composes from six sources, not the two the card named:

#SourceNotes
1schemaFields (the fields prop)authored projection, arm A
2schemaColumns (the columns prop) via columnIdentityauthored projection, arm B
3id, force-added by ensureIdrow navigation / record key
4conditionalFormattingcondition, expression, and the native { field } shapepredicate harvest
5rowActionDefs / bulkActionDefs / object actionsvisible, disabled, and recordIdFieldpredicate harvest
6OBJECT-level userActionsvisibleWhen / disabledWhenpredicate harvest (never the view-level block)

Source 5's recordIdField (objectstack#8018) is the one the card did not name. All of 4–6 pass isProjectableField, which also admits the undeclared platform columns (created_at, owner_id, organization_id, …).

The inference-leak trap — measured, and it is NOT a new card

The ruling flagged that a field the principal cannot read used as a filter may be an inference leak even when the value never returns. Measured: already closed server-side, before this card existed.plugin-security's assertReadableQueryFields (anti filter-oracle, #2251) rejects with 403 PERMISSION_DENIED, naming the offending field, when the caller's own where / orderBy / groupBy / having / aggregations reference an unreadable field — and it deliberately runs against the caller's verbatim AST, before RLS injection, so injected policy filters referencing owner_id are not caught by it. Pinned live in the same dogfood file: filtering and sorting on the denied field each answer 403, while the entitled caller still can. No card filed — the suspected gap does not exist.

The change

perms.checkField(object, field, 'read') now gates the projection, on both authored arms and on the predicate harvest.

Two limits are deliberate, and both are pinned:

  • Only keys the object DECLARES are judged. The card is right that this does not transfer automatically from the render path — on the render path an undeclared key is a derived column, in a $select it is what the host asked the server for, so it had to be re-derived. It lands in the same place for a reason about checkField rather than about drawing: checkField answers false for a field no policy mentions, so judging an undeclared key is not a stricter reading of this rule, it is a different and wrong one — it would strip a host's derived or joined column out of its own query.
  • id survives even a policy that denies it, structurally: every arm composes ensureId(...)after the gate. Keeping the restoration in the composition rather than in a branch means it cannot drift out of one arm. This is the card's decision point 2 — a naive filter breaks navigation rather than closing a hole.

Denied predicate operands are dropped too, and this costs nothing that was working: against ObjectStack the server already deletes that key from every row, so the operand never arrived and the CEL predicate was already faulting No such key and failing closed. Dropping it changes what we ask for, not what we got. Against a non-enforcing backend it turns "the button works, and the denied value sits in memory" into "the button hides" — the correct direction for a predicate gated on a field this principal may not read. Readable operands are untouched, so #3501 does not regress (pinned).

One non-obvious dependency, and why it is load-bearing

The fetch effect now also depends on perms.isLoaded. /me/permissions resolves asynchronously, so on the first render isLoaded is false and the gate correctly defers. Without this dependency nothing would ever rebuild the projection after the policy answered, and the gate would be dead on the only fetch most grids make. Ablation B below is the isolated proof. The boolean rather than perms itself: it flips false→true exactly once, so this costs at most one refetch, where the context object's identity would refetch on every render. PermissionProvider reports true synchronously and the no-provider default stays false forever, so neither pays anything.

Known limit, stated rather than papered over

Under MePermissionsProvider the first request still goes out ungated, in the window before /me/permissions answers; the projection is corrected on the refetch. Closing that window entirely would mean blocking the grid's fetch on permissions, which would hang every host with no PermissionProvider (isLoaded is false there forever). Given the measured server behaviour the residual against ObjectStack is nil.

Verification

All runs at final commit 91c67d2, through the shared verification lock. Exit codes captured before any pipe.

RunResult
vitest run packages/plugin-grid/101 files / 930 tests passed, exit 0
pnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
eslint --no-inline-config on both changed filesexit 0, 0 errors

The suite was first run from the package directory and the repo's own invocation guard refused it (#3378 — a package-cwd run silently executes the console package's 22 files and reports green). Re-run from the repo root as the guard prescribes; the numbers above are from the correct invocation.

Lint narrowing, declared. Repo-wide pnpm lint is CI's run. The narrowing to 2 files is a measurement, not a skip: the population comes from eslint's own config rather than my guess, the count (2) is read from --format json, and type-aware linting is not enabled in eslint.config.js (no parserOptions.project / projectService) — so this diff cannot move the verdict on any file it does not touch. Warning counts are byte-identical to origin/main rule-for-rule (213 → 213, react-hooks/exhaustive-deps unchanged at 10, so the new dependency satisfies the rule rather than suppressing it).

Ablation — the gate can actually see the change

Both legs committed first, so the restore leg had a real reference. Each mutation was proven on disk by blob hash before the suite ran (an editor's exit code is not evidence — a zero-hit replace exits 0), and each restore proven by git diff HEAD empty and blob equality against HEAD.

Ablation A — strip the gate from both authored arms. HEAD blob 1de48053, mutated blob 2c565d99.
4 of 10 red: PIN 1 (denied column still requested), PIN 2 (fields arm), PIN 5 (legacy { name } spelling), PIN 9.

Ablation B — remove only the perms.isLoaded dependency. HEAD blob 1de48053, mutated blob bdac2a69.
exactly 1 of 10 red: PIN 9, "the gate is not dead on the first fetch". The other nine stayed green — which is the point: with a dead gate, nine pins would have reported success.

Restore verified after both: git hash-object = 1de48053 = HEAD, working tree clean.

Scope

packages/plugin-grid/src/ only — ObjectGrid.tsx plus its new test, and the changeset. generateColumns() is untouched: the maintainer's 2026-08-30 ruling deliberately scoped #6799 to that one function.

The export path was checked and needs nothing: it derives its fields from generateColumns(), so it already inherits the #6799 render-half gate.


Generated by Claude Code

…ecurity
ObjectGrid's getSelectFields() built the projection from the authored
columns/fields with no FLS gate, so after the render-half fix the hidden
field was still being requested from the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
`vi.fn(async () => ...)` narrows the mock's arg tuple to `[]`, and every
assertion reads `find.mock.calls.at(-1)?.[1].$select` — the second arg.
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)3149.3 KB3191.4 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-DArX1IDC.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)14.51KB5.35KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)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)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.30KB63.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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-warren
os-warren marked this pull request as ready for review August 31, 2026 17:52
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 1349400Aug 31, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6898-objectgrid-select-fls branch August 31, 2026 20:38
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.

plugin-grid: ObjectGrid builds the server $select from the authored projection with no FLS gate

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): gate the server $select projection on field-level security - #7090

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls
Aug 31, 2026
Merged

fix(plugin-grid): gate the server $select projection on field-level security#7090
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6898

Field-level security on ObjectGrid's server $select projection — the FETCH half of the gap #6799 closed on the RENDER half.

The escalation gate, answered first (this is what the grade rests on)

Triage made one measurement the first mandatory step: does ObjectStack's own REST enforce FLS on the $select projection? Answered by reading the enforcement path in the objectstack sibling checkout, not by assuming the server catches it.

Answer: ENFORCED — but on the RECORD, not on the projection. Branch 1 of the ruling. p2 stands; this PR is defence-in-depth.

Four independent readings, listed weakest to strongest:

  1. plugins/plugin-security/src/security-plugin.ts step 4 (post-next()) runs FieldMasker.maskResults on every find / findOne / insert / update result.

  2. field-masker.ts's maskRecord does delete result[field] for each non-readable field — the key is deleted, not nulled.

  3. predicate-guard.ts says so in terms, and explains why the projection is deliberately left unguarded:

    fields (projection) is intentionally NOT collected — selecting a hidden field is harmless because FieldMasker strips it from the result; only predicates leak.

  4. An executed end-to-end HTTP pin, qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts:

    it('an explicit \select` of the denied field returns the record WITHOUT it (no error, no value)')`

    GET /data/showcase_project/{id}?select=name,{denied} answers 200 with the denied key absent, and the same projection serves an admin the real value.

That is precisely the ruling's first branch — "a server that enforces FLS refuses or omits it — no exposure". ObjectStack omits. So nothing here is load-bearing for ObjectStack, and the code comment at the gate says so, to stop a future reader concluding otherwise. It becomes load-bearing for any backend that does not strip — the same argument the #6723 / #6799 rulings accepted for the render half.

Mandatory question 2 — what else is concatenated into $select, as an enumeration

getSelectFields() (re-derived at L1460, not the card's day-old L1402) composes from six sources, not the two the card named:

#SourceNotes
1schemaFields (the fields prop)authored projection, arm A
2schemaColumns (the columns prop) via columnIdentityauthored projection, arm B
3id, force-added by ensureIdrow navigation / record key
4conditionalFormattingcondition, expression, and the native { field } shapepredicate harvest
5rowActionDefs / bulkActionDefs / object actionsvisible, disabled, and recordIdFieldpredicate harvest
6OBJECT-level userActionsvisibleWhen / disabledWhenpredicate harvest (never the view-level block)

Source 5's recordIdField (objectstack#8018) is the one the card did not name. All of 4–6 pass isProjectableField, which also admits the undeclared platform columns (created_at, owner_id, organization_id, …).

The inference-leak trap — measured, and it is NOT a new card

The ruling flagged that a field the principal cannot read used as a filter may be an inference leak even when the value never returns. Measured: already closed server-side, before this card existed.plugin-security's assertReadableQueryFields (anti filter-oracle, #2251) rejects with 403 PERMISSION_DENIED, naming the offending field, when the caller's own where / orderBy / groupBy / having / aggregations reference an unreadable field — and it deliberately runs against the caller's verbatim AST, before RLS injection, so injected policy filters referencing owner_id are not caught by it. Pinned live in the same dogfood file: filtering and sorting on the denied field each answer 403, while the entitled caller still can. No card filed — the suspected gap does not exist.

The change

perms.checkField(object, field, 'read') now gates the projection, on both authored arms and on the predicate harvest.

Two limits are deliberate, and both are pinned:

  • Only keys the object DECLARES are judged. The card is right that this does not transfer automatically from the render path — on the render path an undeclared key is a derived column, in a $select it is what the host asked the server for, so it had to be re-derived. It lands in the same place for a reason about checkField rather than about drawing: checkField answers false for a field no policy mentions, so judging an undeclared key is not a stricter reading of this rule, it is a different and wrong one — it would strip a host's derived or joined column out of its own query.
  • id survives even a policy that denies it, structurally: every arm composes ensureId(...)after the gate. Keeping the restoration in the composition rather than in a branch means it cannot drift out of one arm. This is the card's decision point 2 — a naive filter breaks navigation rather than closing a hole.

Denied predicate operands are dropped too, and this costs nothing that was working: against ObjectStack the server already deletes that key from every row, so the operand never arrived and the CEL predicate was already faulting No such key and failing closed. Dropping it changes what we ask for, not what we got. Against a non-enforcing backend it turns "the button works, and the denied value sits in memory" into "the button hides" — the correct direction for a predicate gated on a field this principal may not read. Readable operands are untouched, so #3501 does not regress (pinned).

One non-obvious dependency, and why it is load-bearing

The fetch effect now also depends on perms.isLoaded. /me/permissions resolves asynchronously, so on the first render isLoaded is false and the gate correctly defers. Without this dependency nothing would ever rebuild the projection after the policy answered, and the gate would be dead on the only fetch most grids make. Ablation B below is the isolated proof. The boolean rather than perms itself: it flips false→true exactly once, so this costs at most one refetch, where the context object's identity would refetch on every render. PermissionProvider reports true synchronously and the no-provider default stays false forever, so neither pays anything.

Known limit, stated rather than papered over

Under MePermissionsProvider the first request still goes out ungated, in the window before /me/permissions answers; the projection is corrected on the refetch. Closing that window entirely would mean blocking the grid's fetch on permissions, which would hang every host with no PermissionProvider (isLoaded is false there forever). Given the measured server behaviour the residual against ObjectStack is nil.

Verification

All runs at final commit 91c67d2, through the shared verification lock. Exit codes captured before any pipe.

RunResult
vitest run packages/plugin-grid/101 files / 930 tests passed, exit 0
pnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
eslint --no-inline-config on both changed filesexit 0, 0 errors

The suite was first run from the package directory and the repo's own invocation guard refused it (#3378 — a package-cwd run silently executes the console package's 22 files and reports green). Re-run from the repo root as the guard prescribes; the numbers above are from the correct invocation.

Lint narrowing, declared. Repo-wide pnpm lint is CI's run. The narrowing to 2 files is a measurement, not a skip: the population comes from eslint's own config rather than my guess, the count (2) is read from --format json, and type-aware linting is not enabled in eslint.config.js (no parserOptions.project / projectService) — so this diff cannot move the verdict on any file it does not touch. Warning counts are byte-identical to origin/main rule-for-rule (213 → 213, react-hooks/exhaustive-deps unchanged at 10, so the new dependency satisfies the rule rather than suppressing it).

Ablation — the gate can actually see the change

Both legs committed first, so the restore leg had a real reference. Each mutation was proven on disk by blob hash before the suite ran (an editor's exit code is not evidence — a zero-hit replace exits 0), and each restore proven by git diff HEAD empty and blob equality against HEAD.

Ablation A — strip the gate from both authored arms. HEAD blob 1de48053, mutated blob 2c565d99.
4 of 10 red: PIN 1 (denied column still requested), PIN 2 (fields arm), PIN 5 (legacy { name } spelling), PIN 9.

Ablation B — remove only the perms.isLoaded dependency. HEAD blob 1de48053, mutated blob bdac2a69.
exactly 1 of 10 red: PIN 9, "the gate is not dead on the first fetch". The other nine stayed green — which is the point: with a dead gate, nine pins would have reported success.

Restore verified after both: git hash-object = 1de48053 = HEAD, working tree clean.

Scope

packages/plugin-grid/src/ only — ObjectGrid.tsx plus its new test, and the changeset. generateColumns() is untouched: the maintainer's 2026-08-30 ruling deliberately scoped #6799 to that one function.

The export path was checked and needs nothing: it derives its fields from generateColumns(), so it already inherits the #6799 render-half gate.


Generated by Claude Code

…ecurity
ObjectGrid's getSelectFields() built the projection from the authored
columns/fields with no FLS gate, so after the render-half fix the hidden
field was still being requested from the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
`vi.fn(async () => ...)` narrows the mock's arg tuple to `[]`, and every
assertion reads `find.mock.calls.at(-1)?.[1].$select` — the second arg.
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)3149.3 KB3191.4 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-DArX1IDC.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)14.51KB5.35KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)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)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.30KB63.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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-warren
os-warren marked this pull request as ready for review August 31, 2026 17:52
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 1349400Aug 31, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6898-objectgrid-select-fls branch August 31, 2026 20:38
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.

plugin-grid: ObjectGrid builds the server $select from the authored projection with no FLS gate

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): gate the server $select projection on field-level security - #7090

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls
Aug 31, 2026
Merged

fix(plugin-grid): gate the server $select projection on field-level security#7090
os-warren merged 2 commits into
mainfrom
claude/issue-6898-objectgrid-select-fls

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6898

Field-level security on ObjectGrid's server $select projection — the FETCH half of the gap #6799 closed on the RENDER half.

The escalation gate, answered first (this is what the grade rests on)

Triage made one measurement the first mandatory step: does ObjectStack's own REST enforce FLS on the $select projection? Answered by reading the enforcement path in the objectstack sibling checkout, not by assuming the server catches it.

Answer: ENFORCED — but on the RECORD, not on the projection. Branch 1 of the ruling. p2 stands; this PR is defence-in-depth.

Four independent readings, listed weakest to strongest:

  1. plugins/plugin-security/src/security-plugin.ts step 4 (post-next()) runs FieldMasker.maskResults on every find / findOne / insert / update result.

  2. field-masker.ts's maskRecord does delete result[field] for each non-readable field — the key is deleted, not nulled.

  3. predicate-guard.ts says so in terms, and explains why the projection is deliberately left unguarded:

    fields (projection) is intentionally NOT collected — selecting a hidden field is harmless because FieldMasker strips it from the result; only predicates leak.

  4. An executed end-to-end HTTP pin, qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts:

    it('an explicit \select` of the denied field returns the record WITHOUT it (no error, no value)')`

    GET /data/showcase_project/{id}?select=name,{denied} answers 200 with the denied key absent, and the same projection serves an admin the real value.

That is precisely the ruling's first branch — "a server that enforces FLS refuses or omits it — no exposure". ObjectStack omits. So nothing here is load-bearing for ObjectStack, and the code comment at the gate says so, to stop a future reader concluding otherwise. It becomes load-bearing for any backend that does not strip — the same argument the #6723 / #6799 rulings accepted for the render half.

Mandatory question 2 — what else is concatenated into $select, as an enumeration

getSelectFields() (re-derived at L1460, not the card's day-old L1402) composes from six sources, not the two the card named:

#SourceNotes
1schemaFields (the fields prop)authored projection, arm A
2schemaColumns (the columns prop) via columnIdentityauthored projection, arm B
3id, force-added by ensureIdrow navigation / record key
4conditionalFormattingcondition, expression, and the native { field } shapepredicate harvest
5rowActionDefs / bulkActionDefs / object actionsvisible, disabled, and recordIdFieldpredicate harvest
6OBJECT-level userActionsvisibleWhen / disabledWhenpredicate harvest (never the view-level block)

Source 5's recordIdField (objectstack#8018) is the one the card did not name. All of 4–6 pass isProjectableField, which also admits the undeclared platform columns (created_at, owner_id, organization_id, …).

The inference-leak trap — measured, and it is NOT a new card

The ruling flagged that a field the principal cannot read used as a filter may be an inference leak even when the value never returns. Measured: already closed server-side, before this card existed.plugin-security's assertReadableQueryFields (anti filter-oracle, #2251) rejects with 403 PERMISSION_DENIED, naming the offending field, when the caller's own where / orderBy / groupBy / having / aggregations reference an unreadable field — and it deliberately runs against the caller's verbatim AST, before RLS injection, so injected policy filters referencing owner_id are not caught by it. Pinned live in the same dogfood file: filtering and sorting on the denied field each answer 403, while the entitled caller still can. No card filed — the suspected gap does not exist.

The change

perms.checkField(object, field, 'read') now gates the projection, on both authored arms and on the predicate harvest.

Two limits are deliberate, and both are pinned:

  • Only keys the object DECLARES are judged. The card is right that this does not transfer automatically from the render path — on the render path an undeclared key is a derived column, in a $select it is what the host asked the server for, so it had to be re-derived. It lands in the same place for a reason about checkField rather than about drawing: checkField answers false for a field no policy mentions, so judging an undeclared key is not a stricter reading of this rule, it is a different and wrong one — it would strip a host's derived or joined column out of its own query.
  • id survives even a policy that denies it, structurally: every arm composes ensureId(...)after the gate. Keeping the restoration in the composition rather than in a branch means it cannot drift out of one arm. This is the card's decision point 2 — a naive filter breaks navigation rather than closing a hole.

Denied predicate operands are dropped too, and this costs nothing that was working: against ObjectStack the server already deletes that key from every row, so the operand never arrived and the CEL predicate was already faulting No such key and failing closed. Dropping it changes what we ask for, not what we got. Against a non-enforcing backend it turns "the button works, and the denied value sits in memory" into "the button hides" — the correct direction for a predicate gated on a field this principal may not read. Readable operands are untouched, so #3501 does not regress (pinned).

One non-obvious dependency, and why it is load-bearing

The fetch effect now also depends on perms.isLoaded. /me/permissions resolves asynchronously, so on the first render isLoaded is false and the gate correctly defers. Without this dependency nothing would ever rebuild the projection after the policy answered, and the gate would be dead on the only fetch most grids make. Ablation B below is the isolated proof. The boolean rather than perms itself: it flips false→true exactly once, so this costs at most one refetch, where the context object's identity would refetch on every render. PermissionProvider reports true synchronously and the no-provider default stays false forever, so neither pays anything.

Known limit, stated rather than papered over

Under MePermissionsProvider the first request still goes out ungated, in the window before /me/permissions answers; the projection is corrected on the refetch. Closing that window entirely would mean blocking the grid's fetch on permissions, which would hang every host with no PermissionProvider (isLoaded is false there forever). Given the measured server behaviour the residual against ObjectStack is nil.

Verification

All runs at final commit 91c67d2, through the shared verification lock. Exit codes captured before any pipe.

RunResult
vitest run packages/plugin-grid/101 files / 930 tests passed, exit 0
pnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
eslint --no-inline-config on both changed filesexit 0, 0 errors

The suite was first run from the package directory and the repo's own invocation guard refused it (#3378 — a package-cwd run silently executes the console package's 22 files and reports green). Re-run from the repo root as the guard prescribes; the numbers above are from the correct invocation.

Lint narrowing, declared. Repo-wide pnpm lint is CI's run. The narrowing to 2 files is a measurement, not a skip: the population comes from eslint's own config rather than my guess, the count (2) is read from --format json, and type-aware linting is not enabled in eslint.config.js (no parserOptions.project / projectService) — so this diff cannot move the verdict on any file it does not touch. Warning counts are byte-identical to origin/main rule-for-rule (213 → 213, react-hooks/exhaustive-deps unchanged at 10, so the new dependency satisfies the rule rather than suppressing it).

Ablation — the gate can actually see the change

Both legs committed first, so the restore leg had a real reference. Each mutation was proven on disk by blob hash before the suite ran (an editor's exit code is not evidence — a zero-hit replace exits 0), and each restore proven by git diff HEAD empty and blob equality against HEAD.

Ablation A — strip the gate from both authored arms. HEAD blob 1de48053, mutated blob 2c565d99.
4 of 10 red: PIN 1 (denied column still requested), PIN 2 (fields arm), PIN 5 (legacy { name } spelling), PIN 9.

Ablation B — remove only the perms.isLoaded dependency. HEAD blob 1de48053, mutated blob bdac2a69.
exactly 1 of 10 red: PIN 9, "the gate is not dead on the first fetch". The other nine stayed green — which is the point: with a dead gate, nine pins would have reported success.

Restore verified after both: git hash-object = 1de48053 = HEAD, working tree clean.

Scope

packages/plugin-grid/src/ only — ObjectGrid.tsx plus its new test, and the changeset. generateColumns() is untouched: the maintainer's 2026-08-30 ruling deliberately scoped #6799 to that one function.

The export path was checked and needs nothing: it derives its fields from generateColumns(), so it already inherits the #6799 render-half gate.


Generated by Claude Code

…ecurity
ObjectGrid's getSelectFields() built the projection from the authored
columns/fields with no FLS gate, so after the render-half fix the hidden
field was still being requested from the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
`vi.fn(async () => ...)` narrows the mock's arg tuple to `[]`, and every
assertion reads `find.mock.calls.at(-1)?.[1].$select` — the second arg.
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)3149.3 KB3191.4 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-DArX1IDC.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)14.51KB5.35KB
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)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)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)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.30KB63.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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-warren
os-warren marked this pull request as ready for review August 31, 2026 17:52
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 1349400Aug 31, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6898-objectgrid-select-fls branch August 31, 2026 20:38
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.

plugin-grid: ObjectGrid builds the server $select from the authored projection with no FLS gate

2 participants

@os-warren@claude