Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

namespace Drupal\Tests\language\Functional\Rest;

use Drupal\Tests\rest\Functional\BasicAuthResourceWithInterfaceTranslationTestTrait;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;

/**
* @group rest
*/
class ConfigurableLanguageJsonBasicAuthTest extends ConfigurableLanguageResourceTestBase {

use BasicAuthResourceWithInterfaceTranslationTestTrait;
use BasicAuthResourceTestTrait;

/**
* {@inheritdoc}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

namespace Drupal\Tests\language\Functional\Rest;

use Drupal\Tests\rest\Functional\BasicAuthResourceWithInterfaceTranslationTestTrait;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;

/**
* @group rest
*/
class ConfigurableLanguageXmlBasicAuthTest extends ConfigurableLanguageResourceTestBase {

use BasicAuthResourceWithInterfaceTranslationTestTrait;
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

namespace Drupal\Tests\language\Functional\Rest;

use Drupal\Tests\rest\Functional\BasicAuthResourceWithInterfaceTranslationTestTrait;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;

/**
* @group rest
*/
class ContentLanguageSettingsJsonBasicAuthTest extends ContentLanguageSettingsResourceTestBase {

use BasicAuthResourceWithInterfaceTranslationTestTrait;
use BasicAuthResourceTestTrait;

/**
* {@inheritdoc}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

namespace Drupal\Tests\language\Functional\Rest;

use Drupal\Tests\rest\Functional\BasicAuthResourceWithInterfaceTranslationTestTrait;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;

/**
* @group rest
*/
class ContentLanguageSettingsXmlBasicAuthTest extends ContentLanguageSettingsResourceTestBase {

use BasicAuthResourceWithInterfaceTranslationTestTrait;
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;

/**
Expand Down
7 changes: 6 additions & 1 deletion core/modules/link/src/Plugin/Field/FieldType/LinkItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,12 @@ public function setValue($values, $notify = TRUE) {
// SqlContentEntityStorage::loadFieldItems, see
// https://www.drupal.org/node/2414835
if (is_string($values['options'])) {
$values['options'] = unserialize($values['options']);
if (version_compare(PHP_VERSION, '7.0.0', '>=')) {
$values['options'] = unserialize($values['options'], ['allowed_classes' => FALSE]);
}
else {
$values['options'] = unserialize($values['options']);
}
}
parent::setValue($values, $notify);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ protected function uploadFile() {

// To still run the complete test coverage for POSTing a Media entity, we
// must revoke the additional permissions that we granted.
$role = Role::load(static::$auth ? RoleInterface::AUTHENTICATED_ID : RoleInterface::AUTHENTICATED_ID);
$role = Role::load(static::$auth ? RoleInterface::AUTHENTICATED_ID : RoleInterface::ANONYMOUS_ID);
$role->revokePermission('create camelids media');
$role->trustData()->save();
}
Expand Down Expand Up @@ -422,9 +422,9 @@ protected function getExpectedNormalizedFileEntity() {
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
protected function getExpectedUnauthorizedEntityAccessCacheability($is_authenticated) {
// @see \Drupal\media\MediaAccessControlHandler::checkAccess()
return parent::getExpectedUnauthorizedAccessCacheability()
return parent::getExpectedUnauthorizedEntityAccessCacheability($is_authenticated)
->addCacheTags(['media:1']);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter
}

case 'delete':
return AccessResult::allowedIf(!$entity->isNew() && $account->hasPermission('administer menu'))->cachePerPermissions()->addCacheableDependency($entity);
return AccessResult::allowedIfHasPermission($account, 'administer menu')
->andIf(AccessResult::allowedIf(!$entity->isNew())->addCacheableDependency($entity));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ protected function getExpectedUnauthorizedAccessMessage($method) {

switch ($method) {
case 'DELETE':
return "You are not authorized to delete this menu_link_content entity.";
return "The 'administer menu' permission is required.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
Expand Down
2 changes: 1 addition & 1 deletion core/modules/node/src/NodeAccessControlHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public function createAccess($entity_bundle = NULL, AccountInterface $account =
return $return_as_object ? $result : $result->isAllowed();
}
if (!$account->hasPermission('access content')) {
$result = AccessResult::forbidden()->cachePerPermissions();
$result = AccessResult::forbidden("The 'access content' permission is required.")->cachePerPermissions();
return $return_as_object ? $result : $result->isAllowed();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ protected function getExpectedUnauthorizedAccessMessage($method) {
return parent::getExpectedUnauthorizedAccessMessage($method);
}

if ($method === 'GET' || $method == 'PATCH' || $method == 'DELETE') {
if ($method === 'GET' || $method == 'PATCH' || $method == 'DELETE' || $method == 'POST') {
return "The 'access content' permission is required.";
}
return parent::getExpectedUnauthorizedAccessMessage($method);
Expand Down
7 changes: 7 additions & 0 deletions core/modules/rest/rest.post_update.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,10 @@ function rest_post_update_resource_granularity() {
}
}
}

/**
* Clear caches due to changes in route definitions.
*/
function rest_post_update_161923() {
// Empty post-update hook.
}
37 changes: 22 additions & 15 deletions core/modules/rest/src/Plugin/rest/resource/EntityResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
use Drupal\Core\Routing\AccessAwareRouterInterface;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\rest\ModifiedResourceResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
Expand Down Expand Up @@ -121,14 +122,11 @@ public static function create(ContainerInterface $container, array $configuratio
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function get(EntityInterface $entity) {
$entity_access = $entity->access('view', NULL, TRUE);
if (!$entity_access->isAllowed()) {
throw new CacheableAccessDeniedHttpException($entity_access, $entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'view'));
}

$request = \Drupal::request();
$response = new ResourceResponse($entity, 200);
// @todo Either remove the line below or remove this todo in https://www.drupal.org/project/drupal/issues/2973356
$response->addCacheableDependency($request->attributes->get(AccessAwareRouterInterface::ACCESS_RESULT));
$response->addCacheableDependency($entity);
$response->addCacheableDependency($entity_access);

if ($entity instanceof FieldableEntityInterface) {
foreach ($entity as $field_name => $field) {
Expand Down Expand Up @@ -223,10 +221,6 @@ public function patch(EntityInterface $original_entity, EntityInterface $entity
if ($entity->getEntityTypeId() != $definition['entity_type']) {
throw new BadRequestHttpException('Invalid entity type');
}
$entity_access = $original_entity->access('update', NULL, TRUE);
if (!$entity_access->isAllowed()) {
throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'update'));
}

// Overwrite the received fields.
// @todo Remove $changed_fields in https://www.drupal.org/project/drupal/issues/2862574.
Expand Down Expand Up @@ -327,10 +321,6 @@ protected function checkPatchFieldAccess(FieldItemListInterface $original_field,
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function delete(EntityInterface $entity) {
$entity_access = $entity->access('delete', NULL, TRUE);
if (!$entity_access->isAllowed()) {
throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'delete'));
}
try {
$entity->delete();
$this->logger->notice('Deleted entity %type with ID %id.', ['%type' => $entity->getEntityTypeId(), '%id' => $entity->id()]);
Expand Down Expand Up @@ -383,6 +373,23 @@ public function permissions() {
*/
protected function getBaseRoute($canonical_path, $method) {
$route = parent::getBaseRoute($canonical_path, $method);

switch ($method) {
case 'GET':
$route->setRequirement('_entity_access', $this->entityType->id() . '.view');
break;
case 'POST':
$route->setRequirement('_entity_create_any_access', $this->entityType->id());
$route->setOption('_ignore_create_bundle_access', TRUE);
break;
case 'PATCH':
$route->setRequirement('_entity_access', $this->entityType->id() . '.update');
break;
case 'DELETE':
$route->setRequirement('_entity_access', $this->entityType->id() . '.delete');
break;
}

$definition = $this->getPluginDefinition();

$parameters = $route->getOption('parameters') ?: [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultReasonInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;

Expand All @@ -26,5 +27,9 @@ function config_test_rest_config_test_access(EntityInterface $entity, $operation
// Add permission, so that EntityResourceTestBase's scenarios can test access
// being denied. By default, all access is always allowed for the config_test
// config entity.
return AccessResult::forbiddenIf(!$account->hasPermission('view config_test'))->cachePerPermissions();
$access_result = AccessResult::forbiddenIf(!$account->hasPermission('view config_test'))->cachePerPermissions();
if (!$access_result->isAllowed() && $access_result instanceof AccessResultReasonInterface) {
$access_result->setReason("The 'view config_test' permission is required.");
}
return $access_result;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
* authenticated, a 401 response must be sent.
* - Because every request must send an authorization, there is no danger of
* CSRF attacks.
*
* @see \Drupal\Tests\rest\Functional\BasicAuthResourceWithInterfaceTranslationTestTrait
*/
trait BasicAuthResourceTestTrait {

Expand All @@ -34,10 +32,23 @@ protected function getAuthenticationRequestOptions($method) {
* {@inheritdoc}
*/
protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response) {
if ($method !== 'GET') {
return $this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response);
}

$expected_page_cache_header_value = $method === 'GET' ? 'MISS' : FALSE;
// @see \Drupal\basic_auth\Authentication\Provider\BasicAuth::challengeException()
$expected_dynamic_page_cache_header_value = $expected_page_cache_header_value;
$this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response, ['4xx-response', 'config:system.site', 'config:user.role.anonymous', 'http_response'], ['user.roles:anonymous'], $expected_page_cache_header_value, $expected_dynamic_page_cache_header_value);
$expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability()
->addCacheableDependency($this->getExpectedUnauthorizedEntityAccessCacheability(FALSE))
// @see \Drupal\basic_auth\Authentication\Provider\BasicAuth::challengeException()
->addCacheableDependency($this->config('system.site'))
// @see \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber::onRespond()
->addCacheTags(['config:user.role.anonymous']);
// Only add the 'user.roles:anonymous' cache context if its parent cache
// context is not already present.
if (!in_array('user.roles', $expected_cacheability->getCacheContexts(), TRUE)) {
$expected_cacheability->addCacheContexts(['user.roles:anonymous']);
}
$this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), $expected_page_cache_header_value, FALSE);
}

/**
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ protected function assertResponseWhenMissingAuthentication($method, ResponseInte
// @see \Drupal\user\Authentication\Provider\Cookie
// @todo https://www.drupal.org/node/2847623
if ($method === 'GET') {
$expected_cookie_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
$expected_cookie_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability()
// @see \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber::onRespond()
->addCacheableDependency($this->getExpectedUnauthorizedEntityAccessCacheability(FALSE));
// - \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber applies
// to cacheable anonymous responses: it updates their cacheability.
// - A 403 response to a GET request is cacheable.
Expand All @@ -111,7 +113,7 @@ protected function assertResponseWhenMissingAuthentication($method, ResponseInte
if (static::$entityTypeId === 'block') {
$expected_cookie_403_cacheability->setCacheTags(str_replace('user:2', 'user:0', $expected_cookie_403_cacheability->getCacheTags()));
}
$this->assertResourceErrorResponse(403, FALSE, $response, $expected_cookie_403_cacheability->getCacheTags(), $expected_cookie_403_cacheability->getCacheContexts(), 'MISS', 'MISS');
$this->assertResourceErrorResponse(403, FALSE, $response, $expected_cookie_403_cacheability->getCacheTags(), $expected_cookie_403_cacheability->getCacheContexts(), 'MISS', FALSE);
}
else {
$this->assertResourceErrorResponse(403, FALSE, $response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,20 @@ protected function getExpectedUnauthorizedAccessCacheability() {
->setCacheContexts(['user.permissions']);
}

/**
* The cacheability of unauthorized 'view' entity access.
*
* @param bool $is_authenticated
* Whether the current request is authenticated or not. This matters for
* some entity access control handlers, but not for most.
*
* @return \Drupal\Core\Cache\CacheableMetadata
* The expected cacheability.
*/
protected function getExpectedUnauthorizedEntityAccessCacheability($is_authenticated) {
return new CacheableMetadata();
}

/**
* The expected cache tags for the GET/HEAD response of the test entity.
*
Expand Down Expand Up @@ -441,7 +455,11 @@ public function testGet() {
// response because ?_format query string is present.
$response = $this->request('GET', $url, $request_options);
if ($has_canonical_url) {
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
$expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability()
// @see \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber::onRespond()
->addCacheTags(['config:user.role.anonymous']);
$expected_cacheability->addCacheableDependency($this->getExpectedUnauthorizedEntityAccessCacheability(FALSE));
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'MISS', FALSE);
}
else {
$this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
Expand Down Expand Up @@ -474,7 +492,8 @@ public function testGet() {

// First: single format. Drupal will automatically pick the only format.
$this->provisionEntityResource(TRUE);
$expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
$expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability()
->addCacheableDependency($this->getExpectedUnauthorizedEntityAccessCacheability(static::$auth !== FALSE));
// DX: 403 because unauthorized single-format route, ?_format is omittable.
$url->setOption('query', []);
$response = $this->request('GET', $url, $request_options);
Expand All @@ -483,13 +502,13 @@ public function testGet() {
$this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
}
else {
$this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
$this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', FALSE);
}
$this->assertSame(static::$auth ? [] : ['MISS'], $response->getHeader('X-Drupal-Cache'));
// DX: 403 because unauthorized.
$url->setOption('query', ['_format' => static::$format]);
$response = $this->request('GET', $url, $request_options);
$this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', $has_canonical_url ? 'MISS' : 'HIT');
$this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', FALSE);

// Then, what we'll use for the remainder of the test: multiple formats.
$this->provisionEntityResource();
Expand All @@ -509,7 +528,7 @@ public function testGet() {
// DX: 403 because unauthorized.
$url->setOption('query', ['_format' => static::$format]);
$response = $this->request('GET', $url, $request_options);
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'HIT');
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', FALSE);
$this->assertArrayNotHasKey('Link', $response->getHeaders());

$this->setUpAuthorization('GET');
Expand Down Expand Up @@ -687,7 +706,15 @@ public function testGet() {

// DX: 403 when unauthorized.
$response = $this->request('GET', $url, $request_options);
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
$expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
// Permission checking now happens first, so it's the only cache context we
// could possibly vary by.
$expected_403_cacheability->setCacheContexts(['user.permissions']);
// @see \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber::onRespond()
if (static::$auth === FALSE) {
$expected_403_cacheability->addCacheTags(['config:user.role.anonymous']);
}
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', FALSE);

$this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityTypeId]);

Expand Down Expand Up @@ -839,18 +866,6 @@ public function testPost() {

$request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;

// DX: 400 when no request body.
$response = $this->request('POST', $url, $request_options);
$this->assertResourceErrorResponse(400, 'No entity content received.', $response);

$request_options[RequestOptions::BODY] = $unparseable_request_body;

// DX: 400 when unparseable request body.
$response = $this->request('POST', $url, $request_options);
$this->assertResourceErrorResponse(400, 'Syntax error', $response);

$request_options[RequestOptions::BODY] = $parseable_invalid_request_body;

if (static::$auth) {
// DX: forgetting authentication: authentication provider-specific error
// response.
Expand All @@ -862,16 +877,22 @@ public function testPost() {

// DX: 403 when unauthorized.
$response = $this->request('POST', $url, $request_options);
// @todo Remove this if-test in https://www.drupal.org/project/drupal/issues/2820364
if (static::$entityTypeId === 'media' && !static::$auth) {
$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nname: Name: this field cannot hold more than 1 values.\nfield_media_file.0: You do not have access to the referenced entity (file: 3).\n", $response);
}
else {
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
}
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);

$this->setUpAuthorization('POST');

// DX: 400 when no request body.
$response = $this->request('POST', $url, $request_options);
$this->assertResourceErrorResponse(400, 'No entity content received.', $response);

$request_options[RequestOptions::BODY] = $unparseable_request_body;

// DX: 400 when unparseable request body.
$response = $this->request('POST', $url, $request_options);
$this->assertResourceErrorResponse(400, 'Syntax error', $response);

$request_options[RequestOptions::BODY] = $parseable_invalid_request_body;

// DX: 422 when invalid entity: multiple values sent for single-value field.
$response = $this->request('POST', $url, $request_options);
$label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
Expand Down Expand Up @@ -1074,18 +1095,6 @@ public function testPatch() {

$request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;

// DX: 400 when no request body.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(400, 'No entity content received.', $response);

$request_options[RequestOptions::BODY] = $unparseable_request_body;

// DX: 400 when unparseable request body.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(400, 'Syntax error', $response);

$request_options[RequestOptions::BODY] = $parseable_invalid_request_body;

if (static::$auth) {
// DX: forgetting authentication: authentication provider-specific error
// response.
Expand All @@ -1101,6 +1110,18 @@ public function testPatch() {

$this->setUpAuthorization('PATCH');

// DX: 400 when no request body.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(400, 'No entity content received.', $response);

$request_options[RequestOptions::BODY] = $unparseable_request_body;

// DX: 400 when unparseable request body.
$response = $this->request('PATCH', $url, $request_options);
$this->assertResourceErrorResponse(400, 'Syntax error', $response);

$request_options[RequestOptions::BODY] = $parseable_invalid_request_body;

// DX: 422 when invalid entity: multiple values sent for single-value field.
$response = $this->request('PATCH', $url, $request_options);
$label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ protected function getExpectedUnauthorizedAccessMessage($method) {
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
protected function getExpectedUnauthorizedEntityAccessCacheability($is_authenticated) {
// @see \Drupal\search\SearchPageAccessControlHandler::checkAccess()
return parent::getExpectedUnauthorizedAccessCacheability()
return parent::getExpectedUnauthorizedEntityAccessCacheability($is_authenticated)
->addCacheTags(['config:search.page.hinode_search']);
}

Expand Down
16 changes: 16 additions & 0 deletions core/modules/serialization/src/Normalizer/FieldItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
*/
class FieldItemNormalizer extends ComplexDataNormalizer implements DenormalizerInterface {

use SerializedColumnNormalizerTrait;

/**
* {@inheritdoc}
*/
Expand All @@ -30,6 +32,7 @@ public function denormalize($data, $class, $format = NULL, array $context = [])

/** @var \Drupal\Core\Field\FieldItemInterface $field_item */
$field_item = $context['target_instance'];
$this->checkForSerializedStrings($data, $class, $field_item);

$field_item->setValue($this->constructValue($data, $context));
return $field_item;
Expand All @@ -51,6 +54,19 @@ public function denormalize($data, $class, $format = NULL, array $context = [])
* The value to use in Entity::setValue().
*/
protected function constructValue($data, $context) {
/** @var \Drupal\Core\Field\FieldItemInterface $field_item */
$field_item = $context['target_instance'];
$serialized_property_names = $this->getCustomSerializedPropertyNames($field_item);

// Explicitly serialize the input, unlike properties that rely on
// being automatically serialized, manually managed serialized properties
// expect to receive serialized input.
foreach ($serialized_property_names as $serialized_property_name) {
if (!empty($data[$serialized_property_name])) {
$data[$serialized_property_name] = serialize($data[$serialized_property_name]);
}
}

return $data;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

namespace Drupal\serialization\Normalizer;

use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\TypedData\PrimitiveInterface;

/**
* Converts primitive data objects to their casted values.
*/
class PrimitiveDataNormalizer extends NormalizerBase {

use SerializedColumnNormalizerTrait;

/**
* The interface or class that this Normalizer supports.
*
Expand All @@ -20,6 +23,14 @@ class PrimitiveDataNormalizer extends NormalizerBase {
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = []) {
$parent = $object->getParent();
if ($parent instanceof FieldItemInterface && $object->getValue()) {
$serialized_property_names = $this->getCustomSerializedPropertyNames($parent);
if (in_array($object->getName(), $serialized_property_names, TRUE)) {
return unserialize($object->getValue());
}
}

// Typed data casts NULL objects to their empty variants, so for example
// the empty string ('') for string type data, or 0 for integer typed data.
// In a better world with typed data implementing algebraic data types,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

namespace Drupal\serialization\Normalizer;

use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Field\FieldItemInterface;

/**
* A trait providing methods for serialized columns.
*/
trait SerializedColumnNormalizerTrait {

/**
* Checks if there is a serialized string for a column.
*
* @param mixed $data
* The field item data to denormalize.
* @param string $class
* The expected class to instantiate.
* @param \Drupal\Core\Field\FieldItemInterface $field_item
* The field item.
*/
protected function checkForSerializedStrings($data, $class, FieldItemInterface $field_item) {
// Require specialized denormalizers for fields with 'serialize' columns.
// Note: this cannot be checked in ::supportsDenormalization() because at
// that time we only have the field item class. ::hasSerializeColumn()
// must be able to call $field_item->schema(), which requires a field
// storage definition. To determine that, the entity type and bundle
// must be known, which is contextual information that the Symfony
// serializer does not pass to ::supportsDenormalization().
if (!is_array($data)) {
$data = [$field_item->getDataDefinition()->getMainPropertyName() => $data];
}
if ($this->dataHasStringForSerializeColumn($field_item, $data)) {
$field_name = $field_item->getParent() ? $field_item->getParent()->getName() : $field_item->getName();
throw new \LogicException(sprintf('The generic FieldItemNormalizer cannot denormalize string values for "%s" properties of the "%s" field (field item class: %s).', implode('", "', $this->getSerializedPropertyNames($field_item)), $field_name, $class));
}
}

/**
* Checks if the data contains string value for serialize column.
*
* @param \Drupal\Core\Field\FieldItemInterface $field_item
* The field item.
* @param array $data
* The data being denormalized.
*
* @return bool
* TRUE if there is a string value for serialize column, otherwise FALSE.
*/
protected function dataHasStringForSerializeColumn(FieldItemInterface $field_item, array $data) {
foreach ($this->getSerializedPropertyNames($field_item) as $property_name) {
if (isset($data[$property_name]) && is_string($data[$property_name])) {
return TRUE;
}
}
return FALSE;
}

/**
* Gets the names of all serialized properties.
*
* @param \Drupal\Core\Field\FieldItemInterface $field_item
* The field item.
*
* @return string[]
* The property names for serialized properties.
*/
protected function getSerializedPropertyNames(FieldItemInterface $field_item) {
$field_storage_definition = $field_item->getFieldDefinition()->getFieldStorageDefinition();

if ($custom_property_names = $this->getCustomSerializedPropertyNames($field_item)) {
return $custom_property_names;
}

$field_storage_schema = $field_item->schema($field_storage_definition);
// If there are no columns then there are no serialized properties.
if (!isset($field_storage_schema['columns'])) {
return [];
}
$serialized_columns = array_filter($field_storage_schema['columns'], function ($column_schema) {
return isset($column_schema['serialize']) && $column_schema['serialize'] === TRUE;
});
return array_keys($serialized_columns);
}

/**
* Gets the names of all properties the plugin treats as serialized data.
*
* This allows the field storage definition or entity type to provide a
* setting for serialized properties. This can be used for fields that
* handle serialized data themselves and do not rely on the serialized schema
* flag.
*
* @param \Drupal\Core\Field\FieldItemInterface $field_item
* The field item.
*
* @return string[]
* The property names for serialized properties.
*/
protected function getCustomSerializedPropertyNames(FieldItemInterface $field_item) {
if ($field_item instanceof PluginInspectionInterface) {
$definition = $field_item->getPluginDefinition();
$serialized_fields = $field_item->getEntity()->getEntityType()->get('serialized_field_property_names');
$field_name = $field_item->getFieldDefinition()->getName();
if (is_array($serialized_fields) && isset($serialized_fields[$field_name]) && is_array($serialized_fields[$field_name])) {
return $serialized_fields[$field_name];
}
if (isset($definition['serialized_property_names']) && is_array($definition['serialized_property_names'])) {
return $definition['serialized_property_names'];
}
}
return [];
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Drupal\Component\Serialization\Json;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\entity_test\Entity\EntitySerializedField;
use Drupal\entity_test\Entity\EntityTestMulRev;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Drupal\Tests\serialization\Unit\Normalizer;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\TypedData\Type\IntegerInterface;
use Drupal\Core\TypedData\TypedDataInterface;
Expand Down Expand Up @@ -364,6 +365,33 @@ protected function assertDenormalize(array $data) {
->shouldBeCalled();
}

// Avoid a static method call by returning dummy property data.
$this->fieldDefinition
->getFieldStorageDefinition()
->willReturn()
->shouldBeCalled();
$this->fieldDefinition
->getName()
->willReturn('field_reference')
->shouldBeCalled();
$entity = $this->prophesize(EntityInterface::class);
$entity_type = $this->prophesize(EntityTypeInterface::class);
$entity->getEntityType()
->willReturn($entity_type->reveal())
->shouldBeCalled();
$this->fieldItem
->getPluginDefinition()
->willReturn([
'serialized_property_names' => [
'foo' => 'bar',
],
])
->shouldBeCalled();
$this->fieldItem
->getEntity()
->willReturn($entity->reveal())
->shouldBeCalled();

$context = ['target_instance' => $this->fieldItem->reveal()];
$denormalized = $this->normalizer->denormalize($data, EntityReferenceItem::class, 'json', $context);
$this->assertSame($context['target_instance'], $denormalized);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace Drupal\Tests\serialization\Unit\Normalizer;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\CreatedItem;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem;
Expand Down Expand Up @@ -110,6 +113,29 @@ public function testDenormalizeValidFormats($value, $expected) {
$timestamp_item->setValue(['value' => $expected])
->shouldBeCalled();

// Avoid a static method call by returning dummy property data.
$field_definition = $this->prophesize(FieldDefinitionInterface::class);
$timestamp_item
->getFieldDefinition()
->willReturn($field_definition->reveal())
->shouldBeCalled();
$timestamp_item->getPluginDefinition()
->willReturn([
'serialized_property_names' => [
'foo' => 'bar',
],
])
->shouldBeCalled();
$entity = $this->prophesize(EntityInterface::class);
$entity_type = $this->prophesize(EntityTypeInterface::class);
$entity->getEntityType()
->willReturn($entity_type->reveal())
->shouldBeCalled();
$timestamp_item
->getEntity()
->willReturn($entity->reveal())
->shouldBeCalled();

$context = ['target_instance' => $timestamp_item->reveal()];

$denormalized = $this->normalizer->denormalize($normalized, TimestampItem::class, NULL, $context);
Expand Down Expand Up @@ -146,7 +172,32 @@ public function providerTestDenormalizeValidFormats() {
public function testDenormalizeException() {
$this->setExpectedException(UnexpectedValueException::class, 'The specified date "2016/11/06 09:02am GMT" is not in an accepted format: "U" (UNIX timestamp), "Y-m-d\TH:i:sO" (ISO 8601), "Y-m-d\TH:i:sP" (RFC 3339).');

$context = ['target_instance' => $this->createTimestampItemProphecy()->reveal()];
$timestamp_item = $this->createTimestampItemProphecy();

// Avoid a static method call by returning dummy serialized property data.
$field_definition = $this->prophesize(FieldDefinitionInterface::class);
$timestamp_item
->getFieldDefinition()
->willReturn($field_definition->reveal())
->shouldBeCalled();
$timestamp_item->getPluginDefinition()
->willReturn([
'serialized_property_names' => [
'foo' => 'bar',
],
])
->shouldBeCalled();
$entity = $this->prophesize(EntityInterface::class);
$entity_type = $this->prophesize(EntityTypeInterface::class);
$entity->getEntityType()
->willReturn($entity_type->reveal())
->shouldBeCalled();
$timestamp_item
->getEntity()
->willReturn($entity->reveal())
->shouldBeCalled();

$context = ['target_instance' => $timestamp_item->reveal()];

$normalized = ['value' => '2016/11/06 09:02am GMT'];
$this->normalizer->denormalize($normalized, TimestampItem::class, NULL, $context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class ShortcutSetAccessControlHandler extends EntityAccessControlHandler {
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
switch ($operation) {
case 'view':
return AccessResult::allowedIf($account->hasPermission('access shortcuts'))->cachePerPermissions();
return AccessResult::allowedIfHasPermission($account, 'access shortcuts');
case 'update':
if ($account->hasPermission('administer shortcuts')) {
return AccessResult::allowed()->cachePerPermissions();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,20 @@ protected function getNormalizedPostEntity() {
// @todo Update in https://www.drupal.org/node/2300677.
}

/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
return parent::getExpectedUnauthorizedAccessMessage($method);
}

switch ($method) {
case 'GET':
return "The 'access shortcuts' permission is required.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
}

}
65 changes: 65 additions & 0 deletions core/modules/system/system.install
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\DrupalKernel;
use Drupal\Core\Extension\Extension;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Site\Settings;
use Drupal\Core\StreamWrapper\PrivateStream;
Expand Down Expand Up @@ -2173,3 +2174,67 @@ function system_update_8501() {
}
}
}

/**
* Fix missing install profile after updating to Drupal 8.6.9 with Drush 8.
*/
function system_update_8601() {
$extension_config = \Drupal::configFactory()->getEditable('core.extension');
$install_profile = $extension_config->get('profile');
if (!$install_profile) {
// There's no install profile configured.
return;
}
$modules = $extension_config->get('module');
if (isset($modules[$install_profile])) {
// The install profile is already in the installed module list.
return;
}

// Ensure the install profile is available.
if (!\Drupal::service('extension.list.module')->exists($install_profile)) {
return t('The %install_profile install profile configured in core.extension is not available.', ['%install_profile' => $install_profile]);
}

// Add the install profile to the list of enabled modules.
$modules[$install_profile] = 1000;
$modules = module_config_sort($modules);
$extension_config
->set('module', $modules)
->save(TRUE);

// Build a module list from the updated extension configuration.
$current_module_filenames = \Drupal::moduleHandler()->getModuleList();
$current_modules = array_fill_keys(array_keys($current_module_filenames), 0);
$current_modules = module_config_sort(array_merge($current_modules, $extension_config->get('module')));
$module_filenames = [];
foreach ($current_modules as $name => $weight) {
if (isset($current_module_filenames[$name])) {
$module_filenames[$name] = $current_module_filenames[$name];
}
else {
$module_path = \Drupal::service('extension.list.module')->getPath($name);
$pathname = "$module_path/$name.info.yml";
$filename = file_exists($module_path . "/$name.module") ? "$name.module" : NULL;
$module_filenames[$name] = new Extension(\Drupal::root(), 'module', $pathname, $filename);
}
}

// Update the module handler list to contain the missing install profile.
\Drupal::moduleHandler()->setModuleList($module_filenames);
\Drupal::moduleHandler()->load($install_profile);

// Clear the static cache of the "extension.list.module" service to pick
// up the new install profile correctly.
\Drupal::service('extension.list.profile')->reset();

// Clear the static cache of the "extension.list.module" service to pick
// up the new module, since it merges the installation status of modules
// into its statically cached list.
\Drupal::service('extension.list.module')->reset();

// Update the kernel to include the missing profile.
\Drupal::service('kernel')->updateModules($module_filenames, $module_filenames);

return t('The %install_profile install profile has been added to the installed module list.', ['%install_profile' => $install_profile]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Drupal\Tests\system\Functional\Update;

use Drupal\Core\Database\Database;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
use Symfony\Component\DependencyInjection\ContainerInterface;

Expand Down Expand Up @@ -39,6 +40,41 @@ public function testUpdatedSite() {
$this->assertSame('Australia/Sydney', $this->config('system.date')->get('timezone.default'));
}

/**
* Tests system_update_8601().
*/
public function testWithMissingProfile() {
// Remove the install profile from the module list to simulate how Drush 8
// and update_fix_compatibility() worked together to remove the install
// profile. See https://www.drupal.org/project/drupal/issues/3031740.
$connection = Database::getConnection();
$config = $connection->select('config')
->fields('config', ['data'])
->condition('collection', '')
->condition('name', 'core.extension')
->execute()
->fetchField();
$config = unserialize($config);
unset($config['module']['minimal']);
$connection->update('config')
->fields([
'data' => serialize($config),
'collection' => '',
'name' => 'core.extension',
])
->condition('collection', '')
->condition('name', 'core.extension')
->execute();

$this->runUpdates();
$this->assertSession()->pageTextContains('The minimal install profile has been added to the installed module list.');

// Login and check that the status report is working correctly.
$this->drupalLogin($this->rootUser);
$this->drupalGet('admin/reports/status');
$this->assertSession()->pageTextContains("Installation Profile Minimal");
}

/**
* {@inheritdoc}
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,10 +356,10 @@ public function providerTestGetTermWithParent() {
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
protected function getExpectedUnauthorizedEntityAccessCacheability($is_authenticated) {
// @see \Drupal\taxonomy\TermAccessControlHandler::checkAccess()
return parent::getExpectedUnauthorizedAccessCacheability()
->addCacheTags(['taxonomy_term:1']);
return parent::getExpectedUnauthorizedEntityAccessCacheability($is_authenticated)
->addCacheTags(['taxonomy_term:1']);;
}

}
10 changes: 8 additions & 2 deletions core/modules/user/src/UserAccessControlHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultNeutral;
use Drupal\Core\Access\AccessResultReasonInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Field\FieldDefinitionInterface;
Expand Down Expand Up @@ -64,11 +65,16 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter

case 'update':
// Users can always edit their own account.
return AccessResult::allowedIf($account->id() == $entity->id())->cachePerUser();
$access_result = AccessResult::allowedIf($account->id() == $entity->id())->cachePerUser();
if (!$access_result->isAllowed() && $access_result instanceof AccessResultReasonInterface) {
$access_result->setReason("Users can only update their own account, unless they have the 'administer users' permission.");
}
return $access_result;

case 'delete':
// Users with 'cancel account' permission can cancel their own account.
return AccessResult::allowedIf($account->id() == $entity->id() && $account->hasPermission('cancel account'))->cachePerPermissions()->cachePerUser();
return AccessResult::allowedIfHasPermission($account, 'cancel account')
->andIf(AccessResult::allowedIf($account->id() == $entity->id())->cachePerUser());
}

// No opinion.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,9 @@ protected function getExpectedUnauthorizedAccessMessage($method) {
case 'GET':
return "The 'access user profiles' permission is required and the user must be active.";
case 'PATCH':
return "You are not authorized to update this user entity.";
return "Users can only update their own account, unless they have the 'administer users' permission.";
case 'DELETE':
return 'You are not authorized to delete this user entity.';
return "The 'cancel account' permission is required.";
default:
return parent::getExpectedUnauthorizedAccessMessage($method);
}
Expand All @@ -320,9 +320,9 @@ protected function getExpectedUnauthorizedAccessMessage($method) {
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
protected function getExpectedUnauthorizedEntityAccessCacheability($is_authenticated) {
// @see \Drupal\user\UserAccessControlHandler::checkAccess()
return parent::getExpectedUnauthorizedAccessCacheability()
return parent::getExpectedUnauthorizedEntityAccessCacheability($is_authenticated)
->addCacheTags(['user:3']);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Drupal\KernelTests\Core\Routing;

use Drupal\Component\Utility\Html;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\KernelTests\KernelTestBase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand Down Expand Up @@ -56,6 +57,7 @@ public function testJson403() {
$this->assertEqual($response->getStatusCode(), Response::HTTP_FORBIDDEN);
$this->assertEqual($response->headers->get('Content-type'), 'application/json');
$this->assertEqual('{"message":""}', $response->getContent());
$this->assertInstanceOf(CacheableJsonResponse::class, $response);
}

/**
Expand Down