Skip to content

Commit 89c2ba9

Browse files
hotlongclaude
andcommitted
test(rest): pin /audit as the third authoring door; changeset and docs
The real-stack authoring-door file now runs every refusal, builder and one-predicate pin over /diff, /history and /audit, with the drafts saved by an author. The census row for /audit turns authoring, and the two /audit route tests whose question is an admitted caller's now call as one. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ffcf087 commit 89c2ba9

7 files changed

Lines changed: 126 additions & 48 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
**`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.
6+
7+
Clause-②: no
8+
9+
- **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.
10+
- **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.
11+
- **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.
12+
13+
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.

‎content/docs/api/client-sdk.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 })
233233
// Introspection & governance
234234
const bad = await client.meta.getDiagnostics({ severity: 'error' });
235235
const refs = await client.meta.getReferences('object', 'account');
236+
// Authoring-only too, like diffItem: without studio.access, setup.access or manage_metadata → 403 FORBIDDEN
236237
const trail = await client.meta.getAudit('object', 'account', { limit: 20 });
237238
const tree = await client.meta.getBookTree('handbook');
238239

‎content/docs/ui/apps.mdx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -379,12 +379,13 @@ an app an author may not open is refused on these doors too. Reading a
379379
needs an authoring capability (`studio.access`, `setup.access` or
380380
`manage_metadata`: the check `GET /api/v1/meta/_drafts` makes). A caller
381381
without one is answered as if the parameter were absent: the published app,
382-
or `404` for an app that has never been published. `/diff`, and the change log
383-
`/history` beside it, need that capability outright: both read the version log,
384-
which records a draft save like any other, so they have no published-only
385-
answer to fall back to. A caller without one is refused with `403`, as
386-
`GET /api/v1/meta/_drafts` refuses, before anything is read — the same answer
387-
whether or not the app exists.
382+
or `404` for an app that has never been published. `/diff`, the change log
383+
`/history` beside it and the protection-audit trail `/audit` need that
384+
capability outright: the version log and the audit trail each record a draft
385+
save like any other, so none of them has a published-only answer to fall back
386+
to. A caller without one is refused with `403`, as `GET /api/v1/meta/_drafts`
387+
refuses, before anything is read — the same answer whether or not the app
388+
exists.
388389

389390
`visible` did **not** move server-side with them, and that asymmetry is
390391
deliberate: CEL is evaluated in the browser because server-side evaluation needs

‎packages/rest/src/meta-alternate-door-read-gates.test.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@
6060
* refuses — 403 `FORBIDDEN`, before any read. Everything this census says
6161
* about them holds for the callers they admit. `/layers` and
6262
* `?layers=true` keep the pruned plain-read answer for everyone.
63+
*
64+
* [#20441] **`/audit` is the third** (triage's grade 5871509797 carrying
65+
* ruling 5865708652 to it): `sys_metadata_audit` records a draft save with
66+
* `note: 'draft'`, its actor and its time, so it takes the same refusal
67+
* before any read.
6368
* - **`/references`** is declared exempt: it serves the identities of OTHER
6469
* items that point at this one, never a member of this item's document.
6570
*
@@ -358,9 +363,10 @@ type DoorKind = 'document' | 'stored' | 'events' | 'exempt';
358363
* envelope (its `item`).
359364
*/
360365
/**
361-
* `authoring` — [#20378] ruling 5865708652: the door refuses a caller who may
362-
* not read drafts (`readsDrafts`) with the `GET /meta/_drafts` 403, before any
363-
* read; the rest of its row holds for the callers it admits.
366+
* `authoring` — [#20378] ruling 5865708652 (and [#20441] its carriage to
367+
* `/audit`): the door refuses a caller who may not read drafts (`readsDrafts`)
368+
* with the `GET /meta/_drafts` 403, before any read; the rest of its row holds
369+
* for the callers it admits.
364370
*/
365371
interface Door { kind: DoorKind; suffix: string; query?: Record<string, string>; reason?: string; serves?: 'layers' | 'diff' | 'draft'; authoring?: true }
366372

@@ -373,7 +379,7 @@ const DOORS: Record<string, Door> = {
373379
'/published': { kind: 'document', suffix: '/published' },
374380
'/diff': { kind: 'stored', suffix: '/diff', serves: 'diff', authoring: true },
375381
'/history': { kind: 'events', suffix: '/history', authoring: true },
376-
'/audit': { kind: 'events', suffix: '/audit' },
382+
'/audit': { kind: 'events', suffix: '/audit', authoring: true },
377383
'/references': {
378384
kind: 'exempt',
379385
suffix: '/references',
@@ -669,6 +675,7 @@ describe(`[#20156] every alternate door answers what the plain read answers, or
669675
expect(protocol.getMetaItem).not.toHaveBeenCalled();
670676
expect(protocol.diffMetaItem).not.toHaveBeenCalled();
671677
expect(protocol.historyMetaItem).not.toHaveBeenCalled();
678+
expect(protocol.auditMetaItem).not.toHaveBeenCalled();
672679
return;
673680
}
674681
if (door.serves === 'draft' && CALLERS[callerName].ctx) {

‎packages/rest/src/meta-audit-capability-gap.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,10 @@ function boot(protocol: Record<string, unknown>) {
114114
// A RESOLVABLE execution context. Measured in #8747's pin on this same
115115
// route: an `undefined` context is refused by the anonymous floor
116116
// (`enforceAuth`) with a 401 BEFORE the handler body runs, so the branch
117-
// under test would never be reached.
118-
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', tenantId: 'org_alpha' });
117+
// under test would never be reached. [#20441] And an ADMITTED one: `/audit`
118+
// is an authoring door, so a caller without an authoring capability is
119+
// refused 403 before the protocol is resolved, and would not reach it either.
120+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', tenantId: 'org_alpha', systemPermissions: ['manage_metadata'] });
119121
rest.registerRoutes();
120122

121123
const found = (rest as any).getRoutes().find(

‎packages/rest/src/meta-history-diff-authoring-door.test.ts‎

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@
3636
* `ObjectStackProtocolImplementation` and the real routes — booted exactly as
3737
* `meta-draft-read-builder-gate.test.ts` boots it. The stubs are the auth
3838
* boundary (`resolveExecCtx`) and the service probe that says `tenancy` is off.
39+
*
40+
* ## [#20441] `/audit` is the third authoring door
41+
*
42+
* `saveMetaItem` appends a success row to `sys_metadata_audit` for every save,
43+
* a draft save included, and `auditMetaItem` serves its `note: 'draft'`, its
44+
* actor and its time. Measured on this harness before the fix (`main` at
45+
* `acd009521e`): the member read `note: 'draft'` and `actor: 'u_author'` for
46+
* `app/atlas` and `view/opportunity.pipeline`, and for the never-published
47+
* `app/beacon` and `view/opportunity.forecast`, whose plain read answers them
48+
* `404`, it read that event while a missing name read `{ events: [] }` — an
49+
* existence oracle. Triage's grade 5871509797 carried ruling 5865708652's
50+
* letter to this door: the same refusal, before any read. The three doors share
51+
* one refusal (`refuseNonAuthoringCaller`), and every pin below runs over all
52+
* three. The draft saves here are made by the `author` caller, so the actor a
53+
* refusal must never carry is a real one.
3954
*/
4055

4156
import { describe, it, expect, afterEach, vi } from 'vitest';
@@ -140,7 +155,7 @@ function makeRes() {
140155
const META = '/api/v1/meta';
141156

142157
/** The protocol members a refused caller must never reach. */
143-
const READS = ['getMetaItem', 'getMetaItemLayered', 'historyMetaItem', 'diffMetaItem'] as const;
158+
const READS = ['getMetaItem', 'getMetaItemLayered', 'historyMetaItem', 'diffMetaItem', 'auditMetaItem'] as const;
144159

145160
async function boot() {
146161
const engine = new ObjectQL();
@@ -189,19 +204,21 @@ async function boot() {
189204
as(who, 'GET', `${META}/:type/:name${suffix}`, { path: `${META}/${type}/${name}${suffix}`, params: { type, name }, query });
190205
/** `GET /meta/_drafts` — the door whose refusal these two now give. */
191206
const drafts = (who: CallerName) => as(who, 'GET', `${META}/_drafts`, { path: `${META}/_drafts` });
192-
const save = async (type: string, item: { name: string }, query: Record<string, string>) => {
193-
const res = await as('system', 'PUT', `${META}/:type/:name`, {
207+
const save = async (type: string, item: { name: string }, query: Record<string, string>, who: CallerName = 'system') => {
208+
const res = await as(who, 'PUT', `${META}/:type/:name`, {
194209
path: `${META}/${type}/${item.name}`, params: { type, name: item.name }, query, body: item,
195210
});
196211
if (res.statusCode !== 200) throw new Error(`seeding ${type}/${item.name} failed: ${JSON.stringify(res.body)}`);
197212
};
198213

214+
// The published rows are machine writes; every draft is an AUTHOR's save
215+
// (#20441: the actor `/audit` records, which a refusal must never carry).
199216
await save('app', ATLAS, {});
200-
await save('app', ATLAS_DRAFT, { mode: 'draft' });
201-
await save('app', BEACON_DRAFT, { mode: 'draft' });
217+
await save('app', ATLAS_DRAFT, { mode: 'draft' }, 'author');
218+
await save('app', BEACON_DRAFT, { mode: 'draft' }, 'author');
202219
await save('view', PIPELINE, {});
203-
await save('view', PIPELINE_DRAFT, { mode: 'draft' });
204-
await save('view', FORECAST_DRAFT, { mode: 'draft' });
220+
await save('view', PIPELINE_DRAFT, { mode: 'draft' }, 'author');
221+
await save('view', FORECAST_DRAFT, { mode: 'draft' }, 'author');
205222

206223
/** Spies on every protocol read a refused caller must never reach, armed AFTER seeding. */
207224
const spies = Object.fromEntries(READS.map((m) => [m, vi.spyOn(protocol, m)])) as Record<(typeof READS)[number], ReturnType<typeof vi.spyOn>>;
@@ -222,9 +239,13 @@ const navIds = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e
222239
/** The keys of a body and of its nested `error` — the envelope's SHAPE, never its prose. */
223240
const shape = (res: any) => ({ top: Object.keys(res.body ?? {}).sort(), error: Object.keys(res.body?.error ?? {}).sort() });
224241

225-
const AUTHORING_DOORS = ['/diff', '/history'] as const;
242+
const AUTHORING_DOORS = ['/diff', '/history', '/audit'] as const;
243+
/** The protocol read each authoring door makes for a caller it admits — the live-spy control. */
244+
const DOOR_READ = { '/diff': 'diffMetaItem', '/history': 'historyMetaItem', '/audit': 'auditMetaItem' } as const;
245+
/** What an event or version answer carries, and a refusal never does. */
246+
const ANSWER_KEYS = ['fromVersion', 'toVersion', 'events', 'added', 'changed', 'note', 'actor', 'occurredAt', 'u_author'];
226247

227-
describe('[#20378] a member without an authoring capability is refused /diff and /history — the /meta/_drafts refusal, before any read', () => {
248+
describe('[#20378 · #20441] a member without an authoring capability is refused /diff, /history and /audit — the /meta/_drafts refusal, before any read', () => {
228249
for (const suffix of AUTHORING_DOORS) {
229250
for (const type of Object.keys(SUBJECTS) as SubjectType[]) {
230251
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
245266
// `error` with a code and a message, and nothing beside it.
246267
expect(shape(res)).toEqual(shape(listing));
247268
// No item or version detail: not the name, not a draft
248-
// string, not a version, not an event.
269+
// string, not a version, not an event, not its actor.
249270
for (const name of Object.values(names)) expect(text(res)).not.toContain(name);
250271
for (const s of DRAFT_ONLY_TEXT) expect(text(res)).not.toContain(s);
251-
for (const k of ['fromVersion', 'toVersion', 'events', 'added', 'changed']) expect(text(res)).not.toContain(k);
272+
for (const k of ANSWER_KEYS) expect(text(res)).not.toContain(k);
252273
}
253274
// No existence oracle: the three answers are byte-identical.
254275
expect(answers[1].body).toEqual(answers[0].body);
@@ -260,7 +281,7 @@ describe('[#20378] a member without an authoring capability is refused /diff and
260281
// reading, not a harness that never sees a call.
261282
const builder = await door('author', suffix, type, names.published);
262283
expect(builder.statusCode).toBe(200);
263-
expect(spies[suffix === '/diff' ? 'diffMetaItem' : 'historyMetaItem']).toHaveBeenCalled();
284+
expect(spies[DOOR_READ[suffix]]).toHaveBeenCalled();
264285
}, 60_000);
265286
}
266287
}
@@ -281,18 +302,20 @@ describe('[#20378] a member without an authoring capability is refused /diff and
281302
expect(builder.statusCode).toBe(400);
282303
}, 60_000);
283304

284-
it('/history: an unparseable `limit` answers the member the same refusal — decided before the query parse', async () => {
285-
const { door } = await boot();
286-
const plain = await door('member', '/history', 'app', 'atlas');
287-
const res = await door('member', '/history', 'app', 'atlas', { limit: 'abc' });
288-
expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' });
289-
expect(res.body).toEqual(plain.body);
290-
const builder = await door('studioBuilder', '/history', 'app', 'atlas', { limit: 'abc' });
291-
expect(builder.statusCode).toBe(400);
292-
}, 60_000);
305+
for (const suffix of ['/history', '/audit'] as const) {
306+
it(`${suffix}: an unparseable \`limit\` answers the member the same refusal — decided before the query parse`, async () => {
307+
const { door } = await boot();
308+
const plain = await door('member', suffix, 'app', 'atlas');
309+
const res = await door('member', suffix, 'app', 'atlas', { limit: 'abc' });
310+
expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' });
311+
expect(res.body).toEqual(plain.body);
312+
const builder = await door('studioBuilder', suffix, 'app', 'atlas', { limit: 'abc' });
313+
expect(builder.statusCode).toBe(400);
314+
}, 60_000);
315+
}
293316
});
294317

295-
describe('[#20378] builders read /diff and /history as before — the control', () => {
318+
describe('[#20378 · #20441] builders read /diff, /history and /audit as before — the control', () => {
296319
it('/diff app: every builder reads the draft version; the author reads it whole, every other builder pruned as the plain read prunes', async () => {
297320
const { door, draftVersion } = await boot();
298321
const v = await draftVersion('app', 'atlas');
@@ -334,6 +357,26 @@ describe('[#20378] builders read /diff and /history as before — the control',
334357
}
335358
}
336359
}, 60_000);
360+
361+
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 () => {
362+
const { door } = await boot();
363+
for (const who of BUILDERS) {
364+
for (const [type, name] of [['app', 'atlas'], ['view', 'opportunity.pipeline']] as const) {
365+
const res = await door(who, '/audit', type, name);
366+
expect(res.statusCode, `${who} ${type}`).toBe(200);
367+
// The published save and the draft save, each `allowed`.
368+
const notes = (res.body?.events ?? []).map((e: any) => `${e.operation}:${e.outcome}:${e.note}`).sort();
369+
expect(notes, `${who} ${type}`).toEqual(['save:allowed:active', 'save:allowed:draft']);
370+
const draft = res.body.events.find((e: any) => e.note === 'draft');
371+
expect(draft?.actor, `${who} ${type}`).toBe('u_author');
372+
}
373+
for (const [type, name] of [['app', 'beacon'], ['view', 'opportunity.forecast']] as const) {
374+
const res = await door(who, '/audit', type, name);
375+
expect(res.statusCode, `${who} ${type}`).toBe(200);
376+
expect(res.body?.events?.map((e: any) => e.note), `${who} ${type}`).toEqual(['draft']);
377+
}
378+
}
379+
}, 60_000);
337380
});
338381

339382
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
363406
}, 60_000);
364407
});
365408

366-
describe('[#20378] one predicate: /diff and /history refuse exactly the callers /meta/_drafts refuses', () => {
367-
it('for each caller, the three doors agree', async () => {
409+
describe('[#20378 · #20441] one predicate: /diff, /history and /audit refuse exactly the callers /meta/_drafts refuses', () => {
410+
it('for each caller, the four doors agree', async () => {
368411
const { door, drafts } = await boot();
369412
for (const who of ['member', ...BUILDERS] as const) {
370413
const refused = (await drafts(who)).statusCode === 403;

0 commit comments

Comments
 (0)