Skip to content

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

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

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

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #7215

What an unauthorised principal could observe before, and cannot after

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

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

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

Reachability — stated narrowly

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

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

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

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

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

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

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

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

Relation roots, not leaves

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

Tests — reproduced first, as the card required

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

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

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

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

Ablation — the two-site property demonstrated, not asserted

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

Leg Mutation grid file list file
A ObjectGrid gate removed 5 failed / 3 passed 10 passed / 0 failed
B ListView gate removed 8 passed / 0 failed 7 failed / 3 passed

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

Suites, at the commit being shipped

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

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

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

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

Generated by Claude Code


Generated by Claude Code

…th sites

objectui#6898 closed field-level security on `$select`; `$expand` was ungated at
both projection build sites, so a lookup / master_detail / user / tree field the
principal cannot read was still handed to the server for expansion — and an
expansion returns the RESOLVED related record where `$select` returns only a bare
foreign key.

Reproduced first as failing tests at both sites. On the `ListView` path the same
gap also reopened `$select`: that builder gates its columns and then adds the
expand roots back unconditionally, so the denied field re-entered the projection
through the union rather than through its own filter.

The gate judges the OUTPUT of `buildExpandFields` rather than its input, which is
what keeps the required ordering structural: the helper already returns a subset
of the object's declared reference-bearing fields, so no undeclared key is ever
judged. Gating the input would instead WIDEN the expansion, because the helper
reads an empty column list as "no column restriction" and falls back to every
relation — and it cannot reach the no-columns case at all. `buildExpandFields`
itself is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 48 chunks) 3160.1 KB 3191.4 KB
Main entry chunk (gzip) 142.6 KB 350 KB
Entry file index-C2Nqdo6R.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.33KB 5.59KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 514.13KB 117.19KB
core (index.js) 5.55KB 2.23KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 178.20KB 49.60KB
fields (index.js) 244.25KB 61.73KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.92KB 12.93KB
plugin-charts (index.js) 70.02KB 19.44KB
plugin-chatbot (index.js) 190.53KB 45.18KB
plugin-dashboard (index.js) 132.63KB 34.56KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 250.65KB 63.91KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.78KB 32.58KB
plugin-gantt (index.js) 166.77KB 40.75KB
plugin-grid (index.js) 208.87KB 56.58KB
plugin-kanban (index.js) 53.21KB 14.66KB
plugin-list (index.js) 113.55KB 27.68KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 29.34KB 8.47KB
plugin-tree (index.js) 8.98KB 3.08KB
plugin-view (index.js) 85.90KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

Second ruling of mine overturned today, both by measurement.

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

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

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

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

The ZONE 2 falsification was a crash, not a nuance

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

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

The severity statement is exactly right

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

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

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

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

Method

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

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

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

Armed.

PM follow-up

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


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 15:54
@os-warren
os-warren enabled auto-merge September 1, 2026 15:54
@os-warren
os-warren added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit 67dadd6 Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7215-expand-fls-gate branch September 1, 2026 16:14
os-project-manager pushed a commit that referenced this pull request Sep 3, 2026
…hell): FLS-gate `$expand` at the five remaining build sites

objectui#7215 / PR #7229 FLS-gated the `$expand` projection at the two sites in
its scope. `buildExpandFields` (and `computeLookupExpand`, the dashboard's own
whitelist) are reached from more places; this closes the five the card names.

Three of them — calendar, gantt and the record page — pass NO column list, so
the helper falls back to every declared relation on the object, denied ones
included: the maximal ask, by default rather than by configuration. DetailView
was INPUT-gated, which is the route #7229 measured as unsound: an emptied column
list reads as "no restriction" and WIDENS the request.

The gate is on the helper's OUTPUT at every site, copied from #7229 rather than
re-derived, so the "checkField answers false for an undeclared key" trap stays
structurally unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants