From d9b2e34fac1b6a6b1d9211725bebf523a8730eed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 06:10:51 +0000 Subject: [PATCH 1/4] test(rest): pin the public lookup picker's search and sort key against the caller's queryable fields The picker route keys its search and its order on one display field. These pins state that key by caller class: for a caller whose masking rule applies to the first display field, rows are sorted and searched on the next display field the security service answers queryable, the masked field is still served masked, and no request the engine receives names it; a picker with no queryable display field answers the engine's refusal (403 PERMISSION_DENIED) without asking the engine; a picker with no masked display field and a caller the rule is lifted for keep the first display field; and a security service that cannot give the query answer passes over every display field declaring a masking rule. One real-boot pin in the dogfood suite drives the same door. Red on this commit: the route still keys on the first display field. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- ...ublic-picker-queryable-key.dogfood.test.ts | 157 +++++++ ...c-form-lookup-picker-queryable-key.test.ts | 396 ++++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts create mode 100644 packages/rest/src/public-form-lookup-picker-queryable-key.test.ts diff --git a/packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts b/packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts new file mode 100644 index 00000000000..99e296c85e6 --- /dev/null +++ b/packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21062] A public lookup picker whose FIRST display field declares a masking +// rule serves its rows, on a real boot, to a caller the rule applies to. +// +// The picker searches and sorts by one key. It is the first display field the +// caller may query on, by the security service's published answer +// (`getQueryableFields`), not blindly the first display field. A field whose +// masking rule applies to a caller is served to it masked and may not be +// searched or sorted on, so a picker keyed on it answered `403` to every such +// caller. +// +// What is asserted, by class, for a visitor with no session on a deployment +// that registers no profile for public forms: the scene is real (a system read +// carries the stored values); the rows are sorted on the next queryable display +// field, and the search matches it; the masked field is still served, masked; +// a signed-in caller reaching the same door is served the same (the door builds +// its own context); and a picker whose only display field is masked answers +// the engine's refusal, `403 PERMISSION_DENIED`. +// +// `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and +// auth layers. `@objectstack/rest` resolves to its BUILT output here (no source +// alias), so build it before reading a verdict. Fixtures are synthetic. + +import { describe, it, expect } from 'vitest'; +import { bootStack } from '@objectstack/verify'; +import { defineStack, defineView } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +const CONTACT = 'pqkey_contact'; +const INQUIRY = 'pqkey_inquiry'; +const KEY = 'pqkey_code'; +const SYS = { context: { isSystem: true } } as const; + +/** Synthetic rows whose order by name and by the masked field differ. */ +const SEED = [ + { name: 'Bravo Synthetic', [KEY]: 'SYNTH1AAA' }, + { name: 'Alpha Synthetic', [KEY]: 'SYNTH3CCC' }, + { name: 'Charlie Synthetic', [KEY]: 'SYNTH2BBB' }, +]; +const BY_NAME = ['Alpha Synthetic', 'Bravo Synthetic', 'Charlie Synthetic']; + +const PqkeyContact = ObjectSchema.create({ + name: CONTACT, + label: 'Picker Key Contact', + pluralLabel: 'Picker Key Contacts', + sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true }), + [KEY]: Field.text({ label: 'Code', maskingRule: { keepHead: 1, keepTail: 1 } }), + }, +}); + +const PqkeyInquiry = ObjectSchema.create({ + name: INQUIRY, + label: 'Picker Key Inquiry', + pluralLabel: 'Picker Key Inquiries', + sharingModel: 'public_read_write', + fields: { + subject: Field.text({ label: 'Subject', required: true }), + contact: Field.lookup(CONTACT, { label: 'Contact' }), + contact_masked_only: Field.lookup(CONTACT, { label: 'Contact (masked only)' }), + }, +}); + +const data = { provider: 'object' as const, object: INQUIRY }; +const PqkeyInquiryViews = defineView({ + list: { label: 'Inquiries', type: 'grid', data, columns: [{ field: 'subject' }] }, + formViews: { + intake: { + type: 'simple', + data, + sections: [ + { + name: 'intake', + label: 'Intake', + columns: 1, + fields: [ + { field: 'subject', required: true }, + { field: 'contact', publicPicker: { displayFields: [KEY, 'name'] } }, + { field: 'contact_masked_only', publicPicker: { displayFields: [KEY] } }, + ], + }, + ], + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/pqkey-intake' }, + }, + }, +}); + +const pqkeyStack = defineStack({ + manifest: { + id: 'com.dogfood.picker-queryable-key', + namespace: 'pqkey', + version: '0.0.0', + type: 'app', + name: 'Picker Queryable Key Fixture', + description: 'One object with a masked field, and a public form whose picker displays it first.', + }, + objects: [PqkeyContact, PqkeyInquiry], + views: [PqkeyInquiryViews], +}); + +type Stack = Parameters[0]; +type Row = Record; + +function expectServedMasked(rows: Row[]): void { + const stored = new Map(SEED.map((r) => [r.name, r[KEY]])); + for (const row of rows) { + const value = row[KEY]; + expect(typeof value, 'the masked field is served, as a string').toBe('string'); + expect(value).not.toBe(stored.get(String(row.name))); + expect(String(value)).toContain('*'); + } +} + +describe('[#21062] a public picker whose first display field is masked serves rows sorted on the next queryable one', () => { + it( + 'to a visitor with no session and to a signed-in caller; a picker with no queryable display field is refused', + async () => { + const stack = await bootStack(pqkeyStack as unknown as Stack); + try { + const ql = (await stack.kernel.getServiceAsync('objectql')) as any; + for (const row of SEED) await ql.insert(CONTACT, { ...row }, SYS); + const stored = await ql.find(CONTACT, { where: {}, context: { isSystem: true } }); + expect(stored.map((r: Row) => r[KEY]).sort(), 'the stored values are what a system read serves') + .toEqual(SEED.map((r) => r[KEY]).sort()); + + const token = await stack.signUp('pqkey-member@verify.test'); + for (const [who, call] of [ + ['a visitor with no session', (path: string) => stack.api(path)], + ['a signed-in caller', (path: string) => stack.apiAs(token, 'GET', path)], + ] as const) { + const listed = await call('/forms/pqkey-intake/lookup/contact'); + expect(listed.status, who).toBe(200); + const rows = ((await listed.json()) as { data: Row[] }).data; + expect(rows.map((r) => r.name), `${who}: sorted on the next queryable display field`).toEqual(BY_NAME); + expectServedMasked(rows); + + const searched = await call('/forms/pqkey-intake/lookup/contact?q=Charlie'); + expect(searched.status, who).toBe(200); + const hits = ((await searched.json()) as { data: Row[] }).data; + expect(hits.map((r) => r.name), `${who}: the search matches the next queryable display field`) + .toEqual(['Charlie Synthetic']); + expectServedMasked(hits); + } + + const refused = await stack.api('/forms/pqkey-intake/lookup/contact_masked_only'); + expect(refused.status).toBe(403); + const refusal = (await refused.json()) as { code?: unknown; error?: { code?: unknown } }; + expect(refusal.code ?? refusal.error?.code).toBe('PERMISSION_DENIED'); + } finally { + await stack.stop(); + } + }, + 120_000, + ); +}); diff --git a/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts b/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts new file mode 100644 index 00000000000..20b15493d5e --- /dev/null +++ b/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts @@ -0,0 +1,396 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21062] The public lookup picker searches and sorts by ONE key: the first of + * its display fields the caller may QUERY ON, by the security service's + * published answer (`getQueryableFields`, #20935) — not blindly the first + * display field. + * + * A field whose masking rule applies to a caller is SERVED to it, its value + * masked, so it is a display field the picker can still show. It is not a + * field the caller may query on: a `contains` search on it rebuilds the masked + * value probe by probe, and an order on it ranks rows by the value the mask + * hides. The engine refuses both, `403 PERMISSION_DENIED`. A picker keyed on + * its first display field therefore answered that 403 to every caller the + * first field's rule applies to, on every request. + * + * What is pinned, by caller class (the picker's own context — the route builds + * it, the session is not read): + * + * - masked classes — the deployment registers no `guest_portal` set (the + * context resolves no set), or registers one that does not hold the + * capability the rule names: rows are sorted, and searched, on the next + * queryable display field; the masked field is still served, masked; and no + * request the engine receives names the masked field; + * - a picker with no queryable display field answers the engine's own refusal + * for those fields, and the engine is never asked; + * - controls — a picker with no masked display field, and a caller the rule is + * lifted for, keep the first display field as the key; + * - a security service that cannot give the query answer: every display field + * whose declaration carries a masking rule is passed over, whoever the + * caller is — the fallback the service contract prescribes. + * + * The composition is real below the route: `SecurityPlugin` over a real + * `ObjectQL` on a real `SqlDriver` (SQLite), and the REAL protocol `findData`, + * so the request the route composes crosses the real ingress and the engine's + * own field guards. The form and object reads are stubbed. Fixtures are + * synthetic. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { FormFieldPublicPickerSchema } from '@objectstack/spec/ui'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; + +const CONTACT = 'rest_pk_contact'; +const INQUIRY = 'rest_pk_inquiry'; +const MASKED = 'pk_code'; +const CAPABILITY = 'pk_unmask'; +const SLUG = 'pk-intake'; + +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; + +/** Three rows whose order by name, by the masked field and by city all differ. */ +const ROWS = [ + { id: 'pk1', name: 'Bravo', [MASKED]: 'AAA111', pk_city: 'Lisbon' }, + { id: 'pk2', name: 'Alpha', [MASKED]: 'CCC333', pk_city: 'Berlin' }, + { id: 'pk3', name: 'Charlie', [MASKED]: 'BBB222', pk_city: 'Austin' }, +]; +const STORED_BY_ID: Record = Object.fromEntries(ROWS.map((r) => [r.id, r[MASKED]])); +const BY_NAME = ['pk2', 'pk1', 'pk3']; +const BY_MASKED = ['pk1', 'pk3', 'pk2']; + +/** The pickers, one per lookup field on the form. Each is parsed by the spec below. */ +const PICKERS = { + c_first: { displayFields: [MASKED, 'name'] }, + c_control: { displayFields: ['name', 'pk_city'] }, + c_only: { displayFields: [MASKED] }, +} as const; + +const MEMBER_SET = PermissionSetSchema.parse({ + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true } }, +}); +/** A guest set that admits the object and does not hold the capability. */ +const GUEST_SET = PermissionSetSchema.parse({ + name: 'guest_portal', + label: 'Guest', + objects: { [CONTACT]: { allowRead: true } }, +}); +/** The same guest set holding the capability the masking rule names: the rule is lifted. */ +const GUEST_UNMASK_SET = PermissionSetSchema.parse({ + name: 'guest_portal', + label: 'Guest', + objects: { [CONTACT]: { allowRead: true } }, + systemPermissions: [CAPABILITY], +}); + +const quiet: any = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + statusCode: 200, + body: undefined as any, + header: () => res, + status: (code: number) => { res.statusCode = code; return res; }, + json: (body: unknown) => { res.body = body; return res; }, + end: () => res, + }; + return res; +} + +/** The stored form, in the flattened registered shape `getMetaItems` serves. */ +const storedForm = { + name: `${INQUIRY}.intake`, + object: INQUIRY, + viewKind: 'form', + config: { + type: 'simple', + data: { provider: 'object', object: INQUIRY }, + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` }, + sections: [{ + label: 'Intake', + fields: Object.entries(PICKERS).map(([field, picker]) => ({ field, publicPicker: picker })), + }], + }, +}; + +type Security = Record; +type Harness = { + engine: ObjectQL; + security: Security; + /** Every `engine.find` call on the picked object since boot: its query. */ + finds: () => Array>; + /** Drive the picker as the route is driven, with a given security service. */ + lookup: (field: keyof typeof PICKERS, q?: string, security?: Security) => Promise<{ status: number; body: any }>; +}; + +async function boot(sets: unknown[]): Promise { + const engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.picker-queryable-key-21062', + name: 'Picker queryable key', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { + name: CONTACT, + label: 'Contact', + sharingModel: 'public_read_write', + fields: { + name: { name: 'name', type: 'text' }, + [MASKED]: { + name: MASKED, type: 'text', maskingRule: { keepHead: 1, keepTail: 1 }, requiredPermissions: [CAPABILITY], + }, + pk_city: { name: 'pk_city', type: 'text' }, + }, + }, + { + name: INQUIRY, + label: 'Inquiry', + sharingModel: 'public_read_write', + fields: Object.fromEntries(Object.keys(PICKERS).map((f) => [f, { name: f, type: 'lookup', reference: CONTACT }])), + }, + ], + } as never); + await engine.syncSchemas(); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + data: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => sets, + }, + }; + const ctx: any = { + logger: quiet, + hook: () => {}, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + replaceService: (name: string, svc: unknown) => { services[name] = svc; }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + await engine.insert(CONTACT, ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + + const findSpy = vi.spyOn(engine, 'find'); + const finds = () => findSpy.mock.calls + .filter((c) => c[0] === CONTACT) + .map((c) => (c[1] ?? {}) as Record); + + const real = new ObjectStackProtocolImplementation(engine as never) as any; + const protocol: any = { + getDiscovery: async () => ({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: async () => [], + getMetaItem: async ({ type, name }: { type: string; name: string }) => + (type === 'object' ? { type, name, item: engine.getSchema(name) } : undefined), + getMetaItems: async ({ type }: { type: string }) => { + if (type === 'view') return [storedForm]; + if (type === 'object') return [engine.getSchema(CONTACT), engine.getSchema(INQUIRY)]; + return []; + }, + findData: (request: unknown) => real.findData(request), + }; + + const security = services.security as Security; + let current: Security | undefined = security; + const rest = new RestServer( + createMockServer() as any, protocol, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + async () => current, + ); + rest.registerRoutes(); + const route: any = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path.endsWith('/forms/:slug/lookup/:field')); + expect(route, 'the picker route is mounted').toBeDefined(); + + const lookup = async (field: keyof typeof PICKERS, q?: string, as: Security = security) => { + current = as; + const res = makeRes(); + await route.handler({ method: 'GET', params: { slug: SLUG, field }, headers: {}, query: q === undefined ? {} : { q } } as any, res); + current = security; + return { status: res.statusCode, body: res.body }; + }; + return { engine, security, finds, lookup }; +} + +const ids = (body: any): string[] => (body?.data ?? []).map((r: any) => r.id); + +/** Every field a query names as a predicate or an order key. */ +function queriedFields(query: Record): string[] { + const out = new Set(); + const walk = (node: unknown): void => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (!node || typeof node !== 'object') return; + for (const [k, v] of Object.entries(node as Record)) { + if (!k.startsWith('$')) out.add(k); + walk(v); + } + }; + walk(query.where); + for (const s of query.orderBy ?? []) if (typeof s?.field === 'string') out.add(s.field); + return [...out]; +} + +function expectServedMasked(body: any): void { + for (const row of body?.data ?? []) { + const value = row[MASKED]; + expect(typeof value, `${row.id}: the masked field is served`).toBe('string'); + expect(value, `${row.id}: served masked, not stored`).not.toBe(STORED_BY_ID[row.id]); + expect(String(value)).toContain('*'); + } +} + +/** The engine's own refusal for a query ordered by `fields`, as the picker's context. */ +async function engineRefusal(engine: ObjectQL, fields: readonly string[]) { + const context = { permissions: ['guest_portal'], anonymous: true }; + return engine + .find(CONTACT, { orderBy: fields.map((field) => ({ field, order: 'asc' })), context } as never) + .then(() => null, (e: any) => ({ status: e?.status ?? e?.statusCode, code: e?.code, message: String(e?.message) })); +} + +/** A security service that predates the query answer: the real one, less that method. */ +function withoutQueryAnswer(security: Security): Security { + const { getQueryableFields: _omitted, ...rest } = security; + return rest; +} + +describe('[#21062] the fixture is authorable', () => { + it('every picker on the form is accepted by the spec', () => { + for (const [field, picker] of Object.entries(PICKERS)) { + expect(FormFieldPublicPickerSchema.safeParse(picker).success, field).toBe(true); + } + }); +}); + +for (const [label, sets] of [ + ['the deployment registers no guest set (the context resolves none)', [MEMBER_SET]], + ['the guest set does not hold the capability the rule names', [MEMBER_SET, GUEST_SET]], +] as const) { + describe(`[#21062] a picker whose first display field is masked for its caller — ${label}`, () => { + let h: Harness; + beforeAll(async () => { h = await boot([...sets]); }, 60_000); + afterAll(async () => { try { await h?.engine.destroy(); } catch { /* noop */ } }); + + it('the premise: the security service answers the masked field readable and not queryable', async () => { + const context = { permissions: ['guest_portal'], anonymous: true }; + expect(await h.security.getReadableFields(CONTACT, context)).toContain(MASKED); + expect(await h.security.getQueryableFields(CONTACT, context)).not.toContain(MASKED); + expect(await engineRefusal(h.engine, [MASKED])).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); + }); + + it('serves rows sorted on the next queryable display field, the masked field served masked', async () => { + const { status, body } = await h.lookup('c_first'); + expect(status, JSON.stringify(body)).toBe(200); + expect(ids(body)).toEqual(BY_NAME); + expectServedMasked(body); + }); + + it('searches the next queryable display field', async () => { + const { status, body } = await h.lookup('c_first', 'Brav'); + expect(status, JSON.stringify(body)).toBe(200); + expect(ids(body)).toEqual(['pk1']); + expectServedMasked(body); + }); + + it('never uses the masked field as a key', async () => { + const before = h.finds().length; + await h.lookup('c_first'); + await h.lookup('c_first', 'Brav'); + const asked = h.finds().slice(before); + expect(asked).toHaveLength(2); + for (const query of asked) { + expect(queriedFields(query)).not.toContain(MASKED); + expect(query.orderBy).toEqual([{ field: 'name', order: 'asc' }]); + } + }); + + it('a picker with no queryable display field answers the engine\'s refusal, and the engine is never asked', async () => { + const reference = await engineRefusal(h.engine, PICKERS.c_only.displayFields); + expect(reference).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); + const before = h.finds().length; + for (const q of [undefined, 'A']) { + const { status, body } = await h.lookup('c_only', q); + expect({ status, code: body?.code, message: body?.error }).toEqual({ + status: reference!.status, code: reference!.code, message: reference!.message, + }); + } + expect(h.finds().length - before, 'the engine was asked').toBe(0); + }); + + it('control: a picker with no masked display field keeps its first display field as the key', async () => { + const sorted = await h.lookup('c_control'); + expect(sorted.status, JSON.stringify(sorted.body)).toBe(200); + expect(ids(sorted.body)).toEqual(BY_NAME); + const searched = await h.lookup('c_control', 'Charl'); + expect(ids(searched.body)).toEqual(['pk3']); + }); + }); +} + +describe('[#21062] control: a caller the masking rule is lifted for keeps the first display field as the key', () => { + let h: Harness; + beforeAll(async () => { h = await boot([MEMBER_SET, GUEST_UNMASK_SET]); }, 60_000); + afterAll(async () => { try { await h?.engine.destroy(); } catch { /* noop */ } }); + + it('rows are sorted, and searched, on the first display field, served as stored', async () => { + const sorted = await h.lookup('c_first'); + expect(sorted.status, JSON.stringify(sorted.body)).toBe(200); + expect(ids(sorted.body)).toEqual(BY_MASKED); + for (const row of sorted.body.data) expect(row[MASKED]).toBe(STORED_BY_ID[row.id]); + const searched = await h.lookup('c_first', 'BBB'); + expect(ids(searched.body)).toEqual(['pk3']); + }); +}); + +describe('[#21062] a security service that cannot give the query answer passes over every display field declaring a masking rule', () => { + for (const [label, sets] of [ + ['a caller the rule applies to', [MEMBER_SET, GUEST_SET]], + ['a caller the rule is lifted for (whoever the caller is)', [MEMBER_SET, GUEST_UNMASK_SET]], + ] as const) { + describe(label, () => { + let h: Harness; + beforeAll(async () => { h = await boot([...sets]); }, 60_000); + afterAll(async () => { try { await h?.engine.destroy(); } catch { /* noop */ } }); + + it('the method absent: rows sorted and searched on the next display field', async () => { + const older = withoutQueryAnswer(h.security); + const sorted = await h.lookup('c_first', undefined, older); + expect(sorted.status, JSON.stringify(sorted.body)).toBe(200); + expect(ids(sorted.body)).toEqual(BY_NAME); + const searched = await h.lookup('c_first', 'Brav', older); + expect(ids(searched.body)).toEqual(['pk1']); + }); + + it('the method answering no answer: the same', async () => { + const silent = { ...h.security, getQueryableFields: async () => undefined }; + const sorted = await h.lookup('c_first', undefined, silent); + expect(sorted.status, JSON.stringify(sorted.body)).toBe(200); + expect(ids(sorted.body)).toEqual(BY_NAME); + }); + }); + } +}); From 2fa3a86e9bf178d9cbd88eb48ad1ec25126558c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 06:41:52 +0000 Subject: [PATCH 2/4] fix(rest): the public lookup picker searches and sorts by the first display field the caller may query The picker route (GET /forms/:slug/lookup/:field) keyed its search predicate and its order on the first entry of publicPicker.displayFields. A field whose masking rule applies to the caller is served masked and is not queryable, so the engine refused that key with 403 PERMISSION_DENIED and a picker whose first display field declares a masking rule answered 403 to every caller the rule applies to. The key is now the first display field in the security service's published query answer (ISecurityService.getQueryableFields), read through the service the handler already resolves. No masking rule is derived in rest. A security service that cannot give the answer gets the contract's fallback: every display field whose declaration carries a maskingRule is passed over. No display field queryable answers the engine's refusal for those fields, in the same envelope (403 PERMISSION_DENIED), before the engine is asked. With no security service the first display field stays the key. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .changeset/21062-picker-queryable-key.md | 36 +++++++++ packages/rest/src/rest-server.ts | 95 +++++++++++++++++++++--- 2 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 .changeset/21062-picker-queryable-key.md diff --git a/.changeset/21062-picker-queryable-key.md b/.changeset/21062-picker-queryable-key.md new file mode 100644 index 00000000000..9c476dd8b4e --- /dev/null +++ b/.changeset/21062-picker-queryable-key.md @@ -0,0 +1,36 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): a public form's lookup picker searches and sorts by the first display field the caller may query, so a picker whose first display field is masked for its caller serves its rows instead of answering 403 (#21062) + +Clause-②: no + +**What changed.** The public lookup picker (`GET /forms/:slug/lookup/:field`) +matches the visitor's search and orders its rows by one key. That key used to +be the first entry of `publicPicker.displayFields`. It is now the first entry +the caller may query on, as the security service answers it +(`ISecurityService.getQueryableFields`). A field whose masking rule applies to +a caller is served to that caller masked, and the engine refuses to search or +sort on it with `403 PERMISSION_DENIED`. A picker whose first display field +declares such a rule therefore answered `403` to every caller the rule applies +to, on every request. It now serves its rows, sorted and searched on the next +display field the caller may query. The masked field is still returned in each +row, masked, as before. + +**When no display field is queryable** for the caller, the picker answers +`403 PERMISSION_DENIED` with the engine's refusal for those fields, without +running a query. + +**Unchanged.** A picker with no masked display field, and a caller the masking +rule is lifted for, keep the first display field as the key. A deployment with +no security service keeps the first display field. A security service that +cannot say which fields are queryable (it predates the method, or has no answer +for the object) gets the fallback its contract prescribes: every display field +whose declaration carries a `maskingRule` is passed over, whoever the caller is. + +**What to do.** Nothing. To choose the field a picker searches when its first +display field is masked for some of its callers, list a field those callers may +query among `displayFields`: the first such entry is the one searched and +sorted on. A picker whose only display fields are masked for its callers is +refused, so give it one they may query. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 8e09988a931..e2f149741bf 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -11028,10 +11028,85 @@ export class RestServer { if (refuseRepeatedQueryParams(req, res, ['q'])) return; const q = String(req.query?.q ?? '').trim().slice(0, 100); + const context: any = { + permissions: ['guest_portal'], + anonymous: true, + }; + + // [#21062] The picker's ONE key — the field the search + // predicate below matches and the order further down sorts + // by — is the first display field THIS CALLER MAY QUERY ON, + // by the security service's published answer + // (`getQueryableFields`, #20935), not blindly the first + // display field. A field whose masking rule applies to the + // caller is SERVED (projected, its value masked) and is NOT + // queryable: a `contains` probe on it rebuilds the masked + // value row by row, and an order on it ranks rows by the + // value the mask hides, so the engine refuses both with + // `403 PERMISSION_DENIED`. Keyed blindly, a picker whose + // first display field is masked answered that 403 to every + // caller the rule applies to, on every request. + // + // ⛔ No second derivation of masking here: the answer is the + // security service's, computed by the derivation the + // engine's predicate guard refuses from, and this door only + // reads it. Three states: + // - no security service: this deployment has no + // field-level security, nothing is masked, and the first + // display field stays the key; + // - an answer: the key is the first display field in it; + // - a service that cannot give it (the method is absent, + // or answered `undefined`): the contract's fallback + // (`ISecurityService.getQueryableFields`) — every display + // field whose DECLARATION carries a `maskingRule` is + // passed over, whoever the caller is. A declaration that + // cannot be read admits no display field. + // No display field queryable → the engine's own refusal for + // those fields, its words and its envelope, before the engine + // is asked: the picker never searches or sorts on a field the + // caller may not query. + const security = await this.resolveSecurityService(environmentId, req); + const queryableAnswer = async (): Promise => { + const answer = typeof security?.getQueryableFields === 'function' + ? await security.getQueryableFields(referenceObject, context) + : undefined; + if (answer !== undefined) return answer; + const declared: any = typeof (p as any).getMetaItem === 'function' + ? (await (p as any).getMetaItem({ + type: 'object', + name: referenceObject, + ...(environmentId ? { environmentId } : {}), + }))?.item?.fields + : undefined; + if (!declared || typeof declared !== 'object') return []; + const declarationOf = (f: string): any => (Array.isArray(declared) + ? declared.find((d: any) => d?.name === f) + : declared[f]); + return displayFields.filter((f) => declarationOf(f)?.maskingRule == null); + }; + const queryable: ReadonlySet | undefined = security + ? new Set(await queryableAnswer()) + : undefined; + const key: string | undefined = queryable ? displayFields.find((f) => queryable.has(f)) : displayFields[0]; + if (key === undefined) { + const refused = displayFields.filter((f) => !queryable?.has(f)); + const denied: any = new Error( + `[Security] Access denied: query on '${referenceObject}' references field(s) not readable by the caller: ` + + `${refused.join(', ')}. Filtering, sorting, grouping, or aggregating by a hidden field ` + + `would leak its values (filter oracle) — remove these predicates or grant field read access.`, + ); + denied.name = 'PermissionDeniedError'; + denied.code = 'PERMISSION_DENIED'; + denied.statusCode = 403; + const mapped = mapDataError(denied); + res.status(mapped.status).json(mapped.body); + return; + } + // Compose filters: form-defined static filter first, - // then the search predicate over displayFields. The - // search predicate uses `contains` on the first - // display field so non-indexed columns still work. + // then the search predicate on the key above. The + // search predicate uses `contains` so non-indexed + // columns still work. // // [#16581] …and then LOWER the composed rows to the filter // grammar the ingress parses. BOTH halves are the authoring @@ -11049,14 +11124,9 @@ export class RestServer { // is teaching `findData` a second dialect. const rules: any[] = []; if (Array.isArray(picker.filter)) rules.push(...picker.filter); - if (q) rules.push({ field: displayFields[0], operator: 'contains', value: q }); + if (q) rules.push({ field: key, operator: 'contains', value: q }); const filters = lowerViewFilterRules(rules); - const context: any = { - permissions: ['guest_portal'], - anonymous: true, - }; - const pickerRequest: ServerScopedDataRequest = { object: referenceObject, // [#16337] Canonical QueryAST: `filters` → `where`, @@ -11081,8 +11151,9 @@ export class RestServer { offset: 0, where: filters, fields: ['id', ...displayFields], - // [#7485] Ordering is FIXED — first display field, - // ascending. This used to read `picker.sort`, a key + // [#7485] Ordering is FIXED — the key above (the + // first display field the caller may query on, + // #21062), ascending. This used to read `picker.sort`, a key // `FormFieldPublicPickerSchema` (#7467) deliberately // never declared: enforced by the route, authorable // nowhere. The maintainer ruled retire-the-read over @@ -11090,7 +11161,7 @@ export class RestServer { // permanently-maintained public key on an // UNAUTHENTICATED surface. A pre-schema stored row // still carrying `sort` is IGNORED, not an error. - orderBy: [{ field: displayFields[0], order: 'asc' }], + orderBy: [{ field: key, order: 'asc' }], }, ...(environmentId ? { environmentId } : {}), context, From d815dd449adc4a2556b6d7dc3d0bd7540af0c66b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:27:39 +0000 Subject: [PATCH 3/4] test(rest): the canonical-QueryAST census names the public picker's order key as the queryable key The census's by-name presence pin still asserted the picker's literal ordered by the first display field. The handler now orders by its one key, the first display field the caller may query, so the pin names that literal and the line that derives the key. The census's purpose is unchanged: the literal is still present and still in the canonical QueryAST spelling. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../rest/src/rest-server-canonical-query-ast.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 86e2112bf69..03d2a7d6e62 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -298,7 +298,16 @@ describe('[#16337] §1 the three sites rest-server.ts names are canonical, by na // Belt to §1's braces: the class-wide assertions above would still pass // over a file that had lost these literals entirely. expect(REST_SERVER).toContain("orderBy: [{ field: 'created_at', order: 'desc' }],"); - expect(REST_SERVER).toContain("orderBy: [{ field: displayFields[0], order: 'asc' }],"); + // [#21062] The public picker's literal. Its order key is the picker's + // ONE key — the first display field the caller may query, by the + // security service's queryable answer — no longer `displayFields[0]` + // blindly; the literal it sits in is the same canonical QueryAST. The + // second line names where `key` comes from, so the first cannot be + // satisfied by some other `key` in the file. + expect(REST_SERVER).toContain("orderBy: [{ field: key, order: 'asc' }],"); + expect(REST_SERVER).toContain( + 'const key: string | undefined = queryable ? displayFields.find((f) => queryable.has(f)) : displayFields[0];', + ); expect(REST_SERVER).toContain("fields: ['id', ...displayFields],"); expect(REST_SERVER).toMatch(/expand: Object\.fromEntries\(/); }); From 207eb98a975ea45f9fc6808ddce1a4b4ee645a35 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 10:34:35 +0000 Subject: [PATCH 4/4] fix(rest)!: declare the public picker's key change as a breaking widening with one narrowing, and pin the narrowing The changeset now grades the change minor with the breaking signal, declares it yes with the narrowing arm, and carries one ADR-0087 disposition marker (not-required, no migration prescription). The narrowing it names: with a security service that cannot give the query answer, every display field declaring a masking rule is passed over for every caller, so a picker whose display fields all declare one is refused 403 PERMISSION_DENIED where it was served. One pin drives that case for a caller the rule is lifted for, with the answering service as its control, and asserts the engine is never asked. The route is unchanged. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .changeset/21062-picker-queryable-key.md | 24 +++++++++++------ ...c-form-lookup-picker-queryable-key.test.ts | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/.changeset/21062-picker-queryable-key.md b/.changeset/21062-picker-queryable-key.md index 9c476dd8b4e..18e50548e58 100644 --- a/.changeset/21062-picker-queryable-key.md +++ b/.changeset/21062-picker-queryable-key.md @@ -1,10 +1,14 @@ --- -'@objectstack/rest': patch +'@objectstack/rest': minor --- -fix(rest): a public form's lookup picker searches and sorts by the first display field the caller may query, so a picker whose first display field is masked for its caller serves its rows instead of answering 403 (#21062) +fix(rest)!: a public form's lookup picker searches and sorts by the first display field the caller may query, so a picker whose first display field is masked for its caller serves its rows instead of answering 403 (#21062) -Clause-②: no +Clause-②: yes (narrowing) + + + +**BREAKING**: this widens what the public lookup picker serves and narrows it in one composition. The narrowing: with a security service that lacks `ISecurityService.getQueryableFields`, or that answers no answer for the object, the picker passes over every display field whose declaration carries a `maskingRule`, for every caller, including a caller the rule is lifted for. So its search and order move to the next display field that declares no rule, and a picker whose display fields all declare a rule is refused `403 PERMISSION_DENIED` without the engine being asked, where it used to be served. The security service this repository ships implements the method, so a deployment using it is not narrowed. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. **What changed.** The public lookup picker (`GET /forms/:slug/lookup/:field`) matches the visitor's search and orders its rows by one key. That key used to @@ -23,14 +27,18 @@ row, masked, as before. running a query. **Unchanged.** A picker with no masked display field, and a caller the masking -rule is lifted for, keep the first display field as the key. A deployment with -no security service keeps the first display field. A security service that -cannot say which fields are queryable (it predates the method, or has no answer -for the object) gets the fallback its contract prescribes: every display field -whose declaration carries a `maskingRule` is passed over, whoever the caller is. +rule is lifted for, keep the first display field as the key, with the security +service this repository ships. A deployment with no security service keeps the +first display field. **What to do.** Nothing. To choose the field a picker searches when its first display field is masked for some of its callers, list a field those callers may query among `displayFields`: the first such entry is the one searched and sorted on. A picker whose only display fields are masked for its callers is refused, so give it one they may query. + +**What to do after upgrading, if your security service predates `getQueryableFields`.** +Implement `getQueryableFields` on it: it answers which fields a caller may filter, +sort, group or aggregate by, and the picker then keys on the first display field +in that answer. Until it does, give each picker at least one display field that +declares no `maskingRule`, or the picker is refused for every caller. diff --git a/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts b/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts index 20b15493d5e..88f2fbe4be1 100644 --- a/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts +++ b/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts @@ -394,3 +394,29 @@ describe('[#21062] a security service that cannot give the query answer passes o }); } }); + +/** + * The narrowing the changeset declares (`Clause-②: yes (narrowing)`). With a + * security service that cannot give the query answer, the fallback passes over + * every display field declaring a masking rule WHOEVER the caller is, so for a + * caller the rule is lifted for, a picker whose display fields all declare one + * goes from served to refused. Its control is the same caller and picker with + * the service that answers: served. + */ +describe('[#21062] the declared narrowing: a security service without the query answer refuses a picker whose display fields all declare a masking rule', () => { + let h: Harness; + beforeAll(async () => { h = await boot([MEMBER_SET, GUEST_UNMASK_SET]); }, 60_000); + afterAll(async () => { try { await h?.engine.destroy(); } catch { /* noop */ } }); + + it('a caller the rule is lifted for is refused 403 PERMISSION_DENIED, and the engine is never asked', async () => { + const served = await h.lookup('c_only'); + expect(served.status, `control, the service that answers: ${JSON.stringify(served.body)}`).toBe(200); + + for (const older of [withoutQueryAnswer(h.security), { ...h.security, getQueryableFields: async () => undefined }]) { + const before = h.finds().length; + const refused = await h.lookup('c_only', undefined, older); + expect({ status: refused.status, code: refused.body?.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(h.finds().length - before, 'the engine was asked').toBe(0); + } + }); +});