From ff449eddedf7cb86c683c7799d8e3e98836bd9a7 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 10 Feb 2021 18:47:52 -0800 Subject: [PATCH] Afform - Allow `af-entity` to specify the `security` and `actions` * `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 | 2 + ext/afform/core/Civi/Afform/FormDataModel.php | 58 +++++++++++++++++-- .../phpunit/Civi/Afform/FormDataModelTest.php | 20 +++++++ ext/afform/mock/ang/mockPublicForm.aff.html | 3 +- .../api/v4/formatExamples/entity-security.php | 57 ++++++++++++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 ext/afform/mock/tests/phpunit/api/v4/formatExamples/entity-security.php diff --git a/ext/afform/core/CRM/Afform/ArrayHtml.php b/ext/afform/core/CRM/Afform/ArrayHtml.php index 1ff7c703f9..f24a2f27e0 100644 --- a/ext/afform/core/CRM/Afform/ArrayHtml.php +++ b/ext/afform/core/CRM/Afform/ArrayHtml.php @@ -29,6 +29,8 @@ class CRM_Afform_ArrayHtml { 'name' => 'text', 'type' => 'text', 'data' => 'js', + 'security' => 'text', + 'actions' => 'js', ], 'af-field' => [ '#selfClose' => TRUE, diff --git a/ext/afform/core/Civi/Afform/FormDataModel.php b/ext/afform/core/Civi/Afform/FormDataModel.php index 26eb41fc2d..c31c7de2be 100644 --- a/ext/afform/core/Civi/Afform/FormDataModel.php +++ b/ext/afform/core/Civi/Afform/FormDataModel.php @@ -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 diff --git a/ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php b/ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php index 43a4a55744..00513dd3ee 100644 --- a/ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php +++ b/ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php @@ -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' => '
', + 'entities' => [ + 'foobar' => [ + 'type' => 'Foo', + 'name' => 'foobar', + 'fields' => [], + 'joins' => [], + 'security' => 'FBAC', + 'actions' => ['create' => FALSE, 'update' => TRUE], ], ], ]; diff --git a/ext/afform/mock/ang/mockPublicForm.aff.html b/ext/afform/mock/ang/mockPublicForm.aff.html index 3ee436441c..593154b507 100644 --- a/ext/afform/mock/ang/mockPublicForm.aff.html +++ b/ext/afform/mock/ang/mockPublicForm.aff.html @@ -1,5 +1,6 @@ + - +
Individual 1
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 index 0000000000..ff93ab805f --- /dev/null +++ b/ext/afform/mock/tests/phpunit/api/v4/formatExamples/entity-security.php @@ -0,0 +1,57 @@ + '', + 'pretty' => ' + + +', + '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], + ], + ], + ], + ], +]; -- 2.25.1