Skip to content

REST search on sys_user answers 403 for non-admin members (search expands to hidden role / ban_reason / last_login_ip) — every user picker breaks when typing #25672

Closed

Description

@objectstack-fleet

Filing gate: ① product defect with reach (HTTP + console, measured once).

Business reason. Every user / owner picker in an app (e.g. a contract's legal reviewer, a category's category manager) is unusable for non-admin users as soon as they type: the picker shows a red security error instead of matches. Admins don't see it, so it slips past admin-only testing. Found during the hotsrm v0.3 acceptance browser test.

Who acts. The seat owning ObjectQL field-level read guards / REST search expansion (objectstack). The console only sends search=; no objectui change needed unless the fix is to narrow the search columns client-side.

Repro (18.0.0, objectstack dev, sqlite)

  1. Sign in as any ordinary member (no admin role), e.g. a user holding only an app position.
  2. GET /api/v1/data/sys_user?search=abc

Actual: 403

[Security] Access denied: query on 'sys_user' references field(s) not readable by the caller: role, ban_reason, last_login_ip. Filtering, sorting, grouping, or aggregating by a hidden field would leak its values (filter oracle) — remove these predicates or grant field read access.

Same call without search (?filter=["banned","!=",true]) answers 200. The console's user lookup sends GET /api/v1/data/sys_user?populate=primary_business_unit_id&top=25&search=<typed text>&filter=["banned","!=",true], so opening the picker lists users but typing any character shows the error.

Expected: the free-text search expansion only covers fields the caller can read (or sys_user's searchable fields exclude role, ban_reason, last_login_ip), so a member gets matches on name / email.

Dedupe

Searched objectstack-ai issues (open + closed) for "user lookup search sys_user filter oracle ban_reason last_login_ip" and "user field lookup typeahead search non-admin hidden fields": 0 relevant hits.

Session: session_015D447s1xb4MsZZeDgwGPZT (hotsrm v0.3 acceptance).

Activity

  1. added
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    bugSomething isn't working
    domain:serverDomain lane — seat card indexed by label:pm:seat
    priority:p1High: required for production / M2
    on Oct 24, 2026
  2. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade — bug · priority:p1 · domain:server · area:records · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-24T15:06Z

    ⛔ Not a claim, ⛔ not a dispatch.

    • p1: every user or owner picker breaks for non-admin members the moment they type. This was found in hotsrm's v0.3 acceptance pass.
    • ⛔ The guard stays. The 403 is the field-read guard refusing a filter oracle, which is correct. The fix narrows the free-text search expansion to the fields the caller can read, or takes role, ban_reason and last_login_ip out of sys_user's searchable set. It does not relax the guard.
    • Acceptance: a member's ?search= on sys_user answers 200 with matches on name and email; an admin's results are unchanged; the hidden-field filter refusal still holds for an explicit filter on those fields.

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01JVTd3PprStGa26aez5omLB
    Account: sunhaolin (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-25672-search-readable-fields
    Worktree: objectstack-issue-25672
    Domain: domain:server
    Seat: domain:server#2
    File surface: the free-text search expansion (packages/objectql/src/search-filter.ts, and its call site in packages/objectql/src/engine.ts near collectInternalReadFields, ⛔ not the region #25522 cut 3 moves), the field-read source it needs (packages/plugins/plugin-security/src/** read-only unless measured necessary), tests, one changeset. ⛔ No relaxing of the field-read guard (triage, 6385800045). packages/platform-objects/src/identity/sys-user.object.ts only if the general route is measured impossible (open PR #25626 edits it) (stop on breach; explain in the report)
    Container & model: M, mode:cloud (maintainer: 「并发加到5,可以用云卡片」; two mode:subagent devs already share this seat's container), model: TIER_DEFAULT (dispatch-gates --tier: "no path-derived mandate … the tier stays the PM's per-card judgment call"), effort: xhigh (p1)
    Clause-②: no
    Metadata-protocol: none
    Responsibility: ObjectQL's free-text search expansion resolves over fields the caller cannot read, so the field-read guard refuses the expanded filter as a filter oracle | the engine already withholds internal fields from the search set (collectInternalReadFields) and the guard already knows the caller's readable fields | every non-admin member who types into a user or owner picker; measured on 18.0.0 over HTTP and in the console by hotsrm's v0.3 acceptance (triage, 6385800045)
    Thread-read: 6385800045
    Serial constraints cleared: no open PR touches packages/objectql/src/search-filter.ts or packages/spec/src/data/search-fields.ts (every open PR's file list read this fire). #25522 cut 3 (cloud, in flight) moves checkDriversHealth / resolvePrimaryDatasource in engine.ts; this card stays out of that region. PR #25626 (another author) edits sys-user.object.ts.
    Premise: re-read on origin/main: engine.ts builds scanFields minus collectInternalReadFields(schema) and calls expandSearchToFilter(raw, { fields: scanFields, searchableFields, requestedFields, displayField }); nothing there removes fields the caller cannot read.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25672,
      "status": "done",
      "branch": "claude/issue-25672-search-readable-fields",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25705",
      "session": "session_018sBs7EHHJdB92hpDWae2e2",
      "premise_still_valid": true,
      "summary": "General route taken: expandSearchOnAst asks the registered security service's getQueryableFields(object, context) through the existing SECURITY_SERVICE_SOURCE_SLOT, and expandSearchToFilter withholds every RESOLVED search field outside that set. That answer is the published complement of what the field-read predicate guard refuses (same computeQueryGuardFieldPerms derivation, already used by searchAll), so the engine adds no second field-permission rule and no layering break. The guard is untouched, and a field the caller names in searchFields is not withheld, so it is still refused with 403. Narrowing runs after resolution, so a declared set that is all hidden never falls through to the auto-default. A set withheld to empty answers the existing #24284 400 INVALID_FIELD (param search), with a message naming field-level security. It applies to find, findOne, count and aggregate. No context, no service or method, or an undefined answer narrows nothing. sys-user.object.ts is untouched.",
      "tests": "At 61177d0f12. objectql full suite `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2`: Test Files 468 passed (468), Tests 8719 passed (8719), lock VERDICT command-exit 0. objectql typecheck: VERDICT command-exit 0 (check:test-typecheck OK). plugin-security: 222 files, 4628 passed, 45 skipped. plugin-audit: 60 files, 985 passed (VERDICT 0, against the rebuilt objectql dist). New: engine-search-queryable-fields.test.ts and 4 cases in search-filter.test.ts. HTTP on a fresh `pnpm dev:crm -- --fresh`, non-admin org member: ?search=Mia gives 200 with the matching user; ?search=admin@objectos gives 200 (email match); the console shape populate=primary_business_unit_id&top=25&search=Adm&filter=[banned,!=,true] gives 200. Controls: the member's filter on ban_reason gives 403 PERMISSION_DENIED; $searchFields=ban_reason gives 403; searchFields=name,role gives 403; the admin's rows are identical between the fixed and ablated runs (ids aside), and ?search=user still matches on role for the admin. Ablation: scripts/ablation-replace.mjs dropped queryableFields from the call (anchor 1 to 0, blob 7383042fc6ad to cf902b78f292), rebuilt objectql, and ablation-dist-preflight --absent passed. Re-booted, the member's 3 searches gave 403 '... not readable by the caller: role, ban_reason, last_login_ip'. Restore: git checkout HEAD --, disk blob = HEAD blob 7383042fc6ad, git status --porcelain empty, rebuilt, and the preflight finds the marker in dist/core.{js,mjs}.",
      "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack: 73 commands; every one run with its exit code captured before any pipe; all exit 0. check-engine-split-ratio --days 90 and check-plugin-teardown-shape --self-test first exited 2 and 3 as shallow-clone refusals and exited 0 after `git fetch --unshallow origin main`. --ran reconciliation: '73 derived famil(ies) accounted for, 73 run, 0 NOT-MEASURED (a DERIVED zero)'. Lint: `pnpm --filter @objectstack/objectql lint` has no lint script (ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT); `pnpm exec eslint --no-inline-config` on the 4 touched source and test files exits 0. CI check-runs on #25705: in_progress at report time, not waited on.",
      "dogfood_battery": "n/a: no `!` in the PR title and Clause-② is `no` with no (narrowing) arm",
      "mcp_calls": "3: mcp__github__issue_read (get, get_comments) on #25672, and mcp__github__pull_request_read (get) on #25705. All reads, no write tool.",
      "api_writes": "2 relay strokes. (1) fleet-write pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft) plus POST /repos/objectstack-ai/objectstack/issues/25705/assignees, run 40123381934 success, read-back byte-identical (4636 bytes). (2) post-stamped: POST /repos/objectstack-ai/objectstack/issues/25672/comments (this report). git push is not counted.",
      "open_questions": [],
      "out_of_scope_findings": [
        "deviations: (a) The second-object proof is unit-level (engine-search-queryable-fields.test.ts, an auto-default object losing its hidden private_note), not HTTP: the CRM example has no non-sys_user object with a member-hidden searchable field. (b) A member's search now skips fields they see MASKED (getQueryableFields excludes them, matching the guard); before, such a search answered 403. (c) The empty-set answer keeps #24284's code and status (400 INVALID_FIELD, param search) with a message naming field-level security, because the declaration-fault wording would be false in that case. (d) The worktree was deepened with git fetch --unshallow to run the two history gates.",
        "carrier: none. Noted in the PR Acceptance notes only, not filed."
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #25705 → 5874d2530 · domain:server seat 2 · sunhaolin · session_01JVTd3PprStGa26aez5omLB · 2026-10-25T05:59Z

    reading result
    PR #25705 object merged: true, merge commit 5874d25302cbb80db675eddf1b69198073fbe2a9, merged 2026-10-25T05:59:06Z through the merge queue
    origin/main 5874d2530 is origin/main's tip, 1 parent(s)
    queue branch no gh-readonly-queue/main/* head for PR 25705 remains

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions