Skip to content

fix(rest): attribute a bearer-authenticated metadata write to its caller (#7749) - #7940

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-7749-meta-put-actor-identity
Aug 12, 2026
Merged

fix(rest): attribute a bearer-authenticated metadata write to its caller (#7749)#7940
hotlong merged 1 commit into
mainfrom
claude/issue-7749-meta-put-actor-identity

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#7749

Symptom

An admin's ordinary PUT /api/v1/meta/<type>/<name> was attributed to nobody: the sys_metadata_audit row recorded the sentinel actor: 'system' and the sys_metadata_history row recorded recorded_by: NULL. The real identity appeared only if the caller hand-set a non-standard X-Actor header — so the audit trail could not answer "who changed this" for any normal console or API client.

Premise re-verified

The filer verified five sites on 00e9196. Re-verified by text on current origin/main (2daafe1) — all five still carry the identical chain, at lines 5931 / 6050 / 6195 / 6249 / 6601:

#RouteHad a resolved ctx in scope already?
1PUT /meta/:type/:nameyes (capability gate)
2DELETE /meta/:type/:nameyes (capability gate)
3POST /meta/:type/:name/publishno
4POST /meta/:type/:name/rollbackno
5PUT /meta/:type/:section/:nameyes (capability gate)

Root cause — a fallback chain with no producer

Each site resolved the actor inline as

req.headers['x-actor']??req.headers['X-Actor']??req.user?.id??req.userId

and nothing on this transport ever sets req.user or req.userId. REST resolves identity through resolveExecCtx (better-auth → resolveAuthzContext), which puts it on the returned ExecutionContext and never back onto the raw request. So with no header the expression yielded undefined and the protocol's own defaults took over — recordMetadataAudit's actor ?? 'system' and #4556's actor ?? null. The bearer token was validated; its identity simply never reached the handlers reading for it.

The fix — one producer, not a sixth limb

The two dead limbs are replaced, not widened with a third (which would leave the same "a value everything reads and nothing writes" shape one level down). All five sites now call one shared private producer, resolveMetaWriteActor, which reads the same identity resolution the route's own manage_metadata capability gate reads a few lines earlier. Two consequences worth naming:

resolveExecCtx is memoized per request, so sites 1/2/5 pay nothing extra; sites 3/4 (which had no gate) resolve once.

Deliberately NOT changed — the precedence question

X-Actorstill outranks the authenticated identity, exactly as the original expression read. That ordering was masked while the other limbs were always undefined; fixing the producer makes it load-bearing for the first time, which means an authenticated caller can attribute a metadata write to somebody else by sending a header.

Changing whose name lands in an audit row is a security-semantics decision, not a bug fix, so it is measured and reported on the issue rather than settled here. Measurement: nothing in this repository sets X-Actor outside tests — the only non-test references are the five consumer sites themselves, two CHANGELOG entries, and the QA checklist, which describes the intended contract as "resolved from X-Actor / the session identity, never anonymous". The console (objectui) is a separate repository and is not present in this checkout, so its half of the measurement is unverified here. Full detail is in the report on #7749.

Tests

New packages/rest/src/meta-write-actor-identity.test.ts boots a real better-sqlite3 :memory: engine, the realsys_metadata* object definitions, a realObjectStackProtocolImplementation and the real route, then asserts the persisted rows rather than the call arguments. That matters: the two defaults that swallowed the identity live downstream of REST and differ ('system' vs NULL), so a mock-protocol test asserting "an actor field was passed" would prove neither row, and a fix satisfying only one of them would pass.

  1. authenticated admin, noX-Actorboth rows carry the admin's id (asserted together in one object, so half a fix cannot pass on the first assertion alone);
  2. an internal system write (isSystem, no principal) still records 'system' / NULL, and an anonymous caller is still refused outright (401) with no row written;
  3. an explicit X-Actor behaves exactly as before — the test to change when the maintainer rules on the ordering;
  4. a fifth case removes even the resolveExecCtx stub and drives a real authServiceProvider, pinning the bearer → session → execCtx.userId → actor chain end to end.

One pre-existing assertion in rest.test.ts (expect(arg).not.toHaveProperty('actor')) encoded the bug and is updated to assert the session identity instead.

Reverse-verification (measured)

With the producer fix reverted and the tests unchanged:

CaseRevertedWith fix
admin, no X-Actor🔴 { audit: 'system', history: null }🟢 { audit: 'usr_admin_7749', history: 'usr_admin_7749' }
internal system write🟢 'system' / NULL🟢 unchanged
anonymous caller🟢 401, no row🟢 unchanged
explicit X-Actor🟢 'user_42'🟢 unchanged

The reverted admin case reproduces the issue's symptom exactly.

Gates

  • packages/rest suite: 93 files / 1524 tests, all green
  • pnpm check:authz-resolver, check:cross-package-test-inputs, check:meta-type-normalized, check:route-envelope, check:nul-bytes — all pass (route-envelope ratchet unmoved: stringError 44 / siblingCode 77)
  • pnpm check:type-check-debt (the TypeScript Type Check ratchet, run with the full closure built): the new test file initially added +5 to @objectstack/rest TEST_DEBT (155 → 160). Those five were fixed in the test — an explicit .js import specifier and the required packageId argument to registry.registerObject — bringing the package back to exactly 155. No ledger entry was raised. Final: OK — 36 ledger entries re-measured, none above its recorded number.
  • eslint --no-inline-config on all changed files: clean

File surface

packages/rest/src/rest-server.ts, packages/rest/src/rest.test.ts, the new test file, and the changeset. Nothing outside the declared surface; packages/runtime/src/http-dispatcher.ts was not touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WVchQDTf3UjRFWY3JPkdki


Generated by Claude Code

…ler (#7749)
An admin's ordinary `PUT /api/v1/meta/<type>/<name>` was attributed to
nobody: the `sys_metadata_audit` row recorded the sentinel `actor:
'system'` and the `sys_metadata_history` row recorded `recorded_by:
NULL`. The real identity appeared only when the caller hand-set a
non-standard `X-Actor` header, so the audit trail could not answer "who
changed this" for any normal console or API client.
The cause was a fallback chain with no producer. Five `/meta` write
sites — save, delete, publish, rollback and the compound save — each
resolved the actor inline as
req.headers['x-actor'] ?? req.headers['X-Actor']
?? req.user?.id ?? req.userId
and nothing on this transport ever sets `req.user` or `req.userId`:
REST resolves identity through `resolveExecCtx` (better-auth →
`resolveAuthzContext`), which puts it on the returned ExecutionContext
and never back onto the raw request. The bearer token was validated —
its identity simply never reached the handlers that read for it.
Rather than widen the chain with a third limb (which would leave the
same "a value everything reads and nothing writes" shape one level
down), the two dead limbs are replaced by a single shared producer,
`resolveMetaWriteActor`, reading the SAME identity resolution the
route's own `manage_metadata` capability gate reads a few lines
earlier. The caller a write is ATTRIBUTED to can no longer drift from
the caller it was AUTHORIZED against, and all five sites share one rule
instead of five copies — which also means the audit rows #7748 will add
to publish and rollback inherit the fix rather than the bug.
Deliberately unchanged: `X-Actor` still outranks the authenticated
identity, exactly as the original expression read. That ordering was
masked while the other limbs were always `undefined` and becomes
load-bearing now; whether an authenticated caller may keep attributing
a write to somebody else is a security-semantics decision for the audit
contract, measured and reported on the issue rather than settled as a
side effect here. Also unchanged: anonymous and internal system writes
resolve no principal, so they still record `'system'` / `NULL` — a
machine write is never stamped with a real user.
Tests: `meta-write-actor-identity.test.ts` boots a real better-sqlite3
engine, the real `sys_metadata*` objects and a real protocol, then
asserts the PERSISTED rows rather than the call arguments — because the
two defaults that swallowed the identity differ ('system' vs NULL) and
a fix satisfying only one of them would otherwise pass. Reverse-verified:
with the producer reverted the admin case reads
`{ audit: 'system', history: null }` while the system-write, anonymous
and explicit-`X-Actor` cases stay green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WVchQDTf3UjRFWY3JPkdki
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 7:03am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx(via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx(via @objectstack/rest)
  • content/docs/api/index.mdx(via @objectstack/rest)
  • content/docs/permissions/authentication.mdx(via @objectstack/rest)
  • content/docs/permissions/system-context.mdx(via packages/rest)
  • content/docs/plugins/index.mdx(via @objectstack/rest)
  • content/docs/plugins/packages.mdx(via @objectstack/rest)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/rest)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/rest)
  • content/docs/releases/v12.mdx(via @objectstack/rest)
  • content/docs/releases/v17.mdx(via @objectstack/rest)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@hotlong
hotlong marked this pull request as ready for review August 12, 2026 07:26
@hotlong
hotlong added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 54adb1fAug 12, 2026
26 checks passed
@hotlong
hotlong deleted the claude/issue-7749-meta-put-actor-identity branch August 12, 2026 07:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A bearer-authenticated admin metadata write is stamped actor: 'system'req.user / req.userId are unset on the /meta PUT path

2 participants

@hotlong@claude