@@ -1700,6 +1700,39 @@ function mayReadPendingDrafts(caller: unknown): boolean {
17001700 return isObjectSchemaMaskExempt(caller);
17011701}
17021702
1703+ /**
1704+ * [#20378 · #20441] THE AUTHORING-DOOR REFUSAL — ruling 5865708652 (letter B),
1705+ * carried to `/audit` by triage's grade 5871509797. Sends it and answers `true`
1706+ * when {@link mayReadPendingDrafts} does not admit `caller`; answers `false`,
1707+ * sending nothing, when it does. The `refuseRepeatedQueryParams` convention:
1708+ * `if (refuseNonAuthoringCaller(ctx, res, …)) return;`.
1709+ *
1710+ * The item-scoped doors that read an AUTHORING LOG ask it first, before the
1711+ * protocol is resolved, before the query is parsed and before any item or
1712+ * event is read: `/history` and `/diff` read `sys_metadata_history`, and
1713+ * `/audit` reads `sys_metadata_audit`. Both logs record a DRAFT save exactly
1714+ * as they record an active one, so they have no published-only answer to fall
1715+ * back to, and a caller who may not read pending drafts is refused as
1716+ * `GET /meta/_drafts` refuses them: 403 `FORBIDDEN`, the same nested
1717+ * envelope. The answer is the same for an item that exists, one that does
1718+ * not and a draft-only one, so the door is no existence oracle.
1719+ *
1720+ * `reading` names THE DOOR and never drafts: a refusal worded about drafts
1721+ * would read as "this item has one". This is ONE function so the three doors
1722+ * cannot drift apart in their predicate, status, code or envelope; only the
1723+ * door's own name differs between them.
1724+ */
1725+ function refuseNonAuthoringCaller(caller: unknown, res: any, reading: string): boolean {
1726+ if (mayReadPendingDrafts(caller)) return false;
1727+ res.status(403).json({
1728+ error: {
1729+ code: 'FORBIDDEN',
1730+ message: `${reading} requires an authoring capability (studio.access, setup.access or manage_metadata).`,
1731+ },
1732+ });
1733+ return true;
1734+ }
1735+
17031736/**
17041737 * [#20156] What the per-caller read gate of `GET /meta/:type/:name` answers for
17051738 * ONE document — see {@link RestServer.metaItemReadGate}, the one place it is
@@ -7375,18 +7408,12 @@ export class RestServer {
73757408 // the active row and keep the pruned plain-read answer.
73767409 //
73777410 // `historyCtx` is this door's one caller resolution; the org
7378- // partition below reads the same value.
7411+ // partition below reads the same value. The refusal is
7412+ // {@link refuseNonAuthoringCaller}, shared with `/diff` and
7413+ // `/audit` (#20441) so the three cannot drift apart.
73797414 const historyCtx = await this.resolveExecCtx(environmentId, req)
73807415 .catch(rethrowAuthzStoreUnavailable);
7381- if (!mayReadPendingDrafts(historyCtx)) {
7382- res.status(403).json({
7383- error: {
7384- code: 'FORBIDDEN',
7385- message: 'Reading a metadata item\'s version history requires an authoring capability (studio.access, setup.access or manage_metadata).',
7386- },
7387- });
7388- return;
7389- }
7416+ if (refuseNonAuthoringCaller(historyCtx, res, 'Reading a metadata item\'s version history')) return;
73907417 const p = await this.resolveProtocol(environmentId, req);
73917418 // The cast came off when `MetadataProtocol` declared
73927419 // `historyMetaItem` (#12005 — the #11006 pattern, exactly
@@ -7543,13 +7570,44 @@ export class RestServer {
75437570 // reset attempts, both allowed and denied) so Studio's "审计
75447571 // 日志 / Audit log" tab can show who tried what and whether
75457572 // a lock blocked it. Empty array on environments where the
7546- // table is not yet provisioned.
7573+ // table is not yet provisioned. An AUTHORING door (#20441): a
7574+ // caller without an authoring capability is refused 403.
75477575 registerPerItemRoute({
75487576 method: 'GET',
75497577 path: `${metaPath}/:type/:name/audit`,
75507578 handler: async (req: any, res: any) => {
75517579 try {
75527580 const environmentId = isScoped ? req.params?.environmentId : undefined;
7581+ // [#20441] AN AUTHORING DOOR — ruling 5865708652 (letter B),
7582+ // carried to this door by triage's grade 5871509797.
7583+ // `saveMetaItem` appends a success row to
7584+ // `sys_metadata_audit` for EVERY save, a draft save
7585+ // included, and `auditMetaItem` serves its `note: 'draft'`,
7586+ // its actor and its time. So this trail, served to a caller
7587+ // who may not read pending drafts, disclosed that an item
7588+ // had unpublished authoring work, who saved it and when —
7589+ // and for an item with nothing published, that it exists at
7590+ // all, where the plain read answers `404` (ADR-0045 §3).
7591+ // ADR-0106 D4: 「draft/preview reads are admin-gated
7592+ // upstream」.
7593+ //
7594+ // The trail has no published-only answer to fall back to:
7595+ // withholding only the draft-save rows would hand a member
7596+ // a pruned log that reads as a true, complete one, the
7597+ // shape the ruling measured wrong. So the caller is asked
7598+ // {@link mayReadPendingDrafts} FIRST and refused by
7599+ // {@link refuseNonAuthoringCaller} — the refusal `/history`
7600+ // and `/diff` give, and `GET /meta/_drafts`'s shape — before
7601+ // the protocol is resolved (no 501-vs-200 probe), before the
7602+ // query is parsed, and before any item or event is read.
7603+ // Whoever it admits reads exactly what they read before,
7604+ // the per-caller refusal and the org scope below included.
7605+ //
7606+ // `auditCtx` is this door's one caller resolution; the org
7607+ // scope below reads the same value.
7608+ const auditCtx = await this.resolveExecCtx(environmentId, req)
7609+ .catch(rethrowAuthzStoreUnavailable);
7610+ if (refuseNonAuthoringCaller(auditCtx, res, 'Reading a metadata item\'s audit trail')) return;
75537611 const p = await this.resolveProtocol(environmentId, req);
75547612 if (typeof p.auditMetaItem !== 'function') {
75557613 // [#9426 / ADR-0110 D3] A MISS and a FAULT are different
@@ -7628,10 +7686,14 @@ export class RestServer {
76287686 }
76297687 // [#8747] SCOPE THE READ. Without an organization this
76307688 // route returned every tenant's audit rows for a
7631- // `(type, name)` — measured, not inferred — and it carries
7632- // no capability gate (unlike its `PUT` twin, which gates on
7633- // `manage_metadata`), so the cohort was any authenticated
7634- // principal of any tenant, on the published SDK surface.
7689+ // `(type, name)` — measured, not inferred — and it carried
7690+ // no capability gate then (unlike its `PUT` twin, which
7691+ // gates on `manage_metadata`), so the cohort was any
7692+ // authenticated principal of any tenant, on the published
7693+ // SDK surface. [#20441] It carries the authoring-door gate
7694+ // now, and the scope still matters: that gate admits a
7695+ // builder of ONE organization, never a reader of another's
7696+ // trail, so the tenant separation stays this scope's job.
76357697 //
76367698 // The organization comes from `resolveExecCtx`, which this
76377699 // file already calls in 40+ handlers including the `PUT`
@@ -7655,7 +7717,11 @@ export class RestServer {
76557717 // hands back — the same reasoning the `/published` route
76567718 // states below — not from the request payload. It is still
76577719 // read on the two lines that need it.
7658- const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable);
7720+ //
7721+ // `auditCtx` is the caller resolved at the head of this door
7722+ // (#20441), not a second resolution — `resolveExecCtx` is
7723+ // memoised per request (WeakMap keyed by `req`) anyway.
7724+ //
76597725 // The `(p as any)` casts this door carried came off when
76607726 // `MetadataProtocol` declared `auditMetaItem` (the #11006
76617727 // pattern, same as the publish door below): the literal is
@@ -7670,7 +7736,7 @@ export class RestServer {
76707736 const auditRequest: AuditMetaItemRequest = {
76717737 type: req.params.type,
76727738 name: req.params.name,
7673- organizationId: ctx ?.tenantId ?? null,
7739+ organizationId: auditCtx ?.tenantId ?? null,
76747740 // Already finite or absent — the declared parse above
76757741 // refuses anything else.
76767742 ...(limit !== undefined ? { limit } : {}),
@@ -8042,18 +8108,12 @@ export class RestServer {
80428108 // read the active row and keep the pruned plain-read answer.
80438109 //
80448110 // `diffCtx` is this door's one caller resolution; the org
8045- // partition below reads the same value.
8111+ // partition below reads the same value. The refusal is
8112+ // {@link refuseNonAuthoringCaller}, shared with `/history`
8113+ // and `/audit` (#20441) so the three cannot drift apart.
80468114 const diffCtx = await this.resolveExecCtx(environmentId, req)
80478115 .catch(rethrowAuthzStoreUnavailable);
8048- if (!mayReadPendingDrafts(diffCtx)) {
8049- res.status(403).json({
8050- error: {
8051- code: 'FORBIDDEN',
8052- message: 'Comparing a metadata item\'s stored versions requires an authoring capability (studio.access, setup.access or manage_metadata).',
8053- },
8054- });
8055- return;
8056- }
8116+ if (refuseNonAuthoringCaller(diffCtx, res, 'Comparing a metadata item\'s stored versions')) return;
80578117 const p = await this.resolveProtocol(environmentId, req);
80588118 if (!(p as any).diffMetaItem) {
80598119 res.status(501).json({
0 commit comments