Skip to content

feat(rest): gate /meta publish and rollback on manage_metadata (#8919) - #8977

Merged
qq9340100 merged 3 commits into
mainfrom
claude/issue-8919-meta-promotion-capability-gate
Aug 16, 2026
Merged

feat(rest): gate /meta publish and rollback on manage_metadata (#8919)#8977
qq9340100 merged 3 commits into
mainfrom
claude/issue-8919-meta-promotion-capability-gate

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Fixes#8919

Gates POST /meta/:type/:name/publish and POST /meta/:type/:name/rollback on the ADR-0066 D1 manage_metadata capability — the same four lines the four sibling metadata write doors already carry — plus an enumeration pin that makes the convention checkable instead of imitated.

1. The measurement came first, and it confirms the card

Driven through a composed RestServer down to the protocol layer, three principal shapes, both verbs. protocol reached means publishMetaItem / rollbackMetaItem was actually called, not merely that a status came back.

principalpublishrollback
anonymous401, protocol NOT reached401, protocol NOT reached
authenticated, nomanage_metadata200, protocol REACHED200, protocol REACHED
authenticated, manage_metadata (control)200, protocol reached200, protocol reached

The middle row is the defect: any authenticated principal holding no authoring capability at all could promote a draft somebody else authored to live, or restore any historical version over the live row.

⛔ No live exploit against a deployed host is claimed — this is a composed-host measurement, and audit rows are written either way, so the action stays traceable after the fact.

2. One claim in the dispatch is REFUTED — anonymous was never reaching these doors

The dispatch brief reported that enforceAuth is called per route and that no call falls between the two registrations, concluding these doors ran neither guard. The first half is right about the call sites and the conclusion does not follow.

registerMetadataEndpoints wraps the route registrar for the whole of metadata registration, so every route registered by registerMetadataEndpointsInner inherits the anonymous-deny. That inner registrar spans the publish and rollback registrations, so both doors were already refusing anonymous callers. rest-meta-auth.test.ts documents the umbrella, and meta-item-save-capability-gate.test.ts already pinned it for the save door.

So the hole was narrower than the brief stated — the authenticated-but-uncapable cohort, not "no guard at all" — and exactly as wide as the issue body itself said: "the reachable cohort is any authenticated principal, holding no authoring capability at all."

3. No legitimate caller depends on the gap (measured, not assumed)

The stop-and-report condition asked whether the Studio save-then-publish loop publishes through a context lacking manage_metadata. It does not, and the reason is structural: the loop's first step is already gated.

shipped shapestep 1 save ?mode=draftstep 2 publish
admin_full_access (the Studio user)200200
organization_admin403 today200 today
member_default-shaped403 today200 today

admin_full_access is the only shipped set carrying studio.access, and it carries manage_metadata too, so the designer loop clears both steps on one capability. The only callers the gap benefited were the ones already refused the authoring door — able to promote a draft they could not have written. Gating publish cannot break a loop whose save step was already gated.

protocol.ts corroborates the loop shape in its own words: "saves ?mode=draft and then POSTs /publish — which is exactly what Studio's designer surface does on every edit."

4. The PM mechanism hypothesis is CONFIRMED

#4463 D1 is the content gate, not the capability gate. assertRuntimeAuthoringRules takes { type, name, state, body, source?, organizationId? } — no caller, no principal, no capability. Neither publishMetaItem nor rollbackMetaItem accepts a caller argument at all; actor is audit attribution only.

Rollback is thinner still: it runs no content gate. Its only refusals are type-level (isOverlayAllowed to NOT_OVERRIDABLE) and the ADR-0010 lock, neither of which reads the caller. So nothing downstream was ever answering "may this principal press this button", and a valid-but-looser historical body passes straight through — which is why rollback is the sharper verb: the caller supplies toVersion, making it a mechanism for reverting security hardening.

5. No prior ruling leaves these doors open — checked

Read #6603 and #7019 in full, both bodies and every comment. The 2026-08-08 maintainer ruling chose Option B (refuse the write) for PUT /meta/:type/:name, and its dispatch scoped the dev to that one door: 「只动 packages/rest/src/rest-server.tsPUT /meta/:type/:name 那道门」. #7019 then extended the same gate to three further doors. Neither carves out the promotion verbs; they were never in scope, not deliberately exempted. The 2026-08-09 review is explicit that #6603's gate closes one door and that the threat model needs the others.

6. The enumeration pin is the part that stops this recurring

meta-write-door-capability-enumeration.test.ts (39 cases). The load-bearing assertion derives every mutating /meta route from the composed server's own route table and compares it against a declared list of six doors, so a new metadata write door fails the build until it is enumerated and its refusal asserted. The per-door cases then assert, for all six: capability-less caller refused with the ADR-0112 envelope (code AND status) and the protocol never called; studio.access / setup.access alone refused; manage_metadata, isSystem and admin_full_access still reach the protocol; anonymous still gets 401 from the umbrella.

Reverse verification — direction predicted in writing before running

Predicted: with both gates reverted, exactly 6 cases go red (publish and rollback in each of the two refusal blocks, plus the two 501-probe cases); every control, every umbrella case and the anti-drift assertion stay green.

Observed, with rest-server.ts restored from origin/main: 33 passed, 6 failed — precisely that set, AssertionError: expected 200 to be 403. Restored from the branch commit afterwards; git status clean, so the restore is byte-identical to the committed state.

Scope

Only the two handlers and the new test. ⛔ No author/publisher capability split (that needs a different declared capability and is a product call). ⛔ No wider sweep for other ungated doors — the enumeration shows this route family's write population is exactly these six, but other surfaces are a separate card. The gate is the sibling doors' idiom verbatim; the four existing doors are not refactored into a shared helper here.

Verification — union run at HEAD 13521ecb8 (after the final commit)

  • pnpm --filter @objectstack/rest test2001 passed (120 files); baseline before this change was 1962
  • pnpm --filter @objectstack/rest typecheck — clean
  • Gates derived with node scripts/pm/dispatch-gates.mjs against the actual changed paths, all green: check:authz-resolver, check:route-envelope, check:nul-bytes, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:objectui-changeset, check:query-options-erasure, check:durability-log-level, check:type-check-coverage, check:type-check-debt, and check-adr-0087-registration / check-changeset-no-major / check-cross-package-test-inputs / check-empty-changeset

check:type-check-debt initially went red: the new test file added +1 raw tsc error (TS2835, NodeNext relative import) against @objectstack/rest's frozen TEST_DEBT entry of 155. Fixed the error — the extensioned import is already this package's test convention — rather than raising the ledger, which is maintainer-only. Green at the head above.

A minor changeset is included; the behaviour change is breaking for any integration that publishes or rolls back metadata with a principal holding no authoring capability.

Generated by Claude Code


Generated by Claude Code

The four sibling metadata write doors demand ADR-0066 D1's `manage_metadata`
authoring capability; the two promotion verbs did not. Measured before the fix:
an authenticated principal holding no authoring capability reached both
`publishMetaItem` and `rollbackMetaItem` and got 200 — so it could promote a
draft somebody else authored to live, or restore any historical version as the
live row. Anonymous callers were already refused 401 by the `/meta` umbrella.
Adds the identical four-line gate to both doors, and an enumeration pin that
derives the metadata write doors from the composed server's own route table, so
a new mutating `/meta` route cannot arrive ungated and unnoticed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
…on pin
TS2835 under NodeNext — the ledger ratchet (check:type-check-debt) measured the
new test file as +1 raw tsc error in @objectstack/rest's TEST_DEBT entry. Fixing
the error is the author's remedy; raising the entry is maintainer-only. The
extensioned form is already this package's test convention.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
@vercel

vercelBot commented Aug 16, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 16, 2026 3:38am

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The REST /meta publish and rollback doors carry no manage_metadata gate, so the authoring capability the PUT/DELETE doors enforce is reachable around

2 participants

@qq9340100@claude