Skip to content

Commit ffcf087

Browse files
hotlongclaude
andcommitted
fix(rest): /meta/:type/:name/audit is an authoring door, refused as /history and /diff refuse
A caller that mayReadPendingDrafts does not admit is refused 403 FORBIDDEN before the protocol is resolved, the query parsed or any event read. The refusal is one shared helper, refuseNonAuthoringCaller, now used by the /history, /diff and /audit doors alike. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
1 parent acd0095 commit ffcf087

1 file changed

Lines changed: 87 additions & 27 deletions

File tree

‎packages/rest/src/rest-server.ts‎

Lines changed: 87 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)