Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/drafts-authoring-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
63 changes: 63 additions & 0 deletions packages/rest/src/meta-object-fls.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>;
Expand DownExpand Up@@ -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 () => ({
Expand DownExpand Up@@ -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');
}
});
}
});
32 changes: 32 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ import {
ObjectSchemaMaskEvaluationError,
applyObjectSchemaMask,
foldVisibilityFingerprintIntoEtag,
isObjectSchemaMaskExempt,
isObjectSchemaMaskingEnabled,
normalizeIfNoneMatch,
resolveObjectSchemaMaskPosture,
Expand DownExpand Up@@ -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({
Expand Down
62 changes: 61 additions & 1 deletion packages/runtime/src/domains/meta-object-fls.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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));
Expand DownExpand Up@@ -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');
}
});
}
});
24 changes: 24 additions & 0 deletions packages/runtime/src/domains/meta.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import { CoreServiceName } from '@objectstack/spec/system';
import {
ObjectSchemaMaskEvaluationError,
applyObjectSchemaMask,
isObjectSchemaMaskExempt,
isObjectSchemaMaskingEnabled,
resolveObjectSchemaMaskPosture,
type ObjectSchemaMaskPosture,
Expand DownExpand Up@@ -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 {
Expand Down
31 changes: 28 additions & 3 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' }],
Expand All@@ -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',
});
Expand All@@ -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);
});
Expand All@@ -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();
});
});

// ═══════════════════════════════════════════════════════════════
Expand Down
Loading