diff --git a/.changeset/21062-picker-queryable-key.md b/.changeset/21062-picker-queryable-key.md new file mode 100644 index 00000000000..18e50548e58 --- /dev/null +++ b/.changeset/21062-picker-queryable-key.md @@ -0,0 +1,44 @@ +--- +'@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) + +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 +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, 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/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..88f2fbe4be1 --- /dev/null +++ b/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts @@ -0,0 +1,422 @@ +// 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); + }); + }); + } +}); + +/** + * 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); + } + }); +}); 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\(/); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index be566a5db7b..c673773e469 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -11065,10 +11065,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 @@ -11086,14 +11161,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`, @@ -11118,8 +11188,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 @@ -11127,7 +11198,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,