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
13 changes: 13 additions & 0 deletions .changeset/20441-audit-authoring-door.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
13 changes: 7 additions & 6 deletions content/docs/ui/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
];

Expand Down
15 changes: 11 additions & 4 deletions packages/rest/src/meta-alternate-door-read-gates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<string, string>; reason?: string; serves?: 'layers' | 'diff' | 'draft'; authoring?: true }

Expand All @@ -373,7 +379,7 @@ const DOORS: Record<string, Door> = {
'/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',
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions packages/rest/src/meta-audit-capability-gap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,10 @@ function boot(protocol: Record<string, unknown>) {
// 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(
Expand Down
91 changes: 67 additions & 24 deletions packages/rest/src/meta-history-diff-authoring-door.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, string>) => {
const res = await as('system', 'PUT', `${META}/:type/:name`, {
const save = async (type: string, item: { name: string }, query: Record<string, string>, 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<typeof vi.spyOn>>;
Expand All @@ -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 () => {
Expand All @@ -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);
Expand All @@ -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);
}
}
Expand All @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading