Background:

In SA-CONTRIB-2018-081, we moved filter query param denormalization from JsonApiParamEnhancer and agreed to make a public followup to either validate the change or refactor things to be more consistent. This is that issue.

Proposed Solution
Move processing of the sort and page query parameters into EntityResource.
Stop using denormalizers for these parameters.

Outcome
Prerequisite for #2986169: [PP-1] Support sortable, paginated and filterable related resources.
Prerequisite for #2956414: Support mixed-bundle collections (e.g. `/jsonapi/node`)
Fewer normalizers using jsonapi_normalizer_do_not_use_removal_imminent
Less code to maintain/fewer concepts

Prior to commit, a patch for JSON:API Defaults will need to be provided.

Comments

gabesullice created an issue. See original summary.

wim leers’s picture

Thanks for creating this issue. This was originally done in https://security.drupal.org/node/166860#comment-143269, where I wrote:

As luck would have it, after months of waiting for the RTBC patch https://www.drupal.org/project/drupal/issues/2869426 to land, it landed today, and of course it ended up causing some disruption for JSON:API (fixed in https://www.drupal.org/project/jsonapi/issues/3021311), but as it turns out, also for this security patch.

This security patch was affected because … the JSON:API module has been using the routing system in a not-quite-as-intended way for some time: it's using a RouteEnhancerInterface implementation to not enhance route defaults, but map URL query args into rich value objects. And if that fails, an exception is thrown. This security patch merely adds one more case where an exception is thrown: a 403 in case filtering is not allowed.

(The JSON:API module is hardly to blame here; it took 2.5 years for the core REST module to fix its very wrong use of the routing system in https://www.drupal.org/project/drupal/issues/2737751, and that just happened to get finished today!)

As of today, throwing those exceptions that early is causing failures in 8.7.

This reroll changes the place where the filter URL query arg is mapped into rich value objects to a later time in the request; to the actual place it belongs. It makes the minimal changes necessary (it only does this for the filter query arg, sorting/pager are left for a future public issue.)

(That future public issue is this one.)

effulgentsia’s picture

Status: Active » Needs review
StatusFileSize
new1.86 KB

While there may well be good reasons to move all those out of the enhancer, I'm confused by what made it a necessity for 'filter', so uploading this patch to see what tests fails.

effulgentsia’s picture

StatusFileSize
new1.87 KB

Or rather, this.

The last submitted patch, 5: jsonapi-enhancer-test.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

Status: Needs review » Needs work

The last submitted patch, 6: jsonapi-enhancer-test.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

effulgentsia’s picture

Ok, so the problem is that if you throw an exception from within a route enhancer, then that happens before any of the parameters/$defaults (that have been determined by this enhancer or any earlier routing code) have been transferred to $request attributes. That includes the '_route_object' attribute. Which means that exception handlers that have conditional logic based on which route was matched will process the exception as if no route was matched. Which in the case of #6, confuses AuthenticationManager::defaultFilter() into treating the AccessDeniedHttpException exception thrown due to lack of authorization to use a JSON:API filter as something that instead occurred due to a lack of authorization to use Basic Authentication (which is the authentication mechanism used by the test).

I think this is a bug with AccessAwareRouter, and will open a core issue to fix it.

But in the meantime, doing what this issue suggests and moving the sort and page denormalization from the enhancer to where the filter one is seems sensible.

gabesullice’s picture

Issue summary: View changes
Status: Needs work » Needs review
StatusFileSize
new86.61 KB

This patch is primarily dumb moves. I'll call out the material logic changes in my next comment.

gabesullice’s picture

Here are all the significant changes that would be hard to pick out because of the file removals.

  1. +++ b/src/Controller/EntityResource.php
    @@ -740,7 +751,7 @@ class EntityResource {
    -        $path = $field[Sort::PATH_KEY];
    +        $path = $this->fieldResolver->resolveInternalEntityQueryPath($resource_type->getEntityTypeId(), $resource_type->getBundle(), $field[Sort::PATH_KEY]);
    
    +++ /dev/null
    @@ -1,149 +0,0 @@
    -    $expanded = array_map(function ($item) use ($context) {
    -      $item[Sort::PATH_KEY] = $this->fieldResolver->resolveInternalEntityQueryPath(
    -        $context['entity_type_id'],
    -        $context['bundle'],
    -        $item[Sort::PATH_KEY]
    -      );
    -      return $item;
    -    }, $expanded);
    

    Instead of resolving the sort field during denormalization, we do it in EntityResource. Everything else is the same.

  2. +++ b/src/Controller/EntityResource.php
    @@ -1061,18 +1074,23 @@ class EntityResource {
    -      $params[Filter::KEY_NAME] = $serializer->denormalize($request->query->get('filter'), Filter::class, NULL, $context);
    +      $params[Filter::KEY_NAME] = Filter::createFromQueryParameter($request->query->get('filter'), $resource_type, $this->fieldResolver);
    

    Instead of denormalizing, we're just using a static create method. The only caveat is that we need to do field resolution inside this to avoid more drastic logical changes. Perhaps a followup could make those so that we don't need to pass the field resolver in here.

  3. +++ b/src/LinkManager/LinkManager.php
    @@ -93,33 +93,24 @@ class LinkManager {
    -    $params = $request->get('_json_api_params');
    -    if ($page_param = $params[OffsetPage::KEY_NAME]) {
    -      /* @var \Drupal\jsonapi\Query\OffsetPage $page_param */
    -      $offset = $page_param->getOffset();
    -      $size = $page_param->getSize();
    -    }
    -    else {
    -      // Apply the defaults.
    -      $offset = OffsetPage::DEFAULT_OFFSET;
    -      $size = OffsetPage::SIZE_MAX;
    -    }
    +    /* @var \Drupal\jsonapi\Query\OffsetPage $page_param */
    +    $offset = $page_param->getOffset();
    +    $size = $page_param->getSize();
    

    Because the page parameter isn't being "enhanced during the routing phase, the domain object isn't on the request (just the raw query param). Instead, it gets passed in.

    The if/else logic here was unnecessary. The parameter could not have been absent even before the refactor.

  4. +++ /dev/null
    @@ -1,250 +0,0 @@
    -      $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_KEY] = $this->fieldResolver->resolveInternalEntityQueryPath(
    -        $context['entity_type_id'],
    -        $context['bundle'],
    -        $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_KEY]
    -      );
    -    }
    
    +++ b/src/Query/Filter.php
    @@ -107,4 +135,164 @@ class Filter {
    +    foreach ($expanded as &$filter_item) {
    +      if (isset($filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY])) {
    +        $unresolved = $filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY];
    +        $filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY] = $field_resolver->resolveInternalEntityQueryPath($resource_type->getEntityTypeId(), $resource_type->getBundle(), $unresolved);
    +      }
    +    }
    

    This is the only material change to the way the Filter object is generated.

    Without this change we would have had to call \Drupal::service('jsonapi.field_resolver') since it can't be injected. I think this is a small price to pay for the overall reduction in complexity.

gabesullice’s picture

There were some initial discussions about getting rid of these denormalizers in #2987608-32: Move deserialization from RequestHandler to JsonApiParamEnhancer but a separate issue was not created.

gabesullice’s picture

Title: (Re)move _json_api_params from JsonApiParamEnhancer » (Re)move _json_api_params from JsonApiParamEnhancer and do not use denormalization for them
gabesullice’s picture

Issue summary: View changes

Prior to commit, a patch for JSON:API Defaults will need to be provided.

wim leers’s picture

I think this is a bug with AccessAwareRouter, and will open a core issue to fix it.

I'm fine with that. But I'm not sure it's a bug in there. The real problem here is not a bug in one place or the other, but the fact that our routing system design/architecture is not unambiguously defined. It's not clear whether this should or should not work!

Opening that core issue would spark the necessary discussion around this. For the scope of the security issue we could not get blocked on that though.

But in the meantime, doing what this issue suggests and moving the sort and page denormalization from the enhancer to where the filter one is seems sensible.

+1

#10: will review tomorrow. But … EIGHTY SIX KILOBYTES?!

gabesullice’s picture

Patch for JSON:API Defaults ^

gabesullice’s picture

But … EIGHTY SIX KILOBYTES?!

Definitely read #11 first.

wim leers’s picture

Yeah, currently reading this on a mobile device, unfit for patch review. Will do so in the morning.

Great to see progress here in any case :)

@effulgentsia++
@gabesullice++

gabesullice’s picture

Marking this as related to #3022583: [META] Normalization System: clean up/speed up/provide schema because it further reduces the surface area of our Normalizer namespace.

effulgentsia’s picture

wim leers’s picture

StatusFileSize
new88.35 KB

Thanks for #11, that was a big help :) I definitely like the massively increased clarity and simplicity in EntityResource: less magic.

  1. 😍21 files changed, 669 insertions, 1102 deletions. — I'm not surprised!
  2. +++ b/jsonapi.services.yml
    @@ -7,28 +7,6 @@ services:
    -  serializer.normalizer.sort.jsonapi:
    ...
    -  serializer.normalizer.offset_page.jsonapi:
    ...
    -  serializer.normalizer.entity_condition.jsonapi:
    ...
    -  serializer.normalizer.entity_condition_group.jsonapi:
    ...
    -  serializer.normalizer.filter.jsonapi:
    
    --- a/src/Controller/EntityResource.php
    +++ b/src/Controller/EntityResource.php
    …
    

    👍 This all made sense thanks to #11.1 especially.

  3. +++ b/src/LinkManager/LinkManager.php
    @@ -93,33 +93,24 @@ class LinkManager {
    +   * @param \Drupal\jsonapi\Query\OffsetPage $page_param
    +   *   The current pagination parameter for the requested collection.
    ...
    -  public function getPagerLinks(Request $request, array $link_context = []) {
    +  public function getPagerLinks(Request $request, OffsetPage $page_param, array $link_context = []) {
    

    👍 This is now injected for the reason in #11.3. As a side effect, it also results in less magic because less reliance on semi-globals (IMHO Request is the new global in Drupal 8…)

  4. +++ /dev/null
    @@ -1,111 +0,0 @@
    -  const PATH_KEY = 'path';
    ...
    -  protected function validate($data) {
    
    +++ b/src/Query/EntityCondition.php
    @@ -9,6 +12,27 @@ namespace Drupal\jsonapi\Query;
    +  const PATH_KEY = 'path';
    
    @@ -87,4 +111,71 @@ class EntityCondition {
    +  protected static function validate($parameter) {
    

    👍 Validation and constants are moved out of the former normalizers into the value objects that we're keeping. This means we're turning formerly anemic domain models into objects that contain their associated logic.

  5. +++ b/src/Query/Filter.php
    @@ -45,10 +73,10 @@ class Filter {
    -   * @param \Drupal\Entity\Query\QueryInterface $query
    +   * @param \Drupal\Core\Entity\Query\QueryInterface $query
    

    👍 Oh hah, this must always have been wrong then! Seems reasonable to change it here, unless somebody objects.

  6. +++ b/jsonapi.services.yml
    @@ -105,8 +83,8 @@ services:
    +  jsonapi.deserialization.enhancer:
    +    class: Drupal\jsonapi\Routing\DeserializationEnhancer
    

    🤔 This … I was confused by.

    +++ b/src/Routing/DeserializationEnhancer.php
    @@ -19,7 +17,7 @@ use Symfony\Component\Serializer\Exception\UnexpectedValueException;
    -class JsonApiParamEnhancer implements EnhancerInterface, ContainerAwareInterface {
    +class DeserializationEnhancer implements EnhancerInterface, ContainerAwareInterface {
    
    @@ -51,22 +49,7 @@ class JsonApiParamEnhancer implements EnhancerInterface, ContainerAwareInterface
         if (isset($defaults['serialization_class']) && !$request->isMethodSafe(FALSE)) {
           // Deserialize incoming data if available.
    

    😨 Apparently I helped make this happen in #2987608: Move deserialization from RequestHandler to JsonApiParamEnhancer, but I'm now quite confused why we did that. As long as we were doing denormalization of filter/page/sort params in a route enhancer (which we're getting rid of here), it sort of was internally consistent, which is a justification. But it still seems less than ideal, less than clear.

    I'm relieved to see that at the end of #2987608-14: Move deserialization from RequestHandler to JsonApiParamEnhancer, I expressed this same concern. I'm less impressed by my former self's not seeing any problems with this, and actually being in favor of this 😔

    In #2987608-16: Move deserialization from RequestHandler to JsonApiParamEnhancer, @gabesullice did a big refactor of the patch in that issue to introduce this. In hindsight, I think it might've been better to keep EntityResource::deserialize() instead of this.

    Even though changing this is out of scope for this issue, I wonder what @effulgentsia thinks about this? Should we move this logic into EntityResource instead, hence removing this service altogether? We could do that in a separate issue.

  7. --- a/src/Routing/JsonApiParamEnhancer.php
    +++ b/src/Routing/DeserializationEnhancer.php
    

    🤔 Following up on the previous point. For now, I think RequestBodyEnhancer would be a much clearer name. Thoughts?

  8. +++ b/tests/src/Kernel/Controller/EntityResourceTest.php
    @@ -217,18 +213,8 @@ class EntityResourceTest extends JsonapiKernelTestBase {
    -    $sort = new Sort([['path' => 'nid', 'direction' => 'ASC']]);
         $request = Request::create('/jsonapi/node/article');
    -    $request->attributes = new ParameterBag([
    -      '_route_params' => [
    -        '_json_api_params' => [
    -          'sort' => $sort,
    -        ],
    -      ],
    -      '_json_api_params' => [
    -        'sort' => $sort,
    -      ],
    -    ]);
    +    $request->query = new ParameterBag(['sort' => 'nid']);
    

    👏 I hadn't even thought about this, but this madness just disappears, NICE! 👏

    (This is where kernel tests fell disappointingly short: we had to replicate much/most of the implementation details, making the tests highly coupled to implementation details…)

  9. --- a/tests/src/Kernel/Normalizer/FilterNormalizerTest.php
    +++ /dev/null
    @@ -1,160 +0,0 @@
    -  public function denormalizeProvider() {
    ...
    -  public function testDenormalizeNested() {
    
    +++ b/tests/src/Kernel/Query/FilterTest.php
    @@ -336,4 +293,115 @@ class FilterTest extends JsonapiKernelTestBase {
    +  public function parameterProvider() {
    ...
    +  public function testCreateFromQueryParameterNested() {
    

    👍 This test coverage was not lost, but moved.

  10. +++ b/tests/src/Kernel/Query/FilterTest.php
    @@ -142,13 +112,8 @@ class FilterTest extends JsonapiKernelTestBase {
    -    $normalized = [
    -      'colors.foobar' => '',
    -    ];
    -    $this->normalizer->denormalize($normalized, Filter::class, NULL, [
    -      'entity_type_id' => 'node',
    -      'bundle' => 'painting',
    -    ]);
    +    $resource_type = new ResourceType('node', 'painting', NULL);
    +    Filter::createFromQueryParameter(['colors.foobar' => ''], $resource_type, $this->fieldResolver);
    

    👍 These tests are also much easier to understand now, wonderful!

  11. +++ b/tests/src/Kernel/Query/SortTest.php
    @@ -58,18 +41,21 @@ class SortNormalizerTest extends KernelTestBase {
    -      ['lorem', [['path' => 'foo', 'direction' => 'ASC', 'langcode' => NULL]]],
    ...
    +      ['lorem', [['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL]]],
    

    👍 … another case where our existing tests were giving false assurances because they were relying on too much magic.

    Actually, thanks to this refactor, we can convert this to a unit test! 👍 In fact, we can do that for all of these, except for FilterTest, because Filter is the only one that has a dependency on another service. Done!

wim leers’s picture

StatusFileSize
new6.82 KB

I forgot #21's interdiff 😊

Status: Needs review » Needs work

The last submitted patch, 21: 3022531-21.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new532 bytes
new88.35 KB
wim leers’s picture

Confirmed that #16 still works: both JSON:API Extras and its "Defaults" submodules pass tests with it. And it actually became simpler 😀

gabesullice’s picture

  1. +++ b/tests/src/Unit/Query/EntityConditionGroupTest.php
    @@ -1,28 +1,19 @@
    - * @group jsonapi_normalizers
    
    +++ b/tests/src/Unit/Query/EntityConditionTest.php
    @@ -1,29 +1,36 @@
    - * @group jsonapi_normalizers
    
    +++ b/tests/src/Unit/Query/OffsetPageTest.php
    @@ -1,29 +1,36 @@
    - * @group jsonapi_normalizers
    
    +++ b/tests/src/Unit/Query/SortTest.php
    @@ -1,29 +1,36 @@
    - * @group jsonapi_normalizers
    
    @@ -39,7 +46,7 @@ class SortTest extends KernelTestBase {
    -   * Provides a suite of shortcut sort pamaters and their expected expansions.
    +   * Provides a suite of shortcut sort paramaters and their expected expansions.
    

    🙏

  2. +++ b/tests/src/Unit/Query/EntityConditionGroupTest.php
    @@ -1,28 +1,19 @@
    -class EntityConditionGroupTest extends KernelTestBase {
    -
    -  /**
    -   * {@inheritdoc}
    -   */
    -  public static $modules = [
    -    'serialization',
    -    'system',
    -    'jsonapi',
    -  ];
    +class EntityConditionGroupTest extends UnitTestCase {
    

    ❤️

  3. +++ b/tests/src/Unit/Query/EntityConditionGroupTest.php
    @@ -48,7 +39,7 @@ class EntityConditionGroupTest extends KernelTestBase {
    -   * Data provider for testDenormalize.
    +   * Data provider for testConstruct.
    

    🙏

  4. +++ b/tests/src/Unit/Query/EntityConditionTest.php
    @@ -72,6 +79,9 @@ class EntityConditionTest extends KernelTestBase {
         EntityCondition::createFromQueryParameter($input);
    +    if (!$exception) {
    +      $this->assertTrue(TRUE);
    +    }
    

    What is this for?

    After this has an answer^, this is RTBC!

wim leers’s picture

What is this for?

Remove it and re-run that unit test. You'll see that PHPUnit complains about it because there were zero assertions :) It's obvious in hindsight: in the previous patches and in HEAD, we're only doing an assertion in case of an invalid input.

gabesullice’s picture

Status: Needs review » Reviewed & tested by the community
StatusFileSize
new604 bytes
new88.36 KB

Ah, makes sense. I cleared it up a little.

wim leers’s picture

WFM!

wim leers’s picture

I wrote in #25:

Confirmed that #16 still works: both JSON:API Extras and its "Defaults" submodules pass tests with it. And it actually became simpler 😀

I just checked, and with this applied, https://www.drupal.org/project/consumer_image_styles/releases/8.x-3.x-dev also still passes tests, without any changes 👍

e0ipso’s picture

Thanks for this! +1

  • Wim Leers committed daf3549 on 8.x-2.x authored by gabesullice
    Issue #3022531 by gabesullice, Wim Leers, effulgentsia, e0ipso: (Re)move...
wim leers’s picture

Status: Reviewed & tested by the community » Fixed

  • Wim Leers committed d9e9542 on 8.x-2.x
    Issue #3033584 by Wim Leers, gabesullice: Follow-up for #3022531:...

Status: Fixed » Closed (fixed)

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