diff --git a/.changeset/20441-audit-authoring-door.md b/.changeset/20441-audit-authoring-door.md new file mode 100644 index 00000000000..74ecdfd2774 --- /dev/null +++ b/.changeset/20441-audit-authoring-door.md @@ -0,0 +1,13 @@ +--- +"@objectstack/rest": patch +--- + +**`GET /api/v1/meta/:type/:name/audit` is now an authoring door: a caller without an authoring capability is refused, as `/diff`, `/history` and `GET /api/v1/meta/_drafts` refuse.** Before this release, any signed-in caller who could open an item could read its protection-audit trail. Every save appends a row to that trail, a draft save included, and the row carries `note: "draft"`, the actor and the time. So a member could learn that an item had unpublished authoring work, who saved it and when. For an item that had never been published, where the plain read answers `404`, the member could learn that it existed at all. This carries the maintainer's ruling on #20378 (letter B, comment 5865708652) to this door, as triage graded on #20441: draft and preview reads are admin-gated upstream (ADR-0106 D4), and the audit trail, like the version log, has no published-only answer to fall back to. + +Clause-②: no + +- **Who may read it:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts`, `/diff`, `/history` and every draft switch already ask, not a second rule. +- **Everyone else:** `403` with code `FORBIDDEN`, in the same nested `error` envelope `/meta/_drafts` answers. The refusal is decided on the caller before the protocol is resolved, before the query is parsed and before any event is read. So it is the same answer for an item that exists, one that does not, and one that exists only as a draft, and it carries no event, actor or item name. The message names the door, not drafts. +- **Unchanged:** callers with an authoring capability read the trail exactly as before, including the per-caller refusal of an item the plain read refuses them and the organization scope of the read. + +A client that read `/audit` (`client.meta.getAudit`) as a member now receives `403 FORBIDDEN`. To read it, call as a caller holding one of the three capabilities above. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index f1d20e7cab8..fd0645e74d2 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -233,6 +233,7 @@ const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 }) // Introspection & governance const bad = await client.meta.getDiagnostics({ severity: 'error' }); const refs = await client.meta.getReferences('object', 'account'); +// Authoring-only too, like diffItem: without studio.access, setup.access or manage_metadata → 403 FORBIDDEN const trail = await client.meta.getAudit('object', 'account', { limit: 20 }); const tree = await client.meta.getBookTree('handbook'); diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 57028fcce1b..547b17c55d9 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -379,12 +379,13 @@ an app an author may not open is refused on these doors too. Reading a needs an authoring capability (`studio.access`, `setup.access` or `manage_metadata`: the check `GET /api/v1/meta/_drafts` makes). A caller without one is answered as if the parameter were absent: the published app, -or `404` for an app that has never been published. `/diff`, and the change log -`/history` beside it, need that capability outright: both read the version log, -which records a draft save like any other, so they have no published-only -answer to fall back to. A caller without one is refused with `403`, as -`GET /api/v1/meta/_drafts` refuses, before anything is read — the same answer -whether or not the app exists. +or `404` for an app that has never been published. `/diff`, the change log +`/history` beside it and the protection-audit trail `/audit` need that +capability outright: the version log and the audit trail each record a draft +save like any other, so none of them has a published-only answer to fall back +to. A caller without one is refused with `403`, as `GET /api/v1/meta/_drafts` +refuses, before anything is read — the same answer whether or not the app +exists. `visible` did **not** move server-side with them, and that asymmetry is deliberate: CEL is evaluated in the browser because server-side evaluation needs diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 1b4b665c62c..2466d1d0e30 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -542,7 +542,7 @@ describe('[#13160] §4 the 20 locally-caught sites — the half with no shared f } }, 180_000); - it('⭐ with the umbrella ISOLATED, six of the inner sites do NOT refuse on their own reading', async () => { + it('⭐ with the umbrella ISOLATED, four of the inner sites do NOT refuse on their own reading', async () => { // ⛔ Counterfactual, not a production posture — production mounts the // umbrella, and section 4's first case measures that it refuses. What // this separates is DOUBLE-guarded from SINGLE-guarded: an absent @@ -560,12 +560,16 @@ describe('[#13160] §4 the 20 locally-caught sites — the half with no shared f 'DELETE /api/v1/meta/:type/:name', 'POST /api/v1/meta/:type/:name/publish', 'POST /api/v1/meta/:type/:name/rollback', + // [#20441] An authoring door now, like `_drafts`: the authoring + // capability is asked at its head, so an absent context is refused + // there even with the umbrella isolated. It moved from the list + // below, whose length the title states. + 'GET /api/v1/meta/:type/:name/audit', ]; const SERVES_ON_ITS_OWN = [ 'GET /api/v1/meta/:type', // list — org scope only 'GET /api/v1/meta/:type/:name', // item read — org scope only 'GET /api/v1/meta/:type/:name/layers', - 'GET /api/v1/meta/:type/:name/audit', 'GET /api/v1/meta/:type/:name/published', ]; diff --git a/packages/rest/src/meta-alternate-door-read-gates.test.ts b/packages/rest/src/meta-alternate-door-read-gates.test.ts index bf786b6723b..347571c2cdd 100644 --- a/packages/rest/src/meta-alternate-door-read-gates.test.ts +++ b/packages/rest/src/meta-alternate-door-read-gates.test.ts @@ -60,6 +60,11 @@ * refuses — 403 `FORBIDDEN`, before any read. Everything this census says * about them holds for the callers they admit. `/layers` and * `?layers=true` keep the pruned plain-read answer for everyone. + * + * [#20441] **`/audit` is the third** (triage's grade 5871509797 carrying + * ruling 5865708652 to it): `sys_metadata_audit` records a draft save with + * `note: 'draft'`, its actor and its time, so it takes the same refusal + * before any read. * - **`/references`** is declared exempt: it serves the identities of OTHER * items that point at this one, never a member of this item's document. * @@ -358,9 +363,10 @@ type DoorKind = 'document' | 'stored' | 'events' | 'exempt'; * envelope (its `item`). */ /** - * `authoring` — [#20378] ruling 5865708652: the door refuses a caller who may - * not read drafts (`readsDrafts`) with the `GET /meta/_drafts` 403, before any - * read; the rest of its row holds for the callers it admits. + * `authoring` — [#20378] ruling 5865708652 (and [#20441] its carriage to + * `/audit`): the door refuses a caller who may not read drafts (`readsDrafts`) + * with the `GET /meta/_drafts` 403, before any read; the rest of its row holds + * for the callers it admits. */ interface Door { kind: DoorKind; suffix: string; query?: Record; reason?: string; serves?: 'layers' | 'diff' | 'draft'; authoring?: true } @@ -373,7 +379,7 @@ const DOORS: Record = { '/published': { kind: 'document', suffix: '/published' }, '/diff': { kind: 'stored', suffix: '/diff', serves: 'diff', authoring: true }, '/history': { kind: 'events', suffix: '/history', authoring: true }, - '/audit': { kind: 'events', suffix: '/audit' }, + '/audit': { kind: 'events', suffix: '/audit', authoring: true }, '/references': { kind: 'exempt', suffix: '/references', @@ -669,6 +675,7 @@ describe(`[#20156] every alternate door answers what the plain read answers, or expect(protocol.getMetaItem).not.toHaveBeenCalled(); expect(protocol.diffMetaItem).not.toHaveBeenCalled(); expect(protocol.historyMetaItem).not.toHaveBeenCalled(); + expect(protocol.auditMetaItem).not.toHaveBeenCalled(); return; } if (door.serves === 'draft' && CALLERS[callerName].ctx) { diff --git a/packages/rest/src/meta-audit-capability-gap.test.ts b/packages/rest/src/meta-audit-capability-gap.test.ts index 4fc65b2e0e4..62dd1c0fac4 100644 --- a/packages/rest/src/meta-audit-capability-gap.test.ts +++ b/packages/rest/src/meta-audit-capability-gap.test.ts @@ -114,8 +114,10 @@ function boot(protocol: Record) { // A RESOLVABLE execution context. Measured in #8747's pin on this same // route: an `undefined` context is refused by the anonymous floor // (`enforceAuth`) with a 401 BEFORE the handler body runs, so the branch - // under test would never be reached. - (rest as any).resolveExecCtx = async () => ({ userId: 'u1', tenantId: 'org_alpha' }); + // under test would never be reached. [#20441] And an ADMITTED one: `/audit` + // is an authoring door, so a caller without an authoring capability is + // refused 403 before the protocol is resolved, and would not reach it either. + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', tenantId: 'org_alpha', systemPermissions: ['manage_metadata'] }); rest.registerRoutes(); const found = (rest as any).getRoutes().find( diff --git a/packages/rest/src/meta-history-diff-authoring-door.test.ts b/packages/rest/src/meta-history-diff-authoring-door.test.ts index f4e579ffc26..3f7f3bc901c 100644 --- a/packages/rest/src/meta-history-diff-authoring-door.test.ts +++ b/packages/rest/src/meta-history-diff-authoring-door.test.ts @@ -36,6 +36,21 @@ * `ObjectStackProtocolImplementation` and the real routes — booted exactly as * `meta-draft-read-builder-gate.test.ts` boots it. The stubs are the auth * boundary (`resolveExecCtx`) and the service probe that says `tenancy` is off. + * + * ## [#20441] `/audit` is the third authoring door + * + * `saveMetaItem` appends a success row to `sys_metadata_audit` for every save, + * a draft save included, and `auditMetaItem` serves its `note: 'draft'`, its + * actor and its time. Measured on this harness before the fix (`main` at + * `acd009521e`): the member read `note: 'draft'` and `actor: 'u_author'` for + * `app/atlas` and `view/opportunity.pipeline`, and for the never-published + * `app/beacon` and `view/opportunity.forecast`, whose plain read answers them + * `404`, it read that event while a missing name read `{ events: [] }` — an + * existence oracle. Triage's grade 5871509797 carried ruling 5865708652's + * letter to this door: the same refusal, before any read. The three doors share + * one refusal (`refuseNonAuthoringCaller`), and every pin below runs over all + * three. The draft saves here are made by the `author` caller, so the actor a + * refusal must never carry is a real one. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -140,7 +155,7 @@ function makeRes() { const META = '/api/v1/meta'; /** The protocol members a refused caller must never reach. */ -const READS = ['getMetaItem', 'getMetaItemLayered', 'historyMetaItem', 'diffMetaItem'] as const; +const READS = ['getMetaItem', 'getMetaItemLayered', 'historyMetaItem', 'diffMetaItem', 'auditMetaItem'] as const; async function boot() { const engine = new ObjectQL(); @@ -189,19 +204,21 @@ async function boot() { as(who, 'GET', `${META}/:type/:name${suffix}`, { path: `${META}/${type}/${name}${suffix}`, params: { type, name }, query }); /** `GET /meta/_drafts` — the door whose refusal these two now give. */ const drafts = (who: CallerName) => as(who, 'GET', `${META}/_drafts`, { path: `${META}/_drafts` }); - const save = async (type: string, item: { name: string }, query: Record) => { - const res = await as('system', 'PUT', `${META}/:type/:name`, { + const save = async (type: string, item: { name: string }, query: Record, who: CallerName = 'system') => { + const res = await as(who, 'PUT', `${META}/:type/:name`, { path: `${META}/${type}/${item.name}`, params: { type, name: item.name }, query, body: item, }); if (res.statusCode !== 200) throw new Error(`seeding ${type}/${item.name} failed: ${JSON.stringify(res.body)}`); }; + // The published rows are machine writes; every draft is an AUTHOR's save + // (#20441: the actor `/audit` records, which a refusal must never carry). await save('app', ATLAS, {}); - await save('app', ATLAS_DRAFT, { mode: 'draft' }); - await save('app', BEACON_DRAFT, { mode: 'draft' }); + await save('app', ATLAS_DRAFT, { mode: 'draft' }, 'author'); + await save('app', BEACON_DRAFT, { mode: 'draft' }, 'author'); await save('view', PIPELINE, {}); - await save('view', PIPELINE_DRAFT, { mode: 'draft' }); - await save('view', FORECAST_DRAFT, { mode: 'draft' }); + await save('view', PIPELINE_DRAFT, { mode: 'draft' }, 'author'); + await save('view', FORECAST_DRAFT, { mode: 'draft' }, 'author'); /** Spies on every protocol read a refused caller must never reach, armed AFTER seeding. */ const spies = Object.fromEntries(READS.map((m) => [m, vi.spyOn(protocol, m)])) as Record<(typeof READS)[number], ReturnType>; @@ -222,9 +239,13 @@ const navIds = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e /** The keys of a body and of its nested `error` — the envelope's SHAPE, never its prose. */ const shape = (res: any) => ({ top: Object.keys(res.body ?? {}).sort(), error: Object.keys(res.body?.error ?? {}).sort() }); -const AUTHORING_DOORS = ['/diff', '/history'] as const; +const AUTHORING_DOORS = ['/diff', '/history', '/audit'] as const; +/** The protocol read each authoring door makes for a caller it admits — the live-spy control. */ +const DOOR_READ = { '/diff': 'diffMetaItem', '/history': 'historyMetaItem', '/audit': 'auditMetaItem' } as const; +/** What an event or version answer carries, and a refusal never does. */ +const ANSWER_KEYS = ['fromVersion', 'toVersion', 'events', 'added', 'changed', 'note', 'actor', 'occurredAt', 'u_author']; -describe('[#20378] a member without an authoring capability is refused /diff and /history — the /meta/_drafts refusal, before any read', () => { +describe('[#20378 · #20441] a member without an authoring capability is refused /diff, /history and /audit — the /meta/_drafts refusal, before any read', () => { for (const suffix of AUTHORING_DOORS) { for (const type of Object.keys(SUBJECTS) as SubjectType[]) { it(`${suffix} ${type}: 403 FORBIDDEN in the /meta/_drafts envelope — one answer for a published item, a draft-only one and a missing name, and nothing read`, async () => { @@ -245,10 +266,10 @@ describe('[#20378] a member without an authoring capability is refused /diff and // `error` with a code and a message, and nothing beside it. expect(shape(res)).toEqual(shape(listing)); // No item or version detail: not the name, not a draft - // string, not a version, not an event. + // string, not a version, not an event, not its actor. for (const name of Object.values(names)) expect(text(res)).not.toContain(name); for (const s of DRAFT_ONLY_TEXT) expect(text(res)).not.toContain(s); - for (const k of ['fromVersion', 'toVersion', 'events', 'added', 'changed']) expect(text(res)).not.toContain(k); + for (const k of ANSWER_KEYS) expect(text(res)).not.toContain(k); } // No existence oracle: the three answers are byte-identical. expect(answers[1].body).toEqual(answers[0].body); @@ -260,7 +281,7 @@ describe('[#20378] a member without an authoring capability is refused /diff and // reading, not a harness that never sees a call. const builder = await door('author', suffix, type, names.published); expect(builder.statusCode).toBe(200); - expect(spies[suffix === '/diff' ? 'diffMetaItem' : 'historyMetaItem']).toHaveBeenCalled(); + expect(spies[DOOR_READ[suffix]]).toHaveBeenCalled(); }, 60_000); } } @@ -281,18 +302,20 @@ describe('[#20378] a member without an authoring capability is refused /diff and expect(builder.statusCode).toBe(400); }, 60_000); - it('/history: an unparseable `limit` answers the member the same refusal — decided before the query parse', async () => { - const { door } = await boot(); - const plain = await door('member', '/history', 'app', 'atlas'); - const res = await door('member', '/history', 'app', 'atlas', { limit: 'abc' }); - expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' }); - expect(res.body).toEqual(plain.body); - const builder = await door('studioBuilder', '/history', 'app', 'atlas', { limit: 'abc' }); - expect(builder.statusCode).toBe(400); - }, 60_000); + for (const suffix of ['/history', '/audit'] as const) { + it(`${suffix}: an unparseable \`limit\` answers the member the same refusal — decided before the query parse`, async () => { + const { door } = await boot(); + const plain = await door('member', suffix, 'app', 'atlas'); + const res = await door('member', suffix, 'app', 'atlas', { limit: 'abc' }); + expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(res.body).toEqual(plain.body); + const builder = await door('studioBuilder', suffix, 'app', 'atlas', { limit: 'abc' }); + expect(builder.statusCode).toBe(400); + }, 60_000); + } }); -describe('[#20378] builders read /diff and /history as before — the control', () => { +describe('[#20378 · #20441] builders read /diff, /history and /audit as before — the control', () => { it('/diff app: every builder reads the draft version; the author reads it whole, every other builder pruned as the plain read prunes', async () => { const { door, draftVersion } = await boot(); const v = await draftVersion('app', 'atlas'); @@ -334,6 +357,26 @@ describe('[#20378] builders read /diff and /history as before — the control', } } }, 60_000); + + it('/audit: every builder reads the audit trail of an app and a view, the author\'s draft save included, and of a draft-only item', async () => { + const { door } = await boot(); + for (const who of BUILDERS) { + for (const [type, name] of [['app', 'atlas'], ['view', 'opportunity.pipeline']] as const) { + const res = await door(who, '/audit', type, name); + expect(res.statusCode, `${who} ${type}`).toBe(200); + // The published save and the draft save, each `allowed`. + const notes = (res.body?.events ?? []).map((e: any) => `${e.operation}:${e.outcome}:${e.note}`).sort(); + expect(notes, `${who} ${type}`).toEqual(['save:allowed:active', 'save:allowed:draft']); + const draft = res.body.events.find((e: any) => e.note === 'draft'); + expect(draft?.actor, `${who} ${type}`).toBe('u_author'); + } + for (const [type, name] of [['app', 'beacon'], ['view', 'opportunity.forecast']] as const) { + const res = await door(who, '/audit', type, name); + expect(res.statusCode, `${who} ${type}`).toBe(200); + expect(res.body?.events?.map((e: any) => e.note), `${who} ${type}`).toEqual(['draft']); + } + } + }, 60_000); }); describe('[#20378] /layers and ?layers=true are unchanged for the member — the lit control', () => { @@ -363,8 +406,8 @@ describe('[#20378] /layers and ?layers=true are unchanged for the member — the }, 60_000); }); -describe('[#20378] one predicate: /diff and /history refuse exactly the callers /meta/_drafts refuses', () => { - it('for each caller, the three doors agree', async () => { +describe('[#20378 · #20441] one predicate: /diff, /history and /audit refuse exactly the callers /meta/_drafts refuses', () => { + it('for each caller, the four doors agree', async () => { const { door, drafts } = await boot(); for (const who of ['member', ...BUILDERS] as const) { const refused = (await drafts(who)).statusCode === 403; diff --git a/packages/rest/src/rest-server-audit-org-scope.test.ts b/packages/rest/src/rest-server-audit-org-scope.test.ts index 705fe1c9539..4f20dabdb1e 100644 --- a/packages/rest/src/rest-server-audit-org-scope.test.ts +++ b/packages/rest/src/rest-server-audit-org-scope.test.ts @@ -6,12 +6,18 @@ // unscoped on both ends and returned every tenant's audit rows for a // `(type, name)`. // -// This route has no capability gate in its handler — unlike its `PUT` twin, -// which gates on `manage_metadata` — so the reachable cohort was any +// This route had no capability gate in its handler then — unlike its `PUT` +// twin, which gates on `manage_metadata` — so the reachable cohort was any // authenticated principal of any tenant, on the published `meta.getAudit` SDK // surface. That is why the assertions below are about the ARGUMENT rather than // the status code: a 200 was always the answer; what leaked was the payload. // +// [#20441] It is an authoring door now (`refuseNonAuthoringCaller`), so every +// caller below holds `manage_metadata` (`BUILDER`): the question here is which +// organization an ADMITTED caller's read is scoped to. The gate does not do +// that job — it admits a builder of one organization, never a reader of every +// organization's trail — so the scope still carries the tenant separation. +// // The organization comes from `resolveExecCtx`, which this file already calls // in 40+ handlers. Deliberately NOT a new `resolveActiveOrganizationId` — the // `/published` route's comment in `rest-server.ts` records that inventing org @@ -90,12 +96,15 @@ function boot(execCtx: any) { return { auditMetaItem, drive }; } +/** The authoring capability `/audit` asks for (#20441): these cases ask what an admitted caller's read is scoped to. */ +const BUILDER = { systemPermissions: ['manage_metadata'] }; + /** The request object the route handed to `auditMetaItem`. */ const requestFrom = (fn: any) => fn.mock.calls[0][0]; describe('#8747 GET /meta/:type/:name/audit scopes the read to the caller organization', () => { it('threads the execution context tenant as `organizationId`', async () => { - const { auditMetaItem, drive } = boot({ userId: 'u1', tenantId: 'org_alpha' }); + const { auditMetaItem, drive } = boot({ ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }); await drive(); expect(auditMetaItem).toHaveBeenCalledTimes(1); @@ -106,7 +115,7 @@ describe('#8747 GET /meta/:type/:name/audit scopes the read to the caller organi // A principal with no active organization must read env-wide rows, not // become a skeleton key. `null` is the env-wide read downstream; an // ABSENT key would be the pre-fix unscoped call. - const { auditMetaItem, drive } = boot({ userId: 'u1' }); + const { auditMetaItem, drive } = boot({ ...BUILDER, userId: 'u1' }); await drive(); const request = requestFrom(auditMetaItem); @@ -122,9 +131,11 @@ describe('#8747 GET /meta/:type/:name/audit scopes the read to the caller organi // with a 401 before the handler body runs, so the protocol is never // called at all. // - // That floor is the ONLY gate here — this route has no capability gate, - // unlike the `PUT` twin's `manage_metadata` check — which is precisely - // why the organization scope below has to do the tenant separation. + // That floor was the ONLY gate here when this was measured — the route + // had no capability gate then, unlike the `PUT` twin's + // `manage_metadata` check. [#20441] The authoring-door gate now sits in + // the handler, after this floor; neither does the tenant separation, + // which is why the organization scope has to. const { auditMetaItem, drive } = boot(undefined); const answer = await drive(); @@ -134,9 +145,9 @@ describe('#8747 GET /meta/:type/:name/audit scopes the read to the caller organi it('never omits the organization — the call shape that leaked is unreachable', async () => { for (const ctx of [ - { userId: 'u1', tenantId: 'org_alpha' }, - { userId: 'u1', tenantId: undefined }, - { userId: 'u1' }, + { ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }, + { ...BUILDER, userId: 'u1', tenantId: undefined }, + { ...BUILDER, userId: 'u1' }, ]) { const { auditMetaItem, drive } = boot(ctx); await drive(); @@ -152,14 +163,14 @@ describe('#8747 GET /meta/:type/:name/audit scopes the read to the caller organi // Swept with the fix: `auditMetaItem` neither declares nor reads it. // Environment scoping is unaffected — it comes from WHICH protocol // `resolveProtocol` hands back, not from this payload. - const { auditMetaItem, drive } = boot({ userId: 'u1', tenantId: 'org_alpha' }); + const { auditMetaItem, drive } = boot({ ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }); await drive(); expect(requestFrom(auditMetaItem)).not.toHaveProperty('environmentId'); }); it('still forwards the (type, name) key and a well-formed limit', async () => { - const { auditMetaItem, drive } = boot({ userId: 'u1', tenantId: 'org_alpha' }); + const { auditMetaItem, drive } = boot({ ...BUILDER, userId: 'u1', tenantId: 'org_alpha' }); await drive({ query: { limit: '5' } }); const request = requestFrom(auditMetaItem); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e0f7a215dbe..0ab6c5c1edb 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1700,6 +1700,39 @@ function mayReadPendingDrafts(caller: unknown): boolean { return isObjectSchemaMaskExempt(caller); } +/** + * [#20378 · #20441] THE AUTHORING-DOOR REFUSAL — ruling 5865708652 (letter B), + * carried to `/audit` by triage's grade 5871509797. Sends it and answers `true` + * when {@link mayReadPendingDrafts} does not admit `caller`; answers `false`, + * sending nothing, when it does. The `refuseRepeatedQueryParams` convention: + * `if (refuseNonAuthoringCaller(ctx, res, …)) return;`. + * + * The item-scoped doors that read an AUTHORING LOG ask it first, before the + * protocol is resolved, before the query is parsed and before any item or + * event is read: `/history` and `/diff` read `sys_metadata_history`, and + * `/audit` reads `sys_metadata_audit`. Both logs record a DRAFT save exactly + * as they record an active one, so they have no published-only answer to fall + * back to, and a caller who may not read pending drafts is refused as + * `GET /meta/_drafts` refuses them: 403 `FORBIDDEN`, the same nested + * envelope. The answer is the same for an item that exists, one that does + * not and a draft-only one, so the door is no existence oracle. + * + * `reading` names THE DOOR and never drafts: a refusal worded about drafts + * would read as "this item has one". This is ONE function so the three doors + * cannot drift apart in their predicate, status, code or envelope; only the + * door's own name differs between them. + */ +function refuseNonAuthoringCaller(caller: unknown, res: any, reading: string): boolean { + if (mayReadPendingDrafts(caller)) return false; + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: `${reading} requires an authoring capability (studio.access, setup.access or manage_metadata).`, + }, + }); + return true; +} + /** * [#20156] What the per-caller read gate of `GET /meta/:type/:name` answers for * ONE document — see {@link RestServer.metaItemReadGate}, the one place it is @@ -7375,18 +7408,12 @@ export class RestServer { // the active row and keep the pruned plain-read answer. // // `historyCtx` is this door's one caller resolution; the org - // partition below reads the same value. + // partition below reads the same value. The refusal is + // {@link refuseNonAuthoringCaller}, shared with `/diff` and + // `/audit` (#20441) so the three cannot drift apart. const historyCtx = await this.resolveExecCtx(environmentId, req) .catch(rethrowAuthzStoreUnavailable); - if (!mayReadPendingDrafts(historyCtx)) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: 'Reading a metadata item\'s version history requires an authoring capability (studio.access, setup.access or manage_metadata).', - }, - }); - return; - } + if (refuseNonAuthoringCaller(historyCtx, res, 'Reading a metadata item\'s version history')) return; const p = await this.resolveProtocol(environmentId, req); // The cast came off when `MetadataProtocol` declared // `historyMetaItem` (#12005 — the #11006 pattern, exactly @@ -7543,13 +7570,43 @@ export class RestServer { // reset attempts, both allowed and denied) so Studio's "审计 // 日志 / Audit log" tab can show who tried what and whether // a lock blocked it. Empty array on environments where the - // table is not yet provisioned. + // table is not yet provisioned. An AUTHORING door (#20441): a + // caller without an authoring capability is refused 403. registerPerItemRoute({ method: 'GET', path: `${metaPath}/:type/:name/audit`, handler: async (req: any, res: any) => { try { const environmentId = isScoped ? req.params?.environmentId : undefined; + // [#20441] AN AUTHORING DOOR — ruling 5865708652 (letter B), + // carried to this door by triage's grade 5871509797. + // `saveMetaItem` appends a success row to + // `sys_metadata_audit` for EVERY save, a draft save + // included, and `auditMetaItem` serves its `note: 'draft'`, + // its actor and its time. So this trail, served to a caller + // who may not read pending drafts, disclosed that an item + // had unpublished authoring work, who saved it and when — + // and for an item with nothing published, that it exists at + // all, where the plain read answers `404` (ADR-0045 §3). + // ADR-0106 D4: 「draft/preview reads are admin-gated + // upstream」. + // + // The trail has no published-only answer to fall back to: + // withholding only the draft-save rows would hand a member + // a pruned log that reads as a true, complete one, the + // shape the ruling measured wrong. So the caller is asked + // {@link mayReadPendingDrafts} FIRST and refused by + // {@link refuseNonAuthoringCaller} — the refusal `/history` + // and `/diff` give, and `GET /meta/_drafts`'s shape — before + // the protocol is resolved (no 501-vs-200 probe), before the + // query is parsed, and before any item or event is read. + // Whoever it admits reads exactly what they read before, + // the per-caller refusal and the org scope below included. + // + // `auditCtx` is this door's one caller resolution; the org + // scope below reads the same value. + const auditCtx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + if (refuseNonAuthoringCaller(auditCtx, res, 'Reading a metadata item\'s audit trail')) return; const p = await this.resolveProtocol(environmentId, req); if (typeof p.auditMetaItem !== 'function') { // [#9426 / ADR-0110 D3] A MISS and a FAULT are different @@ -7628,10 +7685,14 @@ export class RestServer { } // [#8747] SCOPE THE READ. Without an organization this // route returned every tenant's audit rows for a - // `(type, name)` — measured, not inferred — and it carries - // no capability gate (unlike its `PUT` twin, which gates on - // `manage_metadata`), so the cohort was any authenticated - // principal of any tenant, on the published SDK surface. + // `(type, name)` — measured, not inferred — and it carried + // no capability gate then (unlike its `PUT` twin, which + // gates on `manage_metadata`), so the cohort was any + // authenticated principal of any tenant, on the published + // SDK surface. [#20441] It carries the authoring-door gate + // now, and the scope still matters: that gate admits a + // builder of ONE organization, never a reader of another's + // trail, so the tenant separation stays this scope's job. // // The organization comes from `resolveExecCtx`, which this // file already calls in 40+ handlers including the `PUT` @@ -7655,7 +7716,10 @@ export class RestServer { // hands back — the same reasoning the `/published` route // states below — not from the request payload. It is still // read on the two lines that need it. - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + // + // `auditCtx` is the caller resolved at the head of this door + // (#20441), not a second resolution. + // // The `(p as any)` casts this door carried came off when // `MetadataProtocol` declared `auditMetaItem` (the #11006 // pattern, same as the publish door below): the literal is @@ -7670,7 +7734,7 @@ export class RestServer { const auditRequest: AuditMetaItemRequest = { type: req.params.type, name: req.params.name, - organizationId: ctx?.tenantId ?? null, + organizationId: auditCtx?.tenantId ?? null, // Already finite or absent — the declared parse above // refuses anything else. ...(limit !== undefined ? { limit } : {}), @@ -8042,18 +8106,12 @@ export class RestServer { // read the active row and keep the pruned plain-read answer. // // `diffCtx` is this door's one caller resolution; the org - // partition below reads the same value. + // partition below reads the same value. The refusal is + // {@link refuseNonAuthoringCaller}, shared with `/history` + // and `/audit` (#20441) so the three cannot drift apart. const diffCtx = await this.resolveExecCtx(environmentId, req) .catch(rethrowAuthzStoreUnavailable); - if (!mayReadPendingDrafts(diffCtx)) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: 'Comparing a metadata item\'s stored versions requires an authoring capability (studio.access, setup.access or manage_metadata).', - }, - }); - return; - } + if (refuseNonAuthoringCaller(diffCtx, res, 'Comparing a metadata item\'s stored versions')) return; const p = await this.resolveProtocol(environmentId, req); if (!(p as any).diffMetaItem) { res.status(501).json({