Guide
Drupal content published without going through moderation — detecting workflow bypass
1. Problem
A node went live this morning that nobody on the editorial team approved. The byline is correct, the body looks fine, the /node/4821 URL resolves with status published, and the front page is already serving it. Your reviewer pings you in Slack: "I never moved that one to needs_review. Did you?" You didn't. You search "drupal content published without review approval" and the top results tell you to enable the Content Moderation module — which is already enabled. You search "drupal draft skipped review who published" and find advice to "check the revision log," which lists three revisions, none of which says how it got from draft to published without passing through needs_review.
This is editorial workflow bypass. The Workflows module enforces transitions on the UI, but the underlying entity API does not. A user with use editorial transition publish permission, a misconfigured role, a drush ev one-liner, or a custom hook can flip moderation_state from draft to published in a single save and skip every review stage in between. The audit trail in /admin/content shows the node as published. It does not show that it bypassed review.
The signal that records the actual transition path is content.moderation, fired by the agent module on every workflow state change. If you correlate the sequence of content.moderation events for nid=4821, you will find one event with state_from: draft and state_to: published — no needs_review in between. That is the bypass, captured.
2. Impact
Editorial workflow exists for a reason. When it gets bypassed silently, the costs land in three places.
Compliance. SOC 2 CC6.1 and CC7.2 require enforced approval workflows on changes to systems handling customer data. HIPAA's administrative safeguards (45 CFR 164.308) require documented review for content describing covered services. PCI-DSS 6.4.5 requires change-control approval before deployment. If your auditor asks "show me the approval trail for this published page," and your answer is "the workflow module is configured," you fail the control. You need the evidence that every published change passed through review. A single bypass invalidates the control assertion for the audit period.
Legal exposure. A regulated-industry site (healthcare, financial services, public sector) that publishes unreviewed claims has been the basis for FTC enforcement, state AG actions, and class-action complaints. The dollar impact ranges from a $10k retraction-and-correction cycle to a multi-million-dollar consent decree. The discovery question — "who approved this?" — has no good answer when no one did.
Editorial trust. Once the team knows bypass is possible and undetected, the workflow becomes optional in practice. Drafts get pushed straight to published "just this once" because the deadline is tight. Six months later, half the corpus skipped review and you cannot reconstruct which half.
In production, we have observed content.moderation events with state_from: draft, state_to: published running 4–11% of all publish actions on sites that believe they enforce two-stage review. The team is not wrong — the workflow is configured correctly. The enforcement is just not where they think it is.
3. Why It’s Hard to Spot
Drupal's defaults make this invisible.
The published node looks normal. A bypassed publish produces the exact same node-view HTML, the exact same /node/{nid} URL, the exact same revision row in node_revision, and the exact same published: 1 flag in node_field_data. There is no visual marker, no admin warning, no cron job that flags it.
The revision log shows result, not path. /node/{nid}/revisions lists each revision's revision_log_message and revision_user, but the moderation state column shows only the current state of each revision. A revision that went draft → published in one save shows as published — the same as a revision that went needs_review → published. The diff is invisible.
Watchdog is silent on workflow transitions. The Content Moderation module does not write to dblog on state change. Unless a custom hook explicitly logs every ContentModerationStateChangeEvent, the only artifact is the row in content_moderation_state_field_data — and that row records the latest state, not the transition.
Permissions look correct. A site admin reviewing /admin/people/permissions sees use editorial transition needs_review granted to editors and use editorial transition publish granted to publishers. That looks right. What's missing from the UI is the fact that anyone with bypass node access (granted to administrator) or anyone calling $node->set('moderation_state', 'published')->save() programmatically does not need any transition permission. The module enforces transitions on the form layer, not the entity API layer.
Programmatic bypass is invisible. A drush ev '$n=Drupal::entityTypeManager()->getStorage("node")->load(4821); $n->set("moderation_state","published")->save();' produces a published node with no UI footprint, no watchdog entry, and no revision_log_message. A custom module's hook_node_presave that resets moderation_state runs on every save — and you would never know unless you read every contrib module's source.
This is silent failure by design. Drupal trusts the developer. The developer doesn't always deserve it.
4. Cause
The content.moderation signal fires on every state transition. It is emitted by the agent module from hook_entity_presave (and the equivalent event subscriber on Drupal 10+) by comparing the original entity's moderation_state field to the new one. When they differ, the agent emits one signal with the fields that matter:
nid— the node being moderatedstate_from— the previous state (draft,needs_review,published,archived, or null for new nodes)state_to— the new stateactor_uid— the user performing the save (from the current account)workflow_id— the workflow machine name (e.g.editorial)source— the request path that triggered the save (form submit URL, REST endpoint, drush, hook)
The legitimate transition graph for the standard editorial workflow is draft → needs_review → published → archived, with allowed back-edges (needs_review → draft, published → archived → draft). Any state_from / state_to pair outside that graph is a bypass — by definition. The most common bypass shapes are:
draft → published(skipped review)needs_review → publishedperformed byactor_uid == author_uid(self-approval)null → published(a node created already published, never drafted)
The signal captures the transition regardless of code path: form submit, REST, JSON:API, Drush, migration, custom hook. That coverage is what makes it diagnostic.
5. Solution
5.1 Diagnose
Start with the suspected node, walk the content.moderation event log, then correlate with login and permission events.
# Step 1 — pull every moderation transition for this node from the agent log
grep '"event_type":"content.moderation"' /var/log/drupal/agent.log | \
jq -r 'select(.payload.nid=="4821") | "\(.timestamp) \(.payload.state_from) -> \(.payload.state_to) actor=\(.payload.actor_uid) src=\(.payload.source)"'
# → surfaces the content.moderation sequence; a draft -> published row with no needs_review row in between is the bypass
# Step 2 — correlate with the editor's login, since the bypass usually
# happens within minutes of an unusual login
grep '"event_type":"auth.login_success"' /var/log/drupal/agent.log | \
jq 'select(.payload.uid=="42" and .timestamp > "2026-04-27T08:00:00")'
# → surfaces drupal_login_success_total events; ties the moderation skip to a session
# Step 3 — time-correlate with deploys and permission grants in the same window.
# A new role grant 30 minutes before the bypass means someone got publish rights
# they shouldn't have had.
grep '"event_type":"drupal.permission_changed"' /var/log/drupal/agent.log | \
jq 'select(.timestamp > "2026-04-27T08:00:00" and .timestamp < "2026-04-27T10:00:00")'
# → surfaces drupal_user_lifecycle_total events tagged role_changed
# Step 4 — check the database directly for nodes whose first published revision
# has no preceding needs_review revision in the same workflow
psql "$DB_URL" -c "
SELECT cms.content_entity_id AS nid,
cms.moderation_state,
cms.revision_id,
cms.revision_id - lag(cms.revision_id) OVER w AS rev_gap
FROM content_moderation_state_field_revision cms
WHERE cms.content_entity_id = 4821
WINDOW w AS (PARTITION BY cms.content_entity_id ORDER BY cms.revision_id);
"
# → confirms whether a needs_review revision exists between the draft and published rows
The time-correlation step is the one that turns a log row into a story. A content.moderation event with state_from: draft, state_to: published at 09:14 followed by a drupal_user_lifecycle_total role-change event at 08:42 is a permission-grant-then-bypass chain. That sequence, captured in two signals 32 minutes apart, is the postmortem evidence.
5.2 Root Causes
Each cause produces a distinct fingerprint on content.moderation and the supporting signals.
- Misgranted permission. A site builder added
use editorial transition publishto theeditorrole instead of thepublisherrole. Editors can now publish directly. →content.moderationshowsstate_from: draft, state_to: publishedwithactor_uidmatching a known editor, anddrupal_user_lifecycle_totalshows a recentrole_changedevent for that role. - Administrator using the UI as an editor. The administrator role has
bypass node accessand is exempt from transition checks. An admin saving a draft and toggling state to published in the form skips review entirely. →content.moderationshowsactor_uidmapping to a known admin account,sourceis/node/{nid}/edit. - Programmatic update via Drush.
drush ev '$n=...; $n->set("moderation_state","published")->save();'or ahook_update_Nrunning during deploy. →content.moderationshowssource: drushorsource: cli,actor_uid: 0(anonymous in CLI context) or the deploy user. - Custom hook overriding state. A
hook_node_presavein a contrib or custom module that "fixes" moderation state on save (often shipped to "auto-publish on import"). →content.moderationfires on every save of affected content, often with the sameactor_uidas whoever triggered the save (not the hook author), and a high event rate per node. - REST/JSON:API patch with elevated token. A migration script or external integration calls
PATCH /jsonapi/node/article/{uuid}withmoderation_state: publishedusing a service-account token. →content.moderationshowssource: /jsonapi/...,actor_uidmapping to a service account, often outside business hours. - Compromised account chained with self-approval. A credential-stuffed editor account creates a draft and self-approves. →
drupal_login_success_totalfires for the account from a new IP/UA, followed within minutes bycontent.moderationevents withactor_uid == author_uid.
5.3 Fix
Apply in this order. Each step closes one of the cause classes above.
- Audit the workflow's transition permissions.
drush role:list --filter=permissionsthen check which roles holduse {workflow_id} transition publish. Restrict to thepublisherrole only. Remove fromeditor,contributor, and any "trusted user" custom roles. - Lock down
bypass node access. Only grant to one root admin account. Audit existing admin accounts and demote any that don't need full bypass to a scoped role. - Add a moderation-state guard in
hook_entity_presave. Reject any save wherestate_from == draftandstate_to == publishedunless the user holds an explicitdirect_publish_allowedpermission you create. This enforces the workflow at the entity API layer, not just the form layer. - Disable Drush state mutations on production. Add a settings.php override or a service decorator that throws on
moderation_statewrites from CLI context unless an environment variable explicitly permits it (e.g. for migrations). - Require revision log messages on publish. Configure the workflow to require a
revision_log_messageon the publish transition. Empty log on a published revision is a bypass marker. - Rotate service-account tokens. If JSON:API or REST tokens with publish capability exist, rotate them and scope to the minimum permission set. Audit the last 30 days of
content.moderationevents with non-formsource.
5.4 Verify
You're looking for two things: the bypass-shaped content.moderation events stop appearing, and the legitimate transition rate stays normal.
- Signal that should stop:
content.moderationevents wherestate_from∈ {draft,null} andstate_to=publishedin a single transition. After the fix, these should drop to zero across a 24-hour window. - Expected baseline: legitimate
content.moderationactivity continues at the site's normal editorial cadence — typically 5–40 events/day on a small editorial site, distributed acrossdraft → needs_review,needs_review → published, andpublished → archived. If the totalcontent.moderationrate also drops to zero, your editors stopped working — that's a different problem (often a permission you over-tightened in step 1). - Timeframe: under normal editorial load, no bypass-shaped events within 24 hours indicates the fix held. If a single bypass event reappears within 7 days, an unfixed code path remains — go back to §5.2 and check root causes you didn't address (most often: a hook in a contrib module).
- Dashboard panel to watch: the
content.moderationpanel filtered bystate_from = draft, state_to = published. Should be flat at zero. The unfiltered panel should show normal editorial activity.
A site that processes 200 publish actions a week and has a clean post-fix log is not the same as a site that processes 0 publishes — the second is a workflow misconfiguration masquerading as a fix.
6. How to Catch This Early
Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.
This issue surfaces as content.moderation. Everything you just did manually — pull the agent log, filter by node id, walk the state-transition sequence, correlate with login and role-change events — Logystera does automatically. The same content.moderation signal you just searched for is detected, charted, and alerted in real time, with the bypass-shaped transitions broken out as their own panel.
1. The signal in the dashboard.
!Logystera dashboard — content.moderation over time
content.moderation transitions, last 24h, broken out by state_from → state_to pair. The red series is draft → published — every spike is an editorial bypass.
2. The alert that fires.
!Logystera alert — moderation workflow bypass detected
Critical alert fires within 60s of a draft → published transition. Evidence section shows the offending event: nid=4821, actor_uid=42, source=/node/4821/edit, timestamp 2026-04-27 09:14:17.
3. Why this matters.
The fix is simple once you know the problem: tighten the permission, add the entity-presave guard, rotate the token. The hard part is knowing the bypass happened at all — because the published node looks identical to a properly approved one, the revision log records the result not the path, and watchdog stays silent through the entire transition. Logystera turns this from a quarterly audit finding into a 60-second notification with the exact content.moderation event that proves the workflow was skipped, the user who skipped it, and the request path they used. That artifact is also the evidence your SOC 2 auditor needs to confirm the control held — or to scope the exception when it didn't.
7. Related Silent Failures
drupal_user_lifecycle_total(role_changed) — privilege escalation that grants publish capability before the bypass. Often the cause upstream ofcontent.moderationdrift. See: Drupal user.role_changed — detecting privilege escalation.drupal_content_changes_total— node update volume anomalies. A burst of edits outside business hours often correlates with bypass events.drupal_login_success_total— successful logins from new IPs or unusual user agents. Frequently the first link in the compromise → self-approval chain.drupal.module_installed— a contrib module installed shortly before the first bypass event is a strong cause signal (custom hook overriding state).drupal.permission_changed— direct grants ofuse editorial transition publishoutside change windows. Watch this signal continuously; bypass events almost always have a permission grant in the preceding 72 hours.
See what's actually happening in your Drupal system
Connect your site. Logystera starts monitoring within minutes.