Skip to content

Commit 4dc8a61

Browse files
fix(rest): the authenticated identity is the recorded actor on /meta writes (#7941) (#9119)
X-Actor no longer outranks the authenticated identity when stamping sys_metadata_audit.actor and sys_metadata_history.recorded_by. The header limb is removed rather than reordered, so a caller cannot choose the name an audit row records in any shape. The ordering was inert until #7749 fixed the producer; once the second limb produced a value, any holder of manage_metadata could sign somebody else's name to a metadata write. Attribution now reads the same resolveExecCtx the route's own capability gate reads, so it cannot drift from authorization. Maintainer ruling 2026-08-12, re-confirmed 2026-08-15, premised on a consumer census coming back empty. Census re-run over objectstack and objectui: no caller sets the header. The ruling's conditional carve-out for machine/system callers is therefore not taken. Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza Co-authored-by: Claude <noreply@anthropic.com>
1 parent 83c661d commit 4dc8a61

4 files changed

Lines changed: 145 additions & 48 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
**Audit attribution change — the recorded actor on `/meta` writes is now the authenticated identity, and `X-Actor` is ignored.** All five `/meta` write sites (save, delete/reset, publish, rollback, compound save) stamp `sys_metadata_audit.actor` and `sys_metadata_history.recorded_by` with the identity the request was actually authorized as. A request that sends `X-Actor` is recorded against its own authenticated caller, not the header's value. Maintainer ruling 2026-08-12 on #7941, re-confirmed 2026-08-15.
6+
7+
Why: the header used to outrank the authenticated identity. That ordering was inert for as long as the other limb produced nothing — `req.user` / `req.userId` are never set on this transport — so nothing depended on it. Fixing that producer (#7749) made the precedence load-bearing for the first time, and what it then meant was that any caller already holding `manage_metadata` could sign somebody else's name to a metadata write: the compliance trail answered "who *claimed* to change this" rather than "who changed this", which is the question #7749 was filed to make answerable. Attribution now cannot drift from authorization, because both read the same `resolveExecCtx` the route's own capability gate reads.
8+
9+
The header limb is **removed rather than reordered**. The ruling permitted keeping it for genuine machine/system callers with no authenticated user, but only if a consumer census showed that shape exists — it does not, so a caller cannot choose the recorded name in any shape, including on the machine-write path where there is no identity for the header to lose to.
10+
11+
Deliberately unchanged:
12+
13+
- **Real impersonation still attributes correctly.** The platform's impersonation is session-level (better-auth admin plugin, `sys_session.impersonated_by`), so `resolveExecCtx` already resolves to the impersonated user and their metadata writes are recorded against them. Nothing in that path went through `X-Actor`.
14+
- **Machine and anonymous writes.** No resolved principal still means no actor, so the protocol's own `'system'` / `NULL` defaults apply exactly as before — a machine write is never stamped with a real user.
15+
- **Sending `X-Actor` is not an error.** It is ignored, not rejected; no request that succeeds today starts failing.
16+
17+
Who is affected: any caller that relied on `X-Actor` to attribute a `/meta` write to somebody other than itself. The census over `objectstack` and `objectui` found no such caller — `objectui`'s `MetadataClient` can send the header through an optional `options.actor`, but nothing in that repo ever passes one, leaving that option inert against this server.

packages/rest/src/meta-write-actor-identity.test.ts

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,27 @@
3737
* 2. an internal system write (`isSystem`, no principal) still records
3838
* `'system'` / `NULL` — the fix must not stamp a real user onto machine
3939
* writes, and an anonymous caller is still refused outright;
40-
* 3. an explicit `X-Actor` behaves exactly as it did before, so this change
41-
* stays separable from the precedence question the issue raises (the
42-
* header still outranks the session identity — deliberately unchanged
43-
* here, see `resolveMetaWriteActor`).
40+
* 3. [#7941] an explicit `X-Actor` does NOT outrank the authenticated
41+
* identity — the admin's id is recorded, not the header's value;
42+
* 4. [#7941] and `X-Actor` is inert on the machine-write path too, where
43+
* there is no authenticated user for it to lose to.
4444
*
45-
* The final case closes the loop the other three stub: that a bearer token
46-
* really does land on `resolveExecCtx().userId` for this route, driven through
47-
* a real `authServiceProvider` with no stub in the identity path at all.
45+
* ## The precedence, and why cases 3–4 read the way they do
46+
*
47+
* This file originally pinned the OPPOSITE of case 3: the header won, pinned
48+
* as-is and explicitly not endorsed, labelled as the test to change once the
49+
* ordering was ruled on. Maintainer ruling #7941 (2026-08-12, re-confirmed
50+
* 2026-08-15) removed the header limb rather than reordering it — the recorded
51+
* actor is the identity the request was authorized as, so attribution cannot
52+
* drift from authorization and an audit row answers "who changed this" rather
53+
* than "who claimed to". Case 4 exists because the ruling allowed keeping the
54+
* header for genuine machine/system callers ONLY if a consumer census showed
55+
* that shape exists; it did not (nothing in `objectstack` or `objectui` sets
56+
* the header), so the carve-out was not taken and its absence is pinned.
57+
*
58+
* The final case closes the loop the others stub: that a bearer token really
59+
* does land on `resolveExecCtx().userId` for this route, driven through a real
60+
* `authServiceProvider` with no stub in the identity path at all.
4861
*/
4962

5063
import { describe, it, expect, afterEach } from 'vitest';
@@ -238,23 +251,44 @@ describe('[#7749] PUT /meta/:type/:name records the authenticated caller as the
238251
expect(rows).toHaveLength(0);
239252
}, 60_000);
240253

241-
it('an explicit X-Actor still wins over the session identity — precedence unchanged', async () => {
242-
// ⚠️ Pinned as-is, NOT endorsed: with the producer fixed this ordering
243-
// is live for the first time, so an authenticated caller can attribute
244-
// a write to somebody else. Changing whose name lands in an audit row
245-
// is a security-semantics decision for the audit contract, tracked
246-
// separately on #7749 — this test exists to keep that decision
247-
// separable from this fix, and it is the test to CHANGE when the
248-
// maintainer rules on the ordering.
254+
it('[#7941] an explicit X-Actor does NOT outrank the authenticated identity', async () => {
255+
// The flip. This test previously pinned the opposite — the header won,
256+
// pinned as-is and explicitly NOT endorsed, as the test to change when
257+
// the ordering was ruled on. It has been (#7941, 2026-08-12,
258+
// re-confirmed 2026-08-15): the recorded actor is the identity the
259+
// request was authorized as, so an admin holding `manage_metadata` can
260+
// no longer sign somebody else's name to a metadata write.
249261
const { engine, route } = await boot({
250262
userId: ADMIN, systemPermissions: ['manage_metadata'],
251263
});
252264

253265
const res = await putMeta(route, 'header_probe_view', { 'x-actor': 'user_42' });
254266
expect(res._json?.success).toBe(true);
255267

256-
expect(await auditActor(engine, 'header_probe_view')).toBe('user_42');
257-
expect(await historyActor(engine, 'header_probe_view')).toBe('user_42');
268+
// Both rows, together in one object for the same reason as the first
269+
// test: `user_42` must appear in NEITHER, and a flip that fixed only
270+
// one of the two defaults would otherwise pass on the first assertion.
271+
expect({
272+
audit: await auditActor(engine, 'header_probe_view'),
273+
history: await historyActor(engine, 'header_probe_view'),
274+
}).toEqual({ audit: ADMIN, history: ADMIN });
275+
}, 60_000);
276+
277+
it('[#7941] X-Actor cannot attribute a write that has NO authenticated user either', async () => {
278+
// The carve-out the ruling permitted only if the census showed the
279+
// shape exists — honouring the header for genuine machine/system
280+
// callers with no authenticated user. The census found no caller that
281+
// sets `X-Actor` at all, so the carve-out is not taken, and this pins
282+
// that: the header is inert on the machine-write path too, rather than
283+
// surviving as a fallback that reintroduces caller-chosen attribution
284+
// wherever the identity happens to be absent.
285+
const { engine, route } = await boot({ isSystem: true });
286+
287+
const res = await putMeta(route, 'system_header_probe_view', { 'x-actor': 'user_42' });
288+
expect(res._json?.success).toBe(true);
289+
290+
expect(await auditActor(engine, 'system_header_probe_view')).toBe('system');
291+
expect(await historyActor(engine, 'system_header_probe_view')).toBeFalsy();
258292
}, 60_000);
259293

260294
it('the identity comes from the REAL bearer→session→execCtx chain, no stub', async () => {
@@ -289,5 +323,20 @@ describe('[#7749] PUT /meta/:type/:name records the authenticated caller as the
289323
// falls through to the protocol's `'system'` / NULL defaults.
290324
const anon = await (rest as any).resolveMetaWriteActor(undefined, { headers: {} });
291325
expect(anon).toBeUndefined();
326+
327+
// [#7941] …and a header cannot fill that gap. Asserted at the producer
328+
// as well as through the route, because this is the seam where the
329+
// removed limb used to live: an `X-Actor` on an uncredentialed request
330+
// still resolves to nobody, and one sent ALONGSIDE a valid bearer
331+
// resolves to the bearer's identity, never the header's value.
332+
const headerOnly = await (rest as any).resolveMetaWriteActor(undefined, {
333+
headers: { 'x-actor': 'user_42' },
334+
});
335+
expect(headerOnly).toBeUndefined();
336+
337+
const headerVsBearer = await (rest as any).resolveMetaWriteActor(undefined, {
338+
headers: { authorization: `Bearer token-for-${ADMIN}`, 'x-actor': 'user_42' },
339+
});
340+
expect(headerVsBearer).toBe(ADMIN);
292341
}, 60_000);
293342
});

packages/rest/src/rest-server.ts

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,16 +1388,38 @@ export class RestServer {
13881388
* is memoized per request, so the three routes that already resolved a
13891389
* context for their gate pay nothing extra for this.
13901390
*
1391-
* ## Precedence — deliberately unchanged (#7749)
1391+
* ## Precedence — ruled on #7941: the authenticated identity wins
13921392
*
1393-
* `X-Actor` still outranks the authenticated identity, exactly as the
1394-
* expression above read. That ordering was masked while the other limbs
1395-
* were always `undefined`; it becomes load-bearing the moment this method
1396-
* produces one. Whether an authenticated caller may keep attributing a
1397-
* metadata write to somebody else by sending a header is a security
1398-
* semantics question for the audit contract, not something to settle as a
1399-
* side effect of fixing the producer — so it is measured and reported on
1400-
* the issue rather than quietly reordered here.
1393+
* `X-Actor` used to outrank the authenticated identity, as the expression
1394+
* above read. That ordering was masked while the other limbs were always
1395+
* `undefined`; fixing the producer made it load-bearing, and it meant any
1396+
* caller already holding `manage_metadata` could sign somebody else's name
1397+
* to a metadata write — `sys_metadata_audit.actor` and
1398+
* `sys_metadata_history.recorded_by` would name that other person.
1399+
*
1400+
* Maintainer ruling (2026-08-12, re-confirmed 2026-08-15), premised on a
1401+
* consumer census coming back empty: **the header limb is removed, not
1402+
* reordered.** The recorded actor is the identity the request was actually
1403+
* authorized as — the SAME `resolveExecCtx` the route's own
1404+
* `manage_metadata` gate reads a few lines earlier — so attribution can
1405+
* never drift from authorization, and the audit trail answers "who changed
1406+
* this" rather than "who claimed to".
1407+
*
1408+
* The census (`objectstack` + `objectui`, all paths) found no caller that
1409+
* sets the header: `objectui`'s `MetadataClient` can send it via an
1410+
* optional `options.actor`, but nothing in that repo ever passes one. So
1411+
* the ruling's conditional carve-out — keep honouring the header for
1412+
* genuine machine/system callers that have no authenticated user — is
1413+
* deliberately NOT taken: the census did not show that shape exists, and a
1414+
* limb nothing produces is the "declared but never written" shape
1415+
* Prime Directive #10 exists to keep out. A delegation path, if one is ever
1416+
* genuinely needed, is option C on #7941 (an explicit impersonation
1417+
* capability), not an ambient header.
1418+
*
1419+
* Note this does not disturb the platform's REAL impersonation: that is
1420+
* session-level (better-auth admin plugin, `sys_session.impersonated_by`),
1421+
* so `resolveExecCtx` already resolves to the impersonated user and an
1422+
* impersonated metadata write is still attributed to them.
14011423
*
14021424
* Anonymous / internal writes are unaffected: no resolved principal → no
14031425
* context → `undefined` → the protocol's `'system'` / `NULL` defaults still
@@ -1407,13 +1429,10 @@ export class RestServer {
14071429
environmentId: string | undefined,
14081430
req: any,
14091431
): Promise<string | undefined> {
1410-
const header = req?.headers?.['x-actor'] ?? req?.headers?.['X-Actor'];
1411-
// A well-formed header wins, as before. A PRESENT-but-unusable header
1412-
// (repeated → array, or empty) falls through to the session rather than
1413-
// suppressing attribution: recording the real caller beats recording
1414-
// `'system'` for a malformed request, and it keeps "no usable header →
1415-
// the authenticated identity" a single rule.
1416-
if (typeof header === 'string' && header) return header;
1432+
// [#7941] No header limb, by ruling. `X-Actor` on the request is
1433+
// ignored outright — it is not consulted for user principals, and not
1434+
// as a fallback for unauthenticated ones either, so there is no shape
1435+
// in which a caller can choose the name the audit row records.
14171436
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
14181437
const userId = (ctx as any)?.userId;
14191438
return typeof userId === 'string' && userId ? userId : undefined;
@@ -4911,8 +4930,9 @@ export class RestServer {
49114930
const parentVersion = typeof ifMatchHeader === 'string'
49124931
? ifMatchHeader.replace(/^"|"$/g, '') // strip ETag-style quotes
49134932
: undefined;
4914-
// [#7749] Header, else the request's authenticated identity — one
4915-
// producer, shared by every `/meta` write (see resolveMetaWriteActor).
4933+
// [#7749 producer, #7941 precedence] The request's authenticated
4934+
// identity — one producer, shared by every `/meta` write (see
4935+
// resolveMetaWriteActor). `X-Actor` is not consulted.
49164936
const actor = await this.resolveMetaWriteActor(environmentId, req);
49174937
// Phase 3a-destructive: `?force=true` opts past the
49184938
// destructive-change safety check. Accept any truthy
@@ -5075,15 +5095,16 @@ export class RestServer {
50755095
// Mirror saveMetaItem's OCC + actor plumbing (ADR-0008
50765096
// PR-10d wiring): `If-Match` pins the expected current
50775097
// version so concurrent edits get a 409 instead of a
5078-
// silent reset; `X-Actor` — or, since #7749, the request's
5079-
// authenticated identity — flows into the history
5080-
// tombstone row.
5098+
// silent reset; the request's authenticated identity flows
5099+
// into the history tombstone row (#7749 producer; #7941
5100+
// dropped the `X-Actor` limb that used to outrank it).
50815101
const ifMatchHeader = req.headers?.['if-match'] ?? req.headers?.['If-Match'];
50825102
const parentVersion = typeof ifMatchHeader === 'string'
50835103
? ifMatchHeader.replace(/^"|"$/g, '')
50845104
: undefined;
5085-
// [#7749] Header, else the request's authenticated identity — one
5086-
// producer, shared by every `/meta` write (see resolveMetaWriteActor).
5105+
// [#7749 producer, #7941 precedence] The request's authenticated
5106+
// identity — one producer, shared by every `/meta` write (see
5107+
// resolveMetaWriteActor). `X-Actor` is not consulted.
50875108
const actor = await this.resolveMetaWriteActor(environmentId, req);
50885109

50895110
// [#6877] `?state=` and the destructive `?dropStorage=`
@@ -5314,8 +5335,9 @@ export class RestServer {
53145335
});
53155336
return;
53165337
}
5317-
// [#7749] Header, else the request's authenticated identity — one
5318-
// producer, shared by every `/meta` write (see resolveMetaWriteActor).
5338+
// [#7749 producer, #7941 precedence] The request's authenticated
5339+
// identity — one producer, shared by every `/meta` write (see
5340+
// resolveMetaWriteActor). `X-Actor` is not consulted.
53195341
const actor = await this.resolveMetaWriteActor(environmentId, req);
53205342
const body = (req.body && typeof req.body === 'object') ? req.body : {};
53215343
const message = typeof body.message === 'string' ? body.message : undefined;
@@ -5424,8 +5446,9 @@ export class RestServer {
54245446
});
54255447
return;
54265448
}
5427-
// [#7749] Header, else the request's authenticated identity — one
5428-
// producer, shared by every `/meta` write (see resolveMetaWriteActor).
5449+
// [#7749 producer, #7941 precedence] The request's authenticated
5450+
// identity — one producer, shared by every `/meta` write (see
5451+
// resolveMetaWriteActor). `X-Actor` is not consulted.
54295452
const actor = await this.resolveMetaWriteActor(environmentId, req);
54305453
const message = typeof body.message === 'string' ? body.message : undefined;
54315454
// [#8805] The rollback half. Same argument as publish, one
@@ -5908,8 +5931,9 @@ export class RestServer {
59085931
const parentVersion = typeof ifMatchHeader === 'string'
59095932
? ifMatchHeader.replace(/^"|"$/g, '')
59105933
: undefined;
5911-
// [#7749] Header, else the request's authenticated identity — one
5912-
// producer, shared by every `/meta` write (see resolveMetaWriteActor).
5934+
// [#7749 producer, #7941 precedence] The request's authenticated
5935+
// identity — one producer, shared by every `/meta` write (see
5936+
// resolveMetaWriteActor). `X-Actor` is not consulted.
59135937
const actor = await this.resolveMetaWriteActor(environmentId, req);
59145938

59155939
// [#6877] The `typeof` guard below dropped a repeated

packages/rest/src/rest.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1806,7 +1806,8 @@ describe('createRestApiPlugin', () => {
18061806
});
18071807

18081808
// ---------------------------------------------------------------------------
1809-
// PUT /meta/:type/:name — If-Match → parentVersion / X-Actor → actor (PR-10d.4)
1809+
// PUT /meta/:type/:name — If-Match → parentVersion (PR-10d.4); the actor comes
1810+
// from the authenticated identity, never a header (#7941)
18101811
// ---------------------------------------------------------------------------
18111812

18121813
describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d.4)', () => {
@@ -1835,12 +1836,18 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d.
18351836

18361837
await route.handler(req, res);
18371838

1839+
// [#7941] `actor` is the authenticated identity, NOT the `x-actor` header
1840+
// this request also sends. The header is retained in the fixture on
1841+
// purpose: it is the one assertion that shows the header is ignored even
1842+
// when present, which is the whole content of the ruling. Asserting
1843+
// `parentVersion` and `actor` together also keeps the If-Match plumbing
1844+
// this test is named for covered by the same call.
18381845
expect(protocol.saveMetaItem).toHaveBeenCalledWith(
18391846
expect.objectContaining({
18401847
type: 'view',
18411848
name: 'cases',
18421849
parentVersion: 'sha256:abc',
1843-
actor: 'user_42',
1850+
actor: 'test-user',
18441851
}),
18451852
);
18461853
});

0 commit comments

Comments
 (0)