Follow-up to #3425701: Doesn't respect published/unpublished status of translations, which was closed as fixed but whose reported symptom is still present in 8.x-1.8. I cannot reopen that issue (maintainers only), so filing here.
Problem/Motivation
On a multilingual site, a node with one published and one unpublished translation ends up with no node_access record at all for the published translation. Every user without bypass node access gets a 403 on the published page:
Path: /node/N. Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException: in Drupal\Core\Routing\AccessAwareRouter->checkAccess() (line 114 of core/lib/Drupal/Core/Routing/AccessAwareRouter.php).
The per-translation patch from #3425701: Doesn't respect published/unpublished status of translations (comments #11/#12/#15) is in the release, and it did fix the unpublished side — grants are now written per langcode. But published translations were never given a record of their own, so the originally reported symptom survived. Comment #16 there reports "Patch doesn't work for me", which I suspect is this.
The bug is symmetric: it does not depend on which translation is the original. Whichever translations are published lose their access record as soon as any sibling is unpublished.
Cause
unpublished_node_permissions_node_access_records() iterates every translation but only emits grants for the unpublished ones:
foreach ($node->getTranslationLanguages() as $langcode => $language) { $translation = $node->getTranslation($langcode); if (!$translation->isPublished()) { $grants[] = [...'langcode' => $langcode]; // three realms } // published translations: nothing is emitted }
Core supplies its default "all" grant only when the returned array is empty:
// NodeAccessControlHandler::acquireGrants() $grants = $this->moduleHandler->invokeAll('node_access_records', [$node]); $this->moduleHandler->alter('node_access_records', $grants, $node); if (empty($grants) && $node->isPublished()) { $grants[] = ['realm' => 'all', 'gid' => 0, 'grant_view' => 1, 'grant_update' => 0, 'grant_delete' => 0]; }
empty() is evaluated over the aggregate array, not per language. One unpublished translation makes it non-empty, so the default is skipped for the whole node — including its published translations. The langcode key on a grant record does not create a per-language exemption from that: as hook_node_access_records() documents, a module returning grants for a node takes over that node's grants entirely, which is why the example implementation returns nothing at all for nodes it does not care about.
At request time NodeGrantDatabaseStorage::access() filters on the requested translation's langcode:
$nids = $query->andConditionGroup()->condition('nid', $node->id()); if (!$node->isNewTranslation()) { $nids->condition('langcode', $node->language()->getId()); }
so the published translation matches no row. The OR nid = 0 fallback for published nodes does not rescue it either — that default record is only written by NodeAccessControlHandler::writeDefaultGrant() when no module implements hook_node_grants(), which by definition is not the case with this module installed.
This also explains why View any unpublished content does not help. It maps to gid 1 in realm view_unpublished_any, and rows in that realm exist only for the unpublished langcode. Nothing in the table is keyed to the published langcode, so no permission can match. Only bypass node access passes, because it is short-circuited in NodeAccessControlHandler::access() before grants are consulted — which is why the problem is invisible to administrators.
Steps to reproduce
Tested on Drupal 11.4 with unpublished_node_permissions 8.x-1.8, two languages (English default, Hebrew prefixed /he), content type article.
Create a node, translate it, then set a mix of published and unpublished translations and save. Both orientations fail.
Case A — the scenario from #3425701: Doesn't respect published/unpublished status of translations. Original language en unpublished, translation he published:
node_access rows for nid N: N en view_unpublished_any gid 1 N en view_unpublished_author gid <uid> N en view_unpublished_article_node gid 1 <- no row of any realm for langcode 'he' [en] published=0 | editor=ALLOW | anonymous=DENY [he] published=1 | editor=DENY | anonymous=DENY <- published, unreachable GET /he/node/N -> 403 (published translation) GET /node/N -> 403 (unpublished original, correctly denied)
Case B — the mirror case. Original language en published, translation he unpublished:
node_access rows for nid M: M he view_unpublished_any gid 1 M he view_unpublished_author gid <uid> M he view_unpublished_article_node gid 1 <- no row of any realm for langcode 'en' [en] published=1 | editor=DENY | anonymous=DENY <- published, unreachable [he] published=0 | editor=ALLOW | anonymous=DENY GET /node/M -> 403 (published translation)
In both cases "editor" holds View any unpublished content, and in both cases user 1 gets a 200.
To find every affected node on an existing site:
SELECT nid FROM node_field_data GROUP BY nid HAVING SUM(status = 0) > 0 AND SUM(status = 1) > 0;
On our site that returned 16 nodes, all of them silently unreachable in one language.
Proposed resolution
Emit the default all / gid 0 grant for the published translations, so that returning grants for one translation does not strip access from the others. Patch attached, against 8.x-1.8:
--- a/unpublished_node_permissions.module +++ b/unpublished_node_permissions.module @@ -64,6 +64,20 @@ 'langcode' => $langcode, ]; } + else { + // The published translations must keep the default "all" grant. Core + // only adds it when the whole $grants array is empty, so a node with a + // mix of published and unpublished translations would otherwise lose + // public access on the published ones. + $grants[] = [ + 'realm' => 'all', + 'gid' => 0, + 'grant_view' => 1, + 'grant_update' => 0, + 'grant_delete' => 0, + 'langcode' => $langcode, + ]; + } }
After the patch, verified over HTTP in both orientations: the published translation returns 200 where it previously returned 403, and the unpublished one still returns 403 to anonymous users while remaining visible to holders of the module's permissions.
Note for site owners: the patch alone does not repair existing content. Grants are written when a node is saved, so rows already in node_access stay wrong until they are rewritten — as comment #17 on #3425701: Doesn't respect published/unpublished status of translations noted for the earlier patch. A node_access_rebuild() after applying it is required.
Remaining tasks
- Review the patch.
- Add a
hook_update_Nthat rebuilds grants, so existing sites are repaired on update.unpublished_node_permissions_update_8001()through_8003()already callnode_access_rebuild(TRUE)for exactly this reason; an_8004()in the release carrying this fix would do it without each site having to know to. - Add test coverage for a node with mixed per-translation status, in both orientations. Neither is covered today, which is how #3425701: Doesn't respect published/unpublished status of translations could reach Fixed with its reported symptom still present.
User interface changes
None.
API changes
None.
Data model changes
None to the schema. The contents of node_access change for nodes with a mix of published and unpublished translations: those nodes gain the all / gid 0 record for each published translation. Existing rows need a node_access_rebuild() to pick this up.
| Comment | File | Size | Author |
|---|---|---|---|
| unpublished-node-permissions-mixed-translation-status.patch | 663 bytes | rhayun |
Comments
Comment #2
fabsgugu commentedHello,
If I understand the solution correctly, that would mean reverting the last commit?
We can't do that, because it's a security fix : https://www.drupal.org/sa-contrib-2026-132
Comment #3
hoporr commentedI want to second that this is a MAJOR problem.
One of my clients has a large multi-language site and has a whole workflow built around this, where some articles are published in some languages but not in others.
This was working before the security update 2026-132 referred to above, and is broken since.
If patch #1 indeed reverts the security release (can somebody please confirm this?), then we need a different solution.