106. 实体访问控制处理器AccessControlHandler
实体访问控制处理器用于判断账户是否有某种实体操作权限,是整个权限系统的一部分,专门针对实体,本系列已经发布了权限系统上下集,请务必先查看再阅读本篇内容。
是否允许对实体执行某种操作,由该实体类型定义的访问控制处理器来判断,她是相对于实体类型而存在的,换句话说一个实体访问控制器只处理该类型的实体,其属于实体处理器,被设置在实体释文处理器根键的access键下,采用实体处理器的实例化方法,所有实体类型都需要定义访问控制处理器,自定义实体类型如没有特殊逻辑可直接使用系统提供的默认基类。
使用示例代码:
在使用上所有的实体类型遵循统一的方式,得到实体访问控制处理器对象如下:
$accessControlHandler=\Drupal::entityTypeManager()->getAccessControlHandler($entityTypeId);
普通操作(可视为除新建操作以外的所有操作)的检查方法:
$accessControlHandler->access($entity, $operation, $account = NULL, $return_as_object = FALSE);
新建操作的检查方法:
$accessControlHandler->createAccess($bundle = NULL, $account = NULL, $context = [], $return_as_object = FALSE);
如果已经存在实体对象,可在其上直接调用:
$entity->access($operation, AccountInterface $account = NULL, $return_as_object = FALSE);
实体对象上调用不用区分是新建还是其他操作,但在判断新建权限时无法传递上下文参数,该方法位于以下实体基类中:
\Drupal\Core\Entity\Entity::access($operation, $account = NULL, $return_as_object = FALSE)
以上调用中$return_as_object为false时返回布尔值,为true时返回一个访问结果对象(见权限系统上集),对象方式返回是为了携带可缓存元数据,该对象的类是以下类的子类:
\Drupal\Core\Access\AccessResult
各类操作的操作名,用特定的字符串表示,如下:
"view":查看
"view label":查看标题
"update":更新
"delete":更新
"create":新建
"edit ":编辑,通常用于实体字段
"download":下载
"use":允许使用,用于格式化器
某操作必须强制使用某个字符串吗?比如编辑操作必须强制使用"edit"吗?查看可以用“show”吗?要回答这个问题必须先明白操作名是某种操作的表示,模块依据该表示来判断在进行何种操作,需要操作发生处和权限判断处达成一致,从这个角度看已经在用的、存在的操作名的字符表示是强制的,是固定的,是达成一致的结果,这就好比人类世界用“10”这个符号来代表十,如果某人用其它符号就无法沟通了,但如果你正在实现某种没有适合表示的操作,那么可以为其取一个操作名,一旦取定,其它模块就必须使用这个名字来识别她代表的操作,现存的操作名也都是这样来的,换句话说操作名由操作定义处定义;此外某种操作可能并不适用于某实体,比如节点实体就没有下载操作,因此某些操作名并不适用于所有实体。
注意不要混淆操作名和权限名,权限名又叫做权限标识符,操作名和权限名不是同一事物,她们是对应关系
如果用户对象为NULL将采用当前用户(并非指匿名用户)
系统很少在控制器中直接调用实体访问控制处理器,而是在路由中设置相关检查器,由后者在进入控制器之前的路由阶段调用该处理器实现权限判断
实体访问控制处理器默认基类:
所有实体访问控制处理器需要实现以下接口:
\Drupal\Core\Entity\EntityAccessControlHandlerInterface
由于以实体处理器方式实例化,所以如果存在createInstance方法,将以该方法来返回实例化对象
系统默认提供了一个内容实体和配置实体的统一基类:
\Drupal\Core\Entity\EntityAccessControlHandler
默认安装中提供的所有实体类型的访问控制处理器都继承或直接使用了该默认基类,自定义实体类型如无特别需求可直接使用该基类,其中的方法解释如下:
public function access(EntityInterface $entity, $operation, AccountInterface $account = NULL, $return_as_object = FALSE)
判断除新建操作以外的操作是否具备权限;默认情况下“view label”操作将被当做“view”操作,但$viewLabelOperation属性被设置为true时将独立判断,访问控制可以区分版本、语言;权限判断来自两个方面:模块和实体通用判断,该方法首先派发以下钩子收集模块的判断意见:
entity_access
$EntityTypeId. '_access'
有三个参数:$entity, $operation, $account,其中$account已做过处理(如未传递已被赋值当前账户)一定存在,这两个钩子没有优先级顺序(没有覆写关系),同等对待,钩子函数签名如下:
hook_entity_access($entity, $operation, $account)
钩子必须返回访问结果对象,如果没有模块实现钩子则系统以中立结果为准,模块返回的结果对象以orIf方法合并,如果结果为禁止则权限判断逻辑结束,以此为准,反之继续执行实体通用检查,其结果依然以orIf合并,通用检查逻辑如下:
如果是“delete”操作,但实体对象却是新的则返回禁止,如果实体释文中定义了管理权限标识符,释文键为“admin_permission”则检查账户是否具备该权限,具备则允许,不具备则返回中立,没有定义管理权限标识符时也将返回中立结果
通过以上逻辑得出的检查结果作为最终结果并缓存返回。
注意在该方法中是采用orIf方法合并,结果如果是中立那么是否允许由调用者决定,但在路由的入站检查中,各检查器的结果是以andIf方法合并,其结果如果是中立将当做禁止看待。
public function createAccess($entity_bundle = NULL, AccountInterface $account = NULL, array $context = [], $return_as_object = FALSE)
检查实体的创建权限,参数解释如下:
$entity_bundle:实体的bundle,如果支持bundle则需要传递,否则默认为null
$account:账户对象,如传递值等效为false将采用当前账户
$context:额外上下文参数,见后
$return_as_object:是否以对象形式返回
该方法和access大同小异,但是只处理创建操作,有时创建操作需要很细化的判断,需要一些额外信息,因此该方法采用上下文参数以传递额外信息,上下文参数是一个数组,默认键名有:
entity_type_id:实体类型id,通常不需要指定,系统自动补充
langcode:正在创建的实体的语言id,默认为“x-default”,表示第一个版本首次创建
用户可根据需求传递更多额外信息,创建权限判断也来自两个方面:模块和实体通用判断,该方法首先派发以下钩子收集模块的判断意见:
entity_create_access
$entityTypeId . '_create_access'
有三个参数:$account(已做处理的账户对象,如未传递已被赋值当前账户), $context(前文所讲上下文参数), $entity_bundle(字符串值实体bundle,不支持则为NULL),这两个钩子没有优先级顺序(没有覆写关系)钩子函数签名如下:
hook_entity_create_access($account, array $context, $entity_bundle);
钩子必须返回访问结果对象,如果没有实现钩子的模块则系统以中立结果为准,模块返回的结果对象以orIf方法合并,如果结果为禁止则权限判断逻辑结束,以此为准,反之继续执行实体通用创建检查,其结果依然以orIf合并,通用创建检查逻辑如下:
如果实体释文中定义了管理权限标识符,释文键为“admin_permission”则检查账户是否具备该权限,具备则允许,不具备则返回中立,没有定义管理权限标识符时也将返回中立结果
protected function getCache($cid, $operation, $langcode, AccountInterface $account)
protected function setCache($access, $cid, $operation, $langcode, AccountInterface $account)
public function resetCache()
由于权限计算是一个很耗费资源的问题,为了提高性能,采用了静态属性缓存,结构如下:
$this->accessCache[$account->id()][$cid][$langcode][$operation]
这里需要调用者注意缓存失效问题,如果在访问控制处理器对象的生命周期内,出现两次相同的权限判断,但其间实体或权限改变了,那么缓存应该失效,但该缓存并不失效,所以调用者应该主动调用重置缓存方法
protected function processAccessHookResults(array $access)
合并模块检查结果,参数为数组,键名通常为整数,无意义不重要,键值是检查结果对象,如果为空将当做中立处理,否则以orIf方法合并
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account)
执行实体通用检查,如果是“delete”操作,但实体对象却是新的则返回禁止,如果实体释文中定义了管理权限标识符,释文键为“admin_permission”则检查账户是否具备该权限,都不是将返回中立结果
protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL)
执行实体通用创建检查,如果实体释文中定义了管理权限标识符,释文键为“admin_permission”则检查账户是否具备该权限,具备则允许,不具备则返回中立,没有定义管理权限标识符时也将返回中立结果
protected function prepareUser(AccountInterface $account = NULL)
如果不传入账户对象,将默认采用当前账户
public function fieldAccess($operation, FieldDefinitionInterface $field_definition, AccountInterface $account = NULL, FieldItemListInterface $items = NULL, $return_as_object = FALSE)
检查是否具备在实体字段对象上执行某操作的权限,参数依次为:操作名、字段定义对象、账户对象、字段对象、指示结果标识,针对字段的权限检查来自4个方面:
1、字段对象上的默认检查:如果传递了字段对象将执行其defaultAccess方法,否则该部分默认允许 ,注意字段定义对象上不涉及权限检查相关内容
2、实体级别的检查:如果实体对象不是新的,在执行UUID或非字符串类型的实体ID的编辑edit操作,将直接拒绝,这两者是不允许编辑的
3、字段对象上的通用检查:由访问控制处理器依据该实体类型情况设定,默认为允许,其和字段对象上的默认检查结果以andIf方式合并,合并结果这里称为默认结果对象
4、派发钩子执行来自模块的检查
派发的钩子名如下:
entity_field_access
该钩子依次接收四个参数:$operation, $field_definition, $account, $items,返回访问结果对象
执行完毕后系统继续派发该钩子的修改钩,允许模块对总结果进行修正,修改钩函数签名如下:
function hook_entity_field_access_alter(array &$grants, array $context)
参数$grants是一个数组,键名为模块名,键值为该模块返回的访问结果对象,包含默认结果对象,其键名为“:default”,参数$context为上下文数组,有如下键名:
$context = [
'operation' => $operation,
'field_definition' => $field_definition,
'items' => $items,
'account' => $account,
];
修改钩需要保持$grants是一维数组形式,可以增加、删除、修改元素,键名随意,但须保证键值为访问结果对象,可以为空数组,经过修改钩子处理后的$grants数组将以orIf方式合并各元素(如为空数组返回中立),合并的结果将作为该字段对象的访问检查结果返回
如果存在字段对象,则可在其上直接调用以下方法:
access($operation = 'view', AccountInterface $account = NULL, $return_as_object = FALSE)
这将等同于调用了本方法,相当于快捷方式,因此往往在没有字段对象的情况下才直接调用本方法
protected function checkFieldAccess($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL)
执行通用字段检查,此处默认为允许,各实体类型可具体控制通用逻辑
各实体类型的访问控制处理器:
查看各实体类型的处理器类可在控制器中运行以下代码:
$definitions = \Drupal::entityTypeManager()->getDefinitions();
$data = [];
foreach ($definitions as $entityTypeID => $entity_type) {
$data[$entityTypeID] = $entity_type->getAccessControlClass();
}
print_r($data);
die;
默认安装如下:
Array
(
[block] => Drupal\block\BlockAccessControlHandler
[block_content] => Drupal\block_content\BlockContentAccessControlHandler
[block_content_type] => Drupal\Core\Entity\EntityAccessControlHandler
[comment] => Drupal\comment\CommentAccessControlHandler
[comment_type] => Drupal\Core\Entity\EntityAccessControlHandler
[contact_form] => Drupal\contact\ContactFormAccessControlHandler
[contact_message] => Drupal\contact\ContactMessageAccessControlHandler
[editor] => Drupal\editor\EditorAccessControlHandler
[field_config] => Drupal\field\FieldConfigAccessControlHandler
[field_storage_config] => Drupal\field\FieldStorageConfigAccessControlHandler
[file] => Drupal\file\FileAccessControlHandler
[filter_format] => Drupal\filter\FilterFormatAccessControlHandler
[image_style] => Drupal\Core\Entity\EntityAccessControlHandler
[configurable_language] => Drupal\language\LanguageAccessControlHandler
[language_content_settings] => Drupal\Core\Entity\EntityAccessControlHandler
[node] => Drupal\node\NodeAccessControlHandler
[node_type] => Drupal\node\NodeTypeAccessControlHandler
[rdf_mapping] => Drupal\Core\Entity\EntityAccessControlHandler
[search_page] => Drupal\search\SearchPageAccessControlHandler
[shortcut] => Drupal\shortcut\ShortcutAccessControlHandler
[shortcut_set] => Drupal\shortcut\ShortcutSetAccessControlHandler
[action] => Drupal\Core\Entity\EntityAccessControlHandler
[menu] => Drupal\system\MenuAccessControlHandler
[taxonomy_term] => Drupal\taxonomy\TermAccessControlHandler
[taxonomy_vocabulary] => Drupal\taxonomy\VocabularyAccessControlHandler
[tour] => Drupal\tour\TourAccessControlHandler
[user_role] => Drupal\user\RoleAccessControlHandler
[user] => Drupal\user\UserAccessControlHandler
[menu_link_content] => Drupal\menu_link_content\MenuLinkContentAccessControlHandler
[view] => Drupal\Core\Entity\EntityAccessControlHandler
[date_format] => Drupal\system\DateFormatAccessControlHandler
[entity_form_display] => \Drupal\Core\Entity\Entity\Access\EntityFormDisplayAccessControlHandler
[entity_form_mode] => Drupal\Core\Entity\EntityAccessControlHandler
[entity_view_display] => \Drupal\Core\Entity\Entity\Access\EntityViewDisplayAccessControlHandler
[entity_view_mode] => Drupal\Core\Entity\EntityAccessControlHandler
[base_field_override] => Drupal\Core\Field\BaseFieldOverrideAccessControlHandler
)
自定义访问控制处理器时,通常继承以上默认基类:
\Drupal\Core\Entity\EntityAccessControlHandler
覆写以下的访问控制方法:
checkAccess(EntityInterface $entity, $operation, AccountInterface $account)
checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL)
参考块访问控制处理器:
\Drupal\block\BlockAccessControlHandler
操作细化问题:
你可曾想过操作可以细化,从而产生层级,比如“edit”操作,可以细化为编辑版本、编辑翻译,那么是否有必要为这些细化操作专门定义操作名呢?细化可以有很多层级,或者多种条件,比如编辑有多个翻译的版本,因此如果细化将产生大量操作名,操作名变的像权限名了,在软件架构上违反了封装原则,细化检查应该在权限判断逻辑中检查,而不应该通过定义操作名的方式进行,因此系统并没有“edit revision”“create translation”这样的操作名
实际上翻译的创建需要考虑很多问题,比如是否允许被翻译成目标语言id,是否已存在翻译等,因此翻译创建权限并不在权限访问控制器中判断,而在内容翻译处理器中,详见:
\Drupal\content_translation\ContentTranslationHandler::getTranslationAccess
同时也见权限检查标识“_access_content_translation_manage”
能否创建新版本可通过版本字段的更新权限实现:
$entity->get($entity_type->getKey('revision'))->access('update');
实体访问的路由相关控制:
在路由定义中可以在requirements键下使用一些权限检查标识来控制实体相关的访问(权限检查标识见本系列权限系统上集),如:
_entity_access: 'block_content.update'
_entity_create_access: 'menu'
注意权限检查标识和“_entity_view、_entity_list 、_form、_entity_form”等不一样,后者是在设置控制器,在路由定义中的defaults键下进行,而权限检查标识是在设置权限需求。
系统默认定义了一些和实体相关的权限检查标识,她们大多调用了实体访问控制处理器,权限检查标识及其相应的权限检查器如下:
权限检查标识:_entity_access
检查器服务id:access_check.entity
检查器类:Drupal\Core\Entity\EntityAccessCheck
检查方法:access
对实体的通用操作进行权限检查,检查标识的值格式为“实体类型ID.操作名”,操作名如:'view', 'update', 'create', 'delete'等,注意这包括了创建create操作,操作名不可省略,许多时候就是实体释文中执行该操作的表单处理器键名,通常也做表单模式名,该检查器需要路由在经过参数转化后参数中存在实体对象,且参数名为实体类型ID,否则该检查器返回中立结果,实际的检查逻辑在实体的访问控制处理器中完成。
权限检查标识:_entity_create_access
检查器服务id:access_check.entity_create
检查器类:Drupal\Core\Entity\EntityCreateAccessCheck
检查方法:access
检查实体的创建权限,检查标识的值格式为“实体类型ID :bundle”,其中bundle部分可以是一个变量(这也是通常的情况),此时bundle部分应写作“{name}”,name为路由原始参数中bundle的变量名,其值为bundle值,在获取bundle时会进行替换,如:“node:{node_type}”,此时路由原始参数中应存在$node_type,其值为bundle值;如果实体类型不支持bundle,则bundle部分可以省略,实际的检查逻辑在实体的访问控制处理器中完成
权限检查标识:_entity_create_any_access
检查器服务id:access_check.entity_create_any
检查器类:Drupal\Core\Entity\EntityCreateAnyAccessCheck
检查方法:access
检查标识的值是实体类型ID,如果该实体类型不支持bundle则该检查标识完全等同于_entity_create_access,反之只要具备任何一个bundle的创建权限就允许访问,如果有创建其bundle实体本身的权限则放行,以节点实体说明,如果有任何一个内容类型的创建权限即允许访问,或者有新建内容类型的权限也允许访问;该检查标识通常用在实体类型的内容添加页。
但目前(V8.6.9)该检查器有bug,实现逻辑不正确,比如在无权创建bundle实体本身或无权创建属于第一个bundle的实体时结果将禁止,而不管属于其他bundle的实体是否有创建权限,已提交,见:
https://www.drupal.org/project/drupal/issues/3039629
权限检查标识:_entity_delete_multiple_access
检查器服务id:access_check.entity_delete_multiple
检查器类:Drupal\Core\Entity\EntityDeleteMultipleAccessCheck
检查方法:access
判断是否允许删除多个实体,其值为实体类型id(但该值并不重要,没被使用),被删除的实体须为同一实体类型,因此在内部判断时只要有一个实体的删除权限即被允许,如果没Session,也没有被删除的实体,则返回中立,如果需要将权限具体到单个实体对象,则需要在控制器中做进一步检查,由于该检查器用到了$entity_type_id参数,因此路由中需要有该参数,其值为实体类型id
权限检查标识:_node_add_access
检查器服务id:access_check.node.add
检查器类:Drupal\node\Access\NodeAddAccessCheck
检查方法:access
功能和_entity_create_any_access相同,只不过是专门用于节点实体,值为“node:{node_type}”,由于是节点专用,所以其值并不重要(检查器没有用到该值),开发社区中已在讨论采用更泛化的_entity_create_any_access代替,在目前(V8.6.9)的代码中尚未声明弃用。有内容类型创建权限、当前bundle创建权限、任意bundle节点创建权限时均允许访问,否则返回中立
有开发者认为该检查器存在bug,不应该有内容类型创建权限就允许,关于此见:
https://www.drupal.org/project/drupal/issues/2744381
现在的逻辑是能创建内容类型就一定能添加内容,你是否认为这合理呢?
权限检查标识:_access_node_revision
检查器服务id:access_check.node.revision
检查器类:Drupal\node\Access\NodeRevisionAccessCheck
检查方法:access
检查是否具备节点实体某版本的某个操作权限,其值为操作名,是view、update、delete三者之一,如果传入其他操作名将直接拒绝
权限检查标识:_node_preview_access
检查器服务id:access_check.node.preview
检查器类:Drupal\node\Access\NodePreviewAccessCheck
检查方法:access
判断是否能够进入节点预览页,其值为“{node_preview}”,该值并不重要(检查器没有用到该值),如果节点是新的则以是否有新建权限为结果,否则以是否具备更新权限为结果
权限检查标识:_access_quickedit_entity_field
检查器服务id:access_check.quickedit.entity_field
检查器类:Drupal\quickedit\Access\QuickEditEntityFieldAccessCheck
检查方法:access
判断是否具备编辑实体某字段的权限,检查标识的值不被使用,因此不重要,通常写作true即可,该检查器会验证这些信息:实体是否具备该字段、实体是否有传递的语言参数所示的翻译、实体是否有更新权限、实体字段是否有编辑权限,这些检查如果有一个为否,即禁止通过;该检查器往往用于前端的就地编辑
权限检查标识:_field_ui_view_mode_access
检查器服务id:access_check.field_ui.view_mode
检查器类:Drupal\field_ui\Access\ViewModeAccessCheck
检查方法:access
判断能否访问视图模式配置管理页面(该页面可为字段指定格式化器等),检查标识值为视图模式配置管理的权限标识符,如“administer node display”,该检查器首先检查该视图模式是否被启用,其次检查当前用户是否具备前文所示的权限,任一回答为否即禁止;如果路由使用了该检查标识,那么路由参数中应该以这些参数:entity_type_id(实体类型id)、view_mode_name(视图模式名,默认为default)、bundle(或者通过bundle实体类型id指定)。
权限检查标识:_field_ui_form_mode_access
检查器服务id:access_check.field_ui.form_mode
检查器类:Drupal\field_ui\Access\FormModeAccessCheck
检查方法:access
和_field_ui_view_mode_access完全相同,只不过该检查标识用于实体表单模式
补充:
1、bug(D8.6.9):\Drupal\Core\Access\AccessResult::andIf方法实现有问题,在A为中立,B为允许时,结果没有合并B的缓存元数据,这就导致在缓存有效期内B变为禁止时,结果还是中立
此外\Drupal\Core\Access\AccessResult::orIf方法也有问题,也是出现在缓存上,已报告官方
2、实体权限检查分实体对象层面和字段对象层面,实体层面允许不代表字段层面也允许,反之亦然。
3、权限检查是内聚的,只要任何一个模块拒绝,那么结果将是拒绝
4、节点实体在系统中占有非常重要的地位,除本篇所涉知识外还涉及授权控制表,本系列将在下一篇独立讲解其访问控制处理器
5、本文权限检查钩子:
entity_access
$EntityTypeId. '_access'
entity_create_access
$entityTypeId . '_create_access'
必须返回访问检查结果对象:
\Drupal\Core\Access\AccessResultInterface
不能返回布尔或NULL值,一般就是以下三者之一:
return \Drupal\Core\Access\AccessResult::neutral();
return \Drupal\Core\Access\AccessResult::forbidden();
return \Drupal\Core\Access\AccessResult::allowed();