diff --git a/.changeset/drafts-authoring-gate.md b/.changeset/drafts-authoring-gate.md new file mode 100644 index 0000000000..655291d1c4 --- /dev/null +++ b/.changeset/drafts-authoring-gate.md @@ -0,0 +1,31 @@ +--- +"@objectstack/rest": patch +"@objectstack/runtime": patch +--- + +fix(meta): gate `GET /meta/_drafts` and `GET /metadata/_drafts` as authoring surfaces (ADR-0106 D5(4), #6599) + +The two `_drafts` outlets were the one schema-serving endpoint the ADR-0106 +implementation-time sweep (#3682) left uncovered. Both called +`protocol.listDrafts()` and returned the result verbatim, so an authenticated +caller with no read access to a field still learned that a **pending object +draft** carried it — the field's label, type, picklist options, formula and +`requiredPermissions` — exactly the disclosure ADR-0106 closes on every other +`/meta` outlet. + +Per the #6599 ruling, `_drafts` is treated as an **authoring surface** rather +than a general read (its only consumers are the console's pending-changes and +Studio/Setup design surfaces). It now gates per caller on the SAME `systemPermissions` +judgement ADR-0106 D4 uses for its mask exemption (`isObjectSchemaMaskExempt`: +`studio.access` / `setup.access` / `manage_metadata`, or `isSystem`) and answers +**403** to everyone else — rather than projecting the draft field-by-field. The +gate runs before the protocol is resolved, so the 501-vs-200 answer cannot be +used to probe kernel support, and it is independent of the D8 per-field-mask +escape hatch. Authors' access is unchanged; non-authors, who have no pending +drafts to publish, receive a refusal instead of the disclosure. + +The refusal envelope follows each transport's existing precedent: REST answers +`FORBIDDEN`, the runtime dispatcher answers `PERMISSION_DENIED` (derived from the +403 status). Both faces are pinned in the shared ADR-0106 case table +(`meta-object-fls.test.ts` in `@objectstack/rest` and `@objectstack/runtime`), +driven by the same case list so the two transports cannot diverge silently. diff --git a/packages/rest/src/meta-object-fls.test.ts b/packages/rest/src/meta-object-fls.test.ts index 4e06a407d6..58fb61dcad 100644 --- a/packages/rest/src/meta-object-fls.test.ts +++ b/packages/rest/src/meta-object-fls.test.ts @@ -37,6 +37,7 @@ import { type ObjectSchemaMaskExit, type ObjectSchemaMaskOutcome, } from '@objectstack/metadata-core/testing'; +import { isObjectSchemaMaskExempt } from '@objectstack/metadata-core'; import { RestServer } from './rest-server'; const ACCOUNT = FLS_CONTRACT_OBJECT as unknown as Record; @@ -84,6 +85,10 @@ function boot(opts: BootOptions) { createData: vi.fn().mockResolvedValue({ id: '1' }), updateData: vi.fn().mockResolvedValue({}), deleteData: vi.fn().mockResolvedValue({ success: true }), + // [#6599] `_drafts` serves whatever `listDrafts` returns, verbatim — a + // pending object draft carries its full `fields`, including the + // sensitive one the authoring gate exists to withhold from non-authors. + listDrafts: vi.fn(async () => [{ type: 'object', name: 'account', item: account() }]), }; if (opts.cached) { protocol.getMetaItemCached = vi.fn(async () => ({ @@ -361,3 +366,61 @@ describe('[ADR-0106 D6] failure postures on the wire', () => { expect(body?.item).toBeUndefined(); }); }); + +/** + * [ADR-0106 D5(4) / #6599] `GET /meta/_drafts` — the outlet the #3682 sweep left + * uncovered. It is NOT masked like its five siblings above: a draft list is an + * AUTHORING surface (the console's pending-changes view), so it GATES per caller + * on the SAME D4 exemption predicate the mask uses (`isObjectSchemaMaskExempt`) + * and 403s a non-author, rather than projecting fields. + * + * Driven through the SAME `OBJECT_SCHEMA_MASK_CASES` the mask exits use — not a + * bespoke case list — so the gate cannot silently diverge from the mask's notion + * of "who is an author", and its dispatcher twin + * (`packages/runtime/src/domains/meta-object-fls.test.ts`) drives the identical + * table. This is a SEPARATE block, not one more `EXITS` row, because the gate's + * contract differs from the mask's: `assertObjectSchemaMaskCase` expects a + * masked document for a restricted caller, where this outlet expects a 403. + * + * The verdict is derived, not hand-tabulated: `isObjectSchemaMaskExempt(context)` + * decides allow-vs-refuse for every case, so a NEW exemption principal added to + * the shared table flows into this gate automatically. Two consequences worth + * pinning fall out of that: + * - `unrestricted-caller/byte-identical` (readable = every field, but NO + * authoring capability) is a 403 here — the gate is stricter than the mask, + * which is the whole point of route (a); + * - `masking-disabled/D8` is STILL a 403 — the authoring gate is independent + * of the per-field-mask escape hatch. + */ +const DRAFTS_PATH = '/api/v1/meta/_drafts'; + +describe('[ADR-0106 D5(4)] GET /meta/_drafts — per-caller authoring gate', () => { + for (const testCase of OBJECT_SCHEMA_MASK_CASES) { + it(testCase.id, async () => { + const { rest } = boot({ testCase }); + const res = mockRes(); + await routeFor(rest, DRAFTS_PATH)!.handler( + { params: {}, query: {}, headers: {} }, res, + ); + // Last `res.json(...)` argument. Indexed rather than `.at(-1)`: + // this package's tsconfig lib predates `Array.prototype.at`, and a + // new use of it would raise the #4311 TEST_DEBT ledger. + const calls = res.json.mock.calls; + const body = calls[calls.length - 1]?.[0]; + const wire = JSON.stringify(body); + + if (isObjectSchemaMaskExempt(testCase.context)) { + // An author reads the drafts unfiltered — the gate masks nothing. + expect(res.statusCode).toBe(200); + expect(wire).toContain('salary_grade'); + } else { + // ADR-0112 envelope: code AND status, not just "it 403s". + expect(res.statusCode).toBe(403); + expect(body?.error?.code).toBe('FORBIDDEN'); + // The refusal discloses nothing — no hidden field leaks through + // the 403 body, which is the disclosure this gate closes. + expect(wire).not.toContain('salary_grade'); + } + }); + } +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 65b6f1d942..c24d9af6d6 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -26,6 +26,7 @@ import { ObjectSchemaMaskEvaluationError, applyObjectSchemaMask, foldVisibilityFingerprintIntoEtag, + isObjectSchemaMaskExempt, isObjectSchemaMaskingEnabled, normalizeIfNoneMatch, resolveObjectSchemaMaskPosture, @@ -4552,6 +4553,37 @@ export class RestServer { handler: async (req: any, res: any) => { try { const environmentId = isScoped ? req.params?.environmentId : undefined; + // [ADR-0106 D5(4) / #6599] `_drafts` is an AUTHORING + // surface — the console's pending-changes view and + // draft-aware package reads — not a general read. A + // pending object draft carries its full `fields` map, so + // serving it unfiltered leaks every hidden field's + // label, type, options, formula and `requiredPermissions` + // to any authenticated caller, which is the disclosure + // ADR-0106 closes one route over. The other `/meta` + // exits MASK per field; this one GATES per caller, on the + // SAME `systemPermissions` judgement D4 uses for its + // read exemption (`isObjectSchemaMaskExempt`) — a caller + // who could not see a field on `/meta/object` has no + // authoring reason to see the draft that carries it. The + // gate is intentionally independent of the D8 field-mask + // escape hatch: opting out of per-field masking is not + // consent to expose pending drafts to non-authors. + // + // Gate FIRST — before resolving the protocol — so an + // unauthorized caller cannot use the 501-vs-200 answer to + // probe which kernels support drafts (same posture as + // `_migrate-stored` below). + const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); + if (!isObjectSchemaMaskExempt(ctx)) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: 'Reading pending metadata drafts requires an authoring capability (studio.access, setup.access or manage_metadata).', + }, + }); + return; + } const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).listDrafts !== 'function') { res.status(501).json({ diff --git a/packages/runtime/src/domains/meta-object-fls.test.ts b/packages/runtime/src/domains/meta-object-fls.test.ts index 613de1984f..4a25c38e75 100644 --- a/packages/runtime/src/domains/meta-object-fls.test.ts +++ b/packages/runtime/src/domains/meta-object-fls.test.ts @@ -28,7 +28,7 @@ import { type ObjectSchemaMaskExit, type ObjectSchemaMaskOutcome, } from '@objectstack/metadata-core/testing'; -import { OBJECT_SCHEMA_MASK_DISABLE_ENV } from '@objectstack/metadata-core'; +import { OBJECT_SCHEMA_MASK_DISABLE_ENV, isObjectSchemaMaskExempt } from '@objectstack/metadata-core'; import { HttpDispatcher } from '../http-dispatcher.js'; const account = () => JSON.parse(JSON.stringify(FLS_CONTRACT_OBJECT)); @@ -193,3 +193,63 @@ describe('[ADR-0106 D6 tier 3] a masking fault is not a lookup miss', () => { expect(JSON.stringify(res.response.body)).not.toContain('salary_grade'); }); }); + +/** + * [ADR-0106 D5(4) / #6599] `GET /metadata/_drafts` — the dispatcher face of the + * same authoring gate the REST `/meta/_drafts` route carries + * (`packages/rest/src/meta-object-fls.test.ts`). Not masked like the five + * resolvers above: a pending-drafts list is an AUTHORING surface, so it GATES + * per caller on the SAME D4 exemption predicate the mask uses + * (`isObjectSchemaMaskExempt`) and 403s a non-author. + * + * Driven through the SAME `OBJECT_SCHEMA_MASK_CASES` table as its REST twin — + * the point the card makes about a two-face contract: one shared case list, so + * the two transports cannot drift on "who is an author" without a red test. + * Separate block, not one more `EXITS` row, for the same reason the REST twin + * is: the gate's contract (403 for a restricted caller) is not the mask's + * (a masked document), so `assertObjectSchemaMaskCase` does not apply. + * + * The runtime transport derives its ADR-0112 code from the 403 status + * (`PERMISSION_DENIED`), matching `_migrate-stored`'s next-door precedent — the + * deliberate spelling difference from the REST twin's `FORBIDDEN`, not a drift. + */ +async function runDraftsExit(testCase: ObjectSchemaMaskCase) { + const dispatcher = make({ + protocol: { + // Serves the pending object draft verbatim — full `fields`, sensitive + // one included — so an author reads it and a non-author must be + // refused BEFORE it is reached. + listDrafts: vi.fn(async () => [{ type: 'object', name: 'account', item: account() }]), + }, + }); + const res = await dispatcher.handleMetadata('/_drafts', ctxFor(testCase), 'GET'); + // `HttpDispatcherResult.response` is optional — a missing one would mean + // `_drafts` was not handled at all, which is its own failure and must not + // be read as "no disclosure". Asserting it here gives every case below a + // real answer to inspect (and keeps the #4311 TEST_DEBT ledger flat). + if (!res.response) { + throw new Error(`/metadata/_drafts was not handled for case '${testCase.id}'`); + } + return res.response; +} + +describe('[ADR-0106 D5(4)] GET /metadata/_drafts — per-caller authoring gate', () => { + for (const testCase of OBJECT_SCHEMA_MASK_CASES) { + it(testCase.id, async () => { + const response = await runDraftsExit(testCase); + const wire = JSON.stringify(response.body); + + if (isObjectSchemaMaskExempt(testCase.context)) { + // An author reads the drafts unfiltered — the gate masks nothing. + expect(response.status).toBe(200); + expect(wire).toContain('salary_grade'); + } else { + // ADR-0112 envelope: code AND status, not just "it 403s". + expect(response.status).toBe(403); + expect(response.body?.error?.code).toBe('PERMISSION_DENIED'); + // The refusal discloses nothing — no hidden field leaks out. + expect(wire).not.toContain('salary_grade'); + } + }); + } +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 94ce23727b..acb3fa3039 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -20,6 +20,7 @@ import { CoreServiceName } from '@objectstack/spec/system'; import { ObjectSchemaMaskEvaluationError, applyObjectSchemaMask, + isObjectSchemaMaskExempt, isObjectSchemaMaskingEnabled, resolveObjectSchemaMaskPosture, type ObjectSchemaMaskPosture, @@ -472,6 +473,29 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // `_drafts` is intercepted before the generic `:type` handler below so it // is never mistaken for a metadata type name. if (parts.length === 1 && parts[0] === '_drafts' && (!method || method.toUpperCase() === 'GET')) { + // [ADR-0106 D5(4) / #6599] The dispatcher face of the same authoring + // gate the REST `/meta/_drafts` route carries. A pending object draft + // ships its full `fields` map, so serving `listDrafts()` verbatim leaks + // every hidden field's definition — the disclosure ADR-0106 closes on + // every other `/meta` outlet. `_drafts` is authored-metadata, not a + // general read, so it GATES per caller on the SAME D4 exemption + // predicate (`isObjectSchemaMaskExempt`) the mask exits use — 403 for a + // non-author — rather than masking per field. Gate FIRST, before the + // protocol is resolved, so the 501-vs-200 answer cannot be used to probe + // (same posture as `_migrate-stored` below). The runtime transport + // derives the ADR-0112 code from the 403 status (`PERMISSION_DENIED`), + // matching `_migrate-stored`'s next-door precedent rather than the REST + // twin's `FORBIDDEN`. + const ec: any = _context.executionContext; + if (!isObjectSchemaMaskExempt(ec)) { + return { + handled: true, + response: deps.error( + 'Reading pending metadata drafts requires an authoring capability (studio.access, setup.access or manage_metadata).', + 403, + ), + }; + } const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof protocol.listDrafts === 'function') { try { diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 57570a2606..e815bb3253 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2181,6 +2181,14 @@ describe('HttpDispatcher', () => { // ═══════════════════════════════════════════════════════════════ describe('GET /metadata/_drafts', () => { + // [ADR-0106 D5(4) / #6599] `_drafts` is authoring-gated: a pending object + // draft carries its full `fields`, so the route 403s a non-author rather + // than serve it. These plumbing cases therefore run as an AUTHOR + // (`studio.access` — one of the D4 exemptions); the non-author refusal + // itself is pinned just below, and the full per-caller matrix lives in + // the shared ADR-0106 case table (`domains/meta-object-fls.test.ts`). + const AUTHOR = { userId: 'u1', systemPermissions: ['studio.access'] }; + it('routes to protocol.listDrafts with packageId + type and returns drafts', async () => { const listDrafts = vi.fn().mockResolvedValue({ drafts: [{ type: 'object', name: 'course', packageId: 'app.edu', updatedAt: 't1', updatedBy: 'ai' }], @@ -2190,7 +2198,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handleMetadata('_drafts', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET', undefined, { + const result = await dispatcher.handleMetadata('_drafts', { request: {}, executionContext: { ...AUTHOR } } as any, 'GET', undefined, { packageId: 'app.edu', type: 'object', }); @@ -2209,7 +2217,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handleMetadata('_drafts', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET', undefined, {}); + const result = await dispatcher.handleMetadata('_drafts', { request: {}, executionContext: { ...AUTHOR } } as any, 'GET', undefined, {}); expect(result.handled).toBe(true); expect(result.response?.status).toBe(501); }); @@ -2222,10 +2230,27 @@ describe('HttpDispatcher', () => { return null; }); - await dispatcher.handleMetadata('_drafts', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET', undefined, {}); + await dispatcher.handleMetadata('_drafts', { request: {}, executionContext: { ...AUTHOR } } as any, 'GET', undefined, {}); expect(listDrafts).toHaveBeenCalledTimes(1); expect(getMetaItems).not.toHaveBeenCalled(); }); + + it('[#6599] 403s a non-author BEFORE the protocol is resolved (gate-first)', async () => { + // The gate must refuse without ever touching listDrafts, so the + // 501-vs-200 answer cannot be used to probe kernel support. + const listDrafts = vi.fn().mockResolvedValue({ drafts: [] }); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ listDrafts }); + return null; + }); + + const result = await dispatcher.handleMetadata('_drafts', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET', undefined, {}); + + // ADR-0112 envelope: code AND status, not just "it 403s". + expect(result.response?.status).toBe(403); + expect((result.response as any)?.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(listDrafts).not.toHaveBeenCalled(); + }); }); // ═══════════════════════════════════════════════════════════════