Fallback reads can 403: the candidate walk ignores per-translation view access
The language-fallback-reads spec promises that an individual fallback read of an existing entity the client may view returns 200 with the best available translation, because the entity's default translation is the terminal fallback candidate. An access check can break that promise.
Fallback resolution goes through core's EntityRepository::getTranslationFromContext(), which walks the site's language fallback candidates and returns the first candidate the entity has, with no access check anywhere in the walk. When that candidate is not viewable, the ordinary editorial state for a translation drafted but not yet published, the caller's own access check rejects it and the read becomes 403 instead of continuing down the chain to a translation the client can view. Collection items and ?include= targets take the same path and are reported as access omissions rather than served in a viewable language.
Scope, stated honestly: a default install is already protected, because core's content_translation implements hook_language_fallback_candidates_entity_view_alter() and prunes unpublished, inaccessible candidates itself. Two things leave the gap open anyway. Core merges the generic and operation-specific alter hooks into a single alter call ordered by module rather than by hook specificity, so a fallback-policy module that rebuilds the candidate list wholesale (which is language_hierarchy's model, and the main reason includeFallback exists) reintroduces the pruned candidates whenever module weight places it after content_translation; nothing in the hook contract guarantees the safe order. And content_translation prunes only translations its own metadata marks unpublished, so any other per-translation view denial is unprotected regardless of ordering. This is therefore hardening: it makes the module's guaranteed-200 contract independent of another module's hook order.
A private helper mirrors core's walk exactly, building the same context array and forcing the entity's default language as the terminal candidate the same way, then returns the first candidate the entity has and the user may view. Every access result consulted is added to the response's cacheability, so a cached fallback body varies by the requesting user's access outcome rather than by URL alone; without that the fix would return the right body and then serve it to the wrong audience. When no candidate is viewable, the helper defers to core's ordinary resolution, so a fully restricted entity keeps behaving exactly as it does today with core's own access wording. The helper is duplicated across the controller and the access checker, matching this module's existing convention of duplicating short helpers per class.
Not changed: when the requested translation exists but is not viewable, the read still fails with the entity-level access result in strict and fallback mode alike. The spec covers that case separately ("the fallback read MUST serve it, identically to the strict read").
Marked DIVERGENCE(core), since core's resolver is access-blind by design. An access-aware option on getTranslationFromContext() upstream would let this helper be deleted at handoff.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Closes #3614387