APIv4 - Add checkAccess action
authorColeman Watts <coleman@civicrm.org>
Tue, 27 Apr 2021 18:51:02 +0000 (14:51 -0400)
committerTim Otten <totten@civicrm.org>
Mon, 7 Jun 2021 03:18:52 +0000 (20:18 -0700)
Call checkAccess action before creating, updating or deleting

Civi/Api4/CustomValue.php
Civi/Api4/Generic/AbstractCreateAction.php
Civi/Api4/Generic/AbstractEntity.php
Civi/Api4/Generic/AbstractSaveAction.php
Civi/Api4/Generic/BasicBatchAction.php
Civi/Api4/Generic/BasicUpdateAction.php
Civi/Api4/Generic/CheckAccessAction.php [new file with mode: 0644]
Civi/Api4/Generic/DAODeleteAction.php
Civi/Api4/Generic/DAOUpdateAction.php
Civi/Api4/Utils/CoreUtil.php
tests/phpunit/api/v4/Action/BasicActionsTest.php

index 40b4d96160814290585125f1c32e6c7639f70c60..e036aa48e8441f89b0c02ba69710833b1779cfdb 100644 (file)
@@ -122,6 +122,13 @@ class CustomValue {
       ->setCheckPermissions($checkPermissions);
   }
 
+  /**
+   * @return \Civi\Api4\Generic\CheckAccessAction
+   */
+  public static function checkAccess($customGroup) {
+    return new Generic\CheckAccessAction("Custom_$customGroup", __FUNCTION__);
+  }
+
   /**
    * @see \Civi\Api4\Generic\AbstractEntity::permissions()
    * @return array
index 153e26ce6f59431ea13b4ea977ed3a5ac7beb76f..b08cb3e39c2f331b21b16c48cf3a3d2f14a5bbcb 100644 (file)
@@ -20,6 +20,8 @@
 namespace Civi\Api4\Generic;
 
 use Civi\Api4\Event\ValidateValuesEvent;
+use Civi\API\Exception\UnauthorizedException;
+use Civi\Api4\Utils\CoreUtil;
 
 /**
  * Base class for all `Create` api actions.
@@ -59,6 +61,7 @@ abstract class AbstractCreateAction extends AbstractAction {
 
   /**
    * @throws \API_Exception
+   * @throws \Civi\API\Exception\UnauthorizedException
    */
   protected function validateValues() {
     // FIXME: There should be a protocol to report a full list of errors... Perhaps a subclass of API_Exception?
@@ -66,6 +69,11 @@ abstract class AbstractCreateAction extends AbstractAction {
     if ($unmatched) {
       throw new \API_Exception("Mandatory values missing from Api4 {$this->getEntityName()}::{$this->getActionName()}: " . implode(", ", $unmatched), "mandatory_missing", ["fields" => $unmatched]);
     }
+
+    if ($this->checkPermissions && !CoreUtil::checkAccess($this->getEntityName(), $this->getActionName(), $this->getValues())) {
+      throw new UnauthorizedException("ACL check failed");
+    }
+
     $e = new ValidateValuesEvent($this, [$this->getValues()], new \CRM_Utils_LazyArray(function () {
       return [['old' => NULL, 'new' => $this->getValues()]];
     }));
index 72ef2bab19b4f2751a9566c5612caf849f6acb61..348015fb3c3988365c627024915ffa448b40b344 100644 (file)
@@ -55,6 +55,13 @@ abstract class AbstractEntity {
    */
   abstract public static function getFields();
 
+  /**
+   * @return \Civi\Api4\Generic\CheckAccessAction
+   */
+  public static function checkAccess() {
+    return new CheckAccessAction(self::getEntityName(), __FUNCTION__);
+  }
+
   /**
    * Returns a list of permissions needed to access the various actions in this api.
    *
index 29f329cc661e147daca5fe4bdd7cbc563849882e..aefd3725edbbe2f4873176cda13e1eafbd6c80cb 100644 (file)
@@ -20,6 +20,8 @@
 namespace Civi\Api4\Generic;
 
 use Civi\Api4\Event\ValidateValuesEvent;
+use Civi\API\Exception\UnauthorizedException;
+use Civi\Api4\Utils\CoreUtil;
 
 /**
  * Create or update one or more $ENTITIES.
@@ -93,6 +95,7 @@ abstract class AbstractSaveAction extends AbstractAction {
 
   /**
    * @throws \API_Exception
+   * @throws \Civi\API\Exception\UnauthorizedException
    */
   protected function validateValues() {
     // FIXME: There should be a protocol to report a full list of errors... Perhaps a subclass of API_Exception?
@@ -105,6 +108,16 @@ abstract class AbstractSaveAction extends AbstractAction {
     if ($unmatched) {
       throw new \API_Exception("Mandatory values missing from Api4 {$this->getEntityName()}::{$this->getActionName()}: " . implode(", ", $unmatched), "mandatory_missing", ["fields" => $unmatched]);
     }
+
+    if ($this->checkPermissions) {
+      foreach ($this->records as $record) {
+        $action = empty($record[$this->idField]) ? 'create' : 'update';
+        if (!CoreUtil::checkAccess($this->getEntityName(), $action, $record)) {
+          throw new UnauthorizedException("ACL check failed");
+        }
+      }
+    }
+
     $e = new ValidateValuesEvent($this, $this->records, new \CRM_Utils_LazyArray(function() {
       $existingIds = array_column($this->records, $this->idField);
       $existing = civicrm_api4($this->getEntityName(), 'get', [
index d1787bd44a127ca9ee3208c571a9cdfccbd28596..1806f9165bef501590634b29c9b3ae54686ac359 100644 (file)
@@ -20,6 +20,8 @@
 namespace Civi\Api4\Generic;
 
 use Civi\API\Exception\NotImplementedException;
+use Civi\API\Exception\UnauthorizedException;
+use Civi\Api4\Utils\CoreUtil;
 
 /**
  * $ACTION one or more $ENTITIES.
@@ -65,6 +67,9 @@ class BasicBatchAction extends AbstractBatchAction {
    */
   public function _run(Result $result) {
     foreach ($this->getBatchRecords() as $item) {
+      if ($this->checkPermissions && !CoreUtil::checkAccess($this->getEntityName(), $this->getActionName(), $item)) {
+        throw new UnauthorizedException("ACL check failed");
+      }
       $result[] = $this->doTask($item);
     }
   }
index 4fd8fe6c156b103b542407786225ad77ee196cd7..f00dfe358f48e468660a93b7cd7cd5b3db2b87c9 100644 (file)
@@ -20,6 +20,8 @@
 namespace Civi\Api4\Generic;
 
 use Civi\API\Exception\NotImplementedException;
+use Civi\API\Exception\UnauthorizedException;
+use Civi\Api4\Utils\CoreUtil;
 
 /**
  * Update one or more $ENTITY with new values.
@@ -60,7 +62,11 @@ class BasicUpdateAction extends AbstractUpdateAction {
     $this->formatWriteValues($this->values);
     $this->validateValues();
     foreach ($this->getBatchRecords() as $item) {
-      $result[] = $this->writeRecord($this->values + $item);
+      $record = $this->values + $item;
+      if ($this->checkPermissions && !CoreUtil::checkAccess($this->getEntityName(), $this->getActionName(), $record)) {
+        throw new UnauthorizedException("ACL check failed");
+      }
+      $result[] = $this->writeRecord($record);
     }
   }
 
diff --git a/Civi/Api4/Generic/CheckAccessAction.php b/Civi/Api4/Generic/CheckAccessAction.php
new file mode 100644 (file)
index 0000000..cc26a5e
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+
+namespace Civi\Api4\Generic;
+
+use Civi\Api4\Utils\CoreUtil;
+
+/**
+ * Check if current user is authorized to perform specified action on a given $ENTITY.
+ *
+ * @method $this setAction(string $action)
+ * @method string getAction()
+ * @method $this setValues(array $values)
+ * @method array getValues()
+ */
+class CheckAccessAction extends AbstractAction {
+
+  /**
+   * @var string
+   * @required
+   */
+  protected $action;
+
+  /**
+   * @var array
+   * @required
+   */
+  protected $values = [];
+
+  /**
+   * @param \Civi\Api4\Generic\Result $result
+   */
+  public function _run(Result $result) {
+    // Prevent circular checks
+    if ($this->action === 'checkAccess') {
+      $granted = TRUE;
+    }
+    else {
+      $granted = CoreUtil::checkAccess($this->getEntityName(), $this->action, $this->values);
+    }
+    $result->exchangeArray([['access' => $granted]]);
+  }
+
+  /**
+   * This action is always allowed
+   *
+   * @return bool
+   */
+  public function isAuthorized() {
+    return TRUE;
+  }
+
+  /**
+   * Add an item to the values array
+   * @param string $fieldName
+   * @param mixed $value
+   * @return $this
+   */
+  public function addValue(string $fieldName, $value) {
+    $this->values[$fieldName] = $value;
+    return $this;
+  }
+
+}
index ecd9c91c131687f423398b0776421908bce1e346..bf335c0f404c1ad68f83aa993289c57426ee4f9a 100644 (file)
@@ -19,6 +19,9 @@
 
 namespace Civi\Api4\Generic;
 
+use Civi\API\Exception\UnauthorizedException;
+use Civi\Api4\Utils\CoreUtil;
+
 /**
  * Delete one or more $ENTITIES.
  *
@@ -53,6 +56,9 @@ class DAODeleteAction extends AbstractBatchAction {
 
     if ($this->getCheckPermissions()) {
       foreach (array_keys($items) as $key) {
+        if (!CoreUtil::checkAccess($this->getEntityName(), $this->getActionName(), $items[$key])) {
+          throw new UnauthorizedException("ACL check failed");
+        }
         $items[$key]['check_permissions'] = TRUE;
         $this->checkContactPermissions($baoName, $items[$key]);
       }
index aef6618729f56c552a02f85c01b26230bf315e4f..b545fbc2254031b1704a5e72ba626e7c5f77f5c2 100644 (file)
@@ -19,6 +19,9 @@
 
 namespace Civi\Api4\Generic;
 
+use Civi\API\Exception\UnauthorizedException;
+use Civi\Api4\Utils\CoreUtil;
+
 /**
  * Update one or more $ENTITY with new values.
  *
@@ -60,6 +63,9 @@ class DAOUpdateAction extends AbstractUpdateAction {
     // Update a single record by ID unless select requires more than id
     if ($this->getSelect() === ['id'] && count($this->where) === 1 && $this->where[0][0] === 'id' && $this->where[0][1] === '=' && !empty($this->where[0][2])) {
       $this->values['id'] = $this->where[0][2];
+      if ($this->checkPermissions && !CoreUtil::checkAccess($this->getEntityName(), $this->getActionName(), $this->values)) {
+        throw new UnauthorizedException("ACL check failed");
+      }
       $items = [$this->values];
       $this->validateValues();
       $result->exchangeArray($this->writeObjects($items));
@@ -70,6 +76,9 @@ class DAOUpdateAction extends AbstractUpdateAction {
     $items = $this->getBatchRecords();
     foreach ($items as &$item) {
       $item = $this->values + $item;
+      if ($this->checkPermissions && !CoreUtil::checkAccess($this->getEntityName(), $this->getActionName(), $item)) {
+        throw new UnauthorizedException("ACL check failed");
+      }
     }
 
     $this->validateValues();
index c5b0d5e82957f3d2022586a2c92be1f053b30bea..65a3d81ed9fda16ce9b7937abcc52f59323de087 100644 (file)
@@ -19,6 +19,7 @@
 
 namespace Civi\Api4\Utils;
 
+use Civi\API\Request;
 use CRM_Core_DAO_AllCoreTables as AllCoreTables;
 
 class CoreUtil {
@@ -151,4 +152,40 @@ class CoreUtil {
     return $customGroupName && \CRM_Core_DAO::getFieldValue('CRM_Core_DAO_CustomGroup', $customGroupName, 'is_multiple', 'name');
   }
 
+  /**
+   * Check if current user is authorized to perform specified action on a given entity.
+   *
+   * @param string $entityName
+   * @param string $actionName
+   * @param array $record
+   * @return bool
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   * @throws \Civi\API\Exception\NotImplementedException
+   * @throws \Civi\API\Exception\UnauthorizedException
+   */
+  public static function checkAccess(string $entityName, string $actionName, array $record) {
+    $action = Request::create($entityName, $actionName, ['version' => 4]);
+    // This checks gatekeeper permissions
+    $granted = $action->isAuthorized();
+    // For get actions, just run a get and ACLs will be applied to the query.
+    // It's a cheap trick and not as efficient as not running the query at all,
+    // but BAO::checkAccess doesn't consistently check permissions for the "get" action.
+    if (is_a($action, '\Civi\Api4\Generic\DAOGetAction')) {
+      $granted = $granted && $action->addSelect('id')->addWhere('id', '=', $record['id'])->execute()->count();
+    }
+    else {
+      $baoName = self::getBAOFromApiName($entityName);
+      // If entity has a BAO, run the BAO::checkAccess function, which will call the hook
+      if ($baoName && strpos($baoName, '_BAO_')) {
+        $baoName::checkAccess($actionName, $record, NULL, $granted);
+      }
+      // Otherwise, call the hook directly
+      else {
+        \CRM_Utils_Hook::checkAccess($entityName, $actionName, $record, NULL, $granted);
+      }
+    }
+    return $granted;
+  }
+
 }
index 40048d6e4bbab9ba0b323eb70547391265305fb8..68fcf7c341e4266cc7db0cbf77020a191b32ed85 100644 (file)
@@ -260,7 +260,7 @@ class BasicActionsTest extends UnitTestCase {
 
   public function testEmptyAndNullOperators() {
     $records = [
-      [],
+      ['id' => NULL],
       ['color' => '', 'weight' => 0],
       ['color' => 'yellow', 'weight' => 100000000000],
     ];