Separate access and executable checks and make access completely optional

Created on 19 March 2025, 15 days ago

Problem/Motivation

Since the beginning, ECA executes an action plugin in the following way:

  • Calling the access method of the action plugin
  • If that access callback returns access allowed, then the action will be executed
  • Otherwise, ECA will stop the complete chain of subsequent actions.

Since this is the only place for being able to stop the actual action execution, many action plugins mix up access and executable checks. For example the LoadEntity plugin of eca_content does this:

  /**
   * {@inheritdoc}
   */
  public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
    $access_result = AccessResult::forbidden();
    $reason = NULL;
    if ($entity = $this->doLoadEntity($object)) {
      $access_result = $entity->access('view', $account, TRUE);
      if (!$access_result->isAllowed()) {
        $reason = 'No permission to view the entity.';
      }
    }
    else {
      $reason = 'No entity available.';
    }
    if ($reason !== NULL && $access_result instanceof AccessResultReasonInterface) {
      $access_result->setReason($reason);
    }
    return $return_as_object ? $access_result : $access_result->isAllowed();
  }

As we can see in that implementation, there is a mix of validating whether the action can be executed by checking whether the entity exists, and the other part is doing an actual access check whether the current user is allowed to view the targeted entity.

Many sites are doing "administrative" tasks with ECA and therefore either need to switch the user account within the ECA process every time such administrative task is to be executed, or use a system user here ECA is automatically switching to all the time. Both ways have their reasons to exist (we use the latter way for example), but also both ways are not always ideal.

Example 1: some things like rendering a view within ECA using a privileged user, then always the context of that privileged user is used, showing information of that user in the rendered view (Views is not aware that there can be another session_user in place).

Example 2: Access checks can be expensive, especially when dozens or hundreds of entitiy in a list may be involved. When using the Group module, every access check involves at least one additional database query leading to significant increase of total response times.

Steps to reproduce

Proposed resolution

In general, mixing up multiple purposes in one function is not a good practice. Therefore I suggest for 3.x to split up the checks into two distinct methods:

  • the access callback of the action plugin only checks for access and nothing else
  • introduce a new method "executable" for action plugins that validate whether the action can be executed

Then the ECA engine involves both methods to check whether the action can be executed. However, the check for access should then be made optional. We may discuss how the optional access check may be provided via UI, either providing an option for every single action, or provide a global default (or both).

Remaining tasks

User interface changes

New options to be introduced for optional access checks

API changes

The ECA engine will change internally.

All action plugins need to be revisited. We could try to remain some BC for other contrib modules by providing a default method implementation in the base class of the ECA action plugin.

Data model changes

📌 Task
Status

Active

Version

3.0

Component

Miscellaneous

Created by

🇩🇪Germany mxh Offenburg

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

  • Issue created by @mxh
  • 🇩🇪Germany jurgenhaas Gottmadingen

    I'm very much in favour of this proposal. Event want to add to the global user switch issue that we found this to be so bad in most cases because most access control features become unusable in that context, and we've banned that feature from all our projects.

    My only worry is that separating access from executable checks is not always that obvious. E.g. in the given example with loading an entity, what else should be access controlled if not the view permission on the given entity? And to accomplish that, the entity needs to be loaded, which inherently also checks that the entity even exists. What I'm saying here is that in this case there won't be any access control possible if this will be part of the executable check.

    Nevertheless, making access control optional sounds tempting, and I could see a lot of admin ECAs benefitting from that. But that setting should probably not be global or on a per action plugin basis, it should be configurable per ECA model. That's the only way to make them shareable across multiple sites.

  • 🇩🇪Germany mxh Offenburg

    Yeah, the global user switch is a rabbit hole where it's hard to get out from later on.

    And to accomplish that, the entity needs to be loaded, which inherently also checks that the entity even exists. What I'm saying here is that in this case there won't be any access control possible if this will be part of the executable check.

    In this example, yes it obviously needs to load it. Maybe not the best example picked up. Still there may be an executable method that only checks whether it exists. If I don't care about the actual access check then, I could skip it. Loading the entity multiple times here could be optimized in case we see a potential performance issue.

    But that setting should probably not be global or on a per action plugin basis, it should be configurable per ECA model.

    Sounds good.

  • 🇩🇪Germany mxh Offenburg

    In the example of LoadEntity, the refactor may look something like this:

    class LoadEntity extends ConfigurableActionBase ... {
    
      /**
       * {@inheritdoc}
       */
      public function executable(...$args): ExecutableResultInterface {
        if ($this->doLoadEntity($object)) {
          return ExecutableResult::executable();
        }
        return ExecutableResult::notExecutable('No entity available.');
      }
    
      /**
       * {@inheritdoc}
       */
      public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
        $reason = NULL;
        $entity = $this->doLoadEntity($object);
        $access_result = $entity->access('view', $account, TRUE);
        if (!$access_result->isAllowed()) {
          $reason = 'No permission to view the entity.';
        }
        if ($reason !== NULL && $access_result instanceof AccessResultReasonInterface) {
          $access_result->setReason($reason);
        }
        return $return_as_object ? $access_result : $access_result->isAllowed();
      }
    
    }
    
    // Within ECA Processor:
    
    if ($action->executable(...$args)->isExecutable() && $action->access(...$args)->isAllowed()) {
      $action->execute(...$args);
    }
    

    .. which would streamline the access check to its purpose, since it doesn't need to check anymore whether the entity exists as this was already checked by executable().

  • 🇩🇪Germany jurgenhaas Gottmadingen

    Looks great. And in the processor, where we call $action->access(...$args)->isAllowed(), this can be disabled e.g. per ECA model.

Production build 0.71.5 2024