Problem/Motivation

Fallback mode promises content. The language-fallback-reads spec puts it plainly: "An individual fallback read of an existing entity the client may view MUST return 200 with the best available translation; the entity's default translation is the terminal fallback candidate, so the read never misses for language."

That promise can be broken by an access check. 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. If 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 request becomes 403, instead of continuing down the chain to a translation the client can view.

Scope, stated honestly: a default install is already protected. 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 hook_language_fallback_candidates_alter() and the operation-specific variant into a single alter call ordered by module, not by hook specificity. 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 about the hook contract guarantees the safe order.
  • content_translation prunes only translations its own metadata marks unpublished. Any other per-translation view denial is unprotected regardless of ordering.

So this is hardening rather than a live defect on a stock site: it makes the module's own guaranteed-200 contract independent of another module's hook order.

Steps to reproduce

  1. Site with en (default), fr and it, and a fallback-policy module that rebuilds the candidate list so that fr falls back to it before en.
  2. An article with a published en translation, an unpublished it translation, and no fr translation.
  3. As a user who may view published content only: GET /jsonapi/node/article/{uuid}?langCode=fr&includeFallback=1.
  4. Observed: 403. Expected per the spec: 200 serving en.

The same shape applies to collection items and ?include= targets, where the item is reported as an access omission instead of being served in a viewable language.

Proposed resolution

Resolve fallback over the candidates the requesting user can actually view.

  • A private helper mirrors core's walk exactly, building the same context array core builds 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 during the walk 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 this the fix would return the right body and then serve it to the wrong audience.
  • When no candidate is viewable at all, the helper defers to core's ordinary resolution, so the response ends in core's normal access denial rather than a synthesized outcome. A fully restricted entity keeps behaving exactly as it does today.
  • The helper is added to both the controller and the access checker, matching this module's existing convention of duplicating short helpers per class rather than introducing a shared service for a loop.

Explicitly 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"), and the strict path's posture matches core JSON:API's.

Marked DIVERGENCE(core):, since core's resolver is access-blind by design. This is a reasonable upstream candidate: an access-aware option on getTranslationFromContext() would let the module delete the helper at handoff.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

lauriii created an issue. See original summary.

  • lauriii committed b0b6e0b6 on 1.0.x
    fix: #3614387 Fallback reads can 403: the candidate walk ignores per-...
lauriii’s picture

Status: Active » Fixed

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.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.