Hello, guys!

JSON API rocks 8-) We've been using it for a project that we are developing and seems like we've hit an edge case. Let me elaborate.

Have the following scenario:

  1. Have a node type A, and a node type B.
  2. Have node type A reference type B through some entity reference field.
  3. Create a specific node A (type A) that references a node B (type B). Make sure node A is accessible by the current user, while node B is not accessible.
  4. query JSON API like the following: /jsonapi/node/type-a/[uuid]?include=field_that_points_to_node_b
  5. At this point you should see a fine response enlisting contents of node A and a message under the omitted clause that node B was omitted.
  6. Up until this point it all goes as expected.. But the problem is that the X-Drupal-Cache-Tags response header will not contain the cache-tags of $node_b->access(), i.e. the cacheability of denied access on node B did not make it into the cacheability of JSON API document response.
  7. At this point if something changes and node B becomes accessible, this JSON API response will be cached and will not get invalidated whenever access to node B changes.

I followed how it all unrolls in debugger and seems like the following happens. Eventually the denied access on node B gets translated into an EntityAccessDeniedHttpException object. Then it gets normalized as part of the omitted clause. Cacheability is correctly propagated until this point. The problem is how EntityAccessDeniedHttpException gets normalized, namely (this is a snippet from HttpExceptionNormalizer.php):

  /**
   * {@inheritdoc}
   */
  public function normalize($object, $format = NULL, array $context = []) {
    return new HttpExceptionNormalizerValue(new CacheableMetadata(), static::rasterizeValueRecursive($this->buildErrorObjects($object)));
  }

We are forcing empty cachebaility into the return of normalization. The object we are normalizing _could_ carry some cacheability therein and at this point it gets lost.

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

bucefal91 created an issue. See original summary.

bucefal91’s picture

I propose to change that snippet to the version that explicitly "absorbs" the cacheability of whatever HttpException we are normalizing. Some HttpExceptions might not implement the CacheableDependencyInterface, so I craft a dedicated cacheability object which then intakes cacheability of the exception we are normalizing. So it'd either repeat the cacheability of the exception (if the latter implements the interface) or would would have its max-age = 0 which is quite logical -- we cannot cache a response that contains an exception that does not report its cacheability.

I do confirm with this patch applied the cacheability of the overall JSON API response is OK (per my steps in the main description of this ticket).

bucefal91’s picture

Status: Active » Needs review
Wim Leers’s picture

Title: Losing cacheability while normalizing HttpException » Losing cacheability while normalizing EntityAccessDeniedHttpException for an inaccessible include
Priority: Normal » Major
Status: Needs review » Needs work
Issue tags: +D8 cacheability, +Needs tests

i.e. the cacheability of denied access on node B did not make it into the cacheability of JSON API document response.

That definitely sounds like a bug!

The patch in #2 looks correct. Great work, and thank you! 🙏


Originally, JSON:API did not handle cacheability at all. Now it obviously does. EntityAccessDeniedHttpException indeed carries cacheability. It looks like we forgot some edge case.

I'm saying "some edge case", because we do have explicit test coverage for precisely this. But we don't have such test coverage for inaccessible includes apparently. Even though ::doTestIncluded() (see https://github.com/drupal/drupal/blob/8.7.6/core/modules/jsonapi/tests/s...) does grant the necessary permissions prior to testing (see https://github.com/drupal/drupal/blob/8.7.6/core/modules/jsonapi/tests/s...), it does not test what the response is before granting those permissions.

We cannot just commit this patch, we need to add test coverage to prove this fixes the problem and to prevent this from ever regressing again. So we need to either add a single clearly focused regression test that fails on HEAD to JsonApiRegressionTest (this contains many prior examples so should be pretty easy) or we need to expand ::doTestIncluded() (this test coverage is very abstract so that's pretty hard). Do you think you could do that? :)

bucefal91’s picture

Hello!

Wow, your tests seem to be one of the fanciest I've ever seen in my life! :) Packing the test into JsonApiRegressionTest definitely could work and sounds significantly easier than amplifying the ::doTestIncluded(). I've reviewed both approaches by now.

This weekend I should have some spare time, so I'd like to try cracking it as part of the ::doTestIncluded(). I hope we could get sufficient cases to hit the edge case this ticket is about. For example, we could expose it on the case where the resource is Node and the omitted included resource is User (when "current user" does not have the "view user profiles" permission).. Or something along those lines.

I'll invest as much time as I can during the weekend into it and if I see I am not getting to finish line, then I'll retrocede and will code a simple test case within the regression class.

You will hear from me on Sunday :)

Wim Leers’s picture

Awesome, thanks so much @bucefal91! 🥳

bucefal91’s picture

Status: Needs work » Needs review
FileSize
2.4 KB

Here's the test failure. This patch only contains an extra test in JsonApiRegressionTest.php and thus is expected to fail.

Eventually I chose to do in the regression test class because entire notion of ::doTestIncluded() was built around granting extra permissions to make included entities visible. The cache context user.permissions would make it into overall cacheability due to other reasons besides inferring it from EntityAccessDeniedHttpException. So at the end of the day I couldn't find any suitable case that would expose this edge case. Hence I opted for crafting a dedicated test method that works just against this specific case :) I'll wait until this patch fails testing and then will upload a combined patch (#2 + #7, i.e. fix + test).

Status: Needs review » Needs work

The last submitted patch, 7: 3074710-jsonapi-access-denied-cacheability-7.patch, failed testing. View results

bucefal91’s picture

Status: Needs work » Needs review
FileSize
3.27 KB

Good. Now uploading a fix + the test case. I noticed my test case threw a deprecation warning, so I made a minor adjustment to get rid of it (it was about invoking the deprecated $user->getUsername()).

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
Issue tags: -Needs tests

Perfect! 🥳 Thanks!

Wim Leers’s picture

Issue tags: +API-First Initiative
bucefal91’s picture

Thank you once again for being so responsive and making sure JSON API has such great adaptation in Drupal. You are doing a magnificent job :) Keep up :)

larowlan’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
@@ -989,4 +989,53 @@ public function testLeakedCacheMetadataViaRdfFromIssue3053827() {
+    $response = Json::decode((string) $response->getBody());
+    $this->assertArrayNotHasKey('included', $response, 'JSON API response does not contain "included" taxonomy term as the latter is not published, i.e not accessible.');

i think we should add a positive assertion here too, e.g. check that the omitted key is present and contains what we expect, otherwise we've got a bit of a false sense of coverage

Other than that, great work and nice sleuth work 🕵️‍♂️

Wim Leers’s picture

Ah, of course! Great point, @larowlan! 👍

Back to you, @bucefal91, and thank you very much for the kind words :)

bucefal91’s picture

I'll get tests more tailored, hopefully this weekend :)

bucefal91’s picture

I made an additional explicit assertion on the "omitted" section too. See the attached interdiff.

I hope now it looks good.

Status: Needs review » Needs work
bucefal91’s picture

Ouch. I think this will fix the deprecation notice.

Wim Leers’s picture

Status: Needs review » Needs work

#18 is a zero-byte patch sadly :D Can you reupload? :)

bucefal91’s picture

Status: Needs work » Needs review
FileSize
3.75 KB

Yep, here comes the good one :)

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

Thanks!

larowlan’s picture

sokru’s picture

Wim Leers’s picture

Thanks!

Wanted to re-RTBC, but then noticed a few things.

  1. +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
    @@ -1091,6 +1091,63 @@ public function testInvalidDataTriggersUnprocessableEntityErrorFromIssue3052954(
    +  }
    +
    +
       /**
    

    During the reroll, this added one line 😅🤓

    That can be fixed on commit.

  2. +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
    @@ -1091,6 +1091,63 @@ public function testInvalidDataTriggersUnprocessableEntityErrorFromIssue3052954(
    +  public function testLeakCacheMetadataInOmittedFromIssue3074710() {
    

    Oh but actually this should also still remove the FromIssue… suffix.

So, let's remove that suffix, remove the blank line and … actually, let's move this to the very end of the class too — if we're adding a new test, it should just be appended to the end, not inserted before the end.

sokru’s picture

(Edit: not so) Proper reroll with changes from #24

sokru’s picture

-

sokru’s picture

Wim Leers’s picture

Aaaah, so close!

#25:

+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
@@ -48,7 +48,7 @@ class JsonApiRegressionTest extends JsonApiFunctionalTestBase {
-  public function testBundleSpecificTargetEntityTypeFromIssue2953207() {
+  public function testBundleSpecificTargetEntityType() {

@@ -1131,4 +1131,60 @@ public function testEmptyMapFieldTypeDenormalization() {
+  public function testLeakCacheMetadataInOmittedFromIssue3074710() {

You changed an existing method name instead of the new one 😛 I see you fixed that in #27, but not entirely:

+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
@@ -1131,4 +1131,60 @@ public function testEmptyMapFieldTypeDenormalization() {
+  public function testLeakCacheMetadataInOmittedFromIssue() {

The FromIssue suffix should still be removed!

sokru’s picture

Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community
Issue tags: -Needs reroll

🥳

  • catch committed 0e29b53 on 8.8.x
    Issue #3074710 by bucefal91, sokru, Wim Leers, larowlan: Losing...
catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed 0e29b53 and pushed to 8.8.x. Thanks!

Status: Fixed » Closed (fixed)

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