You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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)
Sign in as any ordinary member (no admin role), e.g. a user holding only an app position.
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.
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.
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.
{
"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."
]
}
Activity
objectstack-fleet commentedon Oct 24, 2026
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.
searchexpansion to the fields the caller can read, or takesrole,ban_reasonandlast_login_ipout ofsys_user's searchable set. It does not relax the guard.?search=onsys_useranswers 200 with matches on name and email; an admin's results are unchanged; the hidden-field filter refusal still holds for an explicitfilteron those fields.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Claim: PM loop round 1
Session:
session_01JVTd3PprStGa26aez5omLBAccount:
sunhaolin(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-25672-search-readable-fieldsWorktree:
objectstack-issue-25672Domain:
domain:serverSeat:
domain:server#2File surface: the free-text
searchexpansion (packages/objectql/src/search-filter.ts, and its call site inpackages/objectql/src/engine.tsnearcollectInternalReadFields, ⛔ 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.tsonly 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,可以用云卡片」; twomode:subagentdevs 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
searchexpansion 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.tsorpackages/spec/src/data/search-fields.ts(every open PR's file list read this fire). #25522 cut 3 (cloud, in flight) movescheckDriversHealth/resolvePrimaryDatasourceinengine.ts; this card stays out of that region. PR #25626 (another author) editssys-user.object.ts.Premise: re-read on
origin/main:engine.tsbuildsscanFieldsminuscollectInternalReadFields(schema)and callsexpandSearchToFilter(raw, { fields: scanFields, searchableFields, requestedFields, displayField }); nothing there removes fields the caller cannot read.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
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
objectstack-fleet commentedon Oct 25, 2026
Landed: PR #25705 →
5874d2530·domain:serverseat 2 ·sunhaolin·session_01JVTd3PprStGa26aez5omLB· 2026-10-25T05:59Zmerged: true, merge commit5874d25302cbb80db675eddf1b69198073fbe2a9, merged 2026-10-25T05:59:06Z through the merge queueorigin/main5874d2530isorigin/main's tip, 1 parent(s)gh-readonly-queue/main/*head for PR 25705 remainscompletedbyFixes #25672on merge.pm:dispatchedstripped in this act.searchon the REST data door resolves only over the fields the caller may query, from the security service's own derivation, so a non-adminsearchonsys_userno longer answers 403. The field-read guard is unchanged: a named hidden field is still refused, and an all-withheld set answers the existing metadata-protocol / objectql: asearchterm on an object with no searchable field is dropped silently, so the read answers every row (an empty auto-default search set is not refused) #24284 refusal. DeclaredClause-②: yes (widening),patch; theTIER_DEFAULTreview was this seat's (record 6393346560, PASS).plugin-pinyin-search'sboot-reconcile.integration.test.ts, which is not this PR's (stand-down 6393760589, filed as test(plugin-pinyin-search): boot-reconcile.integration.test.ts times out at vitest's 5 s default under merge-queue load — its fixture inserts 2,500 rows one awaited driver.create at a time #25749). It merged on the one re-queue.session_018sBs7EHHJdB92hpDWae2e2, archived in this act.Generated by Claude Code