TemporaryQueryGuard.php
Same filename in other branches
Namespace
Drupal\jsonapi\AccessFile
-
core/
modules/ jsonapi/ src/ Access/ TemporaryQueryGuard.php
View source
<?php
namespace Drupal\jsonapi\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Query\QueryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
use Drupal\jsonapi\Query\EntityCondition;
use Drupal\jsonapi\Query\EntityConditionGroup;
use Drupal\jsonapi\Query\Filter;
/**
* Adds sufficient access control to collection queries.
*
* This class will be removed when new Drupal core APIs have been put in place
* to make it obsolete.
*
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
* may change at any time and could break any dependencies on it.
*
* @todo These additional security measures should eventually reside in the
* Entity API subsystem but were introduced here to address a security
* vulnerability. The following two issues should obsolesce this class:
*
* @see https://www.drupal.org/project/drupal/issues/2809177
* @see https://www.drupal.org/project/drupal/issues/777578
*
* @see https://www.drupal.org/project/drupal/issues/3032787
* @see jsonapi.api.php
*/
class TemporaryQueryGuard {
/**
* The entity field manager.
*
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
*/
protected static $fieldManager;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected static $moduleHandler;
/**
* Sets the entity field manager.
*
* This must be called before calling ::applyAccessControls().
*
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
* The entity field manager.
*/
public static function setFieldManager(EntityFieldManagerInterface $field_manager) {
static::$fieldManager = $field_manager;
}
/**
* Sets the module handler.
*
* This must be called before calling ::applyAccessControls().
*
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
*/
public static function setModuleHandler(ModuleHandlerInterface $module_handler) {
static::$moduleHandler = $module_handler;
}
/**
* Applies access controls to an entity query.
*
* @param \Drupal\jsonapi\Query\Filter $filter
* The filters applicable to the query.
* @param \Drupal\Core\Entity\Query\QueryInterface $query
* The query to which access controls should be applied.
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
* Collects cacheability for the query.
*/
public static function applyAccessControls(Filter $filter, QueryInterface $query, CacheableMetadata $cacheability) {
assert(static::$fieldManager !== NULL);
assert(static::$moduleHandler !== NULL);
$filtered_fields = static::collectFilteredFields($filter->root());
$field_specifiers = array_map(function ($field) {
return explode('.', $field);
}, $filtered_fields);
static::secureQuery($query, $query->getEntityTypeId(), static::buildTree($field_specifiers), $cacheability);
}
/**
* Applies tags, metadata and conditions to secure an entity query.
*
* @param \Drupal\Core\Entity\Query\QueryInterface $query
* The query to be secured.
* @param string $entity_type_id
* An entity type ID.
* @param array $tree
* A tree of field specifiers in an entity query condition. The tree is a
* multi-dimensional array where the keys are field specifiers and the
* values are multi-dimensional array of the same form, containing only
* subsequent specifiers. @see ::buildTree().
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
* Collects cacheability for the query.
* @param string|null $field_prefix
* Internal use only. Contains a string representation of the previously
* visited field specifiers.
* @param \Drupal\Core\Field\FieldStorageDefinitionInterface $field_storage_definition
* Internal use only. The current field storage definition, if known.
*
* @see \Drupal\Core\Database\Query\AlterableInterface::addTag()
* @see \Drupal\Core\Database\Query\AlterableInterface::addMetaData()
* @see \Drupal\Core\Database\Query\ConditionInterface
*/
protected static function secureQuery(QueryInterface $query, $entity_type_id, array $tree, CacheableMetadata $cacheability, $field_prefix = NULL, FieldStorageDefinitionInterface $field_storage_definition = NULL) {
$entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id);
// Config entity types are not fieldable, therefore they do not have field
// access restrictions, nor entity references to other entity types.
if ($entity_type instanceof ConfigEntityTypeInterface) {
return;
}
foreach ($tree as $specifier => $children) {
// The field path reconstructs the entity condition fields.
// E.g. `uid.0` would become `uid.0.name` if $specifier === 'name'.
$child_prefix = is_null($field_prefix) ? $specifier : "{$field_prefix}.{$specifier}";
if (is_null($field_storage_definition)) {
// When the field storage definition is NULL, this specifier is the
// first specifier in an entity query field path or the previous
// specifier was a data reference that has been traversed. In both
// cases, the specifier must be a field name.
$field_storage_definitions = static::$fieldManager->getFieldStorageDefinitions($entity_type_id);
static::secureQuery($query, $entity_type_id, $children, $cacheability, $child_prefix, $field_storage_definitions[$specifier]);
// When $field_prefix is NULL, this must be the first specifier in the
// entity query field path and a condition for the query's base entity
// type must be applied.
if (is_null($field_prefix)) {
static::applyAccessConditions($query, $entity_type_id, NULL, $cacheability);
}
}
else {
// When the specifier is an entity reference, it can contain an entity
// type specifier, like so: `entity:node`. This extracts the `entity`
// portion. JSON:API will have already validated that the property
// exists.
$split_specifier = explode(':', $specifier, 2);
[
$property_name,
$target_entity_type_id,
] = array_merge($split_specifier, count($split_specifier) === 2 ? [] : [
NULL,
]);
// The specifier is either a field property or a delta. If it is a data
// reference or a delta, then it needs to be traversed to the next
// specifier. However, if the specific is a simple field property, i.e.
// it is neither a data reference nor a delta, then there is no need to
// evaluate the remaining specifiers.
$property_definition = $field_storage_definition->getPropertyDefinition($property_name);
if ($property_definition instanceof DataReferenceDefinitionInterface) {
// Because the filter is following an entity reference, ensure
// access is respected on those targeted entities.
// Examples:
// - node_query_node_access_alter()
$target_entity_type_id = $target_entity_type_id ?: $field_storage_definition->getSetting('target_type');
$query->addTag("{$target_entity_type_id}_access");
static::applyAccessConditions($query, $target_entity_type_id, $child_prefix, $cacheability);
// Keep descending the tree.
static::secureQuery($query, $target_entity_type_id, $children, $cacheability, $child_prefix);
}
elseif (is_null($property_definition)) {
assert(is_numeric($property_name), 'The specifier is not a property name, it must be a delta.');
// Keep descending the tree.
static::secureQuery($query, $entity_type_id, $children, $cacheability, $child_prefix, $field_storage_definition);
}
}
}
}
/**
* Applies access conditions to ensure 'view' access is respected.
*
* Since the given entity type might not be the base entity type of the query,
* the field prefix should be applied to ensure that the conditions are
* applied to the right subset of entities in the query.
*
* @param \Drupal\Core\Entity\Query\QueryInterface $query
* The query to which access conditions should be applied.
* @param string $entity_type_id
* The entity type for which to access conditions should be applied.
* @param string|null $field_prefix
* A prefix to add before any query condition fields. NULL if no prefix
* should be added.
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
* Collects cacheability for the query.
*/
protected static function applyAccessConditions(QueryInterface $query, $entity_type_id, $field_prefix, CacheableMetadata $cacheability) {
$access_condition = static::getAccessCondition($entity_type_id, $cacheability);
if ($access_condition) {
$prefixed_condition = !is_null($field_prefix) ? static::addConditionFieldPrefix($access_condition, $field_prefix) : $access_condition;
$filter = new Filter($prefixed_condition);
$query->condition($filter->queryCondition($query));
}
}
/**
* Prefixes all fields in an EntityConditionGroup.
*/
protected static function addConditionFieldPrefix(EntityConditionGroup $group, $field_prefix) {
$prefixed = [];
foreach ($group->members() as $member) {
if ($member instanceof EntityConditionGroup) {
$prefixed[] = static::addConditionFieldPrefix($member, $field_prefix);
}
else {
$field = !empty($field_prefix) ? "{$field_prefix}." . $member->field() : $member->field();
$prefixed[] = new EntityCondition($field, $member->value(), $member->operator());
}
}
return new EntityConditionGroup($group->conjunction(), $prefixed);
}
/**
* Gets an EntityConditionGroup that filters out inaccessible entities.
*
* @param string $entity_type_id
* The entity type ID for which to get an EntityConditionGroup.
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
* Collects cacheability for the query.
*
* @return \Drupal\jsonapi\Query\EntityConditionGroup|null
* An EntityConditionGroup or NULL if no conditions need to be applied to
* secure an entity query.
*/
protected static function getAccessCondition($entity_type_id, CacheableMetadata $cacheability) {
$current_user = \Drupal::currentUser();
$entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id);
// Get the condition that handles generic restrictions, such as published
// and owner.
$generic_condition = static::getAccessConditionForKnownSubsets($entity_type, $current_user, $cacheability);
// Some entity types require additional conditions. We don't know what
// contrib entity types require, so they are responsible for implementing
// hook_query_ENTITY_TYPE_access_alter(). Some core entity types have
// logic in their access control handler that isn't mirrored in
// hook_query_ENTITY_TYPE_access_alter(), so we duplicate that here until
// that's resolved.
$specific_condition = NULL;
switch ($entity_type_id) {
case 'block_content':
// Allow access only to reusable blocks.
// @see \Drupal\block_content\BlockContentAccessControlHandler::checkAccess()
if (isset(static::$fieldManager->getBaseFieldDefinitions($entity_type_id)['reusable'])) {
$specific_condition = new EntityCondition('reusable', 1);
$cacheability->addCacheTags($entity_type->getListCacheTags());
}
break;
case 'comment':
// @see \Drupal\comment\CommentAccessControlHandler::checkAccess()
$specific_condition = static::getCommentAccessCondition($entity_type, $current_user, $cacheability);
break;
case 'entity_test':
// This case is only necessary for testing comment access controls.
// @see \Drupal\jsonapi\Tests\Functional\CommentTest::testCollectionFilterAccess()
$blacklist = \Drupal::state()->get('jsonapi__entity_test_filter_access_blacklist', []);
$cacheability->addCacheTags([
'state:jsonapi__entity_test_filter_access_blacklist',
]);
$specific_conditions = [];
foreach ($blacklist as $id) {
$specific_conditions[] = new EntityCondition('id', $id, '<>');
}
if ($specific_conditions) {
$specific_condition = new EntityConditionGroup('AND', $specific_conditions);
}
break;
case 'file':
// Allow access only to public files and files uploaded by the current
// user.
// @see \Drupal\file\FileAccessControlHandler::checkAccess()
$specific_condition = new EntityConditionGroup('OR', [
new EntityCondition('uri', 'public://', 'STARTS_WITH'),
new EntityCondition('uid', $current_user->id()),
]);
$cacheability->addCacheTags($entity_type->getListCacheTags());
break;
case 'shortcut':
// Unless the user can administer shortcuts, allow access only to the
// user's currently displayed shortcut set.
// @see \Drupal\shortcut\ShortcutAccessControlHandler::checkAccess()
if (!$current_user->hasPermission('administer shortcuts')) {
$specific_condition = new EntityCondition('shortcut_set', shortcut_current_displayed_set()->id());
$cacheability->addCacheContexts([
'user',
]);
$cacheability->addCacheTags($entity_type->getListCacheTags());
}
break;
case 'user':
// Disallow querying values of the anonymous user.
// @see \Drupal\user\UserAccessControlHandler::checkAccess()
$specific_condition = new EntityCondition('uid', '0', '!=');
break;
}
// Return a combined condition.
if ($generic_condition && $specific_condition) {
return new EntityConditionGroup('AND', [
$generic_condition,
$specific_condition,
]);
}
elseif ($generic_condition) {
return $generic_condition instanceof EntityConditionGroup ? $generic_condition : new EntityConditionGroup('AND', [
$generic_condition,
]);
}
elseif ($specific_condition) {
return $specific_condition instanceof EntityConditionGroup ? $specific_condition : new EntityConditionGroup('AND', [
$specific_condition,
]);
}
return NULL;
}
/**
* Gets an access condition for the allowed JSONAPI_FILTER_AMONG_* subsets.
*
* If access is allowed for the JSONAPI_FILTER_AMONG_ALL subset, then no
* conditions are returned. Otherwise, if access is allowed for
* JSONAPI_FILTER_AMONG_PUBLISHED, JSONAPI_FILTER_AMONG_ENABLED, or
* JSONAPI_FILTER_AMONG_OWN, then a condition group is returned for the union
* of allowed subsets. If no subsets are allowed, then static::alwaysFalse()
* is returned.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type for which to check filter access.
* @param \Drupal\Core\Session\AccountInterface $account
* The account for which to check access.
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
* Collects cacheability for the query.
*
* @return \Drupal\jsonapi\Query\EntityConditionGroup|null
* An EntityConditionGroup or NULL if no conditions need to be applied to
* secure an entity query.
*/
protected static function getAccessConditionForKnownSubsets(EntityTypeInterface $entity_type, AccountInterface $account, CacheableMetadata $cacheability) {
// Get the combined access results for each JSONAPI_FILTER_AMONG_* subset.
$access_results = static::getAccessResultsFromEntityFilterHook($entity_type, $account);
// No conditions are needed if access is allowed for all entities.
$cacheability->addCacheableDependency($access_results[JSONAPI_FILTER_AMONG_ALL]);
if ($access_results[JSONAPI_FILTER_AMONG_ALL]->isAllowed()) {
return NULL;
}
// If filtering is not allowed across all entities, but is allowed for
// certain subsets, then add conditions that reflect those subsets. These
// will be grouped in an OR to reflect that access may be granted to
// more than one subset. If no conditions are added below, then
// static::alwaysFalse() is returned.
$conditions = [];
// The "published" subset.
$published_field_name = $entity_type->getKey('published');
if ($published_field_name) {
$access_result = $access_results[JSONAPI_FILTER_AMONG_PUBLISHED];
$cacheability->addCacheableDependency($access_result);
if ($access_result->isAllowed()) {
$conditions[] = new EntityCondition($published_field_name, 1);
$cacheability->addCacheTags($entity_type->getListCacheTags());
}
}
// The "enabled" subset.
// @todo Remove ternary when the 'status' key is added to the User entity type.
$status_field_name = $entity_type->id() === 'user' ? 'status' : $entity_type->getKey('status');
if ($status_field_name) {
$access_result = $access_results[JSONAPI_FILTER_AMONG_ENABLED];
$cacheability->addCacheableDependency($access_result);
if ($access_result->isAllowed()) {
$conditions[] = new EntityCondition($status_field_name, 1);
$cacheability->addCacheTags($entity_type->getListCacheTags());
}
}
// The "owner" subset.
// @todo Remove ternary when the 'uid' key is added to the User entity type.
$owner_field_name = $entity_type->id() === 'user' ? 'uid' : $entity_type->getKey('owner');
if ($owner_field_name) {
$access_result = $access_results[JSONAPI_FILTER_AMONG_OWN];
$cacheability->addCacheableDependency($access_result);
if ($access_result->isAllowed()) {
$cacheability->addCacheContexts([
'user',
]);
if ($account->isAuthenticated()) {
$conditions[] = new EntityCondition($owner_field_name, $account->id());
$cacheability->addCacheTags($entity_type->getListCacheTags());
}
}
}
// If no conditions were added above, then access wasn't granted to any
// subset, so return alwaysFalse().
if (empty($conditions)) {
return static::alwaysFalse($entity_type);
}
// If more than one condition was added above, then access was granted to
// more than one subset, so combine them with an OR.
if (count($conditions) > 1) {
return new EntityConditionGroup('OR', $conditions);
}
// Otherwise return the single condition.
return $conditions[0];
}
/**
* Gets the combined access result for each JSONAPI_FILTER_AMONG_* subset.
*
* This invokes hook_jsonapi_entity_filter_access() and
* hook_jsonapi_ENTITY_TYPE_filter_access() and combines the results from all
* of the modules into a single set of results.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type for which to check filter access.
* @param \Drupal\Core\Session\AccountInterface $account
* The account for which to check access.
*
* @return \Drupal\Core\Access\AccessResultInterface[]
* The array of access results, keyed by subset. See
* hook_jsonapi_entity_filter_access() for details.
*/
protected static function getAccessResultsFromEntityFilterHook(EntityTypeInterface $entity_type, AccountInterface $account) {
/** @var \Drupal\Core\Access\AccessResultInterface[] $combined_access_results */
$combined_access_results = [
JSONAPI_FILTER_AMONG_ALL => AccessResult::neutral(),
JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::neutral(),
JSONAPI_FILTER_AMONG_ENABLED => AccessResult::neutral(),
JSONAPI_FILTER_AMONG_OWN => AccessResult::neutral(),
];
// Invoke hook_jsonapi_entity_filter_access() and
// hook_jsonapi_ENTITY_TYPE_filter_access() for each module and merge its
// results with the combined results.
foreach ([
'jsonapi_entity_filter_access',
'jsonapi_' . $entity_type->id() . '_filter_access',
] as $hook) {
static::$moduleHandler->invokeAllWith($hook, function (callable $hook, string $module) use (&$combined_access_results, $entity_type, $account) {
$module_access_results = $hook($entity_type, $account);
if ($module_access_results) {
foreach ($module_access_results as $subset => $access_result) {
$combined_access_results[$subset] = $combined_access_results[$subset]->orIf($access_result);
}
}
});
}
return $combined_access_results;
}
/**
* Gets an access condition for a comment entity.
*
* Unlike all other core entity types, Comment entities' access control
* depends on access to a referenced entity. More challenging yet, that entity
* reference field may target different entity types depending on the comment
* bundle. This makes the query access conditions sufficiently complex to
* merit a dedicated method.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $comment_entity_type
* The comment entity type object.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
* Collects cacheability for the query.
* @param int $depth
* Internal use only. The recursion depth. It is possible to have comments
* on comments, but since comment access is dependent on access to the
* entity on which they live, this method can recurse endlessly.
*
* @return \Drupal\jsonapi\Query\EntityConditionGroup|null
* An EntityConditionGroup or NULL if no conditions need to be applied to
* secure an entity query.
*/
protected static function getCommentAccessCondition(EntityTypeInterface $comment_entity_type, AccountInterface $current_user, CacheableMetadata $cacheability, $depth = 1) {
// If a comment is assigned to another entity or author the cache needs to
// be invalidated.
$cacheability->addCacheTags($comment_entity_type->getListCacheTags());
// Constructs a big EntityConditionGroup which will filter comments based on
// the current user's access to the entities on which each comment lives.
// This is especially complex because comments of different bundles can
// live on entities of different entity types.
$comment_entity_type_id = $comment_entity_type->id();
$field_map = static::$fieldManager->getFieldMapByFieldType('entity_reference');
assert(isset($field_map[$comment_entity_type_id]['entity_id']['bundles']), 'Every comment has an `entity_id` field.');
$bundle_ids_by_target_entity_type_id = [];
foreach ($field_map[$comment_entity_type_id]['entity_id']['bundles'] as $bundle_id) {
$field_definitions = static::$fieldManager->getFieldDefinitions($comment_entity_type_id, $bundle_id);
$commented_entity_field_definition = $field_definitions['entity_id'];
// Each commented entity field definition has a setting which indicates
// the entity type of the commented entity reference field. This differs
// per bundle.
$target_entity_type_id = $commented_entity_field_definition->getSetting('target_type');
$bundle_ids_by_target_entity_type_id[$target_entity_type_id][] = $bundle_id;
}
$bundle_specific_access_conditions = [];
foreach ($bundle_ids_by_target_entity_type_id as $target_entity_type_id => $bundle_ids) {
// Construct a field specifier prefix which targets the commented entity.
$condition_field_prefix = "entity_id.entity:{$target_entity_type_id}";
// Ensure that for each possible commented entity type (which varies per
// bundle), a condition is created that restricts access based on access
// to the commented entity.
$bundle_condition = new EntityCondition($comment_entity_type->getKey('bundle'), $bundle_ids, 'IN');
// Comments on comments can create an infinite recursion! If the target
// entity type ID is comment, we need special behavior.
if ($target_entity_type_id === $comment_entity_type_id) {
$nested_comment_condition = $depth <= 3 ? static::getCommentAccessCondition($comment_entity_type, $current_user, $cacheability, $depth + 1) : static::alwaysFalse($comment_entity_type);
$prefixed_comment_condition = static::addConditionFieldPrefix($nested_comment_condition, $condition_field_prefix);
$bundle_specific_access_conditions[$target_entity_type_id] = new EntityConditionGroup('AND', [
$bundle_condition,
$prefixed_comment_condition,
]);
}
else {
$target_condition = static::getAccessCondition($target_entity_type_id, $cacheability);
$bundle_specific_access_conditions[$target_entity_type_id] = !is_null($target_condition) ? new EntityConditionGroup('AND', [
$bundle_condition,
static::addConditionFieldPrefix($target_condition, $condition_field_prefix),
]) : $bundle_condition;
}
}
// This condition ensures that the user is only permitted to see the
// comments for which the user is also able to view the entity on which each
// comment lives.
$commented_entity_condition = new EntityConditionGroup('OR', array_values($bundle_specific_access_conditions));
return $commented_entity_condition;
}
/**
* Gets an always FALSE entity condition group for the given entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type for which to construct an impossible condition.
*
* @return \Drupal\jsonapi\Query\EntityConditionGroup
* An EntityConditionGroup which cannot evaluate to TRUE.
*/
protected static function alwaysFalse(EntityTypeInterface $entity_type) {
return new EntityConditionGroup('AND', [
new EntityCondition($entity_type->getKey('id'), 1, '<'),
new EntityCondition($entity_type->getKey('id'), 1, '>'),
]);
}
/**
* Recursively collects all entity query condition fields.
*
* Entity conditions can be nested within AND and OR groups. This recursively
* finds all unique fields in an entity query condition.
*
* @param \Drupal\jsonapi\Query\EntityConditionGroup $group
* The root entity condition group.
* @param array $fields
* Internal use only.
*
* @return array
* An array of entity query condition field names.
*/
protected static function collectFilteredFields(EntityConditionGroup $group, array $fields = []) {
foreach ($group->members() as $member) {
if ($member instanceof EntityConditionGroup) {
$fields = static::collectFilteredFields($member, $fields);
}
else {
$fields[] = $member->field();
}
}
return array_unique($fields);
}
/**
* Copied from \Drupal\jsonapi\IncludeResolver.
*
* @see \Drupal\jsonapi\IncludeResolver::buildTree()
*/
protected static function buildTree(array $paths) {
$merged = [];
foreach ($paths as $parts) {
// This complex expression is needed to handle the string, "0", which
// would be evaluated as FALSE.
if (!is_null($field_name = array_shift($parts))) {
$previous = $merged[$field_name] ?? [];
$merged[$field_name] = array_merge($previous, [
$parts,
]);
}
}
return !empty($merged) ? array_map([
static::class,
__FUNCTION__,
], $merged) : $merged;
}
}
Classes
Title | Deprecated | Summary |
---|---|---|
TemporaryQueryGuard | Adds sufficient access control to collection queries. |
Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.