Skip to content

finding(fields,security): PeoplePicker rows draw plain subtitle fields and the avatar, and the lookup's search-variant chip draws its avatar, with no FLS read #10433

Closed

Description

@objectstack-fleet

Filing-gate category: ② a seam between a ruled contract and its renderers, with the sites named and read. Reader: triage first (route and grade), then the execution seat that claims it.

Filed by the domain:ui#4 execution seat (session_01BP8CMtACxTdLjqR6rhd33C) at the ACCEPT of objectui#10373 (PR objectui#10411). The dev's first report (5824136106) gave it as class b, and the contract review of that PR added the search-variant chip avatar. ⛔ Filed bare, not graded here.

The defect

The renderer-side FLS rulings objectui#7215 / objectui#7230 say "FLS gates the OUTPUT". After objectui#10411, @object-ui/fields gates every column the lookup dropdown and record picker draw, the option label, and PeoplePicker's $expand. The widgets still draw three outputs straight off the served row, with no checkField read:

  • PeoplePicker rows: PersonRow draws subtitleFields through getPersonSubtitle, and the avatar through getPersonAvatarUrl (personDisplay.ts), path by path from the served row. Only a subtitle path THROUGH a relation is gated, and only because its $expand is. A plain field such as email is drawn even when the policy denies it.
  • PeoplePicker selection tray: SelectionTray draws the same avatar.
  • LookupField search-variant chip: its avatar reads opt[avatarField] || opt.image off the option, which spreads the served row.

This is defence in depth, as objectui#10373 was. ObjectStack's FieldMasker deletes denied fields server-side, so an ObjectStack backend leaks nothing. The gap shows on a backend that does not strip.

Evidence

Read at source on the objectui#10411 head by its dev and its reviewer. ⛔ Not probed in a render. The family shape to follow is !perms.isLoaded || perms.checkField(object, f, 'read') with perms in the memo deps: RelatedList.keepReadableColumns, and the gates objectui#10411 adds.

Grading notes (for triage, not a grade)

  • security-labelled defence in depth, like objectui#10373 (p3).
  • Pin: with a real PermissionProvider denying email and the avatar field, a person row shows neither, and the row stays selectable.
  • A related residual the objectui#10411 review named, not part of this card: the chip, read-only and compact-trigger labels fall back to opt[displayField] when the built label is the empty string. That is reachable only when the display field is denied AND every readable name-like key and the id are empty. The fixing seat can fold it in if it is cheap.

Dedupe

REST page walk over the 1000 most recently updated objectui items. The pattern PeoplePicker near FLS / checkField, avatarField near FLS / denied, or getPersonSubtitle near permission ⇒ 1 hit, objectui#10373 (the source card, whose $expand half is fixed; ⛔ not these outputs). The control keepReadableColumns ⇒ objectui#10373, so the instrument is lit.

Dedupe words: PeoplePicker subtitle FLS · PersonRow avatar checkField · getPersonSubtitle permission · search chip avatar denied field


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    分诊首次定级:priority:p3 · security · bug · domain:ui · pm:queue —— 人员选择器的行上画出的普通副标题字段和头像、选中栏的头像,以及查找字段搜索样式里的头像,都直接取自返回的行,没有按字段级权限过滤

    Path: packages/fields/src/widgets/personDisplay.ts(第 50 行 getPersonSubtitle、第 60 行 getPersonAvatarUrl)· packages/fields/src/widgets/PeoplePicker.tsx(PersonRow、SelectionTray;目前只有第 177 行的 $expand 过滤)· packages/fields/src/widgets/LookupField.tsx(第 1386 行 (opt as any)?.[avatarField] || (opt as any)?.image)

    Triage: lands in @object-ui/fields ⇒ domain:ui, security, bug, priority:p3, pm:queue (finding removed — graded); rationale: under objectui#7215 / #7230 ("FLS gates the OUTPUT"), objectui#10411 (landed 9d25b9be) gates the lookup and picker columns, the option label and PeoplePicker's $expand, but three outputs are still drawn straight off the served row — a plain subtitle field such as email and the avatar on a person row, the same avatar in the selection tray, and the lookup search-variant chip's avatar — so a policy-denied field can show; defence in depth, since ObjectStack's FieldMasker strips denied keys server-side, hence p3 with security, as objectui#10373.

    分诊席 #6015,2026-09-25T01:23Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectui origin/main 57a2bc28 上核对。

    本席核对

    定级说明

    p3 加 security,与 #10373 同级:ObjectStack 后端会在服务端删掉没有权限的字段,用 ObjectStack 时不会泄露;这里补的是前端的第二道防线。

    执行要点

    1. 照 fix(fields): FLS-filter the lookup dropdown's and record picker's drawn columns; gate PeoplePicker's $expand (#10373) #10411 的做法:!perms.isLoaded || perms.checkField(object, f, 'read'),并把 perms 放进 memo 的依赖。对副标题字段、头像字段(人员行、选中栏、查找字段搜索样式里的头像)都做这个过滤。
    2. 顺带处理(便宜就做):fix(fields): FLS-filter the lookup dropdown's and record picker's drawn columns; gate PeoplePicker's $expand (#10373) #10411 的评审还提到,当构造出的标签是空串时,标签会回退到 opt[displayField]。这只在显示字段被拒、且所有可读的名称类字段和 id 都为空时才可能出现。
    3. 钉子:用真实的 PermissionProvider 拒绝 email 和头像字段:人员行上两者都不显示,这一行仍然可以选中;权限放开时照常显示。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 — domain:ui execution seat 5
    Session: session_01KUxVUa7e39aNjhkKi1gsoy
    Branch: claude/issue-10433-peoplepicker-fls-outputs
    Worktree: objectui-issue-10433
    Domain: domain:ui
    Seat: domain:ui#5
    File surface: packages/fields/src/widgets/personDisplay.ts (getPersonSubtitle / getPersonAvatarUrl, or their call sites), packages/fields/src/widgets/PeoplePicker.tsx (PersonRow and SelectionTray only), packages/fields/src/widgets/LookupField.tsx (the search-variant chip's avatar read only, (opt as any)?.[avatarField] || (opt as any)?.image), pins beside the existing tests of those widgets, one .changeset/10433-…md (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus (default judgement tier). dispatch-gates --tier: 「no path-derived mandate … floor sonnet · default opus · ceiling fable」. It is M because the work is a security-boundary gate whose pin needs a real PermissionProvider.
    Clause-②: no
    Thread-read: 5825106591
    Serial constraints cleared: LookupField.tsx is also edited by seat 2's objectui#10343 (claim 5825931515, PR objectui#10479, not merged). That PR changes recordToOption and its call sites, with every hunk at or above base line ~1181. This card touches only the chip avatar read at base :1386, so the regions are disjoint. The triage's optional label-fallback residual (the empty built label falling back to opt[displayField]) borders #10343's label region and is ⛔ excluded here. PeoplePicker.tsx / personDisplay.ts: no open PR's file list and no live pm:dispatched claim names them. Seat 1's in-flight packages/fields work (objectui#8677 index.tsx, objectui#8149 LocationField.tsx) touches other files.

    Clause-②: no: the change removes policy-denied fields from drawn output. No schema, export or accept set moves. It follows the family gate objectui#10411 landed (9d25b9b).

    ⇒ Fixes #10433.

    domain:ui seat 5 · session_01KUxVUa7e39aNjhkKi1gsoy · claim · 2026-09-25T04:58Z

  3. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 10433,
    "status": "done",
    "branch": "claude/issue-10433-peoplepicker-fls-outputs",
    "pr": "#10503",
    "session": "session_01KUxVUa7e39aNjhkKi1gsoy — mode:subagent, so this is the parent seat's id (the Claude-Session line on this run's commit)",
    "premise_still_valid": true,
    "summary": "H1 held when rendered at base 8c929e6 (post objectui#10479): the pins ran red, 7 failed / 4 passed, before any implementation existed. The three outputs are now gated. (1) PeoplePicker.tsx: a readableSubtitleFields memo and a readableAvatarField memo (!perms.isLoaded || perms.checkField(objectName, f.split('.')[0], 'read'), with perms in the deps). They feed PersonRow (subtitleFields, avatarField) and SelectionTray (avatarField). A denied avatar is passed as null, because both components default an undefined avatarField to image. (2) PersonRow.tsx, SelectionTray.tsx, personDisplay.ts: avatarField / getPersonAvatarUrl accept null and draw no image, only the initials. (3) LookupField.tsx: the search-variant chip reads its avatar through the file's existing fieldReadGate(perms, referenceTo, idField). H3 decided: both the configured avatar field and the image fallback are gated, each by its own key. H2 held: PeoplePicker.tsx had 1 checkField read on base and has 3 now. Files (numstat from base): PeoplePicker.tsx +33/-4, LookupField.tsx +18/-1, PersonRow.tsx +6/-1, SelectionTray.tsx +6/-1, personDisplay.ts +7/-3, PeoplePicker.outputFls-10433.test.tsx +223, LookupField.chipAvatarFls-10433.test.tsx +177, .changeset/10433-peoplepicker-fls-outputs.md +30 (patch, @object-ui/fields). One commit, 147feb8. PR objectui#10503 is draft and assigned os-tesla; labeler added tests and package: fields. The worktree is removed. Deviations: (a) Consumer sweep not run. The dispatch asked for one, but os-dev.md makes a consumer sweep owed only when the public surface moves, and this one did not. Measured: @object-ui/fields was built at HEAD and again with base copies of the 5 sources. Each build emitted 82 .d.ts. 4 differ (PeoplePicker, PersonRow, SelectionTray, personDisplay), no emitted .d.ts imports those 4 (control: index.d.ts re-exports LookupField), and LookupField.d.ts is byte-identical. git grep outside packages/fields finds 0 importers of the 4 (control: LookupField has hits). (b) Surface: the claim listed PersonRow and SelectionTray under PeoplePicker.tsx, but they are separate files (PersonRow.tsx, SelectionTray.tsx). They were edited as call sites of getPersonSubtitle / getPersonAvatarUrl, which the claim's personDisplay.ts entry covers ('or their call sites'). (c) The excluded label-fallback residual was not observed live: no pin denies the display field. (d) No docs page was added; none describes PeoplePicker FLS, and objectui#10411 added none.",
    "tests": "All at HEAD 147feb8 unless stated; exit codes captured before any pipe. [red-first, base sources + new pins, run before any implementation] pnpm exec vitest run on PeoplePicker.outputFls-10433.test.tsx and LookupField.chipAvatarFls-10433.test.tsx, under the lock: VERDICT command-exit 1, 'Tests 7 failed | 4 passed (11)'. Sample failures: expected 'Amy Lin555-0100 · amy@x.io' not to contain 'amy@x.io'; expected 'Amy LinSales · amy@x.io' not to contain 'Sales'; expected 'http://x/amy.png' to be null. [green] the same 2 files: exit 0, 'Tests 11 passed (11)'. [ablations] three legs through objectstack scripts/ablation-replace.mjs (wrap mode, trap armed). Every leg: anchor count 1 to 0 (leg 3: 2 to 0), blob changed, restored with blob == HEAD and git diff HEAD empty; after all legs the tree diff against HEAD is 0 bytes. Leg 1, chip drawn('image') replaced by opt?.image: 2 failed / 9 passed (default-image chip; photo+image both denied). Leg 2, subtitleFields={readableSubtitleFields} replaced by subtitleFields={subtitleFields}: 2 failed / 9 passed (email row; relation-path row). Leg 3, both avatarField={readableAvatarField} replaced by avatarField={avatarField}: 3 failed / 8 passed (row; custom avatar field; tray). No dist is involved: the subjects are imported relatively from src. [package] pnpm exec vitest run packages/fields/: exit 0, 'Test Files 193 passed | 1 skipped (194)', 'Tests 3171 passed | 7 skipped (3178)'. [types] pnpm turbo run type-check --filter @object-ui/fields --concurrency=2: exit 0, 'Tasks: 12 successful, 12 total'. tsc -p tsconfig.test.json --listFiles: exit 0; both new pin files listed (2/2; control expandFls-10373 1). [lint] pnpm exec eslint on the 7 changed ts/tsx files: exit 0, 0 errors, 94 warnings. --format json: 7 files linted, 0 ignored. Warnings against base copies under the same config: LookupField.tsx 56 to 54, the other 4 sources unchanged; the 12 in the new pins are no-explicit-any. --print-config shows no parserOptions.project or projectService (not type-aware), and no custom rule in eslint-rules reads the disk, so untouched files cannot move. [gates] each exit 0: check:control-bytes, check:test-path-roots, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:new-line-citations ('0 new citation(s), enforcement report-only'), check:changeset-claims, check:pending-changeset-literals, node scripts/check-changeset-presence.mjs ('7 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'). [added from the diff] each exit 0: check:phantom-deps, check:unused-deps, check:unreferenced-sources, check:handler-key-reads, scripts/check-type-check-coverage.mjs, scripts/check-changeset-fixed.mjs, scripts/check-changeset-no-major.mjs, scripts/check-governed-queue-guard.mjs --test over the 8 paths ('NOT GOVERNED'). [not run] check:i18n-keys and check:i18n-drift: no strings were added. Consumer sweep: see summary (a). CI: in_progress, not awaited, per the dispatch contract. [main drift] git log from base to origin/main cc139d8 over the 8 paths: 0 commits (control: LocationField.tsx 1).",
    "mcp_calls": "0",
    "api_writes": "3 — (1) pr_create through the fleet-write relay: POST /repos/objectstack-ai/objectstack/dispatches (204), run 36098737414, which executed POST /repos/objectstack-ai/objectui/pulls (draft, objectui#10503; body read back byte-identical, 8848 bytes); (2) label-write --assign os-tesla through the relay: POST /repos/objectstack-ai/objectstack/dispatches, run 36098793868, which executed POST /repos//issues/10503/assignees (read back: assignees os-tesla); (3) this os-dev-report comment: post-stamped, POST /repos//issues/10433/comments. Not REST: 2 git pushes of the branch (empty probe, then 147feb8).",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: b · PeoplePicker's PersonRow and SelectionTray draw the person's NAME from a display field the loaded policy denies. Contract, as the card quotes objectui#7215 / objectui#7230: 'FLS gates the OUTPUT'. Seam: spec:field read permission on the picker's objectName (checkField read) → runtime:getPersonName(record, displayField) in PersonRow and SelectionTray | renderer:PersonRow. Evidence: a throwaway probe (deleted, not committed) mounted PeoplePicker under the real PermissionProvider with name denied on sys_user; the row's textContent contained 'Amy Lin' and the probe passed, which is the leak. The tray reads the name through the same getPersonName (source read). By contrast, the lookup chip label has been gated since objectui#10411. It needs a choice about what a row falls back to, which is why it is not folded in. Dedupe words: PersonRow name displayField FLS · PeoplePicker row name denied field · getPersonName checkField · SelectionTray name permission",
    "class: b · The read-only UserCellRenderer in packages/fields/src/index.tsx draws image (AvatarImage src) and name off an expanded sys_user value, in both its single and multi branches, with no FLS read. Contract, as above: 'FLS gates the OUTPUT' (objectui#7215 / objectui#7230). Seam: spec:field read permission on sys_user → runtime:UserCellRenderer value.image / user.image | renderer:UserCellRenderer. Evidence: source read only, not rendered. index.tsx is in seat 1's in-flight packages/fields surface (objectui#8677), per the claim. Dedupe words: UserCellRenderer image FLS · user cell avatar checkField · read-only user cell denied field",
    "carrier: 承接者:无 · Divergence: the LookupField chip falls back to image, while PersonRow and SelectionTray read only the configured avatar field. Noted in the PR's Acceptance notes, not filed.",
    "carrier: 承接者:无 · Boundary: a dotted subtitle path's nested segment (for example name on the business unit) is not judged on the related object, because PeoplePicker does not know the relation's target. That is the same boundary as its $expand gate. Noted in the PR's Acceptance notes, not filed."
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: objectui#10503 at 147feb8; ready + auto-merge in this act

    domain:ui seat 5, session_01KUxVUa7e39aNjhkKi1gsoy, 2026-09-25T07:25Z. Checked against the diff and CI, not against the report's own account.

    Implemented-by:  claude/issue-10433-peoplepicker-fls-outputs
    Reviewed-by:     session_01KUxVUa7e39aNjhkKi1gsoy
    
    item reading
    the card's three outputs PeoplePicker gates the row's subtitle paths and avatar (readableSubtitleFields / readableAvatarField, !perms.isLoaded || perms.checkField(objectName, f.split('.')[0], 'read'), with perms in the deps). SelectionTray takes the gated avatar. The search-variant chip reads its avatar through the file's existing fieldReadGate, judging avatarField and the image fallback each by its own key
    no ungated fallback a denied avatar reaches PersonRow / SelectionTray as null, so it skips the 'image' default and draws the initials. Selection is unchanged: onSelect still receives the served record
    pins a real role-based PermissionProvider over a data source that does not strip denied keys. They were red first at the base (7 failed / 4 passed), and three ablation legs each redden their own pins
    contract review PASS at CONTRACT_REVIEW_TIER on this head, record 5828582272 on the PR. The seat read the reviewer transcript: every turn was at tier
    published surface unchanged. The null widening is on internal modules that the @object-ui/fields entry does not export
    semver '@object-ui/fields': patch, and every changeset sentence reads true against the diff
    file surface PersonRow.tsx and SelectionTray.tsx are separate files from PeoplePicker.tsx. They were edited as the call sites of the personDisplay helpers, which the claim's "or their call sites" covers
    governed NOT GOVERNED (8 paths)
    CI 43 check-runs on 147feb8: 40 success, 3 skipped by design, 0 red
    closing keywords the only closing reference in the body is Fixes #10433

    Findings, each with its disposition

    • PersonRow / SelectionTray draw the person NAME, and UserCellRenderer draws name and avatar, with no FLS read ⇒ filed in this act as objectui#10535 (class b, bare, for triage).
    • The optional label-fallback residual stays excluded, per the claim ⇒ Acceptance notes.
    • The chip falls back to image while the row and tray do not. Both are safe, since each key is judged on its own ⇒ Acceptance notes.

    State

    ready and auto-merge (squash) go through the relay ops pr_ready + automerge_enable in this act.

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR objectui#10503 merged as 111fa4c through the merge queue

    domain:ui seat 5, session_01KUxVUa7e39aNjhkKi1gsoy, 2026-09-25T07:39Z. Verified by content on objectui origin/main, not by the sha:

    • PeoplePicker.tsx carries readableSubtitleFields / readableAvatarField. Its checkField reads number 3 (the base had 1, the $expand gate).
    • LookupField.tsx carries chipAvatarReadable: 2 hits.

    The card closed completed through Fixes #10433. pm:dispatched is removed in the same stroke. The sibling finding is objectui#10535.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions