Problem/Motivation

When normalizing JSON:API links, links to inaccessible URLs shouldn't be normalized. This matches the behavior of Drupal menues.

Proposed resolution

Allow Drupal\Core\Urls to return AccessResult objects. Use those objects in the LinkCollectionNormalizer to omit inaccessible links and to capture the cacheability of that result.

User interface changes

None.

API changes

A new optional argument on the Drupal\Core\Url
It's possible that some links that were previously available in JSON:APIs HTTP output will no longer appear, however those links would have led to inaccessible resources anyway.

Data model changes

None.

Release notes snippet

TBD

Issue fork drupal-3055889

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:

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

gabesullice created an issue. See original summary.

gabesullice’s picture

Wim Leers’s picture

Status: Active » Postponed
gabesullice’s picture

Title: [PP-1] JsonApiResource\Link should be able to capture access + cacheability » JsonApiResource\Link should be able to capture access + cacheability
Status: Postponed » Active

I no longer believe that this issue should be postponed on #3014704: Expose API to allow links handling for entities from other modules.

Version: 8.8.x-dev » 8.9.x-dev

Drupal 8.8.0-alpha1 will be released the week of October 14th, 2019, which means new developments and disruptive changes should now be targeted against the 8.9.x-dev branch. (Any changes to 8.9.x will also be committed to 9.0.x in preparation for Drupal 9’s release, but some changes like significant feature additions will be deferred to 9.1.x.). For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

gabesullice’s picture

Converting the parent issue into a related issue. This does not need to be coordinated by the meta issue any longer.

Wim Leers’s picture

So this would help jsonapi_hypermedia, see #3079664: Move cacheability bubbling complexity from LinkProviderManager to the overriding normalizer. Tagging Contributed project soft blocker.

Wim Leers’s picture

Status: Active » Needs review
FileSize
2.41 KB

I think this is basically what this issue is proposing.

Two questions:

  1. I don't see how to make Link actually respect the access result?
  2. Given point 1, why can't the caller of the code just add the access result's cacheability to the first argument of the Link constructor (which is CacheableMetadata $cacheability).

?

Wim Leers’s picture

The only solution for #8.1 that I see is this.

Version: 8.9.x-dev » 9.1.x-dev

Drupal 8.9.0-beta1 was released on March 20, 2020. 8.9.x is the final, long-term support (LTS) minor release of Drupal 8, which means new developments and disruptive changes should now be targeted against the 9.1.x-dev branch. For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

Spokje’s picture

Re-roll of patch #9 against latest 9.1.x branch.

gabesullice’s picture

Status: Needs review » Needs work

I don't see how to make Link actually respect the access result?

It wouldn't be useful if it did. The solution in #9 makes it impossible for code that would otherwise provide a link to bubble the cacheability information upward in order to indicate "this user can't access this link because of {time of day}, but if {time of day} changes, allow me to recompute this." Therefore, I think it's desirable to be able to create inaccessible links.

Note that MenuLinkTreeElement already let's one create links with access results.

However, our implementation doesn't necessarily have to work like that. We could create a new class called InaccessibleLink or similar if you don't like the idea of creating links with access results attached @Wim Leers.

gabesullice’s picture

Title: JsonApiResource\Link should be able to capture access + cacheability » JsonApiResource\Link objects with inaccessible target urls should not be normalized
Issue summary: View changes
Status: Needs work » Needs review
FileSize
3.41 KB

Here's what I'm thinking ☝️

Status: Needs review » Needs work

The last submitted patch, 13: drupal-jsonapi_link_access-3055889-13.patch, failed testing. View results

Version: 9.1.x-dev » 9.2.x-dev

Drupal 9.1.0-alpha1 will be released the week of October 19, 2020, which means new developments and disruptive changes should now be targeted for the 9.2.x-dev branch. For more information see the Drupal 9 minor version schedule and the Allowed changes during the Drupal 9 release cycle.

gabesullice’s picture

Status: Needs work » Needs review
FileSize
1.62 KB
4.08 KB

Status: Needs review » Needs work

The last submitted patch, 16: drupal-jsonapi_link_access-3055889-16.patch, failed testing. View results

Wim Leers’s picture

  1. +++ b/core/modules/jsonapi/src/Normalizer/LinkCollectionNormalizer.php
    @@ -75,7 +77,11 @@ public function normalize($object, $format = NULL, array $context = []) {
    +        $access = $link->getUri()->access(NULL, TRUE);
    

    🤓 I'd rather explicitly inject the current user rather than relying on the NULL-falls-back-to-current-user-in-request-context behavior.

    Core went with with this implicit magic in many places because it couldn't break backwards compatibility, but we can be explicit within JSON:API.

  2. +++ b/core/modules/jsonapi/src/Normalizer/LinkCollectionNormalizer.php
    @@ -75,7 +77,11 @@ public function normalize($object, $format = NULL, array $context = []) {
    +        $normalized[$link_key] = $access->isAllowed()
    +          ? new CacheableNormalization($cacheability, $normalization)
    +          : new CacheableOmission($cacheability);
    

    I think that this is good, but I'd love to see the concrete impact on the normalized output.

    When we add the necessary test coverage, that will become obvious of course! 👍

    I think this means inaccessible links will simply be omitted, because we don't have anything collecting omittedd links. But … I don't remember the JSON:API implementation details well enough to know that for sure.

Wim Leers’s picture

+++ b/core/lib/Drupal/Core/Url.php
@@ -809,15 +810,22 @@ public function getInternalPath() {
+   * @return bool|\Drupal\Core\Access\AccessResultInterface
+   *   The access result. Returns a boolean if $return_as_object is FALSE (this
+   *   is the default) and otherwise an AccessResultInterface object.
+   *   When a boolean is returned, the result of AccessInterface::isAllowed() is
+   *   returned, i.e. TRUE means access is explicitly allowed, FALSE means
+   *   access is either explicitly forbidden or "no opinion".

This bit in particular is something that is IMHO an oversight/omission in the current state of affairs — this pattern exists everywhere else except here!

Wim Leers’s picture

+++ b/core/modules/jsonapi/src/Normalizer/LinkCollectionNormalizer.php
@@ -77,9 +77,18 @@
+        // Checking access on links is not about access to the link itself;
+        // it is about whether the current user has access to the route that is
+        // *targeted* by the link. This is done on a "best effort" basis. That
+        // is, some links target routes that depend on a request to determine if
+        // they're accessible or not. Some other links might target routes to
+        // which the current user will clearly not have access, in that case
+        // this code proactively removes those links from the response.

😍🙏

Great comment!

gabesullice’s picture

I rebased against the latest on 9.2.x, fixed the two test failures in #16 and addressed #18.

+++ b/core/modules/jsonapi/src/Normalizer/LinkCollectionNormalizer.php
@@ -75,7 +77,20 @@ public function normalize($object, $format = NULL, array $context = []) {
+        // Neutral links are included in the response since access will be
+        // definitively checked when the link is followed.
+        $normalized[$link_key] = !$access->isForbidden()

This reasoning in #16 was incorrect. AccessResultNeutral doesn't have any relation to whether a route will be access checked on the request or not. In that case, route definitions use _access: TRUE, which means the route access check will return an AccessResultAllowed.

gabesullice’s picture

Category: Feature request » Bug report
Status: Needs work » Needs review

Recategorizing this as a bug report per #19.

gabesullice’s picture

Status: Needs review » Needs work

New test fails

gabesullice’s picture

33290d67 should be carefully reviewed since it is touching a very important section of code.

The problem it solves is that some route access checks, like _entity_access: user.update, return an AccessResultNeutral when a user isn't allowed to perform that operation. However, AccessManager->check() also returns AccessResultNeutral when it can't determine access without a request object.

This makes it impossible to distinguish between a user should see this link because they might have access to the route and a user should not see this link because are will not be allowed to access to the route.

The logic in the referenced commit attempts to resolve that ambiguity by explicitly allowing route access if it is impossible to get a usable access result without a request object.

The implication of that change is that some links/breadcrumbs may appear which were previously omitted because access was indeterminable. However, I think omitting those links/breadcrumbs in the first place could be considered a bug since it prevents site users from navigating to pages that they actually have access to.

gabesullice’s picture

Status: Needs work » Needs review
jibran’s picture

Issue tags: +Security

Seems like a security improvement. How are we supposed to get a red/green run with MRs?

gabesullice’s picture

Rebased, no changes ^

gabesullice’s picture

Thanks for the reviews!

@jibran, a new MR was the best way I could think of to do R/G runs.

MR !227 is the green branch & MR !268 is the test only branch.

@larowlan, after you pointed it out, I saw that the AccessManager class was not the right place for that logic. I was stumped with where it did belong though, so I ended up pairing with @tim.plunkett. He questioned why there was even a case that all of a routes access checks didn't apply in the first place and challenged me to show that it was actually a legitimate use case. (credited him for that)

When I dug into it, I realized that JSON:API's RelationshipFieldAccess access check can actually be refactored to avoid requiring the request in the first place. In fact, it was this access check that sent me down the AccessManager rabbit hole in the first place since nearly all of JSON:API's tests began failing when practically every related link disappeared from its responses because checking access on them gave AccessResultNeutral results.

Once I refactored that access check, it was able to return an explicit AccessResultAllowed, which meant that the conundrum I faced in #25 disappeared 🎉

I created change record for the newly deprecated route access check.

Note that I couldn't deprecate the service with using the deprecated: YAML property since tagged services are automatically instantiated. I wasn't able to use the @deprecated annotation either because some static analysis recognized that the service definition was referencing a deprecated class.

gabesullice’s picture

@tim.plunkett filed this related issue to discourage/prevent future implementers from only using route access checks which require a request. This would have helped steer JSON:API in the right direction from the start. I don't believe that these issues should block one another.

Wim Leers’s picture

#25: wow, that commit is indeed rather scary and requires the highest level of scrutiny possible!

I was going to suggest the same that @larowlan already suggested a week ago: the risk in this behavior change seems to be too great. A new method with this specific behavior expectation baked in seems a safer option.

But … all that has become irrelevant thanks to @gabesullice pairing with @tim.plunkett apparently 😄🥳

#31 shows how much simpler and more local the changes became — now instead of a high-risk targeted change in infrastructure affecting every single request processed by Drupal, it became a change only affecting the JSON:API module, and instead of being complex it became a trivial refactoring!

When this reaches RTBC — and I think it is very close — then I think a git diff -M5% of RelationshipRouteAccessCheck would help see every reviewer how little this issue is actually changing 🤓👍

Wim Leers’s picture

Aside of that "present" instead of "absent" typo, I think this is RTBC now!

gabesullice’s picture

Status: Needs review » Reviewed & tested by the community
Wim Leers’s picture

RTBC++

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

I probably would have fixed the two nits on commit but the bit about replacing a typehint with an assert needs more discussion. I can't see why that's necessary.

gabesullice’s picture

Status: Needs work » Reviewed & tested by the community

Maybe I'm stuck in the past, are we able to use nullable typehints now?

Answer: yes. Apparently I'm a Drupal dinosaur.

Taking the liberty to go back to self-RTBC 'cause I'm only fixing a couple nits that would have been fixed on commit and I'm only reverting a change as recommended by @alexpott.

alexpott credited larowlan.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

@gabesullice the ? is unnecessary... you could already pass NULL to access.... see https://3v4l.org/spMXd - I'm removing it because it is an unnecessary change. If you want to use the ? in new code fine... but then do ?AccountInterface $account - the = NULL is redundant.

Crediting @larowlan and @jibran for code review.

Committed adf684d and pushed to 9.2.x. Thanks!

diff --git a/core/lib/Drupal/Core/Url.php b/core/lib/Drupal/Core/Url.php
index d59203f666..776a8f778a 100644
--- a/core/lib/Drupal/Core/Url.php
+++ b/core/lib/Drupal/Core/Url.php
@@ -819,7 +819,7 @@ public function getInternalPath() {
    *   returned, i.e. TRUE means access is explicitly allowed, FALSE means
    *   access is either explicitly forbidden or "no opinion".
    */
-  public function access(?AccountInterface $account = NULL, $return_as_object = FALSE) {
+  public function access(AccountInterface $account = NULL, $return_as_object = FALSE) {
     if ($this->isRouted()) {
       return $this->accessManager()->checkNamedRoute($this->getRouteName(), $this->getRouteParameters(), $account, $return_as_object);
     }

Fixed on commit.

  • alexpott committed 99e2947 on 9.2.x
    Issue #3055889 by gabesullice, Wim Leers, Spokje, jibran, alexpott, tim....

Status: Fixed » Closed (fixed)

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