Afform - Allow `af-entity` to specify the `security` and `actions`
authorTim Otten <totten@civicrm.org>
Thu, 11 Feb 2021 02:47:52 +0000 (18:47 -0800)
committerTim Otten <totten@civicrm.org>
Wed, 17 Feb 2021 09:24:25 +0000 (01:24 -0800)
* `actions` indicates whether this entity can be used for creating or updating data
* `security` indicates whether it uses role-based access-control (like the standard Civi admin UI)
  or form-based access-control (dependent entirely on form configuration)

ext/afform/core/CRM/Afform/ArrayHtml.php
ext/afform/core/Civi/Afform/FormDataModel.php
ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php
ext/afform/mock/ang/mockPublicForm.aff.html
ext/afform/mock/tests/phpunit/api/v4/formatExamples/entity-security.php [new file with mode: 0644]

index 1ff7c703f9ac9409a0c884e7a5f747c931dd9915..f24a2f27e0778dc91e44691152b2bd284a60281c 100644 (file)
@@ -29,6 +29,8 @@ class CRM_Afform_ArrayHtml {
       'name' => 'text',
       'type' => 'text',
       'data' => 'js',
+      'security' => 'text',
+      'actions' => 'js',
     ],
     'af-field' => [
       '#selfClose' => TRUE,
index 26eb41fc2df140cbf8667eb807eb90bf854fcf88..c31c7de2beb165223056bad7740df002cd3549b1 100644 (file)
@@ -13,6 +13,8 @@ use Civi\Api4\Afform;
  */
 class FormDataModel {
 
+  protected $defaults = ['security' => 'RBAC', 'actions' => ['create' => TRUE, 'update' => TRUE]];
+
   /**
    * @var array
    *   Ex: $entities['spouse']['type'] = 'Contact';
@@ -34,6 +36,7 @@ class FormDataModel {
     $root = AHQ::makeRoot($layout);
     $this->entities = array_column(AHQ::getTags($root, 'af-entity'), NULL, 'name');
     foreach (array_keys($this->entities) as $entity) {
+      $this->entities[$entity] = array_merge($this->defaults, $this->entities[$entity]);
       $this->entities[$entity]['fields'] = $this->entities[$entity]['joins'] = [];
     }
     // Pre-load full list of afforms in case this layout embeds other afform directives
@@ -60,16 +63,63 @@ class FormDataModel {
         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;
+        $entityDefn = $this->entities[$entityName];
+
+        switch ($entityDefn['security']) {
+          // Role-based access control. Limits driven by the current user's role/group/permissions.
+          case 'RBAC':
+            $params['checkPermissions'] = TRUE;
+            break;
+
+          // Form-based access control. Limits driven by form configuration.
+          case 'FBAC':
+            $params['checkPermissions'] = FALSE;
+            break;
+
+          default:
+            throw new UnauthorizedException("Cannot process APIv4 request for $entityName ($entity.$action): Unrecognized security model");
+        }
+
+        if (!$this->isActionAllowed($entityDefn, $entity, $action, $params)) {
+          throw new UnauthorizedException("Cannot process APIv4 request for $entityName ($entity.$action): Action is not approved");
+        }
+
         return civicrm_api4($entity, $action, $params, $index);
       };
     }
     return $this->secureApi4s[$entityName];
   }
 
+  /**
+   * Determine if we are allowed to perform a given action for this entity.
+   *
+   * @param $entityDefn
+   * @param $entity
+   * @param $action
+   * @param $params
+   *
+   * @return bool
+   */
+  protected function isActionAllowed($entityDefn, $entity, $action, $params) {
+    if ($action === 'save') {
+      foreach ($params['records'] ?? [] as $record) {
+        $nextAction = !isset($record['id']) ? 'create' : 'update';
+        if (!$this->isActionAllowed($entityDefn, $entity, $nextAction, $record)) {
+          return FALSE;
+        }
+      }
+      return TRUE;
+    }
+
+    // "Update" effectively means "read+save".
+    if ($action === 'get') {
+      $action = 'update';
+    }
+
+    $result = !empty($entityDefn['actions'][$action]);
+    return $result;
+  }
+
   /**
    * @param array $nodes
    * @param string $entity
index 43a4a557440e613faa7cb55fffb4618c5e20ffd6..00513dd3ee00b83c87f0a7d1c154e97333bbfdac 100644 (file)
@@ -49,6 +49,8 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI
             'propB' => ['name' => 'propB', 'defn' => ['title' => 'Whiz']],
           ],
           'joins' => [],
+          'security' => 'RBAC',
+          'actions' => ['create' => 1, 'update' => 1],
         ],
       ],
     ];
@@ -61,12 +63,30 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI
           'name' => 'foobar',
           'fields' => [],
           'joins' => [],
+          'security' => 'RBAC',
+          'actions' => ['create' => 1, 'update' => 1],
         ],
         'whiz_bang' => [
           'type' => 'Whiz',
           'name' => 'whiz_bang',
           'fields' => [],
           'joins' => [],
+          'security' => 'RBAC',
+          'actions' => ['create' => 1, 'update' => 1],
+        ],
+      ],
+    ];
+
+    $cases[] = [
+      'html' => '<af-form><div><af-entity type="Foo" name="foobar" security="FBAC" actions="{create: false, update: true}"/></div></af-form>',
+      'entities' => [
+        'foobar' => [
+          'type' => 'Foo',
+          'name' => 'foobar',
+          'fields' => [],
+          'joins' => [],
+          'security' => 'FBAC',
+          'actions' => ['create' => FALSE, 'update' => TRUE],
         ],
       ],
     ];
index 3ee436441c61ae81fd7a11f76e02be5c000969c0..593154b507be4860177552b888503cbaa5af09de 100644 (file)
@@ -1,5 +1,6 @@
+<!-- This example is entirely public; anonymous users may use it to submit a `Contact`, but they cannot view or modify data. -->
 <af-form ctrl="afform">
-  <af-entity data="{contact_type: 'Individual', source: 'Hello'}" url-autofill="1" type="Contact" name="me" label="Myself" />
+  <af-entity data="{contact_type: 'Individual', source: 'Hello'}" url-autofill="1" security="FBAC" actions="{create: true, update: false}" type="Contact" name="me" label="Myself" />
   <fieldset af-fieldset="me">
     <legend class="af-text">Individual 1</legend>
     <div class="af-container">
diff --git a/ext/afform/mock/tests/phpunit/api/v4/formatExamples/entity-security.php b/ext/afform/mock/tests/phpunit/api/v4/formatExamples/entity-security.php
new file mode 100644 (file)
index 0000000..ff93ab8
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+
+return [
+  'html' => '<af-form ctrl="afform"><af-entity security="FBAC" type="Contact" name="me" data="{contact_type: \'Individual\', source: \'Hello\'}" actions="{create: 1, update: 0}" /></af-form>',
+  'pretty' => '<af-form ctrl="afform">
+  <af-entity security="FBAC" type="Contact" name="me" data="{contact_type: \'Individual\', source: \'Hello\'}" actions="{create: 1, update: 0}" />
+</af-form>
+',
+  'stripped' => [
+    [
+      '#tag' => 'af-form',
+      'ctrl' => 'afform',
+      '#children' => [
+        [
+          '#tag' => 'af-entity',
+          'security' => 'FBAC',
+          'type' => 'Contact',
+          'name' => 'me',
+          'data' => '{contact_type: \'Individual\', source: \'Hello\'}',
+          'actions' => '{create: 1, update: 0}',
+        ],
+      ],
+    ],
+  ],
+  'shallow' => [
+    [
+      '#tag' => 'af-form',
+      'ctrl' => 'afform',
+      '#children' => [
+        [
+          '#tag' => 'af-entity',
+          'security' => 'FBAC',
+          'type' => 'Contact',
+          'name' => 'me',
+          'data' => '{contact_type: \'Individual\', source: \'Hello\'}',
+          'actions' => '{create: 1, update: 0}',
+        ],
+      ],
+    ],
+  ],
+  'deep' => [
+    [
+      '#tag' => 'af-form',
+      'ctrl' => 'afform',
+      '#children' => [
+        [
+          '#tag' => 'af-entity',
+          'security' => 'FBAC',
+          'type' => 'Contact',
+          'name' => 'me',
+          'data' => ['contact_type' => 'Individual', 'source' => 'Hello'],
+          'actions' => ['create' => 1, 'update' => 0],
+        ],
+      ],
+    ],
+  ],
+];