Afform - Delegated API calls should use a security helper
authorTim Otten <totten@civicrm.org>
Thu, 11 Feb 2021 02:25:23 +0000 (18:25 -0800)
committerTim Otten <totten@civicrm.org>
Wed, 17 Feb 2021 09:24:25 +0000 (01:24 -0800)
Before
------

The `Prefill`, `Submit`, and `AbstractProcessor` have various calls to `civicrm_api4()`
which are meant to read/write data for a specific entity.

These calls may or may not have `checkPermissions` sprinkled in.

After
-----

Those calls to `civicrm_api4()` now go through a wrapper. For example:

```php
$formDataModel->getSecureApi4('spouse')('Contact', 'get', [...]);
```

In this call, we use the settings for the `spouse` entity to pick a security
policy.  Then, we execute the `Contact.get` API within that security policy.

ext/afform/core/Civi/Afform/FormDataModel.php
ext/afform/core/Civi/Api4/Action/Afform/AbstractProcessor.php
ext/afform/core/Civi/Api4/Action/Afform/Prefill.php
ext/afform/core/Civi/Api4/Action/Afform/Submit.php

index fc89bb267f2bfb92075244dacd27d15a668def9d..26eb41fc2df140cbf8667eb807eb90bf854fcf88 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Civi\Afform;
 
+use Civi\API\Exception\UnauthorizedException;
 use Civi\Api4\Afform;
 
 /**
@@ -23,6 +24,12 @@ class FormDataModel {
    */
   protected $blocks = [];
 
+  /**
+   * @var array
+   *   Ex: $secureApi4s['spouse'] = function($entity, $action, $params){...};
+   */
+  protected $secureApi4s = [];
+
   public function __construct($layout) {
     $root = AHQ::makeRoot($layout);
     $this->entities = array_column(AHQ::getTags($root, 'af-entity'), NULL, 'name');
@@ -34,6 +41,35 @@ class FormDataModel {
     $this->parseFields($layout);
   }
 
+  /**
+   * Prepare to access APIv4 on behalf of a particular entity. This will enforce
+   * any security options associated with that entity.
+   *
+   * $formDataModel->getSecureApi4('me')('Contact', 'get', ['where'=>[...]]);
+   * $formDataModel->getSecureApi4('me')('Email', 'create', [...]);
+   *
+   * @param string $entityName
+   *   Ex: 'Individual1', 'Individual2', 'me', 'spouse', 'children', 'theMeeting'
+   *
+   * @return callable
+   *   API4-style
+   */
+  public function getSecureApi4($entityName) {
+    if (!isset($this->secureApi4s[$entityName])) {
+      if (!isset($this->entities[$entityName])) {
+        throw new UnauthorizedException("Cannot delegate APIv4 calls on behalf of unrecognized entity ($entityName)");
+      }
+      $this->secureApi4s[$entityName] = function(string $entity, string $action, $params = [], $index = NULL) use ($entityName) {
+        // FIXME Pick real value of checkPermissions. Possibly limit by ID.
+        // \Civi::log()->info("secureApi4($entityName): call($entity, $action)");
+        // $params['checkPermissions'] = FALSE;
+        $params['checkPermissions'] = TRUE;
+        return civicrm_api4($entity, $action, $params, $index);
+      };
+    }
+    return $this->secureApi4s[$entityName];
+  }
+
   /**
    * @param array $nodes
    * @param string $entity
index baa12b91e5a4be63da94d16c78ada16c13d99548..077e73cee7241e7077e1f8236f6e6fcbb10e1f23 100644 (file)
@@ -39,13 +39,8 @@ abstract class AbstractProcessor extends \Civi\Api4\Generic\AbstractAction {
   public function _run(Result $result) {
     // This will throw an exception if the form doesn't exist
     $this->_afform = (array) civicrm_api4('Afform', 'get', ['checkPermissions' => FALSE, 'where' => [['name', '=', $this->name]]], 0);
-    if ($this->getCheckPermissions()) {
-      if (!\CRM_Core_Permission::check("@afform:" . $this->_afform['name'])) {
-        throw new \Civi\API\Exception\UnauthorizedException("Authorization failed: Cannot process form " . $this->_afform['name']);
-      }
-    }
-
     $this->_formDataModel = new FormDataModel($this->_afform['layout']);
+    $this->checkPermissions();
     $this->validateArgs();
     $result->exchangeArray($this->processForm());
   }
@@ -64,6 +59,19 @@ abstract class AbstractProcessor extends \Civi\Api4\Generic\AbstractAction {
     }
   }
 
+  /**
+   * Assert that the current form submission is authorized.
+   *
+   * @throws \Civi\API\Exception\UnauthorizedException
+   */
+  protected function checkPermissions() {
+    if ($this->getCheckPermissions()) {
+      if (!\CRM_Core_Permission::check("@afform:" . $this->_afform['name'])) {
+        throw new \Civi\API\Exception\UnauthorizedException("Authorization failed: Cannot process form " . $this->_afform['name']);
+      }
+    }
+  }
+
   /**
    * @return array
    */
index e4b93be85f42960a6038d5667c30db3d087600aa..0b69d395763c5f99c82de0811f0ebad1d0e5bce4 100644 (file)
@@ -36,27 +36,18 @@ class Prefill extends AbstractProcessor {
    * @throws \API_Exception
    */
   private function loadEntity($entity, $id) {
-    $checkPermissions = TRUE;
-    if ($entity['type'] == 'Contact' && !empty($this->args[$entity['name'] . '-cs'])) {
-      $checkSum = civicrm_api4('Contact', 'validateChecksum', [
-        'checksum' => $this->args[$entity['name'] . '-cs'],
-        'contactId' => $id,
-      ]);
-      $checkPermissions = empty($checkSum[0]['valid']);
-    }
-    $result = civicrm_api4($entity['type'], 'get', [
+    $api4 = $this->_formDataModel->getSecureApi4($entity['name']);
+    $result = $api4($entity['type'], 'get', [
       'where' => [['id', '=', $id]],
       'select' => array_keys($entity['fields']),
-      'checkPermissions' => $checkPermissions,
     ]);
     foreach ($result as $item) {
       $data = ['fields' => $item];
       foreach ($entity['joins'] ?? [] as $joinEntity => $join) {
-        $data['joins'][$joinEntity] = (array) civicrm_api4($joinEntity, 'get', [
+        $data['joins'][$joinEntity] = (array) $api4($joinEntity, 'get', [
           'where' => self::getJoinWhereClause($entity['type'], $joinEntity, $item['id']),
           'limit' => !empty($join['af-repeat']) ? $join['max'] ?? 0 : 1,
           'select' => array_keys($join['fields']),
-          'checkPermissions' => $checkPermissions,
           'orderBy' => self::fieldExists($joinEntity, 'is_primary') ? ['is_primary' => 'DESC'] : [],
         ]);
       }
index dda6a41739b6916b859598680dba33a167cf7fe9..8c81828147b8e24edb75f1dd24d701aa84030f2d 100644 (file)
@@ -51,9 +51,10 @@ class Submit extends AbstractProcessor {
    */
   public static function processContacts(AfformSubmitEvent $event) {
     foreach ($event->entityValues['Contact'] ?? [] as $entityName => $contacts) {
+      $api4 = $event->formDataModel->getSecureApi4($entityName);
       foreach ($contacts as $contact) {
-        $saved = civicrm_api4('Contact', 'save', ['records' => [$contact['fields']]])->first();
-        self::saveJoins('Contact', $saved['id'], $contact['joins'] ?? []);
+        $saved = $api4('Contact', 'save', ['records' => [$contact['fields']]])->first();
+        self::saveJoins($api4, 'Contact', $saved['id'], $contact['joins'] ?? []);
       }
     }
     unset($event->entityValues['Contact']);
@@ -68,28 +69,29 @@ class Submit extends AbstractProcessor {
     foreach ($event->entityValues as $entityType => $entities) {
       // Each record is an array of one or more items (can be > 1 if af-repeat is used)
       foreach ($entities as $entityName => $records) {
+        $api4 = $event->formDataModel->getSecureApi4($entityName);
         foreach ($records as $record) {
-          $saved = civicrm_api4($entityType, 'save', ['records' => [$record['fields']]])->first();
-          self::saveJoins($entityType, $saved['id'], $record['joins'] ?? []);
+          $saved = $api4($entityType, 'save', ['records' => [$record['fields']]])->first();
+          self::saveJoins($api4, $entityType, $saved['id'], $record['joins'] ?? []);
         }
       }
       unset($event->entityValues[$entityType]);
     }
   }
 
-  protected static function saveJoins($mainEntityName, $entityId, $joins) {
+  protected static function saveJoins($api4, $mainEntityName, $entityId, $joins) {
     foreach ($joins as $joinEntityName => $join) {
       $values = self::filterEmptyJoins($joinEntityName, $join);
       // FIXME: Replace/delete should only be done to known contacts
       if ($values) {
-        civicrm_api4($joinEntityName, 'replace', [
+        $api4($joinEntityName, 'replace', [
           'where' => self::getJoinWhereClause($mainEntityName, $joinEntityName, $entityId),
           'records' => $values,
         ]);
       }
       else {
         try {
-          civicrm_api4($joinEntityName, 'delete', [
+          $api4($joinEntityName, 'delete', [
             'where' => self::getJoinWhereClause($mainEntityName, $joinEntityName, $entityId),
           ]);
         }