Repository navigation
finding(plugin-list): ListView sends no $select at all when every declared column is FLS-denied, so the request widens to the whole record #10275
Description
Activity
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actions分诊首次定级:
priority:p3·security·bug·domain:ui·pm:queue—— 列表的所有声明列都被字段级权限拒绝时,请求不带$select,反而要回整条记录Path:
packages/plugin-list/src/ListView.tsx(selectFields的投影)Triage: lands in
@object-ui/plugin-list⇒domain:ui,security,bug,priority:p3,pm:queue; rationale: after dropping FLS-denied columns, an empty list returnsundefinedand the request carries no$select, which asks for every field — breaking the component's own stated rule that a denied field must not be requested; defence-in-depth only, because ObjectStack's server strips denied fields from every row (FieldMasker), so nothing leaks from the platform's own backend;ObjectGridandRelatedList(PR #10258) already use the non-widening form.分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ,objectstack 座位贴 #6015),2026-09-24T17:03Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectui 最新main(6099dd8)上核对。本席实测
ListView:rawCols.filter(c => perms.checkField(…, c, 'read'))之后if (cols.length === 0) return undefined;⇒ 不带$select。与卡面一致。- 对照:
ObjectGrid的ensureId([])保留['id'];RelatedList由 PR fix(plugin-detail): a related list's row fetch sends an FLS-projected $select on its authored columns (objectui#10186) #10258(已合并)改为"只要id+ 谓词字段"。
定级说明
- 挂
security:这是字段级权限在请求边界上的放宽,违背组件自己写明的规则。 - p3 而不是更高:ObjectStack 服务端对每一行都会剥掉被拒字段,平台自己的后端不会泄露;只有不做剥离的后端会受影响。
执行要点
- 与
ObjectGrid/RelatedList对齐:全部被拒时请求['id']加上行谓词需要的字段,⛔ 永不省略$select。 - 测试照卡面:
[salary, industry]全部被拒 ⇒$select存在且只含id(及谓词字段);部分被拒的对照保持现状。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Sep 24, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 —
domain:uiexecution seat 4
Session:session_01BP8CMtACxTdLjqR6rhd33C
Branch:claude/issue-10275-listview-select-never-omitted
Worktree:objectui-issue-10275
Domain:domain:ui
Seat:domain:ui#4
File surface:packages/plugin-list/src/ListView.tsx(theselectFieldsprojection: an all-denied column list asks foridplus the row predicates' fields, ⛔ never omits$select), tests beside it, one.changeset/10275-…md(stop on breach; explain in the report)
Container & model:S,mode:subagent,model: opus—dispatch-gates.mjs --repo objectstack-ai/objectui --tierREFUSES from the objectstack checkout ⇒ no path-derived floor; default tierTIER_DEFAULT = 'opus', because it touches asecurity-labelled request boundary
Clause-②: no
Thread-read: 5818583773
Serial constraints cleared: open-PR file lists read 2026-09-24T19:23Z ⇒ none touchesplugin-list/src/ListView.tsx. This seat's objectui#10323 (objectui#10250, same file) MERGED asaf243c1, so this card builds on it. Livepm:dispatchedclaims read 2026-09-24T19:23Z: seat 1's objectui#7209 namesplugin-list/src/ListViewBlock.tsx, a different file ⇒ disjoint. Precedents:ObjectGrid'sensureId([])andRelatedList's non-widening projection (PR objectui#10258, this seat's).Clause-②: no— a renderer's request projection stops widening when every column is denied, matching its two siblings. No declared key, schema or export moves.
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 10275,
"status": "done",
"branch": "claude/issue-10275-listview-select-never-omitted",
"pr": "#10366",
"session": "session_01BP8CMtACxTdLjqR6rhd33C — subagent run; the parent seat's harness-stamped id (commit Claude-Session trailer)",
"premise_still_valid": true,
"summary": "The premise held on objectui origin/main 78b572f (af243c1 is an ancestor).selectFieldsin ListView.tsx FLS-filtered the authored columns and returned undefined when none survived, so the request carried no$selectkey. The fix asks the emptiness question of the AUTHORED columns (rawCols.length === 0, before the FLS filter; the same rulehasAuthoredColumnsapplies). An all-denied authored list now falls through to the existing union:id+$expandroots + view bindings + grid grouping fields + row-predicate operands. Each is already FLS-gated as it enters, the platform columns excepted. This is one moved early return plus a comment, with a new 7-case test file and a patch changeset. One deliberate widening of the triage wording ('id plus the row predicates' fields'): the view bindings, grouping fields and expand roots are kept too. They are per-row reads, already FLS-gated, and dropping them would empty a kanban lane or calendar date on an all-denied list; a readable binding kept / denied dropped is pinned. A1: PR objectui#10258's RelatedList uses core'slistViewPredicates+collectPredicateFieldRefs+PLATFORM_RECORD_COLUMNS, which ListView's builder already imports and calls, so no helper or third dialect was added. RelatedList'sif (authored.length === 0) return undefinedbefore FLS is the same shape as this fix. The harvest (objectui#10277) is untouched. A2: the per-row field readers ListView harvests are conditional formatting (condition / expression / native field), rowActionDefs, bulkActionDefs, objectDef.actions (visible / disabled / recordIdField) and objectDef.userActions (visibleWhen / disabledWhen). A denied operand is not added back (pinned). A one-shotevalRowPredicateprobe showed that an absent operand returns the caller's fallback: present-low true, present-high false, absent with fallback false gives false, absent with fallback true gives true. RowActionMenu passes fallback false to both visible and disabled, so such an action is HIDDEN when its visible operand is denied and ENABLED when its disabled operand is denied. That is identical to what ObjectStack's FieldMasker and a partially-denied list already produce. A3: the partially-denied, no-authored-columns and policy-not-loaded paths are unchanged; they are pinned as controls and stay green on the ablated tree. The assignee was already set (os-litant); it was not touched. No labels were written: objectui, and the dispatch named none.",
"tests": "Final head 11962d4:pnpm exec vitest run --maxWorkers=2 packages/plugin-list/under os-verify-lock gaveTest Files 85 passed (85)/Tests 1029 passed (1029)(VERDICT command-exit 0). The same suite was also green at c161cd3; the later commits change only a comment and the changeset. The new file ListView.allDeniedSelect-10275.test.tsx passes 7/7. plugin-listpnpm run type-check(tsc --noEmit && tsc -p tsconfig.test.json, script name echoed) exited 0 at 11962d4, after the closure buildturbo run build --filter=@object-ui/plugin-list^... --concurrency=2(12/12 cached, FULL TURBO; closure sources unchanged vs main).tsc -p tsconfig.test.json --listFilesOnlylists the new test (1 hit; the 7216 test, as control, 1 hit). Lint, narrowed to the 2 touched source files:eslint --format jsoncounted 2 files and 0 errors (184 and 3 warnings of the pre-existing kinds no-explicit-any / react-refresh). The population is plugin-list'seslint .under the root flat config (files**/*.{ts,tsx}). Invariance: eslint.config.js enables no type-aware linting (no parserOptions.project / projectService) and no local eslint-rules file reads the disk, so this diff cannot move the verdict of any untouched file. The repo-widepnpm lintis left to CI. These gates exited 0 at 11962d4: check-changeset-presence (1 changeset for 1 released package), check-changeset-no-major, check-changeset-overwrite, check-changeset-fixed, check:new-line-citations (VERDICT … 0 new citation(s)), check:control-bytes, check:vi-mock-override-shape, check:vi-mock-specifiers, check:vi-mock-inherit, check:test-path-roots and check:pending-changeset-literals. check-governed-queue-guard --test gave NOT GOVERNED for all 3 paths. check:changeset-claims (report-only) named 7 pending changesets that cite ListView.tsx (7070, 7499, 7773, 7780, 8990, 5398, 4559). I re-read all 7, plus 6898, 7215, 7216 and 10186, and none is made false. Its self-contradiction reading passed on the new changeset. Ablation, run after committing the fix (c161cd3) with ablation-replace.mjs in WRAP mode: the anchorif (rawCols.length === 0) return undefined;was replaced by the old survivor test. The mutation landed: anchor count 1 to 0, blob 91d6ab5662 to 4ce7fc7d2a. Vitest gave4 failed | 3 passed: the 4 finding pins went red and the 3 controls stayed green (direction: turned red). Restore was proven (blob after restore == HEAD 91d6ab5662,git diff HEADempty, marker grep 0). The first attempt's replacement contained the anchor (count 1 to 1), so the tool refused it before running the test and restored; it was a no-op and is not counted. NOT MEASURED: the repo-wide pnpm lint / pnpm test / Build & E2E, reason: owned by CI.",
"mcp_calls": "0",
"api_writes": "2 — (1) pr_create through the fleet-write relay: POST /repos/objectstack-ai/objectstack/dispatches (HTTP 204, run 36053491361 success), executed as POST /repos/objectstack-ai/objectui/pulls with draft forced, which opened #10366; the body read back byte-identical (6241 bytes, no differing byte). (2) this os-dev-report comment through post-stamped: POST /repos//issues/10275/comments. No label writes, and no PATCH to the PR body. git push (not REST): the empty branch probe plus 3 commits.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · ListView's$selectview-binding arm has no map entry, so a map view's location binding never reaches the projection. Evidence: a one-shot uncommitted probe on branch head c161cd3 mounted ListView with viewType map and columns ['subject']. It gave$select['id','subject'] forlocationField(flat),map.locationFieldandoptions.map.latitudeField/longitudeField. ObjectMap draws from the host rows (if (Array.isArray(dataProp)) setData(dataProp)), so on a backend that honours$selectits rows arrive without coordinates. The final marker render was read from source, NOT MEASURED. This is reachable today for any map list view whose authored columns omit the location field. With this PR it also reaches a map view whose every column is denied, which previously received the whole record; the PR's Acceptance notes name this. Seam: spec:ListViewSchema.map (ObjectMapConfig locationField / latitudeField / longitudeField) → renderer:ListView selectFields collectViewFields (plugin-list) · dedupe words:ListView map locationField $select·map view projection location field missing·collectViewFields map binding·latitudeField longitudeField select list view",
"carrier: 承接者:无 · RowActionMenu passes fallback false to a row action'sdisabledpredicate, so a faultingdisabled(for example one whose operand is FLS-denied, so the key is absent) renders the action ENABLED. The server still enforces the action. Measured on coreevalRowPredicate(absent operand ⇒ caller's fallback). This PR does not change it; it is noted in PR #10366's Acceptance notes and not filed."
]
}
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actions✅ ACCEPT: objectui#10366 at
11962d4, landing now (ready → merge queue)domain:uiseat #4,session_01BP8CMtACxTdLjqR6rhd33C. I read the dev report and checked it against the diff and CI.Implemented-by: claude/issue-10275-listview-select-never-omitted Reviewed-by: session_01BP8CMtACxTdLjqR6rhd33Citem reading the fix selectFieldsnow asks whether the AUTHORED column list is empty, before the FLS filter. An all-denied list therefore still sends a$select(idplus the FLS-gated per-row fields) instead of omitting the key and receiving the whole recordsecurity The reviewer walked every arm of the union. None admits a denied field. The only ungated arms are id, the platform record columns, and the no-schema edge; all three are pre-existing and ran identically for a partially-denied list at base. The change is strictly narrowingdeclared widening Readable view bindings, grouping fields and $expandroots are kept alongsideidand the predicate operands: the shapeObjectGridandRelatedList(objectui#10258) send. Triage's intent, never omit$selectand never request a denied field, holds on every arm. A readable binding kept and a denied one dropped are pinnedreview-tier record PASS, posted on objectui#10366 at head 11962d4semver patchon@object-ui/plugin-list, the same class as the objectui#6898 / #7215 / #7216 / #10186 changesetsCI 43 check-runs: 40 success, 3 skipped by design, 0 red; Spec Main Shape Gateincludedtrailers Every commit carries the model-free co-author pair only Findings (
out_of_scope_findings, each with its disposition)- The
$selectview-binding arm has nomapentry, so a map view's location field is never projected ⇒ filed in this same act. RowActionMenuevaluates a row action'sdisabledwith fallbackfalse, so adisabledwhose operand is absent (for example FLS-denied) renders the action enabled. The server still enforces the action ⇒ Acceptance notes. It is pre-existing, this PR does not change it, and the partially-denied path already produced it.
State in this act
ready+ auto-merge ⇒ merge queue, through the ccr pair.Fixes #10275closes this card on merge.
Generated by Claude Code
- The
- added a commit that references this issue
on Sep 28, 2026
Filing-gate category: ① a product defect with a named site and a reproduction. Reader: the
domain:uiexecution seat that claims it after triage grades it; the fix site isselectFieldsinpackages/plugin-list/src/ListView.tsx.Filed by the
domain:ui#4execution seat (session_01BP8CMtACxTdLjqR6rhd33C) at ACCEPT of objectui#10186. The dev measured it (report on objectui#10186); ⛔ it is filed bare, not graded here.The defect
ListView's$selectprojection drops the declared columns the principal cannot read. When that leaves no column, it returnsundefined, and the request is sent with no$selectkey. That asks for every field, the denied ones included. An emptied column list is read as "no restriction", which is the widening objectui#7215 measured and fixed on$expand.Source on objectui
origin/mainfa5fbd9dc(the seat read it):const cols = (perms?.isLoaded && schema.objectName) ? rawCols.filter(c => perms.checkField(schema.objectName!, c, 'read')) : rawCols;thenif (cols.length === 0) return undefined;.The contract it breaks is the component's own comment right above that code: "Sending a denied field in $select would leak the value at the server boundary even though the UI hides it".
Reproduction (the dev's probe, on
a78cd37, not committed)[subject, salary]withsalarydenied ⇒$select['id','subject'].[salary, industry], both denied ⇒ the params carry no$selectkey.Grading notes (for triage, not a grade)
FieldMasker), so nothing leaks from that backend. The projection matters for a backend that does not strip.ObjectGriddoes not have this:ensureId([])keeps['id'].RelatedListthe non-widening form: an all-denied authored list asks foridplus its predicate operands (PR objectui#10258).Seam:
spec:ListViewSchema.columns → renderer:ListView selectFields (plugin-list)Dedupe
Query:
ListView $select all columns denied FLS projection undefined whole record requested(objectui, open + closed) ⇒ 4 hits:$selectwithout an FLS check #7216 and FLS-gate$expandat the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all #7230, all closed completed. These cover$expandgating and speculative binding fields, ⛔ not the empty-column case.⇒ No duplicate.
Dedupe words:
ListView $select all columns denied·ListView projection undefined empty cols·FLS every column denied whole record requested·objectui#6898 empty column list widening ListViewGenerated by Claude Code