diff --git a/app/composer.json b/app/composer.json index a77a69354..527fe89b2 100644 --- a/app/composer.json +++ b/app/composer.json @@ -39,7 +39,8 @@ "FileConnector\\": "availableplugins/FileConnector/src/", "PipelineToolkit\\": "availableplugins/PipelineToolkit/src/", "SqlConnector\\": "availableplugins/SqlConnector/src/", - "CoreJob\\": "plugins/CoreJob/src/" + "CoreJob\\": "plugins/CoreJob/src/", + "OrcidSource\\": "plugins/OrcidSource/src/" } }, "autoload-dev": { @@ -54,7 +55,8 @@ "FileConnector\\Test\\": "availableplugins/FileConnector/tests/", "PipelineToolkit\\Test\\": "availableplugins/PipelineToolkit/tests/", "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/", - "CoreJob\\Test\\": "plugins/CoreJob/tests/" + "CoreJob\\Test\\": "plugins/CoreJob/tests/", + "OrcidSource\\Test\\": "plugins/OrcidSource/tests/" } }, "scripts": { diff --git a/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php b/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php index d21953283..ffc9113e3 100644 --- a/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php +++ b/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php @@ -252,7 +252,7 @@ public function validationDefault(Validator $validator): Validator { 'provider' => 'table' ] ]); - $validator->allowEmptyString('scope'); + $validator->notEmptyString('scope'); $validator->add('refresh_token', [ 'content' => [ diff --git a/app/plugins/EnvSource/templates/EnvSources/fields.inc b/app/plugins/EnvSource/templates/EnvSources/fields.inc index 54fb0f882..073a6c39e 100644 --- a/app/plugins/EnvSource/templates/EnvSources/fields.inc +++ b/app/plugins/EnvSource/templates/EnvSources/fields.inc @@ -48,7 +48,9 @@ if($vv_action == 'edit') { ]); } - print "

" . __d('env_source', 'information.header.map') . "

\n"; + // Print a title + $title = __d('env_source', 'information.header.map'); + print $this->element('form/h3Inject', compact('title')); $defaultNames = [ 'env_identifier_sourcekey' => 'ENV_SOURCE_KEY', diff --git a/app/plugins/OrcidSource/.gitignore b/app/plugins/OrcidSource/.gitignore new file mode 100644 index 000000000..244d127b1 --- /dev/null +++ b/app/plugins/OrcidSource/.gitignore @@ -0,0 +1,8 @@ +/composer.lock +/composer.phar +/phpunit.xml +/.phpunit.result.cache +/phpunit.phar +/config/Migrations/schema-dump-default.lock +/vendor/ +/.idea/ diff --git a/app/plugins/OrcidSource/README.md b/app/plugins/OrcidSource/README.md new file mode 100644 index 000000000..14821f1da --- /dev/null +++ b/app/plugins/OrcidSource/README.md @@ -0,0 +1,11 @@ +# OrcidSource plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/orcid-source +``` diff --git a/app/plugins/OrcidSource/composer.json b/app/plugins/OrcidSource/composer.json new file mode 100644 index 000000000..83e4a9222 --- /dev/null +++ b/app/plugins/OrcidSource/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/orcid-source", + "description": "OrcidSource plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.6.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "OrcidSource\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "OrcidSource\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/OrcidSource/config/routes.php b/app/plugins/OrcidSource/config/routes.php new file mode 100644 index 000000000..322f5105d --- /dev/null +++ b/app/plugins/OrcidSource/config/routes.php @@ -0,0 +1,44 @@ +plugin( + 'OrcidSource', + ['path' => '/orcid-source/'], + function ($routes) { + $routes->setRouteClass(DashedRoute::class); + + $routes->get( + 'orcid-sources/callback', + [ + 'plugin' => 'OrcidSource', + 'controller' => 'OrcidSources', + 'action' => 'callback', + ]); + } +); \ No newline at end of file diff --git a/app/plugins/OrcidSource/phpunit.xml.dist b/app/plugins/OrcidSource/phpunit.xml.dist new file mode 100644 index 000000000..f828b8533 --- /dev/null +++ b/app/plugins/OrcidSource/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po b/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po new file mode 100644 index 000000000..1c629a2ca --- /dev/null +++ b/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po @@ -0,0 +1,110 @@ +# COmanage Registry Localizations (orcid_source domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.2.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.OrcidSourceCollectors" +msgstr "{0,plural,=1{Orcid Source Collector} other{Orcid Source Collectors}}" + +msgid "controller.OrcidSources" +msgstr "{0,plural,=1{Orcid Source} other{Orcid Sources}}" + +msgid "controller.PetitionOrcidIdentities" +msgstr "{0,plural,=1{Petition Orcid Identity} other{Petition Orcid Identities}}" + +msgid "enumeration.OrcidSourceTierEnum.PRO" +msgstr "Production" + +msgid "enumeration.OrcidSourceTierEnum.SBX" +msgstr "Sandbox" + +msgid "enumeration.OrcidSourceApiEnum.AUT" +msgstr "Authorize" + +msgid "enumeration.OrcidSourceApiEnum.MEM" +msgstr "Members" + +msgid "enumeration.OrcidSourceApiEnum.PUB" +msgstr "Public" + +msgid "error.search" +msgstr "Search request returned {0}" + +msgid "error.token.none" +msgstr "Access token not configured (try resaving configuration)" + +msgid "error.param.notfound" +msgstr "{0} was not found" + +msgid "error.orcid_source.no_orcid" +msgstr "ORCID identifier missing from response." + +msgid "field.OrcidSources.api_type" +msgstr "API Type" + +msgid "field.OrcidSources.redirect_uri" +msgstr "Additional ORCID Redirect URI" + +msgid "field.OrcidSources.scope_inherit" +msgstr "Inherit Scope" + +msgid "field.OrcidSources.api_tier" +msgstr "API Tier" + +msgid "field.OrcidSources.name_type_id" +msgstr "Name Type" + +msgid "field.OrcidSources.telephone_number_type_id" +msgstr "Telephone Number Type" + +msgid "field.OrcidSources.address_type_id" +msgstr "Address Type" + +msgid "field.OrcidSources.default_affiliation_type_id" +msgstr "Default Affiliation Type" + +msgid "field.OrcidSources.email_address_type_id" +msgstr "Email Address Type" + +msgid "information.OrcidSources.linked" +msgstr "Obtained ORCID {0} via authenticated OAuth flow" + +msgid "information.orcid_source.identifier" +msgstr "ORCID Identifier" + +msgid "information.OrcidSourceCollectors.authenticate" +msgstr "Authenticate with ORCID" + +msgid "information.OrcidSourceCollectors.sign_in" +msgstr "Sign in with your ORCID account to securely verify your ORCID iD." + +msgid "information.OrcidSources.default.types" +msgstr "Select Default Types for ORCID Fields" + +msgid "result.OrcidSourceCollector.collected" +msgstr "Obtained ORCID Identifier {0}" + +msgid "result.orcid.saved" +msgstr "ORCID Token recorded" + +msgid "result.pipeline.status" +msgstr "Pipeline completed with status {0}" diff --git a/app/plugins/OrcidSource/src/Controller/AppController.php b/app/plugins/OrcidSource/src/Controller/AppController.php new file mode 100644 index 000000000..9e22929f7 --- /dev/null +++ b/app/plugins/OrcidSource/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'OrcidSourceCollectors.id' => 'asc' + ] + ]; + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.2.0 + * @param EventInterface $event Cake Event + */ + public function beforeFilter(\Cake\Event\EventInterface $event) + { + $this->OrcidSources = TableRegistry::getTableLocator()->get('OrcidSource.OrcidSources'); + $this->PetitionOrcids = TableRegistry::getTableLocator()->get('OrcidSource.PetitionOrcids'); + + return parent::beforeFilter($event); + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.2.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->OrcidSourceCollectors->EnrollmentFlowSteps->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->OrcidSourceCollectors->EnrollmentFlowSteps->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->OrcidSourceCollectors->EnrollmentFlowSteps->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.2.0 + * @param string $id Env Source Collector ID + */ + + public function dispatch(string $id) { + $request = $this->getRequest(); + $session = $request->getSession(); + + $op = $this->requestParam('op'); + $code = $this->getRequest()->getQuery('code') ?? null; + $petition = $this->getPetition(); + + $this->set('vv_op', $op); + + $oricdSourceEntity = $this->OrcidSourceCollectors->get( + (int)$id, + [ + 'contain' => [ + 'ExternalIdentitySources' => ['OrcidSources' => ['Servers']] + ]] + ); + + $ServerModel = $oricdSourceEntity->external_identity_source->orcid_source->server->plugin; + $PluginServersTable = TableRegistry::getTableLocator()->get($ServerModel); + $serverId = $oricdSourceEntity->external_identity_source->orcid_source->server->id; + $PluginServerEntity = $PluginServersTable ->find() + ->where(['server_id' => $serverId]) + ->first(); + + + $this->set('vv_config', $oricdSourceEntity); + $this->set('vv_config_server', $PluginServerEntity); + $this->set('controller', $this); + + try { + // Let's authenticate first + if ($op == 'authenticate') { + $this->authenticate($id, $PluginServerEntity); + } else if (!empty($code) && $op !== 'savetoken') { + $response = $PluginServersTable->exchangeCode( + $id, + $code, + $this->OrcidSources->redirectUri( + [ + $id, + '?' => ['petition_id' => $petition->id], + ] + ), + false + ); + + // Use the response and save the data to petitions table + if(empty($response->orcid)) { + throw new \RuntimeException(__d('orcid_source', 'error.orcid_source.no_orcid')); + } + $this->set('vv_orcid', $response->orcid); + $this->set('vv_token', $response); + } if (!empty($code) && $op === 'savetoken') { + $orcid_token = $this->requestParam('orcid_token'); + $this->PetitionOrcids->record( + petitionId: $petition->id, + enrollmentFlowStepId: $oricdSourceEntity->enrollment_flow_step_id, + orcidToken: $orcid_token, + orcidSourceCollectorId: (int)$id, + ); + // On success, indicate the step is completed and generate a redirect + // to the next step + + return $this->finishStep( + enrollmentFlowStepId: $oricdSourceEntity->enrollment_flow_step_id, + petitionId: $petition->id, + comment: __d('orcid_source', 'result.orcid.saved') + ); + } else { + // Fall Through. Let the view render + } + + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + // Fall through and let the form render + + $this->render('/Standard/dispatch'); + } + + + /** + * Authenticate the user with ORCID OAuth2 server + * + * @param string|int $id ID of the collector + * @param EntityInterface $serverCfg ORCID Server configuration + * @return void + * @since COmanage Registry v5.2.0 + */ + protected function authenticate(string|int $id, EntityInterface $serverCfg): void + { + $petition = $this->getPetition(); + $callback = $this->OrcidSources->redirectUri([ + $id, + '?' => ['petition_id' => $petition->id], + ]); + // Build the redirect URI + $redirectUri = Router::url($callback, true); + + $scope = OrcidSourceScopeEnum::DEFAULT_SCOPE; + if (!empty($serverCfg->scope_inherit)) { + $scope = $serverCfg->scope_inherit; + } + + $url = $serverCfg->url . '/authorize?'; + $url .= 'client_id=' . $serverCfg->clientid; + $url .= '&response_type=code'; + $url .= '&scope=' . str_replace(' ', '%20', $scope); + $url .= '&redirect_uri=' . urlencode($redirectUri); + + $this->redirect($url); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.2.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + if($action == 'dispatch') { + // We need to perform special logic (vs StandardEnrollerController) + // to ensure that web server authentication is triggered. + // (This is the same logic as IdentifierCollectorsController.) +// XXX We could maybe move this into StandardEnrollerController with a flag like +// $this->alwaysAuthDispatch(true); + + // To start, we trigger the parent logic. This will return + // notauth: Some error occurred, we don't want to override this + // authz: No token in use + // yes: Token validated + + $auth = parent::willHandleAuth($event); + + // The only status we need to override is 'yes', since we always want authentication + // to run in order to be able to grab $REMOTE_USER. + + return ($auth == 'yes' ? 'authz' : $auth); + } + + return parent::willHandleAuth($event); + } +} diff --git a/app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php b/app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php new file mode 100644 index 000000000..6d838a3db --- /dev/null +++ b/app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php @@ -0,0 +1,64 @@ + [ + 'EnvSources.id' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.2.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->OrcidSources->ExternalIdentitySources->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->OrcidSources->ExternalIdentitySources->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->OrcidSources->ExternalIdentitySources->getPrimaryKey()); + } + $this->set('vv_redirect_uri', $this->OrcidSources->redirectUri()); + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php b/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php new file mode 100644 index 000000000..7942b5288 --- /dev/null +++ b/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php @@ -0,0 +1,188 @@ +name = Models + $modelsName = $this->name; + + $coid = $this->request->getQuery('co_id'); + if (empty($coid)) { + $this->response = $this->response->withStatus( + HttpStatusCodesEnum::HTTP_BAD_REQUEST, + __d('orcid_source', 'error.param.notfound', [__d('controller', 'Cos')]) + ); + $this->response->send(); + $this->getEventManager()->off($event->getName()); // Prevent further event firing + $this->autoRender = false; + return; + } + + $orcid = $this->request->getQuery('orcid'); + if (empty($orcid)) { + $this->response = $this->response->withStatus( + HttpStatusCodesEnum::HTTP_BAD_REQUEST, + __d('orcid_source', 'error.param.notfound', [__d('orcid_source', 'information.orcid_source.identifier')]) + ); + $this->response->send(); + $this->getEventManager()->off($event->getName()); // Prevent further event firing + $this->autoRender = false; + return; + } + + $this->orcidSources = $this->OrcidSources + ->find() + ->contain([]) // No related records loaded + ->innerJoinWith('Oauth2Servers', function ($q) { + return $q->where([ + "LOWER(Oauth2Servers.url) LIKE" => '%orcid%' + ]); + }) + ->where([ + 'Servers.plugin' => 'CoreServer.Oauth2Servers', + 'ExternalIdentitySources.co_id' => $coid + ]) + ->disableHydration() + ->toArray(); + + return parent::beforeFilter(); + } + + + /** + * Retrieve ORCID tokens for a given ORCID identifier + * + * @return void + * @throws \Cake\Http\Exception\MethodNotAllowedException If request method is not allowed + * @since COmanage Registry v5.2.0 + */ + public function token() + { + // Allow only AJAX and GET requests + $this->request->allowMethod(['ajax', 'get']); + + // Set AJAX layout + $this->viewBuilder()->setLayout('ajax'); + + // Extract OrcidSource IDs + $orcid_source_ids = Hash::extract($this->orcidSources, '{n}.OrcidSource.id'); + + // Get ORCID identifier from query string + $orcid = $this->request->getQuery('orcid'); + + // Find token records from the database + $tokens = $this->OrcidTokens->find() + ->where([ + 'OrcidTokens.orcid_identifier' => $orcid, + 'OrcidTokens.orcid_source_id IN' => $orcid_source_ids + ]) + ->all(); + + $columnsToDecrypt = [ + 'access_token', + 'id_token', + 'refresh_token' + ]; + + $data = []; + if (!$tokens->isEmpty()) { + foreach ($tokens as $idx => $token) { + $data[$idx] = []; + $data[$idx]['orcid'] = $token->orcid_identifier; + $orcidSourceIndex = array_search($token->orcid_source_id, $orcid_source_ids); + $data[$idx]['scopes'] = $this->getOauth2ServerScopes( + $this->orcidSources[$orcidSourceIndex]['Server'], + $this->orcidSources[$orcidSourceIndex]['OrcidSource'] + ); + foreach ($columnsToDecrypt as $column) { + $value = $token->{$column} ?? null; + $data[$idx][$column] = !empty($value) ? $this->OrcidTokens->getUnencrypted($value) : ''; + } + } + } + + // Return data in structured format + $this->set('orcid_tokens', $data); + $this->set('vv_model_name', 'OrcidTokens'); + $this->set('vv_table_name', 'orcid_tokens'); + + // Let the view render + $this->render('/Standard/api/v2/json/index'); + } + + + /** + * Get the scopes + * + * @param array $server Server Record + * @param array $orcidSource OrcidSource record + * + * @return string List of scopes + * @since COmanage Registry v5.2.0 + */ + + public function getOauth2ServerScopes(array $server, array $orcidSource): string + { + if(is_bool($orcidSource['scope_inherit']) && $orcidSource['scope_inherit']) { + $Oauth2ServersTable = TableRegistry::getTableLocator()->get('Oauth2Servers'); + $oauth2Server = $Oauth2ServersTable->find() + ->select(['scope']) + ->where(['server_id' => $server['id']]) + ->first(); + + if ($oauth2Server) { + return $oauth2Server->scope; + } + } + + return OrcidSourceScopeEnum::DEFAULT_SCOPE; + } +} diff --git a/app/plugins/OrcidSource/src/Lib/Enum/OrcidSourceApiEnum.php b/app/plugins/OrcidSource/src/Lib/Enum/OrcidSourceApiEnum.php new file mode 100644 index 000000000..e8334f94d --- /dev/null +++ b/app/plugins/OrcidSource/src/Lib/Enum/OrcidSourceApiEnum.php @@ -0,0 +1,38 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/OrcidSource/src/Model/Entity/OrcidSourceCollector.php b/app/plugins/OrcidSource/src/Model/Entity/OrcidSourceCollector.php new file mode 100644 index 000000000..f9935c44a --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Entity/OrcidSourceCollector.php @@ -0,0 +1,51 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/OrcidSource/src/Model/Entity/OrcidToken.php b/app/plugins/OrcidSource/src/Model/Entity/OrcidToken.php new file mode 100644 index 000000000..833bfab2d --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Entity/OrcidToken.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/OrcidSource/src/Model/Entity/PetitionOrcid.php b/app/plugins/OrcidSource/src/Model/Entity/PetitionOrcid.php new file mode 100644 index 000000000..a40df7369 --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Entity/PetitionOrcid.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php new file mode 100644 index 000000000..3ea6700c8 --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php @@ -0,0 +1,217 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('ExternalIdentitySources'); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlowSteps', 'OrcidSource.OrcidSourceCollectors'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'OrcidSource.OrcidSourceCollectors' => ['edit'], + ] + ] + ); + + $this->setAutoViewVars([ + 'externalIdentitySources' => [ + 'type' => 'select', + 'model' => 'ExternalIdentitySources', + 'where' => ['plugin' => 'OrcidSource.OrcidSources'] + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform steps necessary to hydrate the Person record as part of Petition finalization. + * + * @param int $id Env Source Collector ID + * @param Petition $petition Petition + * @return bool true on success + * @since COmanage Registry v5.2.0 + */ + + public function hydrate(int $id, \App\Model\Entity\Petition $petition) { + $orcidSourceCollectorsEntity = $this->get($id); + + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + $PetitionOrcids = TableRegistry::getTableLocator()->get('OrcidSource.PetitionOrcids'); + $OrcidSources = TableRegistry::getTableLocator()->get('OrcidSource.OrcidSources'); + $OrcidTokens = TableRegistry::getTableLocator()->get('OrcidSource.OrcidTokens'); + $ExtIdentitySources = TableRegistry::getTableLocator()->get('ExternalIdentitySources'); + + + $orcid_source = $OrcidSources->find() + ->where(['external_identity_source_id' => $orcidSourceCollectorsEntity->external_identity_source_id]) + ->first(); + + $pOricd = $PetitionOrcids + ->find() + ->where( + ['petition_id' => $petition->id, 'orcid_source_collector_id' => $id]) + ->first(); + + if(!empty($pOricd->orcid_token)) { + // Copy the Identifier to the Person record in accordance with the configuration + $token = unserialize($pOricd->orcid_token); + $data = [ + 'orcid_identifier' => $token->orcid, + 'access_token' => $token->access_token, + 'refresh_token' => $token->refresh_token ?? '', + 'id_token' => $token->id_token ?? '', + 'orcid_source_id' => $orcid_source->id + ]; + + $OrcidTokens->upsertOrFail( + data: $data, + whereClause: [ + 'orcid_source_id' => $orcid_source->id, + 'orcid_identifier' => $token->orcid + ], + ); + + // Continue on to process the sync + // Trigger the ExternalIdentitySource sync and push the data to the pipeline + $status = $ExtIdentitySources->sync( + id: $orcidSourceCollectorsEntity->external_identity_source_id, + sourceKey: $token->orcid, + personId: $petition->enrollee_person_id, + syncOnly: true + ); + + // Record Petition History - Token Save + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $orcidSourceCollectorsEntity->enrollment_flow_step_id, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('orcid_source', 'result.orcid.saved') + ); + + // Record Petition History - Pipeline save + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $orcidSourceCollectorsEntity->enrollment_flow_step_id, + action: PetitionActionEnum::Finalized, + comment: __d('orcid_source', 'result.pipeline.status', [$status]) + ); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('external_identity_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('external_identity_source_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php new file mode 100644 index 000000000..7630cd94b --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php @@ -0,0 +1,593 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ExternalIdentitySources'); + $this->belongsTo('Servers'); + $this->belongsTo('AddressTypes') + ->setClassName('Types') + ->setForeignKey('address_type_id') + ->setProperty('address_type'); + $this->belongsTo('DefaultAffiliationTypes') + ->setClassName('Types') + ->setForeignKey('default_affiliation_type_id') + ->setProperty('default_affiliation_type'); + $this->belongsTo('EmailAddressTypes') + ->setClassName('Types') + ->setForeignKey('email_address_type_id') + ->setProperty('email_address_type'); + $this->belongsTo('NameTypes') + ->setClassName('Types') + ->setForeignKey('name_type_id') + ->setProperty('name_type'); + $this->belongsTo('TelephoneNumberTypes') + ->setClassName('Types') + ->setForeignKey('telephone_number_type_id') + ->setProperty('telephone_number_type'); + + $this->hasMany('OrcidSource.OrcidTokens') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink(['external_identity_source_id']); + $this->setRequiresCO(true); + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['ExternalIdentitySources', 'OrcidSource.OrcidSources', 'ExternalIdentitySources@action.search'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'OrcidSource.OrcidSources' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], + ] + ); + + $this->setEditContains([ + 'Servers' => ['Oauth2Servers'], + 'ExternalIdentitySources', + ]); + + $this->setViewContains([ + 'Servers' => ['Oauth2Servers'], + 'ExternalIdentitySources', + ]); + + $this->setAutoViewVars([ + 'servers' => [ + 'type' => 'select', + 'model' => 'Servers', + 'where' => ['plugin' => 'CoreServer.Oauth2Servers'] + ], + 'api_tiers' => [ + 'type' => 'enum', + 'class' => 'OrcidSource.OrcidSourceTierEnum' + ], + 'api_types' => [ + 'type' => 'enum', + 'class' => 'OrcidSource.OrcidSourceApiEnum' + ], + 'addressTypes' => [ + 'type' => 'type', + 'attribute' => 'Addresses.type' + ], + 'defaultAffiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ], + 'emailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'nameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ], + 'telephoneNumberTypes' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + + $this->orcidTokensTable = TableRegistry::getTableLocator()->get('OrcidSource.OrcidTokens'); + $this->oauth2ServersTable = TableRegistry::getTableLocator()->get('CoreServer.Oauth2Servers'); + } + + + /** + * Get the OAuth2 redirect URI for ORCID callbacks + * + * @param array $extra Additional URL parameters to include in redirect + * @return string Full URL for OAuth2 redirect + * @since COmanage Registry v5.2.0 + */ + public function redirectUri(array $extra = []): string + { + $callback = [ + 'plugin' => 'OrcidSource', + 'controller' => 'OrcidSourceCollectors', + 'action' => 'dispatch', + ]; + + if (!empty($extra)) { + $callback = array_merge($callback, $extra); + } + + return Router::url($callback, true); + } + + /** + * Obtain the set of changed records from the source database. + * + * @since COmanage Registry v5.2.0 + * @param ExternalIdentitySource $source External Identity Source + * @param int $lastStart Timestamp of last run + * @param int $curStart Timestamp of current run + * @return array|bool An array of changed source keys, or false + */ + + public function getChangeList( + \App\Model\Entity\ExternalIdentitySource $source, + int $lastStart, // timestamp of last run + int $curStart // timestamp of current run + ): array|bool { + return false; + } + + /** + * Obtain the full set of records from the source database. + * + * @since COmanage Registry v5.2.0 + * @param ExternalIdentitySource $source External Identity Source + * @return array An array of source keys + */ + + public function inventory( + \App\Model\Entity\ExternalIdentitySource $source + ): array { + return false; + } + + /** + * Convert a record from the OrcidSource data to a record suitable for + * construction of an Entity. This call is for use with Relational Mode. + * + * @since COmanage Registry v5.2.0 + * @param OrcidSource $OrcidSource OrcidSource configuration entity + * @param array $result Array of Orcid attributes + * @return array Entity record (in array format) + */ + + protected function resultToEntityData( + OrcidSource $OrcidSource, + array $result + ): array { + // Build the External Identity as an array + $eidata = []; + + // We don't currently have a field to record DoB, so we need to null it + $eidata['date_of_birth'] = null; + + // Single value fields that map to the External Identity Role + $role = [ + // We only support one role per record + 'role_key' => '1', + 'affiliation' => $this->DefaultAffiliationTypes->getTypeLabel($OrcidSource->default_affiliation_type_id) + ]; + + $eidata['external_identity_roles'][] = $role; + + $name = [ + 'type' => $this->NameTypes->getTypeLabel($OrcidSource->name_type_id), + 'given' => $result['name']['given-names']['value'], + 'family' => $result['name']['family-name']['value'] + ]; + + $eidata['names'][] = $name; + + foreach($result['emails']['email'] as $m) { + $eidata['email_addresses'][] = [ + 'mail' => $m['email'], + 'type' => $this->EmailAddressTypes->getTypeLabel($OrcidSource->email_address_type_id), + 'verified' => $m['verified'] + ]; + } + + if (!empty($result['addresses']['address'])) { + $address = []; + $address['type'] = $this->AddressTypes->getTypeLabel($OrcidSource->address_type_id); + foreach($result['addresses']['address'] as $ad) { + $address['country'] = $ad['country']['value']; + } + $eidata['addresses'][] = $address; + } + + $eidata['identifiers'][] = [ + 'identifier' => $result['name']['path'], + 'type' => 'orcid' + ]; + + return $eidata; + } + + /** + * Retrieve a record from the External Identity Source. + * + * @since COmanage Registry v5.2.0 + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param string $source_key Backend source key for requested record + * @return array Array of source_key, source_record, and entity_data + * @throws InvalidArgumentException + */ + + public function retrieve( + \App\Model\Entity\ExternalIdentitySource $source, + string $source_key + ): array { + try { + $this->httpClient = $this->orcidConnect($source, $source_key); + + $orcidbio = $this->orcidRequest('/v3.0/' . $source_key . '/person'); +// $orcidActivities = $this->orcidRequest('/v3.0/' . $source_key . '/activities'); + } + catch(InvalidArgumentException $e) { + throw new \InvalidArgumentException(__d('error', 'unknown.identifier', [$source_key])); + } + + return [ + 'source_key' => $source_key, + 'source_record' => json_encode($orcidbio), + 'entity_data' => $this->resultToEntityData($source->orcid_source, $orcidbio) + ]; + } + + /** + * Search the External Identity Source. + * The ORCID search will be triggered by the CO/Platform Admin. As a result, we want to use a privileged access key + * i.e. the one the CO Admin got in Oauth2Server setup page + * + * refrence: https://info.orcid.org/documentation/api-tutorials/api-tutorial-searching-the-orcid-registry/ + * + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param array $searchAttrs Array of search attributes and values, as configured by searchAttributes() + * @return array Array of matching records + * @since COmanage Registry v5.2.0 + */ + + public function search( + \App\Model\Entity\ExternalIdentitySource $source, + array $searchAttrs + ): array { + $ret = []; + + if(!isset($searchAttrs['q'])) { + // For now, we only support free form search (though ORCID does support + // search by eg email). + + return []; + } + + // We just let search exceptions pop up the stack + + $this->httpClient = $this->orcidConnect($source); + + $records = $this->orcidRequest('/v3.0/search/', $searchAttrs); + + if(isset($records['num-found']) && $records['num-found'] > 0) { + foreach($records['result'] as $rec) { + if(!empty($rec['orcid-identifier']['path'])) { + $orcid = $rec['orcid-identifier']['path']; + + $orcidbio = $this->orcidRequest('/v3.0/' . $orcid . '/person'); + + if(!empty($orcidbio)) { + $ret[ $orcid ] = $this->resultToEntityData($source->orcid_source, $orcidbio); + } + } + } + } + + return $ret; + } + + /** + * Get the set of searchable attributes for this backend. + * + * @since COmanage Registry v5.2.0 + * @return array Array of searchable attributes and localized descriptions + */ + + public function searchableAttributes(): array { + return [ + 'q' => __d('operation', 'search') + ]; + } + + + /** + * Make an HTTP request to the ORCID API + * + * @param string $urlPath The API endpoint path to request + * @param array $data Request parameters or body data + * @param string $action HTTP method to use (get, post, etc) + * @return array Response data decoded from JSON + * @throws InvalidArgumentException If the ORCID identifier is invalid + * @throws RuntimeException If the API request fails + * @since COmanage Registry v5.2.0 + */ + public function orcidRequest(string $urlPath, array $data=[], string $action="get"): array + { + // Get the user access_token. If none is provided, then throw an exception + $accessToken = match(true) { + $this->orcidToken?->access_token !== null => $this->orcidTokensTable->getUnencrypted($this->orcidToken->access_token), + $this->orcidSource?->server?->oauth2_server?->access_token !== null => $this->orcidSource->server->oauth2_server->access_token, + default => throw new \InvalidArgumentException(__d('orcid_source', 'error.token.none')) + }; + + $options = [ + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/orcid+json' + ] + ]; + + $orcidUrlBase = $this->orcidUrl($this->orcidSource->api_type, $this->orcidSource->api_tier); + $fullUrl = $orcidUrlBase . $urlPath; + $response = $this->httpClient->$action( + url: $fullUrl, + data: ($action == 'get' ? $data : json_encode($data)), + options: $options + ); + + if($response->getStatusCode() == HttpStatusCodesEnum::HTTP_BAD_REQUEST) { + // Most likely retrieving an invalid ORCID + throw new \InvalidArgumentException(__d('orcid_source', 'error.search', [$response->getStatusCode()])); + } + + if($response->getStatusCode() != HttpStatusCodesEnum::HTTP_OK) { + // This is probably an RDF blob, which is slightly annoying to parse. + // Rather than do it properly since we don't parse RDF anywhere else, + // we return a generic error. + throw new \RuntimeException(__d('orcid_source', 'error.search', [$response->getStatusCode()])); + } + + return $response->getJson(); + } + + /** + * Get the root URL for the ORCID API. + * + * @param string $api API type: auth, public, or member + * @param string $tier API tier: prod or sandbox + * @return string URL prefix + * @since COmanage Registry v5.2.0 + */ + + public function orcidUrl(string $api=OrcidSourceApiEnum::PUBLIC, string $tier=OrcidSourceTierEnum::PROD): string + { + $orcidUrls = [ + OrcidSourceApiEnum::AUTH => [ + OrcidSourceTierEnum::PROD => 'https://orcid.org', + OrcidSourceTierEnum::SANDBOX => 'https://sandbox.orcid.org' + ], + OrcidSourceApiEnum::MEMBERS => [ + OrcidSourceTierEnum::PROD => 'https://api.orcid.org', + OrcidSourceTierEnum::SANDBOX => 'https://api.sandbox.orcid.org' + ], + OrcidSourceApiEnum::PUBLIC => [ + OrcidSourceTierEnum::PROD => 'https://pub.orcid.org', + OrcidSourceTierEnum::SANDBOX => 'https://pub.sandbox.orcid.org' + ] + ]; + + return $orcidUrls[$api][$tier]; + } + + + /** + * Establish connection to ORCID API by configuring the HTTP client with appropriate credentials. + * + * @param ExternalIdentitySource $exterrnalIdentitySource + * @param string|null $orcidIdentifier The ORCID identifier to use for authentication + * @return Client Configured HTTP client for ORCID API requests + * @since COmanage Registry v5.2.0 + */ + protected function orcidConnect( + \App\Model\Entity\ExternalIdentitySource $exterrnalIdentitySource, + ?string $orcidIdentifier = null + ): \Cake\Http\Client { + $this->orcidSource = $this->find() + ->contain([ + 'Servers.Oauth2Servers' => function ($q) { + return $q->where(["LOWER(Oauth2Servers.url) LIKE" => '%orcid%']); + }, + 'ExternalIdentitySources', + ]) + ->innerJoinWith('Servers.Oauth2Servers', function ($q) { + return $q->where([ + "LOWER(Oauth2Servers.url) LIKE" => '%orcid%' + ]); + }) + ->innerJoinWith('ExternalIdentitySources') + ->where([ + 'Servers.plugin' => 'CoreServer.Oauth2Servers', + 'ExternalIdentitySources.id' => $exterrnalIdentitySource->id, + 'ExternalIdentitySources.plugin' => 'OrcidSource.OrcidSources' + ]) + ->first(); + + // Set the CO ID + $this->setCurCoId($this->orcidSource->server->co_id); + + if ( empty($this->orcidSource->id)) { + throw new \InvalidArgumentException(__d('error', 'notfound', [__d('core_server', 'controller.Oauth2Servers')])); + } + + // Since this is null, we will use the master access token stored in Oauth2Server Configuration + if ($orcidIdentifier !== null) { + $this->orcidToken = $this->orcidTokensTable + ->find() + ->where([ + 'orcid_source_id' => $this->orcidSource->id, + 'orcid_identifier' => $orcidIdentifier, + ]) + ->first(); + + if (empty($this->orcidToken->access_token)) { + throw new \InvalidArgumentException(__d('orcid_source', 'error.token.none')); + } + } + + return $this->oauth2ServersTable->createHttpClient($this->orcidSource->server->oauth2_server->id); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + foreach([ + 'external_source_identity_id', + 'default_affiliation_type_id', + 'address_type_id', + 'email_address_type_id', + 'name_type_id', + 'telephone_number_type_id' + ] as $field) { + $validator->add($field, [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString($field); + } + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('server_id'); + + $validator->add('api_tier', [ + 'content' => ['rule' => ['inList', OrcidSourceTierEnum::getConstValues()]] + ]); + $validator->allowEmptyString('api_tier'); + + $validator->add('api_type', [ + 'content' => ['rule' => ['inList', OrcidSourceApiEnum::getConstValues()]] + ]); + $validator->allowEmptyString('api_type'); + + $validator->add('scope_inherit', [ + 'content' => ['rule' => 'boolean'] + ]); + $validator->allowEmptyString('scope_inherit'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/plugins/OrcidSource/src/Model/Table/OrcidTokensTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidTokensTable.php new file mode 100644 index 000000000..9a2519a1c --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidTokensTable.php @@ -0,0 +1,173 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + // Define associations + $this->belongsTo('OrcidSource.OrcidSources'); + $this->setDisplayField('orcid_identifier'); + $this->setPrimaryLink('orcid_source_id'); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform actions while marshaling data, before validation. + * + * @param EventInterface $event Event + * @param \ArrayObject $data Object data, in array format + * @param \ArrayObject $options Entity save options + * @since COmanage Registry v5.2.0 + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + // Encryption logic + $key = Security::getSalt(); + + foreach (['id_token', 'access_token', 'refresh_token'] as $column) { + if (!empty($data[$column])) { + // Security::encrypt expects string, $key must be correct length for the cipher! + $payload = base64_encode(Security::encrypt($data[$column], $key)); + + // If updating, try to fetch existing stored value to compare + $stored_key = ''; + if (!empty($data['id'])) { + $entity = $this->find()->select([$column])->where(['id' => $data['id']])->first(); + if ($entity) { + $stored_key = $entity->{$column}; + } + } + + if ($stored_key !== $payload) { + $data[$column] = $payload; + } + } + } + + } + + /** + * Unencrypt a value previously encrypted using salt + * + * @param string $value + * + * @return false|string + * @since COmanage Registry v5.2.0 + */ + + public function getUnencrypted(string $value): string|false + { + if(empty($value)) { + return ''; + } + return Security::decrypt(base64_decode($value), Security::getSalt()); + } + + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('orcid_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('orcid_source_id'); + + foreach(['orcid_identifier', 'access_token', 'id_token', 'refresh_token'] as $column) { + $validator->add($column, [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + } + $validator->notEmptyString('orcid_identifier'); + $validator->notEmptyString('access_token'); + $validator->allowEmptyString('id_token'); + $validator->allowEmptyString('refresh_token'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/plugins/OrcidSource/src/Model/Table/PetitionOrcidsTable.php b/app/plugins/OrcidSource/src/Model/Table/PetitionOrcidsTable.php new file mode 100644 index 000000000..5d61ab95b --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/PetitionOrcidsTable.php @@ -0,0 +1,156 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + $this->belongsTo('OrcidSource.OrcidSourceCollectors'); + + $this->setDisplayField('orcid_token'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false + ] + ]); + } + + /** + * Record an ORCID Token. + * + * @param int $petitionId Petition ID + * @param int $enrollmentFlowStepId Enrollment Flow Step ID + * @param int $orcidSourceCollectorId ORCID Source Collector ID + * @param string $orcid_token ORCID Token Response serialized + * @return void + * @since COmanage Registry v5.2.0 + */ + + public function record( + int $petitionId, + int $enrollmentFlowStepId, + int $orcidSourceCollectorId, + string $orcidToken, + ): void { + // Record the Identifier. We use upsert since at least initially we only support + // one Identifier per Petition. + + $orcid = unserialize($orcidToken); + + $orcidData = [ + 'petition_id' => $petitionId, + 'orcid_token' => $orcidToken, + 'orcid_identifier' => $orcid->orcid, + 'orcid_source_collector_id' => $orcidSourceCollectorId, + ]; + + $this->upsertOrFail( + data: $orcidData, + whereClause: ['petition_id' => $petitionId, 'orcid_identifier' => $orcid->orcid, 'orcid_source_collector_id' => $orcidSourceCollectorId], + ); + + // Record PetitionHistory + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + $PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $enrollmentFlowStepId, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('orcid_source', 'result.OrcidSourceCollector.collected', [$orcid->orcid]) +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('orcid_source_collector_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('orcid_source_collector_id'); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $this->registerStringValidation($validator, $schema, 'orcid_token', true); + + return $validator; + } +} diff --git a/app/plugins/OrcidSource/src/OrcidSourcePlugin.php b/app/plugins/OrcidSource/src/OrcidSourcePlugin.php new file mode 100644 index 000000000..f6fcc78d5 --- /dev/null +++ b/app/plugins/OrcidSource/src/OrcidSourcePlugin.php @@ -0,0 +1,93 @@ +plugin( + 'OrcidSource', + ['path' => '/orcid-source'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/plugins/OrcidSource/src/View/Cell/OrcidSourceCollectorsCell.php b/app/plugins/OrcidSource/src/View/Cell/OrcidSourceCollectorsCell.php new file mode 100644 index 000000000..f15d25003 --- /dev/null +++ b/app/plugins/OrcidSource/src/View/Cell/OrcidSourceCollectorsCell.php @@ -0,0 +1,83 @@ + + */ + protected $_validCellOptions = [ + 'vv_obj', + 'vv_step', + 'viewVars', + ]; + + /** + * Initialization logic run at the end of object construction. + * + * @return void + */ + public function initialize(): void + { + } + + /** + * Default display method. + * + * @param int $petitionId + * @return void + * @since COmanage Registry v5.2.0 + */ + public function display(int $petitionId): void + { + $vv_oi = $this->fetchTable('CoreSource.PetitionOrcids') + ->find() + ->where(['petition_id' => $this->vv_obj->id]) + ->first(); + + $this->set('vv_orcid', $vv_oi);; + + $this->set('vv_step', $this->vv_step); + $this->set('vv_obj', $this->vv_obj); + } +} diff --git a/app/plugins/OrcidSource/src/config/plugin.json b/app/plugins/OrcidSource/src/config/plugin.json new file mode 100644 index 000000000..d83f9ea59 --- /dev/null +++ b/app/plugins/OrcidSource/src/config/plugin.json @@ -0,0 +1,73 @@ +{ + "types": { + "enroller": [ + "OrcidSourceCollectors" + ], + "source": [ + "OrcidSources" + ] + }, + "schema": { + "tables": { + "orcid_source_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "external_identity_source_id": {} + }, + "indexes": { + "orcid_source_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "orcid_source_collectors_i2": { "needed": false, "columns": [ "external_identity_source_id" ] } + } + }, + "orcid_sources": { + "columns": { + "id": {}, + "external_identity_source_id": {}, + "server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" }, "notnull": false }, + "scope_inherit": { "type": "boolean" }, + "api_tier": { "type": "string", "size": "3" }, + "api_type": { "type": "string", "size": "3" }, + "default_affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } } + }, + "indexes": { + "orcid_sources_i1": { "columns": [ "external_identity_source_id" ] } + } + }, + "orcid_tokens": { + "columns": { + "id": {}, + "orcid_source_id": { "type": "integer", "foreignkey": { "table": "orcid_sources", "column": "id" }, "notnull": true }, + "orcid_identifier": { "type": "string", "size": "128" }, + "access_token": { "type": "text" }, + "id_token": { "type": "text" }, + "refresh_token": { "type": "text" } + }, + "indexes": { + "orcid_source_collectors_i1": { + "columns": [ "orcid_source_id", "orcid_identifier"], + "unique": true + }, + "orcid_source_collectors_i2": { "columns": [ "orcid_identifier" ] } + } + }, + "petition_orcids": { + "columns": { + "id": {}, + "petition_id": {}, + "orcid_source_collector_id": { "type": "integer", "foreignkey": { "table": "orcid_source_collectors", "column": "id" }, "notnull": true }, + "orcid_identifier": { "type": "string", "size": "128" }, + "orcid_token": { "type": "text" } + }, + "indexes": { + "petition_orcids_i1": { "columns": [ "petition_id" ] }, + "petition_orcids_i2": { "columns": [ "orcid_source_collector_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc new file mode 100644 index 000000000..c941ef0dd --- /dev/null +++ b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc @@ -0,0 +1,47 @@ +Html->css('OrcidSource/orcid-source', ['block' => true]); + +// Authenticate and fetch the token +if (empty($vv_orcid) || empty($vv_token)) { + print $this->element('OrcidSource.authenticate'); + return; +} + +// Make the Form fields editable and the form submittable +$this->Field->enableFormEditMode(); +print $this->element('OrcidSource.preview'); + diff --git a/app/plugins/OrcidSource/templates/OrcidSourceCollectors/fields.inc b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/fields.inc new file mode 100644 index 000000000..b56061f14 --- /dev/null +++ b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/fields.inc @@ -0,0 +1,36 @@ +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'external_identity_source_id', + 'fieldLabel' => __d('orcid_source', 'controller.OrcidSources', [1]) + ] + ]); +} diff --git a/app/plugins/OrcidSource/templates/OrcidSources/fields-nav.inc b/app/plugins/OrcidSource/templates/OrcidSources/fields-nav.inc new file mode 100644 index 000000000..9a523fabe --- /dev/null +++ b/app/plugins/OrcidSource/templates/OrcidSources/fields-nav.inc @@ -0,0 +1,31 @@ + 'plugin', + 'active' => 'plugin' + ]; \ No newline at end of file diff --git a/app/plugins/OrcidSource/templates/OrcidSources/fields.inc b/app/plugins/OrcidSource/templates/OrcidSources/fields.inc new file mode 100644 index 000000000..d2966deac --- /dev/null +++ b/app/plugins/OrcidSource/templates/OrcidSources/fields.inc @@ -0,0 +1,112 @@ +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'redirect_uri', + 'fieldOptions' => [ + 'readOnly' => true, + 'default' => $vv_redirect_uri + ] + ]]); + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'server_id', + 'fieldOptions' => [ + 'empty' => false, + 'required' => true, + ] + ]]); + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'api_type', + 'fieldSelectOptions' => $api_types, + 'fieldType' => 'select', + 'fieldOptions' => [ + 'empty' => false, + 'required' => true, + ] + ]]); + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'api_tier', + 'fieldSelectOptions' => $api_tiers, + 'fieldType' => 'select', + 'fieldOptions' => [ + 'empty' => false, + 'required' => true, + ] + ]]); + + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'scope_inherit', + ]]); + +$vv_inherited_scopes = OrcidSourceScopeEnum::DEFAULT_SCOPE; +if (filter_var($vv_obj->scope_inherit, FILTER_VALIDATE_BOOLEAN)) { + $vv_inherited_scopes = $vv_obj->server?->oauth2_server?->scope ?? OrcidSourceScopeEnum::DEFAULT_SCOPE; +} + +// Render active scopes +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'Scope', + 'fieldOptions' => [ + 'readOnly' => true, + 'default' => $vv_inherited_scopes + ] + ]]); + +// Print a title +$title = __d('orcid_source', 'information.OrcidSources.default.types'); +print $this->element('form/h3Inject', compact('title')); + +foreach([ + 'address_type_id', + 'default_affiliation_type_id', + 'email_address_type_id', + 'name_type_id', + ] as $field) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field + ] + ]); +} \ No newline at end of file diff --git a/app/plugins/OrcidSource/templates/cell/OrcidSourceCollectors/display.php b/app/plugins/OrcidSource/templates/cell/OrcidSourceCollectors/display.php new file mode 100644 index 000000000..6347ade5d --- /dev/null +++ b/app/plugins/OrcidSource/templates/cell/OrcidSourceCollectors/display.php @@ -0,0 +1,38 @@ + + +orcid_identifier)): ?> + + diff --git a/app/plugins/OrcidSource/templates/element/authenticate.php b/app/plugins/OrcidSource/templates/element/authenticate.php new file mode 100644 index 000000000..6d58c1dec --- /dev/null +++ b/app/plugins/OrcidSource/templates/element/authenticate.php @@ -0,0 +1,51 @@ +login' + . __d('orcid_source', 'information.OrcidSourceCollectors.authenticate'); + +print $this->Form->hidden('op', ['value' => 'authenticate']); + +?> + +
+ Html->image('OrcidSource.orcid_128x128.png', ['alt' => 'Logo', 'class' => 'mb-3']) ?> +

+

+ Form->button( + $btnAuthenticateLabel, + [ + 'id' => 'orcid-auth-btn', + 'escapeTitle' => false, + 'type' => 'submit', + 'class' => 'spin submit-button btn btn-primary d-flex mx-auto', + ] + ) + ?> +
diff --git a/app/plugins/OrcidSource/templates/element/preview.php b/app/plugins/OrcidSource/templates/element/preview.php new file mode 100644 index 000000000..c5ad8ab0b --- /dev/null +++ b/app/plugins/OrcidSource/templates/element/preview.php @@ -0,0 +1,50 @@ +Form->hidden('op', ['value' => 'savetoken']); +print $this->Form->hidden('orcid_token', ['value' => serialize($vv_token)]); + +?> + + + + + + + + + + + + + + + +
+ diff --git a/app/plugins/OrcidSource/tests/bootstrap.php b/app/plugins/OrcidSource/tests/bootstrap.php new file mode 100644 index 000000000..a3cd830d9 --- /dev/null +++ b/app/plugins/OrcidSource/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/OrcidSource/tests/schema.sql b/app/plugins/OrcidSource/tests/schema.sql new file mode 100644 index 000000000..d28524851 --- /dev/null +++ b/app/plugins/OrcidSource/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for OrcidSource diff --git a/app/plugins/OrcidSource/webroot/.gitkeep b/app/plugins/OrcidSource/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/plugins/OrcidSource/webroot/css/orcid-source.css b/app/plugins/OrcidSource/webroot/css/orcid-source.css new file mode 100644 index 000000000..4b8fc336c --- /dev/null +++ b/app/plugins/OrcidSource/webroot/css/orcid-source.css @@ -0,0 +1,18 @@ +.text-center img { + width: 72px; +} + +.text-center h2 { + color: #68b245; +} + +#orcid-auth-btn { + color: white; + font-weight: bolder; +} + +.material-symbols-outlined { + font-size: 22px; + vertical-align: middle; +} + diff --git a/app/plugins/OrcidSource/webroot/img/orcid_128x128.png b/app/plugins/OrcidSource/webroot/img/orcid_128x128.png new file mode 100644 index 000000000..484207317 Binary files /dev/null and b/app/plugins/OrcidSource/webroot/img/orcid_128x128.png differ diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index 4f95c6329..f86d6a80c 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -391,6 +391,9 @@ msgstr "Type {0} is in use as a default (via CO Settings)" msgid "unknown" msgstr "Unknown value \"{0}\"" +msgid "unknown.identifier" +msgstr "Unknown Identifier \"{0}\"" + msgid "ug.task.unknown" msgstr "Task {0} is not defined" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index aee20ec63..7ecb1e294 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -210,6 +210,9 @@ msgstr "Order" msgid "organization" msgstr "Organization" +msgid "other.value" +msgstr "Other Value" + msgid "parameters" msgstr "Parameters" diff --git a/app/src/Model/Table/PetitionsTable.php b/app/src/Model/Table/PetitionsTable.php index 0ef9a2012..43b6b69f7 100644 --- a/app/src/Model/Table/PetitionsTable.php +++ b/app/src/Model/Table/PetitionsTable.php @@ -468,6 +468,7 @@ public function hydrate(int $id) { // of a duplicate identity that was not detected during an earlier step. This will // cause the entire finalization process to fail and rollback. try { + // TODO: We need to take into account if the plugin has been disabled if(method_exists($Plugin, "hydrate")) { // We have "CoreEnroller.AttributeCollectors" but we want "attribute_collector" $pmodel = Inflector::underscore(Inflector::singularize(StringUtilities::pluginModel($step->plugin))); diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php index 66eb1b112..e844a5648 100644 --- a/app/src/Model/Table/PipelinesTable.php +++ b/app/src/Model/Table/PipelinesTable.php @@ -522,18 +522,19 @@ protected function duplicateFilterEntityData($entity): array { return array_filter($newdata, 'is_scalar'); } - /** - * Execute the specified Pipeline on the provided EIS data. - * - * @since COmanage Registry v5.0.0 - * @param int $id Pipeline ID - * @param int $eisId Exxternal Identity Source ID - * @param array $eisBackendRecord Record returned by EIS Backend - * @param bool $force Force the Pipeline to run all steps, even if no changes were detected - * @param int $personId If set, for create operations only use this as the target Person ID - * @param bool $syncOnly If true, do not run Finalize steps - * @return string Record status (new, unchanged, unknown, updated) - */ + /** + * Execute the specified Pipeline on the provided EIS data. + * + * @param int $id Pipeline ID + * @param int $eisId Exxternal Identity Source ID + * @param array $eisBackendRecord Record returned by EIS Backend + * @param bool $force Force the Pipeline to run all steps, even if no changes were detected + * @param int|null $personId If set, for create operations only use this as the target Person ID + * @param bool $syncOnly If true, do not run Finalize steps + * @return string Record status (new, unchanged, unknown, updated) + * @throws \Exception + * @since COmanage Registry v5.0.0 + */ public function execute( int $id, @@ -575,7 +576,8 @@ public function execute( $pipeline, $eis, $eisBackendRecord['source_key'], - $eisBackendRecord['source_record'] + $eisBackendRecord['source_record'], + $personId ); if(!$force && $eisRecord['status'] == 'unchanged') { @@ -703,23 +705,24 @@ public function execute( } } - /** - * Pipeline step to create or update the External Identity Source Record. - * - * @since COmanage Registry v5.0.0 - * @param Pipeline $pipeline Pipeline - * @param ExternalIdentitySource $eis External Identity Source - * @param string $sourceKey Source Key - * @param string $sourceRecord Source Record - * @param array $eisBackendRecord Record returned by EIS Backend - * @return array ExtIdentitySourceRecord and change status - */ + /** + * Pipeline step to create or update the External Identity Source Record. + * + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentitySource $eis External Identity Source + * @param string $sourceKey Source Key + * @param ?string $sourceRecord Source Record + * @param ?int $personId Person ID + * @return array ExtIdentitySourceRecord and change status + * @since COmanage Registry v5.0.0 + */ protected function manageEISRecord( Pipeline $pipeline, ExternalIdentitySource $eis, string $sourceKey, - ?string $sourceRecord, + ?string $sourceRecord=null, + ?int $personId=null, ): array { $status = 'unknown'; @@ -754,6 +757,12 @@ protected function manageEISRecord( throw new \InvalidArgumentException(__d('error', 'Pipelines.eis.record.adopted', [$sourceKey, $eisRecord->adopted_person_id])); } + // The Person that initiated the Link does not match the one of the eisRecord found + if($eisRecord->external_identity?->person_id !== $personId) { + $this->llog('rule', "AR-ExternalIdentity-2 Rejecting request to update duplicate/used record for EIS" . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + throw new \InvalidArgumentException(__d('error', 'Pipelines.eis.record.used', [$sourceKey, $eisRecord->external_identity->person_id])); + } + // Update the record as needed, but only if the source record changed. // We consider any aspect of the source record changing to mark the // EIS record as changed, even if it's not material to the attributes @@ -1007,8 +1016,8 @@ protected function mapIdentifier(int $typeId, string $identifier): ?int { * @param Pipeline $pipeline Pipeline * @param ExternalIdentitySource $eis External Identity Source * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record - * @param array $eisAttributes Attributes provided by EIS Backend - * @param int $personId For create operations, use this as the target Person ID, if set + * @param array|null $eisAttributes Attributes provided by EIS Backend + * @param int|null $personId For create operations, use this as the target Person ID, if set * @return array 'person': Person object * 'status': 'linked', 'created', 'matched', 'requested' * 'strategy': If status = 'matched', the MatchStrategy @@ -1018,8 +1027,8 @@ protected function obtainPerson( Pipeline $pipeline, ExternalIdentitySource $eis, ExtIdentitySourceRecord $eisRecord, - ?array $eisAttributes, - ?int $personId=null + ?array $eisAttributes = null, + ?int $personId = null ): array { // Shorthand... $sourceKey = $eisRecord->source_key; diff --git a/app/templates/element/form/h3Inject.php b/app/templates/element/form/h3Inject.php new file mode 100644 index 000000000..9b53a9adf --- /dev/null +++ b/app/templates/element/form/h3Inject.php @@ -0,0 +1,49 @@ + + + + + +

+ +