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/20378-diff-history-authoring-doors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/rest": patch
---

**`GET /api/v1/meta/:type/:name/diff` and `GET /api/v1/meta/:type/:name/history` are now authoring doors: a caller without an authoring capability is refused, as `GET /api/v1/meta/_drafts` refuses.** Before this release, any signed-in caller who could open an item could read its version diff and its change history. Both doors read the metadata version log, which records a draft save exactly as it records a published save. So a member could read an item's unpublished draft through `/diff`, either by naming the draft save's version in `from`/`to` or through the default range once a draft was pending. Through `/history`, the same member could read the draft-save events. This follows the maintainer's ruling on #20378 (letter B, comment 5865708652), which pulls both doors back into the declared contract: draft and preview reads are admin-gated upstream (ADR-0106 D4). It narrows the earlier ruling that let every caller who may open an app read `/diff` pruned, for these two doors only.

Clause-②: no

- **Who may read them:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts` 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 query is parsed and before any item or version 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 item name, version or event. The message names the door, not drafts.
- **Unchanged:** callers with an authoring capability read both doors exactly as before, per-caller pruning included: on `/diff`, whoever may save an app reads both sides whole, and any other admitted caller reads them pruned. `/layers` and the deprecated `?layers=true` read the active row, so they keep answering every caller who may open the app with the pruned plain-read answer. `/audit` is unchanged.

A client that read `/diff` or `/history` as a member now receives `403 FORBIDDEN`. To read them, 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 @@ -227,6 +227,7 @@ const view = await client.meta.getItem('view', 'crm_lead.pipeline');
// Per-item draft lifecycle (ADR-0033)
await client.meta.publishItem('object', 'account', { message: 'go live' });
await client.meta.rollbackItem('object', 'account', 3);
// Authoring-only, like getHistory and listDrafts: without studio.access, setup.access or manage_metadata → 403 FORBIDDEN
const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 });

// Introspection & governance
Expand Down
7 changes: 6 additions & 1 deletion content/docs/ui/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,12 @@ 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.
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.

`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
51 changes: 45 additions & 6 deletions packages/rest/src/meta-alternate-door-read-gates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@
* answers them what the plain read answers them, byte for byte.
* - **`/history` and `/audit` serve events, never a body**: they refuse where
* the plain read refuses the item whole, and otherwise serve the events.
*
* [#20378] **`/diff` and `/history` are AUTHORING doors** (ruling
* 5865708652, letter B, which narrows ruling 5856774816 item 2 for these
* two doors only). Both read `sys_metadata_history`, where a draft save is
* recorded exactly as an active save, so a caller who may not read drafts
* (`readsDrafts` below) is refused them exactly as `GET /meta/_drafts`
* 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.
* - **`/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 @@ -348,7 +357,12 @@ type DoorKind = 'document' | 'stored' | 'events' | 'exempt';
* side, a `diff` of two versions, or the pending `draft` in the plain read's
* envelope (its `item`).
*/
interface Door { kind: DoorKind; suffix: string; query?: Record<string, string>; reason?: string; serves?: 'layers' | 'diff' | 'draft' }
/**
* `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.
*/
interface Door { kind: DoorKind; suffix: string; query?: Record<string, string>; reason?: string; serves?: 'layers' | 'diff' | 'draft'; authoring?: true }

const DOORS: Record<string, Door> = {
'?layers=true': { kind: 'stored', suffix: '', query: { layers: 'true' }, serves: 'layers' },
Expand All @@ -357,8 +371,8 @@ const DOORS: Record<string, Door> = {
// version — not the rendered world, which is `?preview=draft`.
'?state=draft': { kind: 'stored', suffix: '', query: { state: 'draft' }, serves: 'draft' },
'/published': { kind: 'document', suffix: '/published' },
'/diff': { kind: 'stored', suffix: '/diff', serves: 'diff' },
'/history': { kind: 'events', suffix: '/history' },
'/diff': { kind: 'stored', suffix: '/diff', serves: 'diff', authoring: true },
'/history': { kind: 'events', suffix: '/history', authoring: true },
'/audit': { kind: 'events', suffix: '/audit' },
'/references': {
kind: 'exempt',
Expand Down Expand Up @@ -643,6 +657,20 @@ describe(`[#20156] every alternate door answers what the plain read answers, or
protocol.getMetaItem.mockClear();
const res = await drive(rest, door.suffix, subject.type, subject.name, door.query);
const stored = find(subject.type, subject.name);
if (door.authoring && CALLERS[callerName].ctx && !CALLERS[callerName].readsDrafts) {
// [#20378] ruling 5865708652: an authoring door
// refuses a caller who may not read drafts exactly
// as `GET /meta/_drafts` does, whatever the plain
// read answers them — and before any read. (An
// anonymous caller is refused by the `/meta` auth
// gate first, as on every door.)
expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' });
for (const s of subject.secrets) expect(text(res)).not.toContain(s);
expect(protocol.getMetaItem).not.toHaveBeenCalled();
expect(protocol.diffMetaItem).not.toHaveBeenCalled();
expect(protocol.historyMetaItem).not.toHaveBeenCalled();
return;
}
if (door.serves === 'draft' && CALLERS[callerName].ctx) {
const asked = protocol.getMetaItem.mock.calls.map(([r]: any[]) => r?.state);
if (!CALLERS[callerName].readsDrafts) {
Expand Down Expand Up @@ -827,13 +855,20 @@ describe('[#20156] edges', () => {
const app = await save(rest, 'app', 'crm', clone(CRM_APP));
expect(envelope(app)).toEqual({ status: 403, code: 'FORBIDDEN' });
expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1);
// ...so every stored-version door serves them the plain read's pruned app.
// ...so every stored-version door serves them the plain read's pruned app
// — save `/diff`, an authoring door that refuses them outright
// ([#20378] ruling 5865708652: they may not read drafts either).
const plain = await drive(rest, '', 'app', 'crm');
const expected = navIds(plainItem(plain));
expect(expected).not.toEqual(navIds(CRM_APP));
for (const doorName of AUTHOR_EXEMPTION.doors) {
const door = DOORS[doorName];
const res = await drive(rest, door.suffix, 'app', 'crm', door.query);
if (door.authoring) {
expect(envelope(res), doorName).toEqual({ status: 403, code: 'FORBIDDEN' });
for (const s of ['nav_finance_ledger', 'nav_admin_runbook']) expect(text(res), doorName).not.toContain(s);
continue;
}
expect(res.statusCode, doorName).toBe(200);
for (const s of ['nav_finance_ledger', 'nav_admin_runbook']) expect(text(res), doorName).not.toContain(s);
if (door.serves === 'diff') {
Expand All @@ -848,8 +883,12 @@ describe('[#20156] edges', () => {
}
});

// [#20378] Both edges below drive an ADMITTED caller: a caller who may not
// read drafts is refused `/diff` and `/history` before any read (the
// census rows above), so the question these edges ask is only open for one
// who may.
it('a gated type with nothing behind the name: /diff answers the plain read\'s absence, /history its events', async () => {
const { rest, protocol } = setup('non-reader');
const { rest, protocol } = setup('reader');
protocol.getMetaItem.mockImplementation(async ({ type, name }: any) => ({ type: singular(type), name, item: undefined }));
const diff = await drive(rest, '/diff', 'doc', 'crm_admin_runbook');
const history = await drive(rest, '/history', 'doc', 'crm_admin_runbook');
Expand All @@ -861,7 +900,7 @@ describe('[#20156] edges', () => {
});

it('a type no per-caller gate judges costs its event and diff doors no extra read', async () => {
const { rest, protocol } = setup('non-reader');
const { rest, protocol } = setup('reader');
for (const suffix of ['/history', '/audit', '/diff']) {
protocol.getMetaItem.mockClear();
const res = await drive(rest, suffix, 'view', 'all_leads');
Expand Down
Loading
Loading