From 7e37c11c724f2eb1e8a6c94330596294eb693926 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:52:47 +0000 Subject: [PATCH 1/4] fix(objectql): narrow the search expansion to the caller's queryable fields (WIP) --- packages/objectql/src/engine.ts | 99 +++++++++++++++++++++----- packages/objectql/src/search-filter.ts | 53 ++++++++++++-- 2 files changed, 129 insertions(+), 23 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d93a7935c55..7383042fc6a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2068,14 +2068,22 @@ function assertProjectionHasNoDottedPaths( * `INVALID_FIELD`, the code #4254's explicit-override gate answers on this * same axis (and the one a search naming a formula title gets), not * `INVALID_QUERY`: the request is well-formed, and what cannot be honoured is - * a fact about the object's FIELDS. 400 on every door, and the same answer for - * every caller — it is derived from the declaration, never from the caller's - * permissions (a narrowing by field-level security happens later, in the - * predicate guard, and `searchAll` hands the engine only the queryable subset). + * a fact about the object's FIELDS. 400 on every door. + * + * [#25672] `withheld`: the declared set is not empty, but every field in it is + * one the caller may not query, so the narrowing in `expandSearchOnAst` left + * nothing. Same code and status — the search cannot be honoured for this + * caller either way — with a message that names the cause, not a declaration + * fault the object does not have. */ -function emptySearchSetError(object: string, schema: any, internalExcluded: boolean): Error { +function emptySearchSetError(object: string, schema: any, internalExcluded: boolean, withheld = false): Error { const declared: unknown[] = Array.isArray(schema?.searchableFields) ? schema.searchableFields : []; - const err: any = new Error( + const err: any = withheld ? new Error( + `Object '${object}' has no field this caller may search, so 'search' cannot be honoured on it: ` + + `every field it searches is one field-level security does not let this caller query. ` + + 'Answering every row instead would read as "everything matched". Grant read access to one of ' + + `its searchable fields, or filter on a field this caller can read.`, + ) : new Error( `Object '${object}' has no searchable field, so 'search' cannot be honoured on it` + (declared.length > 0 ? `: its 'searchableFields' (${declared.join(', ')}) names no stored field` @@ -14956,7 +14964,11 @@ export class ObjectQL implements IObjectQLEngine { * would hand the driver a key it does not read, which is the same silent drop * one layer down. */ - private expandSearchOnAst(ast: QueryAST, schema: ServiceObject | undefined): void { + private async expandSearchOnAst( + ast: QueryAST, + schema: ServiceObject | undefined, + context: ExecutionContext | undefined, + ): Promise { // The `$search`/`$searchFields` OData spellings are NOT read here: the // protocol layer normalizes them to the bare keys before the engine // (protocol.ts findData), and a direct engine call carrying one is an @@ -14989,14 +15001,30 @@ export class ObjectQL implements IObjectQLEngine { Object.entries(schema.fields as Record) .filter(([name]) => !internalFields.includes(name)), ); - const searchFilter = expandSearchToFilter(raw, { + const expandOptions = { fields: scanFields, searchableFields: (schema as any).searchableFields, requestedFields, // [ADR-0079] `nameField` is the canonical primary-title pointer; // `displayNameField` is the deprecated alias (still honored). displayField: (schema as any).nameField ?? (schema as any).displayNameField, - }); + }; + // [#25672] …and never a column the CALLER may not query. The field-read + // predicate guard (plugin-security) runs after this expansion and judges + // the expanded `$or` as the caller's own predicate, so one hidden column + // in the server-resolved set refused the whole search with 403 — + // `sys_user`'s `role` / `ban_reason` / `last_login_ip` broke every user + // picker for members. The set is narrowed to the security service's + // `getQueryableFields` answer, the published complement of what that + // guard refuses (one derivation, no second field-permission rule here), + // asked with the context the middleware will see. A field the caller + // NAMED in `searchFields` is not withheld, so the guard still refuses it + // loudly. The guard itself is unchanged, and still judges every + // predicate the caller wrote. + const queryableFields = normalizeSearch(raw).query.trim() + ? await this.resolveSearchQueryableFields(ast.object, context) + : undefined; + const searchFilter = expandSearchToFilter(raw, { ...expandOptions, queryableFields }); if (searchFilter) { ast.where = ast.where ? { $and: [ast.where, searchFilter] } : searchFilter; } else if (normalizeSearch(raw).query.trim()) { @@ -15011,14 +15039,47 @@ export class ObjectQL implements IObjectQLEngine { // the count) and every door (REST, dispatcher, SDK, a hook's // `ctx.api`). An explicit override intersected to nothing falls back // to the allowed set inside `resolveSearchFields`, so it reaches this - // only when that set is empty too. - throw emptySearchSetError(ast.object, schema, internalFields.length > 0); + // only when that set is empty too. [#25672] A set the caller's + // queryable fields withheld down to nothing gets the same answer: the + // declared set expands without the narrowing, so the refusal says so. + const withheld = queryableFields !== undefined && expandSearchToFilter(raw, expandOptions) !== null; + throw emptySearchSetError(ast.object, schema, internalFields.length > 0, withheld); } } delete (ast as any).search; delete (ast as any).searchFields; } + /** + * [#25672] The fields `context` may query on `object`, asked of the + * registered `security` service's `getQueryableFields` — the answer the + * field-read predicate guard is the complement of. `undefined` narrows + * nothing, and is the answer when no context is carried (a context-less + * read is never asked about, as `searchAll` does), when no security service + * is registered or it has no such method, or when it gives no answer: the + * guard still judges the search, so an absent answer can only leave the + * request as strict as it was. A throw from the method propagates. + */ + private async resolveSearchQueryableFields( + object: string, + context: ExecutionContext | undefined, + ): Promise | undefined> { + if (context === undefined) return undefined; + // Read from the engine slot at use (see SECURITY_SERVICE_SOURCE_SLOT), never cached. + const source = (this as unknown as Record)[SECURITY_SERVICE_SOURCE_SLOT]; + if (typeof source !== 'function') return undefined; + let svc: Partial> | undefined; + try { + svc = source() ?? undefined; + } catch { + // The in-repo kernels throw on a name nothing registered: no service. + return undefined; + } + if (typeof svc?.getQueryableFields !== 'function') return undefined; + const queryable = await svc.getQueryableFields(object, context as any); + return queryable === undefined ? undefined : new Set(queryable); + } + /** * The aggregate and count verbs' door into {@link expandSearchOnAst} — ⛔ not * a second expander. `QuerySchema.search` (ADR-0061 D1) is declared beside @@ -15037,11 +15098,15 @@ export class ObjectQL implements IObjectQLEngine { * `opCtx.options` unexpanded. The common path — no search key — returns the * same reference and allocates nothing. */ - private expandSearchOnOptions(object: string, query: T): T { + private async expandSearchOnOptions( + object: string, + query: T, + options: EngineReadOptions | undefined, + ): Promise { if (query.search === undefined && query.searchFields === undefined) return query; const { search, searchFields, ...rest } = query; const carrier = { object, where: rest.where, search, searchFields } as QueryAST; - this.expandSearchOnAst(carrier, this._registry.getObject(object)); + await this.expandSearchOnAst(carrier, this._registry.getObject(object), mergeReadContext(query.context, options?.context)); if (carrier.where === undefined) return rest as T; return { ...rest, where: carrier.where } as T; } @@ -15196,7 +15261,7 @@ export class ObjectQL implements IObjectQLEngine { // fields needed to compute the formulas after fetch. const _findSchema = this._registry.getObject(object); - this.expandSearchOnAst(ast, _findSchema); + await this.expandSearchOnAst(ast, _findSchema, mergeReadContext(query?.context, options?.context)); // [#7642] The caller's OWN projection, captured before any planning pass // rewrites it — the only thing that can answer "did this caller ask for // `__search`?". See `stripSearchCompanionFromRead`. @@ -15517,7 +15582,7 @@ export class ObjectQL implements IObjectQLEngine { const _findOneSchema = this._registry.getObject(objectName); // Before the guard below, so a `search` that resolves to a real filter // counts as the predicate it is (#4419). - this.expandSearchOnAst(ast, _findOneSchema); + await this.expandSearchOnAst(ast, _findOneSchema, mergeReadContext(query?.context, options?.context)); this.requireFindOnePredicate(objectName, ast); // [#7095] Same refusal as `find`, and it matters MORE here: `findOne` // applies `limit: 1`, so `orderBy` is the whole of "which record" — a @@ -21776,7 +21841,7 @@ export class ObjectQL implements IObjectQLEngine { // [#22790] ADR-0061 `search` → the rows are searched before they are // counted, by the one expander `find` runs, at the same point in the // sequence — so the count is the number of rows `find` returns for the bag. - if (query) query = this.expandSearchOnOptions(object, query); + if (query) query = await this.expandSearchOnOptions(object, query, options); const driver = this.getDriver(object); // The AST must ride on the opCtx so the security/sharing middlewares can @@ -21919,7 +21984,7 @@ export class ObjectQL implements IObjectQLEngine { // ADR-0061 `search` → the rows are searched BEFORE they are grouped, by // the one expander `find` runs, at the same point in the sequence (after // the `where` doors above, before the AST is built and tokens resolve). - query = this.expandSearchOnOptions(object, query); + query = await this.expandSearchOnOptions(object, query, options); this.rejectCredentialAggregation(object, query); // [#20783] …and a `groupBy` entry naming a structured-JSON field (`json`, // `composite`, `address`, …) is refused `INVALID_FIELD` / 400 here, before diff --git a/packages/objectql/src/search-filter.ts b/packages/objectql/src/search-filter.ts index eea4dd2f2f1..5fca5421651 100644 --- a/packages/objectql/src/search-filter.ts +++ b/packages/objectql/src/search-filter.ts @@ -92,8 +92,20 @@ export { } from '@objectstack/spec/data'; export type { SearchFieldMeta } from '@objectstack/spec/data'; -/** Historical name for {@link SearchFieldResolutionOptions} — same shape. */ -export type ExpandSearchOptions = SearchFieldResolutionOptions; +/** {@link SearchFieldResolutionOptions}, plus the caller's queryable-field answer. */ +export interface ExpandSearchOptions extends SearchFieldResolutionOptions { + /** + * [#25672] The fields the CALLER may query on — the security service's + * `getQueryableFields` answer, the exact complement of what the engine's + * field-read predicate guard refuses. When present, a field the server + * RESOLVED into the search set that is not in it is withheld, so the + * expansion never names a column the guard would refuse as a filter oracle. + * A field the caller named in `requestedFields` is never withheld: an + * explicit narrowing to a hidden field reaches the guard and is refused + * loudly there. Absent = no answer, nothing withheld. + */ + queryableFields?: ReadonlySet; +} export interface NormalizedSearch { query: string; @@ -197,6 +209,31 @@ function companionWithinSearchFields(searchFields: readonly string[], opts: Expa return sources.every((f) => inSet.has(f)); } +/** + * [#25672] Withhold from the resolved search set every field the caller may + * not query, except one the caller named itself. The resolved set is the + * server's choice (declared `searchableFields` → auto-default), so a hidden + * column in it is the server widening the caller's predicate past what the + * caller could write — and the predicate guard refused the WHOLE search for + * it (`sys_user`'s `role` / `ban_reason` / `last_login_ip` broke every user + * picker for members). Narrowing happens AFTER resolution, never on the field + * map going in: a declaration whose fields are all hidden must stay empty, + * not fall through to the auto-default and search different columns. + */ +function withholdUnqueryable( + searchFields: string[], + requestedFields: string | string[] | undefined, + queryable: ReadonlySet | undefined, +): string[] { + if (!queryable) return searchFields; + const named = new Set( + typeof requestedFields === 'string' + ? requestedFields.split(',').map((f) => f.trim()) + : requestedFields ?? [], + ); + return searchFields.filter((f) => queryable.has(f) || named.has(f)); +} + /** * Expand a `$search` term into a `{ $or: [...] }` (single term) or * `{ $and: [{ $or: [...] }, ...] }` (multi-term) filter. Returns `null` in @@ -209,15 +246,19 @@ function companionWithinSearchFields(searchFields: readonly string[], opts: Expa * that with `400 INVALID_FIELD` rather than leaving `where` untouched, which * would answer the search with every row. This function stays pure and * non-throwing; the judgement lives at the call site every verb reaches. + * [#25672] A set that `queryableFields` withheld down to nothing is this + * case too. */ export function expandSearchToFilter(raw: unknown, opts: ExpandSearchOptions): any | null { const { query, fields: requested } = normalizeSearch(raw); if (!query || !query.trim()) return null; - const searchFields = resolveSearchFields({ - ...opts, - requestedFields: requested ?? opts.requestedFields, - }); + const requestedFields = requested ?? opts.requestedFields; + const searchFields = withholdUnqueryable( + resolveSearchFields({ ...opts, requestedFields }), + requestedFields, + opts.queryableFields, + ); if (searchFields.length === 0) return null; const terms = query.trim().split(/\s+/).filter(Boolean); From 90108613172d713d9c0dda05cc2d233d83575e3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:57:27 +0000 Subject: [PATCH 2/4] test(objectql): pin the queryable-field search narrowing --- .../engine-search-queryable-fields.test.ts | 188 ++++++++++++++++++ packages/objectql/src/search-filter.test.ts | 28 +++ 2 files changed, 216 insertions(+) create mode 100644 packages/objectql/src/engine-search-queryable-fields.test.ts diff --git a/packages/objectql/src/engine-search-queryable-fields.test.ts b/packages/objectql/src/engine-search-queryable-fields.test.ts new file mode 100644 index 00000000000..c980615dd1e --- /dev/null +++ b/packages/objectql/src/engine-search-queryable-fields.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25672] A `search` resolves only over fields the caller may QUERY. + * + * The field-read predicate guard (plugin-security) runs after the expansion and + * judges the expanded `$or` as the caller's own predicate, so one hidden column + * in the server-resolved set refused the whole search with 403: `sys_user` + * searches `role` / `ban_reason` / `last_login_ip`, and every user picker broke + * for members. The engine now asks the registered `security` service's + * `getQueryableFields` (the complement of what that guard refuses) and + * withholds the rest from the RESOLVED set. + * + * Pinned beside the narrowing, so a fix that over-reaches goes red here too: a + * field the caller NAMES in `searchFields` is never withheld (the guard refuses + * it loudly); a context-less read, an absent service and an `undefined` answer + * narrow nothing; a declaration whose fields are all hidden does not fall + * through to the auto-default; and a set withheld down to nothing is the + * #24284 refusal, not every row. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL, SECURITY_SERVICE_SOURCE_SLOT } from './engine.js'; + +type Row = Record; + +const MEMBER = { userId: 'u_member', organizationId: 'org_1' }; + +function makeDriver() { + const seen: Array<{ verb: string; object: string; where: unknown }> = []; + const record = (verb: string) => async (object: string, ast: any) => { + seen.push({ verb, object, where: ast?.where }); + return verb === 'count' ? 0 : verb === 'findOne' ? null : []; + }; + const driver: any = { + name: 'mem', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + find: record('find'), + findOne: record('findOne'), + count: record('count'), + async create(_o: string, d: any) { return d; }, + async update(_o: string, _id: string, d: any) { return d; }, + async delete() { return true; }, + async bulkCreate(_o: string, r: any[]) { return r; }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, seen }; +} + +/** Every field name a filter tree mentions as a key. */ +function fieldsOf(where: unknown, out: Set = new Set()): Set { + if (Array.isArray(where)) { for (const w of where) fieldsOf(w, out); return out; } + if (!where || typeof where !== 'object') return out; + for (const [k, v] of Object.entries(where as Row)) { + if (k.startsWith('$')) fieldsOf(v, out); + else out.add(k); + } + return out; +} + +describe('[#25672] search resolves only over the fields the caller may query', () => { + let engine: ObjectQL; + let seen: ReturnType['seen']; + let asked: Array<{ object: string; context: unknown }>; + let queryable: Record; + + const wire = (svc: unknown) => { + (engine as unknown as Record)[SECURITY_SERVICE_SOURCE_SLOT] = () => svc; + }; + + beforeEach(async () => { + const made = makeDriver(); + seen = made.seen; + engine = new ObjectQL(); + engine.registerDriver(made.driver, true); + await engine.init(); + const registry = engine.registry as any; + // The `sys_user` shape: a declared search set holding admin-only columns. + registry.registerObject({ + name: 'app_person', + nameField: 'name', + searchableFields: ['name', 'email', 'role', 'ban_reason'], + fields: { + name: { type: 'text' }, + email: { type: 'email' }, + role: { type: 'text' }, + ban_reason: { type: 'text' }, + }, + }, 'test'); + // A second shape: the auto-default set, one textual column hidden. + registry.registerObject({ + name: 'app_ticket', + fields: { subject: { type: 'text' }, private_note: { type: 'textarea' } }, + }, 'test'); + // Every declared search field hidden from the member. + registry.registerObject({ + name: 'app_secret', + searchableFields: ['code'], + fields: { code: { type: 'text' }, label: { type: 'text' } }, + }, 'test'); + asked = []; + queryable = { + app_person: ['id', 'name', 'email'], + app_ticket: ['id', 'subject'], + app_secret: ['id', 'label'], + }; + wire({ + async getQueryableFields(object: string, context: unknown) { + asked.push({ object, context }); + return queryable[object]; + }, + }); + }); + + const lastWhere = () => seen[seen.length - 1]?.where; + + it.each([ + ['find', (e: ObjectQL) => e.find('app_person', { search: 'ann', context: MEMBER as any })], + ['findOne', (e: ObjectQL) => e.findOne('app_person', { search: 'ann', context: MEMBER as any })], + ['count', (e: ObjectQL) => e.count('app_person', { search: 'ann', context: MEMBER as any })], + ] as const)('%s searches only the queryable declared fields, asked with the caller context', async (_verb, run) => { + await run(engine); + expect([...fieldsOf(lastWhere())].sort()).toEqual(['email', 'name']); + expect(asked).toEqual([{ object: 'app_person', context: expect.objectContaining(MEMBER) }]); + }); + + it('aggregate searches the same narrowed set', async () => { + const where: unknown[] = []; + (engine as any).getDriver('app_person').aggregate = async (_o: string, ast: any) => { where.push(ast?.where); return []; }; + await engine.aggregate('app_person', { + search: 'ann', groupBy: ['name'], aggregations: [{ function: 'count', alias: 'n' }], context: MEMBER as any, + }).catch(() => undefined); + const named = new Set([...where, lastWhere()].flatMap((w) => [...fieldsOf(w)])); + expect(named.has('role')).toBe(false); + expect(named.has('ban_reason')).toBe(false); + expect(named.has('name')).toBe(true); + }); + + it('is general: an auto-default set loses its hidden column on another object', async () => { + await engine.find('app_ticket', { search: 'printer', context: MEMBER as any }); + expect([...fieldsOf(lastWhere())]).toEqual(['subject']); + }); + + it('a field the caller NAMES in searchFields is not withheld — the guard judges it', async () => { + await engine.find('app_person', { search: 'spam', searchFields: ['name', 'ban_reason'], context: MEMBER as any }); + expect([...fieldsOf(lastWhere())].sort()).toEqual(['ban_reason', 'name']); + }); + + it('a caller who may query every field gets the full declared set', async () => { + queryable.app_person = ['id', 'name', 'email', 'role', 'ban_reason']; + await engine.find('app_person', { search: 'ann', context: { userId: 'u_admin' } as any }); + expect([...fieldsOf(lastWhere())].sort()).toEqual(['ban_reason', 'email', 'name', 'role']); + }); + + it('narrows nothing without an answer: no context, no service, no method, or undefined', async () => { + const full = ['ban_reason', 'email', 'name', 'role']; + await engine.find('app_person', { search: 'ann' }); + expect([...fieldsOf(lastWhere())].sort()).toEqual(full); + expect(asked).toEqual([]); + + queryable.app_person = undefined; + await engine.find('app_person', { search: 'ann', context: MEMBER as any }); + expect([...fieldsOf(lastWhere())].sort()).toEqual(full); + + wire({}); + await engine.find('app_person', { search: 'ann', context: MEMBER as any }); + expect([...fieldsOf(lastWhere())].sort()).toEqual(full); + + (engine as unknown as Record)[SECURITY_SERVICE_SOURCE_SLOT] = () => { throw new Error('not registered'); }; + await engine.find('app_person', { search: 'ann', context: MEMBER as any }); + expect([...fieldsOf(lastWhere())].sort()).toEqual(full); + }); + + it('a blank term asks nothing and adds no predicate', async () => { + await engine.find('app_person', { search: ' ', context: MEMBER as any }); + expect(asked).toEqual([]); + expect(lastWhere()).toBeUndefined(); + }); + + it('a declared set withheld to nothing is the #24284 refusal, never every row nor the auto-default', async () => { + const err: any = await engine.find('app_secret', { search: 'x', context: MEMBER as any }).then(() => null, (e) => e); + expect(err).toMatchObject({ code: 'INVALID_FIELD', status: 400, object: 'app_secret', param: 'search' }); + expect(String(err.message)).toMatch(/^Object 'app_secret' has no field this caller may search/); + expect(seen.filter((s) => s.object === 'app_secret')).toEqual([]); + }); +}); diff --git a/packages/objectql/src/search-filter.test.ts b/packages/objectql/src/search-filter.test.ts index 7627d3a58f8..b3376d63fcd 100644 --- a/packages/objectql/src/search-filter.test.ts +++ b/packages/objectql/src/search-filter.test.ts @@ -249,3 +249,31 @@ describe('expandSearchToFilter', () => { }); }); }); + +describe('[#25672] queryableFields withholds what the caller may not query', () => { + const fields = { name: { type: 'text' }, email: { type: 'email' }, ban_reason: { type: 'text' } }; + const searchableFields = ['name', 'email', 'ban_reason']; + const queryableFields = new Set(['name', 'email']); + const named = (f: unknown) => (f as { $or: Array> }).$or.map((c) => Object.keys(c)[0]); + + it('withholds a resolved field outside the set', () => { + expect(named(expandSearchToFilter('ann', { fields, searchableFields, queryableFields }))).toEqual(['name', 'email']); + }); + + it('keeps a field the caller named, in either spelling', () => { + expect(named(expandSearchToFilter('x', { fields, searchableFields, queryableFields, requestedFields: ['ban_reason'] }))) + .toEqual(['ban_reason']); + expect(named(expandSearchToFilter('x', { fields, searchableFields, queryableFields, requestedFields: 'name, ban_reason' }))) + .toEqual(['name', 'ban_reason']); + expect(named(expandSearchToFilter({ query: 'x', fields: ['ban_reason'] }, { fields, searchableFields, queryableFields }))) + .toEqual(['ban_reason']); + }); + + it('answers null when it withholds every declared field — never the auto-default', () => { + expect(expandSearchToFilter('x', { fields, searchableFields: ['ban_reason'], queryableFields })).toBeNull(); + }); + + it('is a no-op when absent', () => { + expect(named(expandSearchToFilter('ann', { fields, searchableFields }))).toEqual(['name', 'email', 'ban_reason']); + }); +}); From 61177d0f1260a02bd238cf2b23ea5c5f23ef83ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:19:34 +0000 Subject: [PATCH 3/4] chore(changeset): objectql search narrows to queryable fields --- .changeset/25672-search-readable-fields.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/25672-search-readable-fields.md diff --git a/.changeset/25672-search-readable-fields.md b/.changeset/25672-search-readable-fields.md new file mode 100644 index 00000000000..dc56577c77b --- /dev/null +++ b/.changeset/25672-search-readable-fields.md @@ -0,0 +1,10 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): a free-text `search` resolves only over the fields the caller may query + +Clause-②: no +Metadata-protocol: none + +The server-resolved search set used to include fields the caller cannot read, so the field-read predicate guard refused the whole expanded `$or` with 403. On `sys_user`, `role`, `ban_reason` and `last_login_ip` broke every user and owner picker for non-admin members as soon as they typed. The engine now asks the registered security service's `getQueryableFields` (the complement of what the guard refuses) and withholds every other field from the resolved set, on `find`, `findOne`, `count` and `aggregate`. The guard is unchanged: an explicit `filter`, or a `searchFields` naming a hidden field, is still refused with 403. A search whose declared fields are all hidden from the caller answers the existing `400 INVALID_FIELD` empty-search-set refusal, never every row. From 9b04a57b6881136ddef8c6c6955facdd183407f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:35:15 +0000 Subject: [PATCH 4/4] chore(changeset): declare the search narrowing as an acceptance widening --- .changeset/25672-search-readable-fields.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25672-search-readable-fields.md b/.changeset/25672-search-readable-fields.md index dc56577c77b..137cb0bc191 100644 --- a/.changeset/25672-search-readable-fields.md +++ b/.changeset/25672-search-readable-fields.md @@ -4,7 +4,7 @@ fix(objectql): a free-text `search` resolves only over the fields the caller may query -Clause-②: no +Clause-②: yes (widening) Metadata-protocol: none The server-resolved search set used to include fields the caller cannot read, so the field-read predicate guard refused the whole expanded `$or` with 403. On `sys_user`, `role`, `ban_reason` and `last_login_ip` broke every user and owner picker for non-admin members as soon as they typed. The engine now asks the registered security service's `getQueryableFields` (the complement of what the guard refuses) and withholds every other field from the resolved set, on `find`, `findOne`, `count` and `aggregate`. The guard is unchanged: an explicit `filter`, or a `searchFields` naming a hidden field, is still refused with 403. A search whose declared fields are all hidden from the caller answers the existing `400 INVALID_FIELD` empty-search-set refusal, never every row.