diff --git a/.changeset/21454-reader-context-evaluate-refusals.md b/.changeset/21454-reader-context-evaluate-refusals.md new file mode 100644 index 00000000000..98a174fcae5 --- /dev/null +++ b/.changeset/21454-reader-context-evaluate-refusals.md @@ -0,0 +1,15 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/runtime': minor +--- + +fix(runtime)!: the in-process reader contexts refuse the stored-metadata-body family's EVALUATE shapes and serve what a write returns, the way the generic data door does (#21454) + +Clause-②: yes (narrowing) + + + +**BREAKING**: this narrows what an action or hook body's object API, an action handler's scoped API and an action handler's engine handle accept when they read the two stored-metadata tables. A read there that filters, sorts or groups on the stored body column or on a content-hash column, a read that names one of those columns in an explicit search-field list, and a `count` carrying such a filter, ran before this release and now answer the generic data door's `400 INVALID_FIELD` before the query runs. The route: filter, sort, group and search those tables by their scalar columns (the type, the name, the state and the like), and read the bodies with a plain list, which is served projected — the body as its type's read projection, the content hash in keyed form. A default search with no field list is not refused: it is narrowed to the columns the door serves. Every other column of the two tables, and every other object, is unchanged. It ships as `minor` under the launch-window convention for accept-set narrowings. + +- **`@objectstack/metadata-protocol`** now exports the generic data door's four evaluate-refusal predicates — `storedMetadataBodyGroupingRefusal`, `storedMetadataBodyPredicateRefusal`, `storedMetadataHashEvaluateRefusal` and `storedMetadataSearchRefusal` — so the `@objectstack/runtime` reader-context seam refuses the same shapes through the door's own predicates rather than a second copy. Additive: nothing that imported the package before is changed. +- **`@objectstack/runtime`** extends the stored-metadata reader-context seam (`ctx.api.object(...)` for action and hook bodies, a handler's `ctx.api`, and `ctx.engine.find`): a filter, sort, grouping or search that would evaluate the stored body or content hash of `sys_metadata` / `sys_metadata_history` is refused with the door's `INVALID_FIELD` / 400 before the query runs (a `count` with such a predicate included); a default `$search` is narrowed to the door's served field set rather than refused; and the row a write verb returns is served projected and keyed. The engine's own action verb (`ScopedRepo.execute`) is unreachable from a served body and is left untouched. diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 2bdf109ff4d..7b911fbd533 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -207,6 +207,20 @@ export { } from './metadata-redaction.js'; export type { StoredHashDigest } from './metadata-redaction.js'; +// [#21454] The generic data door's EVALUATE refusals on the same family +// (#21086 grouping, #21120 body filter / sort, #21207 content-hash evaluate and +// search). Exported so the in-process reader contexts in `@objectstack/runtime` +// refuse the evaluate shapes the way the door does — each through the door's +// OWN predicate, never a copy: a second definition of which shapes leak a +// stored body or hash is exactly the drift the family's one rule exists to +// prevent. +export { + storedMetadataBodyGroupingRefusal, + storedMetadataBodyPredicateRefusal, + storedMetadataHashEvaluateRefusal, + storedMetadataSearchRefusal, +} from './metadata-redaction.js'; + export type { MetadataHostEngine } from './host-engine.js'; // [#7560] ADR-0070's read-only-package rule. The authoring path (`saveMetaItem` diff --git a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts index e017693e816..71119b52423 100644 --- a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts +++ b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts @@ -118,6 +118,64 @@ const PIN_APP: any = { { name: 'handler_engine_reads_family', label: 'Handler engine read', type: 'script' }, { name: 'handler_engine_reads_history', label: 'Handler engine history read', type: 'script' }, { name: 'handler_api_reads_family', label: 'Handler api read', type: 'script' }, + // [#21454] EVALUATE shapes — each body attempts to evaluate the stored + // body or hash, and each must be refused before the query runs. The + // VALUE in every predicate is an immaterial constant: the query is + // refused unrun, so nothing depends on what it is. + { + name: 'body_filters_body_column', + label: 'Body filters the body column', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ where: { metadata: { $contains: 'z' } } }) };`), + }, + { + name: 'body_sorts_body_column', + label: 'Body sorts by the body column', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ orderBy: [{ field: 'metadata', order: 'asc' }] }) };`), + }, + { + name: 'body_groups_body_column', + label: 'Body groups by the body column', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata_history').aggregate({ groupBy: ['metadata'] }) };`), + }, + { + name: 'body_filters_hash_column', + label: 'Body filters the hash column', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ where: { checksum: 'z' } }) };`), + }, + { + name: 'body_counts_body_column', + label: 'Body counts by the body column', + type: 'script', + body: actionBody(`return { n: await ctx.api.object('sys_metadata').count({ where: { metadata: { $contains: 'z' } } }) };`), + }, + { + name: 'body_searches_body_column', + label: 'Body searches an explicit body column', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ search: 'z', searchFields: ['metadata'] }) };`), + }, + // A DEFAULT search is narrowed, not refused: a body may still search a + // family table by its scalar columns, served like the door. + { + name: 'body_searches_default', + label: 'Body default search', + type: 'script', + body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ search: '${DS_NAME}' }) };`), + }, + // The engine action verb is not on a served body's surface at all. + { + name: 'body_calls_execute', + label: 'Body calls execute', + type: 'script', + body: actionBody(`return { typeofExecute: typeof ctx.api.object('sys_metadata').execute };`), + }, + // ② the engine handle's evaluate shape — a handler whose ctx.engine.find + // filters the body column is refused the same way. + { name: 'handler_engine_filters_body', label: 'Handler engine filters body', type: 'script' }, ], }, ], @@ -167,6 +225,13 @@ const PIN_HANDLER_PLUGIN: Plugin = { async (actionCtx: any) => ({ rows: await actionCtx.api.object('sys_metadata').find({ where }) }), 'pin.reader21454.handler', ); + // [#21454] ② the engine handle's evaluate shape: a filter on the body column. + ql.registerAction( + 'pin_note', + 'handler_engine_filters_body', + async (actionCtx: any) => ({ rows: await actionCtx.engine.find('sys_metadata', { where: { metadata: { $contains: 'z' } } }) }), + 'pin.reader21454.handler', + ); }, }; @@ -461,3 +526,73 @@ describe('[#21454] ② / ③ an action handler\'s engine handle and scoped API', }); } }); + +describe('[#21454] the EVALUATE shapes are refused end to end, the door\'s own refusal', () => { + /** + * Each shape answers the data door's refusal (`INVALID_FIELD` / 400), names + * the offending column, and carries no family content. The envelope's precise + * `param` / `field` are pinned directly on the door's predicate in the unit + * test; across the sandbox boundary only `code`, `status` and the MESSAGE are + * guaranteed (`SANDBOX_ERROR_PASSTHROUGH`), so the column is read from the + * message, which both the sandboxed-body and the host-handler paths carry. + */ + async function expectRefusedAction(action: string, token: string, column: string): Promise { + const res = await as(token, 'POST', `/actions/pin_note/${action}`, { params: {} }); + const payload = await readJson(res); + const err = payload?.error ?? payload; + const text = JSON.stringify(payload ?? null); + expect(res.status, `${action}: refused with 400`).toBe(400); + expect(err?.code, `${action}: the data door's INVALID_FIELD`).toBe('INVALID_FIELD'); + const named = (Array.isArray(err?.fields) && err.fields.includes(column)) + || String(err?.message ?? '').includes(`'${column}'`); + expect(named, `${action}: the refusal names '${column}'`).toBe(true); + expect(text.includes(SENTINEL), `${action}: the stored credential reached the answer`).toBe(false); + for (const h of storedHashes) expect(text.includes(h), `${action}: a stored hash reached the answer`).toBe(false); + } + + for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { + it(`a body's filter / sort / grouping on the body column, invoked by the ${role}`, async () => { + await expectRefusedAction('body_filters_body_column', token(), 'metadata'); + await expectRefusedAction('body_sorts_body_column', token(), 'metadata'); + await expectRefusedAction('body_groups_body_column', token(), 'metadata'); + }); + + it(`a body's filter on a hash column, and count as an oracle, invoked by the ${role}`, async () => { + await expectRefusedAction('body_filters_hash_column', token(), 'checksum'); + await expectRefusedAction('body_counts_body_column', token(), 'metadata'); + }); + + it(`a body's explicit search of the body column, invoked by the ${role}`, async () => { + await expectRefusedAction('body_searches_body_column', token(), 'metadata'); + }); + + it(`the engine handle's filter on the body column, invoked by the ${role}`, async () => { + await expectRefusedAction('handler_engine_filters_body', token(), 'metadata'); + }); + } +}); + +describe('[#21454] a DEFAULT search is narrowed to the door\'s served set, not refused', () => { + for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { + it(`a body's default search runs and is served like the door, invoked by the ${role}`, async () => { + const res = await as(token(), 'POST', '/actions/pin_note/body_searches_default', { params: {} }); + expect(res.status).toBe(200); + // It ran (not refused) and answered the family served, never the stored + // body or hash — the body and hash columns were removed from the scan. + expectServedLikeTheDoor(`default search (${role})`, await readJson(res)); + }); + } +}); + +describe('[#21454] the engine action verb is unreachable from a served body', () => { + it('a sandboxed body sees no `execute` on ctx.api.object(...)', async () => { + const res = await as(adminToken, 'POST', '/actions/pin_note/body_calls_execute', { params: {} }); + expect(res.status).toBe(200); + const text = JSON.stringify(await readJson(res) ?? null); + // The VM bridge installs only find/findOne/count/aggregate and the writes; + // `execute` is not a function the body can call, so no raw scoped context + // is ever handed to a nested action through a served body. (Read from the + // response text, envelope-agnostic.) + expect(text).toContain('"typeofExecute":"undefined"'); + }); +}); diff --git a/packages/runtime/src/stored-metadata-reader-seam.test.ts b/packages/runtime/src/stored-metadata-reader-seam.test.ts index f3358f4156f..59ea232dbd7 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.test.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.test.ts @@ -10,7 +10,11 @@ */ import { describe, it, expect } from 'vitest'; -import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, + assertEngineDeleteDispatch, +} from '@objectstack/metadata-core'; import { ephemeralStoredHashDigest } from '@objectstack/metadata-protocol'; import { serveStoredMetadataRead, serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js'; @@ -36,18 +40,26 @@ const provider = async (plain: string) => `keyed:${plain.length}`; const engineWithProvider = { getKeyedDigest: () => provider }; /** A double of the engine's scoped API: every derived context reads the same store. */ -function scopedApi(seen: { fields: unknown[] } = { fields: [] }): any { +function scopedApi(seen: { fields: unknown[]; reads: number } = { fields: [], reads: 0 }): any { const repo = (name: string) => ({ async find(query?: any) { - seen.fields.push(query?.fields); + seen.reads += 1; + seen.fields.push(query?.searchFields ?? query?.fields); return name.startsWith('sys_metadata') ? [storedRow()] : [{ id: 'n1', metadata: 'ordinary', checksum: STORED_HASH }]; }, async findOne(query?: any) { assertEngineFindOnePredicate(name, query); + seen.reads += 1; return name.startsWith('sys_metadata') ? storedRow() : null; }, - async count() { return 1; }, - async aggregate() { return [{ type: 'datasource', metadata: storedRow().metadata, checksum: STORED_HASH, count: 1 }]; }, + async count() { seen.reads += 1; return 1; }, + async aggregate() { seen.reads += 1; return [{ type: 'datasource', metadata: storedRow().metadata, checksum: STORED_HASH, count: 1 }]; }, + // Write returns: a family row comes back from a write too, and is served. + // update/delete route through the engine's own dispatch predicates so this + // double cannot be looser than ObjectQL's (check:engine-double-contract). + async insert(_data?: any) { return name.startsWith('sys_metadata') ? storedRow() : { id: 'n1' }; }, + async update(data?: any, opts?: any) { assertEngineUpdateDispatch(data, opts); return name.startsWith('sys_metadata') ? storedRow() : 1; }, + async delete(opts?: any) { assertEngineDeleteDispatch(opts); return 1; }, }); return { object: repo, @@ -60,6 +72,18 @@ function scopedApi(seen: { fields: unknown[] } = { fields: [] }): any { }; } +/** + * The engine face the seam reads: the keyed-digest provider, plus a `getObject` + * answering a family-shaped field map so the default-`$search` narrowing can + * resolve a searchable set (the body column is a searchable `textarea`, the + * hash a searchable `text` — exactly the columns the narrowing must remove). + */ +const familySchema = { + fields: { name: { type: 'text' }, type: { type: 'text' }, metadata: { type: 'textarea' }, checksum: { type: 'text' } }, + nameField: 'name', +}; +const engineWithProviderAndSchema = { getKeyedDigest: () => provider, getObject: () => familySchema }; + function expectServed(row: any, hash = 'keyed:71'): void { expect(String(row.metadata)).not.toContain(SENTINEL); expect(String(row.metadata)).toContain('unit.example.invalid'); @@ -72,7 +96,8 @@ describe('[#21454] serveStoredMetadataReadsThrough — the reads it serves', () for (const object of ['sys_metadata', 'sys_metadata_history']) { expectServed((await api.object(object).find({ where: {} }))[0]); expectServed(await api.object(object).findOne({ where: { id: 'row_1' } })); - expectServed((await api.object(object).aggregate({ groupBy: ['type', 'metadata', 'checksum'] }))[0]); + // Grouping by a SCALAR column is served; grouping by the body or hash is refused below. + expectServed((await api.object(object).aggregate({ groupBy: ['type'] }))[0]); expect(await api.object(object).count({})).toBe(1); } }); @@ -105,7 +130,7 @@ describe('[#21454] serveStoredMetadataReadsThrough — the reads it serves', () }); it('a projection naming the body alone reads the type beside it and serves exactly the columns named', async () => { - const seen = { fields: [] as unknown[] }; + const seen = { fields: [] as unknown[], reads: 0 }; const api = serveStoredMetadataReadsThrough(scopedApi(seen), engineWithProvider); const [row] = await api.object('sys_metadata').find({ fields: ['metadata'] }); expect(seen.fields).toEqual([['metadata', 'type']]); @@ -141,3 +166,102 @@ describe('[#21454] serveStoredMetadataRead — one read, served in the shape it expect(out).toBe(answer); }); }); + +describe('[#21454] the EVALUATE shapes are refused, the way the data door refuses them', () => { + // Each case asserts the data door's envelope directly (code / status / param / + // field): the predicates are the door's own, so the seam carries them verbatim. + + it('a filter, sort or grouping on the body column, through the served read', async () => { + const seen = { fields: [] as unknown[], reads: 0 }; + const api = serveStoredMetadataReadsThrough(scopedApi(seen), engineWithProvider); + await expect(api.object('sys_metadata').find({ where: { metadata: { $contains: 'z' } } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'filter', field: 'metadata' }); + await expect(api.object('sys_metadata').find({ orderBy: [{ field: 'metadata', order: 'asc' }] })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'sort', field: 'metadata' }); + await expect(api.object('sys_metadata_history').aggregate({ groupBy: ['metadata'] })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'groupBy', field: 'metadata' }); + // Not one read reached the double: every shape was refused before it ran. + expect(seen.reads).toBe(0); + }); + + it('the array-form filter a direct engine call still honours is refused too (lowered first)', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + await expect(api.object('sys_metadata').find({ where: [['metadata', 'contains', 'z']] })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'filter', field: 'metadata' }); + }); + + it('a filter, sort or grouping on a content-hash column', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + await expect(api.object('sys_metadata').find({ where: { checksum: 'guess' } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'filter', field: 'checksum' }); + await expect(api.object('sys_metadata_history').find({ where: { previous_checksum: 'guess' } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'filter', field: 'previous_checksum' }); + await expect(api.object('sys_metadata').aggregate({ groupBy: [{ field: 'checksum' }] })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'groupBy', field: 'checksum' }); + }); + + it('count with such a predicate — the oracle verb — is refused and never reaches the store', async () => { + const seen = { fields: [] as unknown[], reads: 0 }; + const api = serveStoredMetadataReadsThrough(scopedApi(seen), engineWithProvider); + await expect(api.object('sys_metadata').count({ where: { metadata: { $contains: 'z' } } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, field: 'metadata' }); + await expect(api.object('sys_metadata').count({ where: { checksum: 'guess' } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, field: 'checksum' }); + expect(seen.reads).toBe(0); + }); + + it('an EXPLICIT search field list naming the body or a hash column is refused', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + await expect(api.object('sys_metadata').find({ search: 'z', searchFields: ['metadata'] })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'searchFields', field: 'metadata' }); + await expect(api.object('sys_metadata').find({ search: { term: 'z', fields: ['checksum'] } })) + .rejects.toMatchObject({ code: 'INVALID_FIELD', status: 400, param: 'search', field: 'checksum' }); + }); + + it('a scalar-column filter / sort / grouping is NOT refused — only the family columns are', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + expectServed((await api.object('sys_metadata').find({ where: { type: 'datasource' }, orderBy: [{ field: 'name' }] }))[0]); + expectServed((await api.object('sys_metadata').aggregate({ groupBy: ['type', 'state'] }))[0]); + expect(await api.object('sys_metadata').count({ where: { type: 'datasource' } })).toBe(1); + }); +}); + +describe('[#21454] a DEFAULT $search is narrowed to the door\'s served set, not refused', () => { + it('the body and hash columns are removed, the read runs with the remaining searchable fields', async () => { + const seen = { fields: [] as unknown[], reads: 0 }; + const api = serveStoredMetadataReadsThrough(scopedApi(seen), engineWithProviderAndSchema); + const [row] = await api.object('sys_metadata').find({ search: 'datasource' }); + // The read ran (a body may search a family table by name), served like the door… + expect(seen.reads).toBe(1); + expect(String(row.metadata)).not.toContain(SENTINEL); + // …and the search was narrowed to name/type — never metadata or checksum. + const searchFields = seen.fields[0] as string[]; + expect(searchFields).toContain('name'); + expect(searchFields).not.toContain('metadata'); + expect(searchFields).not.toContain('checksum'); + }); +}); + +describe('[#21454] what a WRITE verb RETURNS is served — body projected, hash keyed', () => { + it('insert and update returning a family row are served; a count return and a non-family write are untouched', async () => { + const api = serveStoredMetadataReadsThrough(scopedApi(), engineWithProvider); + expectServed(await api.object('sys_metadata').insert({ type: 'datasource' })); + expectServed(await api.object('sys_metadata_history').update({ id: 'row_1' })); + expect(await api.object('sys_metadata').delete({ where: { id: 'row_1' } })).toBe(1); + // A non-family write returns by reference, nothing served. + expect(await api.object('pin_note').insert({ x: 1 })).toEqual({ id: 'n1' }); + }); +}); + +describe('[#21454] the engine action verb is never on a served body\'s surface', () => { + it('serveStoredMetadataReadsThrough exposes no execute, sudo or withRunAs beyond the engine\'s own', () => { + // The seam wraps whatever the context carries; a sandboxed body reaches + // only the VM bridge's verbs (find/findOne/count/aggregate + writes), which + // carries no `execute`. The reach reading: `execute` is recorded unreachable + // from a served body on #21454, not closed here. This pin asserts the seam + // adds no execute of its own to a repository that had none. + const repo = { find: async () => [], count: async () => 0 } as any; + const api = serveStoredMetadataReadsThrough({ object: (_name: string) => repo } as any, engineWithProvider); + expect((api.object('sys_metadata') as any).execute).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/stored-metadata-reader-seam.ts b/packages/runtime/src/stored-metadata-reader-seam.ts index 699055f3737..93983e0f501 100644 --- a/packages/runtime/src/stored-metadata-reader-seam.ts +++ b/packages/runtime/src/stored-metadata-reader-seam.ts @@ -4,15 +4,16 @@ * [#21454] The stored-metadata-body family at the in-process READER CONTEXTS. * * The family (`sys_metadata` / `sys_metadata_history`, #21120, #21207) closes - * every door that serves a stored metadata body, or the stored content hash - * over it: the body is served as its type's read projection, with stored - * credential material withheld, and the hash is served in keyed form, never - * the stored value. The generic data door does both, and that is the - * reference answer here. + * every door that serves, copies OR EVALUATES a stored metadata body, or the + * stored content hash over it: the body is served as its type's read + * projection with stored credential material withheld, the hash is served in + * keyed form, and a filter, sort, grouping or search that would EVALUATE either + * one is refused before the query runs. The generic data door does all three, + * and that is the reference answer here. * * Three in-process contexts read the same rows through the engine and served - * them as stored, because nothing between them and the engine applied either - * half: + * them as stored, because nothing between them and the engine applied any part + * of the family's rule: * * - a sandboxed body's `ctx.api.object(...)` (`sandbox/body-runner.ts`, * `buildSandboxApi`): action and hook bodies alike, and with them every @@ -26,36 +27,58 @@ * * The answers all run elevated (`isSystem: true`), so the engine cannot tell * them from the platform's own internal readers of the family, which need the - * stored form. So the projection is applied HERE, at the reader-context seam, - * and never at the engine. + * stored form. So the family's rule is applied HERE, at the reader-context + * seam, and never at the engine. * - * ## One serve, consumed + * ## Three things this seam does to a family READ, consuming the door's own code * - * Every function the serve calls is the data door's own, imported from - * `@objectstack/metadata-protocol`: the `type` companion for a projection - * that names only the body (`storedMetadataBodyProjection`), the body - * projection (`redactStoredMetadataRows`, which consumes the family's ONE - * redactor in `@objectstack/spec/kernel`), and the keyed serve - * (`serveStoredMetadataHashColumnRows`) under the crypto provider's digest or, - * while none is registered, the same process-scoped ephemeral key the door - * keys under (`ephemeralStoredHashDigest`). A copy of any of them here would be - * a second definition of what a credential is, or a second keyed form of one - * row. + * 1. **Refuse the EVALUATE shapes** ({@link refuseOrNarrowStoredMetadataEvaluate}), + * through the generic data door's OWN refusal predicates + * (`storedMetadataBodyGroupingRefusal`, `storedMetadataBodyPredicateRefusal`, + * `storedMetadataHashEvaluateRefusal`, `storedMetadataSearchRefusal`, + * `@objectstack/metadata-protocol`), in the door's own order — a copy of any + * of them here would be a second definition of which shapes leak. A `count` + * with such a predicate is an oracle too, so it is guarded the same way (it + * serves no row, so only the refusal applies to it). A default `$search` + * is NARROWED to the door's served set — the body and hash columns removed, + * judged field by field by the door's own search predicate — rather than + * refused, so a body may still search a family table by `name` exactly as + * the door serves it; a search that would scan nothing after the removal is + * refused. + * 2. **Serve the body projected and the hash keyed** ({@link serveStoredMetadataRead}), + * using the door's `storedMetadataBodyProjection`, `redactStoredMetadataRows` + * (the family's ONE redactor in `@objectstack/spec/kernel`) and + * `serveStoredMetadataHashColumnRows`, under the crypto provider's digest or, + * while none is registered, the same process-scoped ephemeral key the door + * keys under (`ephemeralStoredHashDigest`). A copy would serve a second keyed + * form of one row. + * 3. **Serve what a WRITE verb RETURNS** ({@link serveStoredMetadataWriteReturn}): + * a write whose return carries the family's body or hash is served the same + * projected / keyed way a read is, since a returned row is a serve. Whether a + * body may write the family AT ALL is the write boundary (#21520), not this + * seam: the write itself passes through, only its RETURN is served. * * ## What it does not do * * It judges the object by name with the family's own predicate - * (`isStoredMetadataBodyObject`), exactly as the door does. Writes, `count` - * and the evaluate shapes (a filter, sort or grouping on the body or hash - * columns) are outside it: this seam changes what a READ serves, nothing else. + * (`isStoredMetadataBodyObject`), exactly as the door does. The engine's own + * action verb (`ScopedRepo.execute`) is never reached by a served body — the + * sandbox bridge exposes no `execute`, `sudo` or `withRunAs` — so this seam + * leaves it untouched (the reach is recorded on #21454, not closed here). */ import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; +import { isFilterAST, parseFilterAST, resolveSearchFieldResolution } from '@objectstack/spec/data'; +import { collectConditionFields } from '@objectstack/plugin-security'; import { ephemeralStoredHashDigest, redactStoredMetadataRows, serveStoredMetadataHashColumnRows, storedMetadataBodyProjection, + storedMetadataBodyGroupingRefusal, + storedMetadataBodyPredicateRefusal, + storedMetadataHashEvaluateRefusal, + storedMetadataSearchRefusal, type StoredHashDigest, } from '@objectstack/metadata-protocol'; @@ -76,17 +99,121 @@ function isPlainRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } +/** + * Every column a filter NAMES, structure discarded — lowering a `FilterArray` + * to a `FilterCondition` first so the array door a direct engine call still + * honours (`lowerWhereFilterArray`) is read the same as the object door. + * + * The walk is the generic data door's sibling, `collectConditionFields` + * (`@objectstack/plugin-security`): the door's own `collectFilterFieldKeys` is + * internal to `protocol.ts` and cannot be imported here, and restating it would + * be the second definition the family rule forbids. This collector gates on a + * dotted head and also reads a cross-field `{ $field }` comparand, so it refuses + * a DOTTED or COMPARAND reference to the body or hash columns the door's own + * collector would miss — strictly MORE than the door, never a legitimate + * scalar-column query, since the family columns are the only ones these + * predicates name. + */ +function filterHeadFields(where: unknown): string[] { + if (where == null) return []; + const lowered = isFilterAST(where) ? parseFilterAST(where) : where; + return [...collectConditionFields(lowered)]; +} + +/** The fields an `orderBy` names (`[{ field }]`, or a bare string entry). */ +function sortFieldsOf(orderBy: unknown): unknown[] { + if (!Array.isArray(orderBy)) return []; + return orderBy.map((entry) => (isPlainRecord(entry) ? entry.field : entry)); +} + +/** + * Narrow or refuse a `$search` on a family table the way the data door's + * `narrowStoredMetadataSearch` does, consuming the door's own search predicate + * ({@link storedMetadataSearchRefusal}) as the authority on which columns a + * search may never scan: + * + * - an EXPLICIT field list (`searchFields`, or the object-form `search.fields`) + * naming an unscannable column is refused; + * - a DEFAULT search is narrowed to the resolved searchable set minus the + * columns the predicate refuses, field by field, and the query runs with that + * `searchFields`; a set that narrows to empty is refused. + * + * Returns the query the read should run — the same reference when nothing + * changed, a shallow copy carrying the narrowed `searchFields` otherwise. + */ +function narrowFamilySearch(object: string, query: Record, engine: unknown): Record { + const search = query.search; + const objectForm = search !== null && typeof search === 'object'; + const explicitRaw = query.searchFields != null + ? query.searchFields + : objectForm ? (search as Record).fields : undefined; + const param = query.searchFields != null ? 'searchFields' : 'search'; + const names: string[] = typeof explicitRaw === 'string' + ? explicitRaw.split(',').map((s) => s.trim()).filter(Boolean) + : Array.isArray(explicitRaw) + ? explicitRaw.filter((f): f is string => typeof f === 'string') + : []; + if (names.length > 0) { + const refusal = storedMetadataSearchRefusal(object, names, param); + if (refusal) throw refusal; + return query; + } + if (search == null) return query; + const schema = typeof (engine as { getObject?: (n: string) => unknown })?.getObject === 'function' + ? (engine as { getObject: (n: string) => any }).getObject(object) + : undefined; + const fields = schema?.fields; + if (!fields) return query; + const { allowed } = resolveSearchFieldResolution({ + fields, + searchableFields: schema?.searchableFields, + displayField: schema?.nameField ?? schema?.displayNameField, + }); + const narrowed = allowed.filter((field) => !storedMetadataSearchRefusal(object, [field], 'search')); + if (narrowed.length === 0) { + const refusal = storedMetadataSearchRefusal(object, allowed, 'search'); + if (refusal) throw refusal; + return query; + } + return { ...query, searchFields: narrowed }; +} + +/** + * Refuse every EVALUATE shape on a family read, in the data door's own order + * (search, grouping, body filter / sort, hash filter / sort / grouping), each + * through the door's own predicate. Returns the query the read should run, + * which may carry a narrowed `$search` field set. A non-family object, and a + * query that is not a record, pass through untouched. + */ +function refuseOrNarrowStoredMetadataEvaluate(object: string, query: unknown, engine: unknown): unknown { + if (!isStoredMetadataBodyObject(object) || !isPlainRecord(query)) return query; + const next = narrowFamilySearch(object, query, engine); + const grouping = storedMetadataBodyGroupingRefusal(object, next.groupBy); + if (grouping) throw grouping; + const aggregationFilterFields = Array.isArray(next.aggregations) + ? (next.aggregations as ReadonlyArray<{ filter?: unknown }>).flatMap((a) => filterHeadFields(a?.filter)) + : []; + const filterFields = [...filterHeadFields(next.where), ...filterHeadFields(next.filter), ...aggregationFilterFields]; + const sortFields = sortFieldsOf(next.orderBy); + const bodyPredicate = storedMetadataBodyPredicateRefusal(object, { filterFields, sortFields }); + if (bodyPredicate) throw bodyPredicate; + const hashEvaluate = storedMetadataHashEvaluateRefusal(object, { groupBy: next.groupBy, filterFields, sortFields }); + if (hashEvaluate) throw hashEvaluate; + return next; +} + /** * Run one READ of `object` and serve its answer the way the generic data door * serves the same rows. An object outside the family is read and returned - * untouched, by reference. + * untouched, by reference; a family read first has its EVALUATE shapes refused + * and its `$search` narrowed ({@link refuseOrNarrowStoredMetadataEvaluate}). * - * `read` receives the query to run: the caller's own, or, when its projection - * names the body column without the `type` column that selects the redactor, - * a copy with `type` added; that column is then taken back off the served - * rows, so the caller gets exactly the columns it named. The answer may be a - * row list (`find`, `aggregate`), one row (`findOne`) or `null`; each is served - * in the shape it arrived in. + * `read` receives the query to run: the caller's own (search narrowed), or, + * when its projection names the body column without the `type` column that + * selects the redactor, a copy with `type` added; that column is then taken + * back off the served rows, so the caller gets exactly the columns it named. + * The answer may be a row list (`find`, `aggregate`), one row (`findOne`) or + * `null`; each is served in the shape it arrived in. */ export async function serveStoredMetadataRead( object: string, @@ -95,9 +222,10 @@ export async function serveStoredMetadataRead( read: (query: unknown) => Promise, ): Promise { if (!isStoredMetadataBodyObject(object)) return read(query); - const projection = storedMetadataBodyProjection(object, isPlainRecord(query) ? query.fields : undefined); + const guarded = refuseOrNarrowStoredMetadataEvaluate(object, query, engine); + const projection = storedMetadataBodyProjection(object, isPlainRecord(guarded) ? guarded.fields : undefined); const answer = await read( - projection.addedType && isPlainRecord(query) ? { ...query, fields: projection.fields } : query, + projection.addedType && isPlainRecord(guarded) ? { ...guarded, fields: projection.fields } : guarded, ); const digest = storedHashDigestOf(engine); const opts = { dropType: projection.addedType }; @@ -115,8 +243,35 @@ export async function serveStoredMetadataRead( return answer; } -/** The repository verbs whose answer carries rows. `count` answers a number and serves no row. */ +/** + * Serve what a WRITE verb RETURNS the same projected / keyed way a read is + * served — a returned row carrying the family's body or hash is a serve too. + * A write whose return is a number (an affected-row count), `null`, or carries + * no family column passes through by reference. ⛔ This serves the RETURN only; + * it neither permits nor refuses the write, which is the write boundary's + * question (#21520). + */ +async function serveStoredMetadataWriteReturn(object: string, answer: A, engine: unknown): Promise { + if (!isStoredMetadataBodyObject(object)) return answer; + const digest = storedHashDigestOf(engine); + if (Array.isArray(answer)) { + return (await serveStoredMetadataHashColumnRows(object, redactStoredMetadataRows(object, answer), digest)) as A; + } + if (isPlainRecord(answer)) { + const [served] = await serveStoredMetadataHashColumnRows(object, redactStoredMetadataRows(object, [answer]), digest); + return served as A; + } + return answer; +} + +/** The repository verbs whose answer carries rows this seam serves. */ const ROW_SERVING_READS: ReadonlySet = new Set(['find', 'findOne', 'aggregate']); +/** The verb whose answer is a number: guarded against the evaluate oracle, nothing to serve. */ +const COUNT_READ: PropertyKey = 'count'; +/** The write verbs whose RETURN can carry family content (every alias ObjectRepository exposes). */ +const WRITE_RETURN_VERBS: ReadonlySet = new Set([ + 'insert', 'create', 'update', 'updateById', 'upsert', 'delete', 'deleteById', 'updateMany', 'deleteMany', +]); /** Marks a scoped context this seam already serves through, so a second wrap is a no-op. */ const SERVED_THROUGH_SEAM = Symbol.for('objectstack.runtime.storedMetadataReaderSeam'); @@ -127,9 +282,31 @@ function serveRepository(objectName: string, repo: unknown, engine: unknown): un get(target, prop) { const value = Reflect.get(target, prop, target); if (typeof value !== 'function') return value; - if (!ROW_SERVING_READS.has(prop)) return value.bind(target); - return (query?: unknown, ...rest: unknown[]) => - serveStoredMetadataRead(objectName, query, engine, (q) => value.call(target, q, ...rest)); + if (ROW_SERVING_READS.has(prop)) { + return (query?: unknown, ...rest: unknown[]) => + serveStoredMetadataRead(objectName, query, engine, (q) => value.call(target, q, ...rest)); + } + if (prop === COUNT_READ) { + // `async` so a refusal leaves as a rejected promise, the shape every + // other verb's refusal takes — `count` answers a number, nothing to + // serve, so only the evaluate guard runs. + return async (query?: unknown, ...rest: unknown[]) => { + refuseOrNarrowStoredMetadataEvaluate(objectName, query, engine); + return value.call(target, query, ...rest); + }; + } + if (WRITE_RETURN_VERBS.has(prop)) { + // [#21454] Serve what the write RETURNS. Measured on `main` (pre-#21520): + // an elevated body's family-table write is NOT refused — it runs and + // returns the stored row — so this serve carries real family content. + // [#21520, option A] The write-verb REFUSAL (an elevated body may not + // write a family table at all) attaches HERE, on these same verbs, as a + // throw BEFORE `value.apply` — it needs no reshaping of this branch. This + // seam serves the return and leaves that policy to #21520. + return async (...args: unknown[]) => + serveStoredMetadataWriteReturn(objectName, await value.apply(target, args), engine); + } + return value.bind(target); }, }); } @@ -137,9 +314,8 @@ function serveRepository(objectName: string, repo: unknown, engine: unknown): un /** * The scoped data API (`ctx.api`) a reader context hands to a body or a * handler, with every read of a family object served through - * {@link serveStoredMetadataRead}. Everything else is the same object, by - * delegation: a write, a `count`, and every non-family object reach it - * unchanged. + * {@link serveStoredMetadataRead}, every evaluate shape refused, and every + * write's RETURN served. Everything else is the same object, by delegation. * * The contexts the API can derive are served the same way, so no route around * the seam opens: `object(name)`, `sudo()`, `withRunAs(...)`, the context a diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 30645a03924..caf0b880e35 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3796,11 +3796,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/stored-metadata-reader-seam.test.ts", + "verb": "delete", + "pinned": 1 + }, { "file": "packages/runtime/src/stored-metadata-reader-seam.test.ts", "verb": "findOne", "pinned": 1 }, + { + "file": "packages/runtime/src/stored-metadata-reader-seam.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-automation/src/builtin/crud-bulk-intent.test.ts", "verb": "delete",