Issue #2824851 by Wim Leers, arshadcn, amateescu, effulgentsia, tedbow, timmillwood, cburschka, tstoeckler, Berdir, xjm, catch: EntityResource::patch() makes an incorrect assumption about entity keys, hence results in incorrect behavior
parent
0c20200d12
commit
0dc30938c7
|
@ -25,11 +25,11 @@ class CommentHalJsonAnonTest extends CommentHalJsonTestBase {
|
|||
* @see ::setUpAuthorization
|
||||
*/
|
||||
protected static $patchProtectedFieldNames = [
|
||||
'entity_id',
|
||||
'changed',
|
||||
'thread',
|
||||
'entity_type',
|
||||
'field_name',
|
||||
'entity_id',
|
||||
];
|
||||
|
||||
}
|
||||
|
|
|
@ -26,25 +26,6 @@ abstract class CommentHalJsonTestBase extends CommentResourceTestBase {
|
|||
*/
|
||||
protected static $mimeType = 'application/hal+json';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* The HAL+JSON format causes different PATCH-protected fields. For some
|
||||
* reason, the 'pid' and 'homepage' fields are NOT PATCH-protected, even
|
||||
* though they are for non-HAL+JSON serializations.
|
||||
*
|
||||
* @todo fix in https://www.drupal.org/node/2824271
|
||||
*/
|
||||
protected static $patchProtectedFieldNames = [
|
||||
'status',
|
||||
'created',
|
||||
'changed',
|
||||
'thread',
|
||||
'entity_type',
|
||||
'field_name',
|
||||
'entity_id',
|
||||
'uid',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
|
|
|
@ -70,24 +70,6 @@ trait HalEntityNormalizationTrait {
|
|||
return $normalization;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function removeFieldsFromNormalization(array $normalization, $field_names) {
|
||||
$normalization = parent::removeFieldsFromNormalization($normalization, $field_names);
|
||||
foreach ($field_names as $field_name) {
|
||||
$relation_url = Url::fromUri('base:rest/relation/' . static::$entityTypeId . '/' . $this->entity->bundle() . '/' . $field_name)
|
||||
->setAbsolute(TRUE)
|
||||
->toString();
|
||||
$normalization['_links'] = array_diff_key($normalization['_links'], [$relation_url => TRUE]);
|
||||
if (isset($normalization['_embedded'])) {
|
||||
$normalization['_embedded'] = array_diff_key($normalization['_embedded'], [$relation_url => TRUE]);
|
||||
}
|
||||
}
|
||||
|
||||
return array_diff_key($normalization, array_flip($field_names));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
|
|
@ -30,19 +30,6 @@ class NodeHalJsonAnonTest extends NodeResourceTestBase {
|
|||
*/
|
||||
protected static $mimeType = 'application/hal+json';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $patchProtectedFieldNames = [
|
||||
'revision_timestamp',
|
||||
'created',
|
||||
'changed',
|
||||
'promote',
|
||||
'sticky',
|
||||
'path',
|
||||
'revision_uid',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
|
|
@ -11,6 +11,7 @@ use Drupal\Core\Entity\FieldableEntityInterface;
|
|||
use Drupal\Core\Config\ConfigFactoryInterface;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\EntityStorageException;
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
|
||||
use Drupal\rest\Plugin\ResourceBase;
|
||||
use Drupal\rest\ResourceResponse;
|
||||
|
@ -226,38 +227,18 @@ class EntityResource extends ResourceBase implements DependentPluginInterface {
|
|||
throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'update'));
|
||||
}
|
||||
|
||||
// Overwrite the received properties.
|
||||
$entity_keys = $entity->getEntityType()->getKeys();
|
||||
// Overwrite the received fields.
|
||||
foreach ($entity->_restSubmittedFields as $field_name) {
|
||||
$field = $entity->get($field_name);
|
||||
|
||||
// Entity key fields need special treatment: together they uniquely
|
||||
// identify the entity. Therefore it does not make sense to modify any of
|
||||
// them. However, rather than throwing an error, we just ignore them as
|
||||
// long as their specified values match their current values.
|
||||
if (in_array($field_name, $entity_keys, TRUE)) {
|
||||
// @todo Work around the wrong assumption that entity keys need special
|
||||
// treatment, when only read-only fields need it.
|
||||
// This will be fixed in https://www.drupal.org/node/2824851.
|
||||
if ($entity->getEntityTypeId() == 'comment' && $field_name == 'status' && !$original_entity->get($field_name)->access('edit')) {
|
||||
throw new AccessDeniedHttpException("Access denied on updating field '$field_name'.");
|
||||
}
|
||||
|
||||
// Unchanged values for entity keys don't need access checking.
|
||||
if ($original_entity->get($field_name)->equals($field)) {
|
||||
continue;
|
||||
}
|
||||
// It is not possible to set the language to NULL as it is automatically
|
||||
// re-initialized. As it must not be empty, skip it if it is.
|
||||
elseif (isset($entity_keys['langcode']) && $field_name === $entity_keys['langcode'] && $field->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
// It is not possible to set the language to NULL as it is automatically
|
||||
// re-initialized. As it must not be empty, skip it if it is.
|
||||
// @todo Remove in https://www.drupal.org/project/drupal/issues/2933408.
|
||||
if ($entity->getEntityType()->hasKey('langcode') && $field_name === $entity->getEntityType()->getKey('langcode') && $field->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$original_entity->get($field_name)->access('edit')) {
|
||||
throw new AccessDeniedHttpException("Access denied on updating field '$field_name'.");
|
||||
if ($this->checkPatchFieldAccess($original_entity->get($field_name), $field)) {
|
||||
$original_entity->set($field_name, $field->getValue());
|
||||
}
|
||||
$original_entity->set($field_name, $field->getValue());
|
||||
}
|
||||
|
||||
// Validate the received data before saving.
|
||||
|
@ -274,6 +255,49 @@ class EntityResource extends ResourceBase implements DependentPluginInterface {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given field should be PATCHed.
|
||||
*
|
||||
* @param \Drupal\Core\Field\FieldItemListInterface $original_field
|
||||
* The original (stored) value for the field.
|
||||
* @param \Drupal\Core\Field\FieldItemListInterface $received_field
|
||||
* The received value for the field.
|
||||
*
|
||||
* @return bool
|
||||
* Whether the field should be PATCHed or not.
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
|
||||
* Thrown when the user sending the request is not allowed to update the
|
||||
* field. Only thrown when the user could not abuse this information to
|
||||
* determine the stored value.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function checkPatchFieldAccess(FieldItemListInterface $original_field, FieldItemListInterface $received_field) {
|
||||
// If the user is allowed to edit the field, it is always safe to set the
|
||||
// received value. We may be setting an unchanged value, but that is ok.
|
||||
if ($original_field->access('edit')) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// The user might not have access to edit the field, but still needs to
|
||||
// submit the current field value as part of the PATCH request. For
|
||||
// example, the entity keys required by denormalizers. Therefore, if the
|
||||
// received value equals the stored value, return FALSE without throwing an
|
||||
// exception. But only for fields that the user has access to view, because
|
||||
// the user has no legitimate way of knowing the current value of fields
|
||||
// that they are not allowed to view, and we must not make the presence or
|
||||
// absence of a 403 response a way to find that out.
|
||||
if ($original_field->equals($received_field) && $original_field->access('view')) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// It's helpful and safe to let the user know when they are not allowed to
|
||||
// update a field.
|
||||
$field_name = $received_field->getName();
|
||||
throw new AccessDeniedHttpException("Access denied on updating field '$field_name'.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Responds to entity DELETE requests.
|
||||
*
|
||||
|
|
|
@ -3,15 +3,20 @@
|
|||
namespace Drupal\Tests\rest\Functional\EntityResource;
|
||||
|
||||
use Drupal\Component\Utility\NestedArray;
|
||||
use Drupal\Component\Utility\Random;
|
||||
use Drupal\Core\Cache\Cache;
|
||||
use Drupal\Core\Cache\CacheableResponseInterface;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Config\Entity\ConfigEntityInterface;
|
||||
use Drupal\Core\Entity\ContentEntityNullStorage;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\FieldableEntityInterface;
|
||||
use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
|
||||
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\field\Entity\FieldConfig;
|
||||
use Drupal\field\Entity\FieldStorageConfig;
|
||||
use Drupal\path\Plugin\Field\FieldType\PathItem;
|
||||
use Drupal\rest\ResourceResponseInterface;
|
||||
use Drupal\Tests\rest\Functional\ResourceTestBase;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
|
@ -906,6 +911,10 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
|
|||
$parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
|
||||
$parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), static::$format);
|
||||
$parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
|
||||
// The 'field_rest_test' field does not allow 'view' access, so does not end
|
||||
// up in the normalization. Even when we explicitly add it the normalization
|
||||
// that we send in the body of a PATCH request, it is considered invalid.
|
||||
$parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()], static::$format);
|
||||
|
||||
// The URL and Guzzle request options that will be used in this test. The
|
||||
// request options will be modified/expanded throughout this test:
|
||||
|
@ -997,22 +1006,31 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
|
|||
$response = $this->request('PATCH', $url, $request_options);
|
||||
$this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
|
||||
|
||||
// DX: 403 when sending PATCH request with read-only fields.
|
||||
// First send all fields (the "maximum normalization"). Assert the expected
|
||||
// error message for the first PATCH-protected field. Remove that field from
|
||||
// the normalization, send another request, assert the next PATCH-protected
|
||||
// field error message. And so on.
|
||||
$max_normalization = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format);
|
||||
$request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
|
||||
|
||||
// DX: 403 when entity contains field without 'edit' nor 'view' access, even
|
||||
// when the value for that field matches the current value. This is allowed
|
||||
// in principle, but leads to information disclosure.
|
||||
$response = $this->request('PATCH', $url, $request_options);
|
||||
$this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
|
||||
|
||||
// DX: 403 when sending PATCH request with updated read-only fields.
|
||||
list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity);
|
||||
// Send PATCH request by serializing the modified entity, assert the error
|
||||
// response, change the modified entity field that caused the error response
|
||||
// back to its original value, repeat.
|
||||
for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) {
|
||||
$max_normalization = $this->removeFieldsFromNormalization($max_normalization, array_slice(static::$patchProtectedFieldNames, 0, $i));
|
||||
$request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format);
|
||||
$patch_protected_field_name = static::$patchProtectedFieldNames[$i];
|
||||
$request_options[RequestOptions::BODY] = $this->serializer->serialize($modified_entity, static::$format);
|
||||
$response = $this->request('PATCH', $url, $request_options);
|
||||
$this->assertResourceErrorResponse(403, "Access denied on updating field '" . static::$patchProtectedFieldNames[$i] . "'.", $response);
|
||||
$this->assertResourceErrorResponse(403, "Access denied on updating field '" . $patch_protected_field_name . "'.", $response);
|
||||
$modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]);
|
||||
}
|
||||
|
||||
// 200 for well-formed request that sends the maximum number of fields.
|
||||
$max_normalization = $this->removeFieldsFromNormalization($max_normalization, static::$patchProtectedFieldNames);
|
||||
$request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format);
|
||||
// 200 for well-formed PATCH request that sends all fields (even including
|
||||
// read-only ones, but with unchanged values).
|
||||
$valid_request_body = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format);
|
||||
$request_options[RequestOptions::BODY] = $this->serializer->serialize($valid_request_body, static::$format);
|
||||
$response = $this->request('PATCH', $url, $request_options);
|
||||
$this->assertResourceResponse(200, FALSE, $response);
|
||||
|
||||
|
@ -1234,6 +1252,57 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
|
|||
return $has_create_url ? Url::fromUri('internal:' . $this->entity->getEntityType()->getLinkTemplate('create')) : Url::fromUri('base:entity/' . static::$entityTypeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones the given entity and modifies all PATCH-protected fields.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity being tested and to modify.
|
||||
*
|
||||
* @return array
|
||||
* Contains two items:
|
||||
* 1. The modified entity object.
|
||||
* 2. The original field values, keyed by field name.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) {
|
||||
$modified_entity = clone $entity;
|
||||
$original_values = [];
|
||||
foreach (static::$patchProtectedFieldNames as $field_name) {
|
||||
$field = $modified_entity->get($field_name);
|
||||
$original_values[$field_name] = $field->getValue();
|
||||
switch ($field->getItemDefinition()->getClass()) {
|
||||
case EntityReferenceItem::class:
|
||||
// EntityReferenceItem::generateSampleValue() picks one of the last 50
|
||||
// entities of the supported type & bundle. We don't care if the value
|
||||
// is valid, we only care that it's different.
|
||||
$field->setValue(['target_id' => 99999]);
|
||||
break;
|
||||
case BooleanItem::class:
|
||||
// BooleanItem::generateSampleValue() picks either 0 or 1. So a 50%
|
||||
// chance of not picking a different value.
|
||||
$field->value = ((int) $field->value) === 1 ? '0' : '1';
|
||||
break;
|
||||
case PathItem::class:
|
||||
// PathItem::generateSampleValue() doesn't set a PID, which causes
|
||||
// PathItem::postSave() to fail. Keep the PID (and other properties),
|
||||
// just modify the alias.
|
||||
$value = $field->getValue();
|
||||
$value['alias'] = str_replace(' ', '-', strtolower((new Random())->sentences(3)));
|
||||
$field->setValue($value);
|
||||
break;
|
||||
default:
|
||||
$original_field = clone $field;
|
||||
while ($field->equals($original_field)) {
|
||||
$field->generateSampleItems();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [$modified_entity, $original_values];
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the given entity normalization invalid.
|
||||
*
|
||||
|
@ -1251,23 +1320,6 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
|
|||
return $normalization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes fields from a normalization.
|
||||
*
|
||||
* @param array $normalization
|
||||
* An entity normalization.
|
||||
* @param string[] $field_names
|
||||
* The field names to remove from the entity normalization.
|
||||
*
|
||||
* @return array
|
||||
* The updated entity normalization.
|
||||
*
|
||||
* @see ::testPatch
|
||||
*/
|
||||
protected function removeFieldsFromNormalization(array $normalization, $field_names) {
|
||||
return array_diff_key($normalization, array_flip($field_names));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a 406 response… or in some cases a 403 response, because weirdness.
|
||||
*
|
||||
|
|
|
@ -235,20 +235,6 @@ abstract class NodeResourceTestBase extends EntityResourceTestBase {
|
|||
$response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET'));
|
||||
$normalization = $this->serializer->decode((string) $response->getBody(), static::$format);
|
||||
|
||||
// @todo In https://www.drupal.org/node/2824851, we will be able to stop
|
||||
// unsetting these fields from the normalization, because
|
||||
// EntityResource::patch() will ignore any fields that are sent that
|
||||
// match the current value (and obviously we're sending the current
|
||||
// value).
|
||||
$normalization = $this->removeFieldsFromNormalization($normalization, [
|
||||
'revision_timestamp',
|
||||
'revision_uid',
|
||||
'created',
|
||||
'changed',
|
||||
'promote',
|
||||
'sticky',
|
||||
]);
|
||||
|
||||
// Change node's path alias.
|
||||
$normalization['path'][0]['alias'] .= 's-rule-the-world';
|
||||
|
||||
|
@ -258,8 +244,11 @@ abstract class NodeResourceTestBase extends EntityResourceTestBase {
|
|||
$request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
|
||||
$request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
|
||||
|
||||
// PATCH request: 403 when creating URL aliases unauthorized.
|
||||
// PATCH request: 403 when creating URL aliases unauthorized. Before
|
||||
// asserting the 403 response, assert that the stored path alias remains
|
||||
// unchanged.
|
||||
$response = $this->request('PATCH', $url, $request_options);
|
||||
$this->assertSame('/llama', $this->entityStorage->loadUnchanged($this->entity->id())->get('path')->alias);
|
||||
$this->assertResourceErrorResponse(403, "Access denied on updating field 'path'.", $response);
|
||||
|
||||
// Grant permission to create URL aliases.
|
||||
|
|
|
@ -208,15 +208,6 @@ abstract class TermResourceTestBase extends EntityResourceTestBase {
|
|||
$response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET'));
|
||||
$normalization = $this->serializer->decode((string) $response->getBody(), static::$format);
|
||||
|
||||
// @todo In https://www.drupal.org/node/2824851, we will be able to stop
|
||||
// unsetting these fields from the normalization, because
|
||||
// EntityResource::patch() will ignore any fields that are sent that
|
||||
// match the current value (and obviously we're sending the current
|
||||
// value).
|
||||
$normalization = $this->removeFieldsFromNormalization($normalization, [
|
||||
'changed',
|
||||
]);
|
||||
|
||||
// Change term's path alias.
|
||||
$normalization['path'][0]['alias'] .= 's-rule-the-world';
|
||||
|
||||
|
|
Loading…
Reference in New Issue