Cross-filed from GitHub Wilkes-Liberty/mcp_sentinel#51 (this drupal.org queue is the canonical record).

Summary

The moderation gate denies transitions TO a published state, and 1.11.0 extended publish-class treatment to paragraph edits on published hosts. But a plain entity save that omits moderation_state entirely — e.g. a JSON:API PATCH updating only a field — keeps the node's existing published state, is not classified as a publish transition, and saves directly to the live default revision. A governed content-tier agent can therefore alter live, rendered content on a published node without any human publish action.

Reproduction

1. Editorial workflow with all bundles moderated; agent role governed by a deny-publish policy profile (standard content-writer tier: no publish/unpublish transition permission).
2. As the governed consumer, PATCH /jsonapi/node// on a published node, changing any field (observed with an entity-reference field), with NO moderation_state in the payload.
3. Result: 200; the default (live) revision now carries the change; latest revision state is published. No forward revision, no 422, nothing for a human to approve.

Observed in production against 1.11.0 via bulk JSON:API updates: published nodes modified in place by a content-tier agent.

Expected

Parity with the paragraph gate: any save by a governed role that would modify the default revision of a published entity is publish-class. It should be re-drafted as a forward revision (draft) or refused with 422 — unless the save itself transitions the entity to a non-published state.

Notes

- The unchanged-value case is the subtle one: the gate appears value-aware on moderation_state, and published → published with the field absent from the payload reads as "no transition".
- Suggested test: under a deny-publish profile, PATCH a published node without moderation_state → assert the default revision is unchanged and a draft forward-revision exists (or the request 422s).
- Client-side mitigations (connector defaulting to draft) are defense-in-depth only; enforcement belongs in the module.

Comments

jmcerda created an issue. See original summary.

jmcerda’s picture

Status: Active » Needs review

Fix implemented: https://github.com/Wilkes-Liberty/mcp_sentinel/pull/54

Any save targeting a published state is now publish-class — the previously-allowed "in-place edit of already-published content" was exactly this bypass, and the validator docblock even blessed it; the production evidence here overturns that decision. The formerly-allowed case gets its own 422 with the remedy in the message: submit the edit with a non-published moderation_state (e.g. draft) to create a forward revision for human review. That path is tested open: the identical edit as a draft passes with the live revision untouched.

Coverage includes an end-to-end functional test of the exact reproduction from the summary: governed JSON:API PATCH without moderation_state → 422 and the default revision still serves the original content; the same PATCH with moderation_state: draft → 200, edit on a forward revision.

Two scope notes, deliberately left out of this fix:

  • Unmoderated published entities keep their in-place allowance (now documented in the validator): there is no forward-revision workflow to redirect the edit into, so refusing would leave agents no path at all. Sites wanting that strictness deny writes for the type. Follow-up issue if we want to revisit.
  • Governed unvalidated saves of moderated entities (custom code calling $entity->save() without validation) skip constraints by definition and are near-hypothetical on the governed channel; the presave backstop covers the unmoderated seam only. Follow-up if we want a moderated backstop (the content_moderation hook-ordering makes a forced-draft rewrite there non-trivial).
jmcerda’s picture

Status: Needs review » Fixed

Merged to 1.x (PR #54, merge commit a33ed3e), with two review follow-ups folded in. All CI legs green (coding standards + PHPUnit on Drupal 10.6/11/11.3). Ships in the next release; the changelog entry sits under [Unreleased]. Reminder for the release gate: the functional JSON:API suite covering this fix is deliberately not run in CI — run it manually before tagging.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

jmcerda’s picture

Status: Fixed » Needs work

Reopening: the 1.12.0 fix does not fire on the primary reported vector. The drupalcode GitLab pipeline (which runs the functional suite that GitHub CI deliberately skips) fails on exactly the new end-to-end test: the governed JSON:API PATCH without moderation_state returned 200 and the live title changed.

Root cause of the incomplete fix: JSON:API's PATCH path validates with a field list — EntityValidationTrait::validate($entity, $field_names) calls EntityConstraintViolationList::filterByFields(), which drops violations attached to any field absent from the payload. The new violation is attached via ->atPath('moderation_state') — the very field this bypass omits by definition — so JSON:API filters it away before it can 422. The kernel test passes because it calls $entity->validate() directly, with no filtering; POST is unaffected because the create path validates without a field list (which is why the create-denied coverage stayed green). Entity forms and programmatic validated saves are gated; the JSON:API omitted-field vector — the one observed in production — is not.

Fix: report the in-place violation at the entity level (no property path) — entity-level violations survive filterByFields(). Follow-up commit incoming; will only mark Fixed again once the drupalcode pipeline (functional leg included) is green, and 1.12.1 will carry it.

  • jmcerda committed b7ee166b on 3613146-jsonapi-filter-bypass
    Flip the host-cascade test off the overturned in-place allowance...
jmcerda’s picture

Status: Needs work » Needs review

Follow-up fix ready: https://github.com/Wilkes-Liberty/mcp_sentinel/pull/57

The violation is now reported at the entity level (no property path), which survives JSON:API's filterByFields() on every path, with the mechanism documented in a comment at the violation site. One additional test flipped: McpCompositeRedirectJsonApiTest::testHostCascadeNotRedirected rode the same overturned in-place allowance; its actual subject (the composite redirect must not fire on a host cascade) is preserved — it now asserts the refusal comes from the publish gate, the live revision stays untouched, and no forward draft is forked.

Verified on the full drupalcode pipeline this time — functional leg included, all 518 tests: pipeline #902366, passed (the two advisory jobs, cspell and phpstan, are pre-existing allow-failure warnings unrelated to this change). GitHub matrix (standards + unit/kernel on Drupal 10.6/11/11.3) also green. The end-to-end reproduction — governed PATCH without moderation_state — now gets its 422 with the live revision untouched, for the right reason. Ships in 1.12.1 once merged.

  • jmcerda committed b7ee166b on 1.x
    Flip the host-cascade test off the overturned in-place allowance...
jmcerda’s picture

Status: Needs review » Fixed

Merged (PR #57) and released in 1.12.1. The regression window is 1.12.0 only; sites on it with a deny-publish profile should update promptly. Verified on the full drupalcode pipeline (functional leg, 518 tests) for both the fix and the release commit — pipelines #902366 and #902385.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

jmcerda’s picture

Status: Fixed » Closed (fixed)

Shipped in 1.11.0. Closing after release.