From 455110f5b74dcd51d4a3836fe3282e193a9daba6 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 1 May 2025 11:07:34 +0300 Subject: [PATCH] OrcidSource Plugin --- app/plugins/OrcidSource/.gitignore | 8 + app/plugins/OrcidSource/README.md | 11 + app/plugins/OrcidSource/composer.json | 24 + app/plugins/OrcidSource/config/routes.php | 44 ++ app/plugins/OrcidSource/phpunit.xml.dist | 30 ++ .../resources/locales/en_US/orcid_source.po | 71 +++ .../src/Controller/AppController.php | 10 + .../src/Controller/OrcidSourcesController.php | 68 +++ .../src/Lib/Enum/OrcidSourceApiEnum.php | 38 ++ .../src/Lib/Enum/OrcidSourceScopeEnum.php | 36 ++ .../src/Lib/Enum/OrcidSourceTierEnum.php | 37 ++ .../src/Model/Entity/OrcidSource.php | 49 ++ .../src/Model/Entity/OrcidSourceCollector.php | 51 ++ .../Table/OrcidSourceCollectorsTable.php | 488 ++++++++++++++++++ .../src/Model/Table/OrcidSourcesTable.php | 296 +++++++++++ .../OrcidSource/src/OrcidSourcePlugin.php | 93 ++++ .../OrcidSource/src/config/plugin.json | 55 ++ .../OrcidSourceCollectors/dispatch.inc | 59 +++ .../OrcidSourceCollectors/fields.inc | 36 ++ .../templates/OrcidSources/fields-nav.inc | 31 ++ .../templates/OrcidSources/fields.inc | 95 ++++ app/plugins/OrcidSource/tests/bootstrap.php | 55 ++ app/plugins/OrcidSource/tests/schema.sql | 1 + app/plugins/OrcidSource/webroot/.gitkeep | 0 24 files changed, 1686 insertions(+) create mode 100644 app/plugins/OrcidSource/.gitignore create mode 100644 app/plugins/OrcidSource/README.md create mode 100644 app/plugins/OrcidSource/composer.json create mode 100644 app/plugins/OrcidSource/config/routes.php create mode 100644 app/plugins/OrcidSource/phpunit.xml.dist create mode 100644 app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po create mode 100644 app/plugins/OrcidSource/src/Controller/AppController.php create mode 100644 app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php create mode 100644 app/plugins/OrcidSource/src/Lib/Enum/OrcidSourceApiEnum.php create mode 100644 app/plugins/OrcidSource/src/Lib/Enum/OrcidSourceScopeEnum.php create mode 100644 app/plugins/OrcidSource/src/Lib/Enum/OrcidSourceTierEnum.php create mode 100644 app/plugins/OrcidSource/src/Model/Entity/OrcidSource.php create mode 100644 app/plugins/OrcidSource/src/Model/Entity/OrcidSourceCollector.php create mode 100644 app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php create mode 100644 app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php create mode 100644 app/plugins/OrcidSource/src/OrcidSourcePlugin.php create mode 100644 app/plugins/OrcidSource/src/config/plugin.json create mode 100644 app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc create mode 100644 app/plugins/OrcidSource/templates/OrcidSourceCollectors/fields.inc create mode 100644 app/plugins/OrcidSource/templates/OrcidSources/fields-nav.inc create mode 100644 app/plugins/OrcidSource/templates/OrcidSources/fields.inc create mode 100644 app/plugins/OrcidSource/tests/bootstrap.php create mode 100644 app/plugins/OrcidSource/tests/schema.sql create mode 100644 app/plugins/OrcidSource/webroot/.gitkeep 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..e8b00685c --- /dev/null +++ b/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po @@ -0,0 +1,71 @@ +# 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 "Public" + +msgid "enumeration.OrcidSourceApiEnum.PUB" +msgstr "Members" + +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 "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 "information.orcid_source.linked" +msgstr "Obtained ORCID {0} via authenticated OAuth flow" 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 @@ + [ + '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); + } + + public function callback() { + // dummy + } +} 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/Table/OrcidSourceCollectorsTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php new file mode 100644 index 000000000..5a865dbfe --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php @@ -0,0 +1,488 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('ExternalIdentitySources'); + + $this->hasMany('EnvSource.PetitionEnvIdentities') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $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', 'EnvSource.EnvSourceCollectors'], + // 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'], + 'EnvSource.EnvSourceCollectors' => ['edit'], + ] + ] + ); + + $this->setAutoViewVars([ + 'externalIdentitySources' => [ + 'type' => 'select', + 'model' => 'ExternalIdentitySources', + 'where' => ['plugin' => 'EnvSource.EnvSources'] + ] + ]); + + $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'] + ] + ]); + } + + /** + * Check for an existing External Identity associated with the requested Source Key. + * + * @since COmanage Registry v5.1.0 + * @param int $eisId External Identity Source ID + * @param string $sourceKey Source Key + * @return bool true if the check passes and it is OK to proceed + * @throws OverflowException + */ + + protected function checkDuplicate(int $eisId, string $sourceKey): bool { + $EISRecords = TableRegistry::getTableLocator()->get('ExtIdentitySourceRecords'); + + $dupe = $EISRecords->find() + ->where([ + 'source_key' => $sourceKey, + 'external_identity_source_id' => $eisId + ]) + ->first(); + + if(!empty($dupe)) { + $this->llog('error', "Source Key $sourceKey is already attached to External Identity " . $dupe->external_identity_id . " for External Identity Source ID " . $eisId); + + throw new \OverflowException(__d('env_source', 'error.source_key.duplicate', [$sourceKey, $dupe->external_identity_id])); + } + + return true; + } + + /** + * Perform steps necessary to hydrate the Person record as part of Petition finalization. + * + * @since COmanage Registry v5.1.0 + * @param int $id Env Source Collector ID + * @param Petition $petition Petition + * @return bool true on success + * @throws OverflowException + * @throws RuntimeException + */ + + public function hydrate(int $id, \App\Model\Entity\Petition $petition) { + $cfg = $this->get($id); + + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + // At this point there is a Person record allocated and stored in the Petition. + // We need to sync the EnvSource Identity (which is cached in env_source_identities) + // to the Enrollee Person. + + // We need the Source Key to sync, which is available via the EnvSourceIdentity. + + $pei = $this->PetitionEnvIdentities->find() + ->where(['petition_id' => $petition->id]) + ->contain(['EnvSourceIdentities']) + ->first(); + + if(!empty($pei->env_source_identity->source_key)) { + $ExtIdentitySources = TableRegistry::getTableLocator()->get('ExternalIdentitySources'); + + // If this is a duplicate enrollment for this External Identity (ie: there is + // already an External Identity associated with this Petition Env Identity) we + // need to check for that and throw a duplicate error here. If we allow it to run, + // the Pipeline Sync code will find the existing External Identity and merge this + // request to that record. (In a way, this is technically OK since it's the same + // external record, but this will appear unintuitively as a successful enrollment + // when most deployments will want to treat it as a duplicate.) + + // This will throw OverflowException on duplicate + $this->checkDuplicate($cfg->external_identity_source_id, $pei->env_source_identity->source_key); + + try { + // Continue on to process the sync + $status = $ExtIdentitySources->sync( + id: $cfg->external_identity_source_id, + sourceKey: $pei->env_source_identity->source_key, + personId: $petition->enrollee_person_id, + syncOnly: true + ); + + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + action: PetitionActionEnum::Finalized, + comment: __d('env_source', 'result.pipeline.status', [$status]) + ); + } + catch(\Exception $e) { + // We allow an error in the sync process (probably a duplicate record) to interrupt + // finalization since it could result in an inconsistent state (multiple Person + // records for the same External Identity). We don't bother recording Petition History + // here though since we're about to rollback. + + $this->llog('error', 'Sync failure during hydration of Petition ' . $petition->id . ': ' . $e->getMessage()); + + throw new \RuntimeException($e->getMessage()); + } + } else { + // If there's no source key (which is unlikely) we record an error but don't try + // to abort finalization. + + $this->llog('error', 'No source key found during hydration of Petition ' . $petition->id); + + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + action: PetitionActionEnum::Finalized, + comment: __d('env_source', 'error.source_key') + ); + } + + return true; + } + + /** + * Parse the environment values as per the configuration. + * + * @since COmanag Registry v5.1.0 + * @param EnvSource $envSource EnvSource configuration entity + * @return array Array of env variables and their parsed values + * @throws InvalidArgumentException + */ + + public function parse(\EnvSource\Model\Entity\EnvSource $envSource): array { + // The filtered set of variables to return + $ret = []; + + // XXX getenv() does not return all the environmental variables. We need to check + // one by one. + if(!empty($envSource->lookaside_file)) { + // The look aside file is for debugging purposes. If the file is specified but not found, + // we throw an error to prevent unintended configurations. + + return $this->loadFromLookasideFile($envSource->lookaside_file, $envSource); + } + + // We walk through our configuration and only copy the variables that were configured + foreach($envSource->getVisible() as $field) { + // We only want the fields starting env_ (except env_source_id, which is changelog metadata) + + if(strncmp($field, "env_", 4)==0 && $field != "env_source_id" + && !empty($envSource->$field) // This field is configured with an env var name + && getenv($envSource->$field) // This env var is populated + ) { + // Note we're using the EnvSource field name (eg: env_name_given) as the key + // and not the configured variable name (which might be something like SHIB_FIRST_NAME) + $ret[$field] = getenv($envSource->$field); + } + } + + return $ret; + } + + /** + * Load environment variables from a lookaside file based on the given configuration. + * + * @param string $filename Path to the lookaside file + * @param \EnvSource\Model\Entity\EnvSource $envSource EnvSource configuration entity + * @return array Array of environment variables and their parsed values + * @throws InvalidArgumentException + *@since COmanage Registry v5.1.0 + */ + public function loadFromLookasideFile(string $filename, \EnvSource\Model\Entity\EnvSource $envSource): array { + $src = parse_ini_file($filename); + $ret = []; + + if(!$src) { + throw new \InvalidArgumentException(__d('env_source', 'error.lookaside_file', [$filename])); + } + + // We walk through our configuration and only copy the variables that were configured + foreach($envSource->getVisible() as $field) { + // We only want the fields starting env_ (except env_source_id, which is changelog metadata) + + if(strncmp($field, "env_", 4)==0 && $field != "env_source_id" + && !empty($envSource->$field) // This field is configured with an env var name + && isset($src[$envSource->$field]) // This env var is populated + ) { + // Note we're using the EnvSource field name (eg: env_name_given) as the key + // and not the configured variable name (which might be something like SHIB_FIRST_NAME) + $ret[$field] = $src[$envSource->$field]; + } + } + + return $ret; + } + + /** + * Insert or update a Petition Env Identity. + * + * @since COmanage Registry v5.1.0 + * @param int $id Env Source Collector ID + * @param int $petitionId Petition ID + * @param array $attributes Env Sounce Attributes + * @return bool true on success + * @throws InvalidArgumentException + * @throws OverflowException + * @throws PersistenceFailedException + */ + + public function upsert(int $id, int $petitionId, array $attributes) { + if(empty($attributes['env_identifier_sourcekey'])) { + throw new \InvalidArgumentException(__d('env_source', 'error.source_key')); + } + + $sourceKey = $attributes['env_identifier_sourcekey']; + + // Pulling our configuration is a bit complicated because of the indirect relations + $envSourceCollector = $this->get($id); + + $EnvSources = TableRegistry::getTableLocator()->get('EnvSource.EnvSources'); + + $envSource = $EnvSources->find() + ->where(['external_identity_source_id' => $envSourceCollector->external_identity_source_id]) + ->firstOrFail(); + + // We first check that there is not an External Identity in this CO that + // already has this Source Key from this EnvSource instance. Technically we + // could wait until finalization and let the Pipeline implement this check, + // but it's a better user experience to detect the situation earlier in the flow. + + // Note it is OK if another Petition was started with the same Source Key, but only + // one such Petition can successfully complete - the other(s) will fail at finalize + // (if not sooner). This allows for abandoned enrollments, etc. + + // This will throw OverflowException on duplicate + $this->checkDuplicate($envSourceCollector->external_identity_source_id, $sourceKey); + + // We need to update two tables here because of constraints imposed by how + // EnvSource works. First we insert a record into EnvSourceIdentities, which + // is basically a cache of known identities. The Source Key must be unique + // (within the External Identity Source), so an existing record there is an + // error _unless_ there is not yet a corresponding External Identity. + // EnvSourceIdentities is the table used by EnvSource::retrieve in order to + // sync the Identity to a Person (since at that point there is no concept of + // a Petition). + + $EnvSourceIdentities = TableRegistry::getTableLocator()->get('EnvSource.EnvSourceIdentities'); + + $esi = $EnvSourceIdentities->upsertOrFail( + data: [ + 'env_source_id' => $envSource->id, + 'source_key' => $sourceKey, + 'env_attributes' => json_encode($attributes) + ], + whereClause: [ + 'env_source_id' => $envSource->id, + 'source_key' => $sourceKey + ] + ); + + // We then upsert PetitionEnvIdentities, which is the Petition artifact linking + // to the EnvSourceIdentity. We allow the same source_key to exist in multiple + // Petitions, eg to account for abandoned enrollments. However, only one Petition + // may create the External Identity, so once that happens any other pending + // Petitions will fail to finalize. + +// XXX Each source_key must be unique across External Identities within the CO +// We do allow more than one active, non-finalized Petition to have the same +// source_key, however this should generate a warning as only the first one to +// finalize will be successful. +// - A source_key already associated with an External Identity may not be associated +// with a new Petition within the same CO +// - A source_key already associated with an active, non-finalized Petition may be +// associated with another new Petition within the same CO, however only the first +// Petition to finalize will be associated with the source_key +// The above could be PARs, but these rules are also likely to apply to whatever +// v4 Query mode becomes, and so should maybe be more general (like +// AR-ExternalIdentitySourceRecord-1). + + $pei = $this->PetitionEnvIdentities->upsertOrFail( + data: [ + 'petition_id' => $petitionId, + 'env_source_collector_id' => $id, + 'env_source_identity_id' => $esi->id + ], + whereClause: [ + 'petition_id' => $petitionId, + 'env_source_collector_id' => $id, + ] + ); + + // Record Petition History + + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + $PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $envSourceCollector->enrollment_flow_step_id, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('env_source', 'result.env.saved') + ); + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.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; + } + + /** + * Obtain the set of Email Addresses known to this plugin that are eligible for + * verification or that have already been verified. + * + * @since COmanage Registry v5.1.0 + * @param EntityInterface $config Configuration entity for this plugin + * @param int $petitionId Petition ID + * @return array Array of Email Addresses and verification status + */ + + public function verifiableEmailAddresses( + EntityInterface $config, + int $petitionId + ): array { + // We treat the email address (if any) provided by the external source (IdP) + // as verifiable, or possibly already verified (trusted). EnvSource does not + // support per-record verification flags, either all email addresses from this + // source are verified or none are. This, in turn, is actually configured in the + // Pipeline, which is where record modification happens. (We don't actually create + // any Verification artifacts here -- that will be handled by the Pipeline + // during finalization.) + + $eis = $this->ExternalIdentitySources->get( + $config->external_identity_source_id, + ['contain' => 'Pipelines'] + ); + + $defaultVerified = isset($eis->pipeline->sync_verify_email_addresses) + && ($eis->pipeline->sync_verify_email_addresses === true); + + $pei = $this->PetitionEnvIdentities->find() + ->where(['petition_id' => $petitionId]) + ->contain(['EnvSourceIdentities']) + ->first(); + + if(!empty($pei->env_source_identity->env_attributes)) { + $attrs = json_decode($pei->env_source_identity->env_attributes); + + if(!empty($attrs->env_mail)) { + return [$attrs->env_mail => $defaultVerified]; + } + } + + return []; + } +} \ 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..96ea8e493 --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php @@ -0,0 +1,296 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ExternalIdentitySources'); + $this->belongsTo('Servers'); + +// $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' + ], + ]); + + $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'] + ] + ]); + } + + /** + * Generate a redirect URI. + * + * @return string The full URL of the redirect URI + */ + public function redirectUri(): string + { + $callback = [ + 'plugin' => 'OrcidSource', + 'controller' => 'OrcidSources', + 'action' => 'callback', + ]; + + 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 { +// XXX do we want to implement inventory of cached records? + + 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 Env attributes + * @return array Entity record (in array format) + */ + + protected function resultToEntityData( + OrcidSource $OrcidSource, + array $result + ): array { + return []; + } + + /** + * 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 { + return []; + } + + /** + * Search the External Identity Source. + * + * @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 { + return []; + } + + /** + * Obtain 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 [ + 'source_key' => __d('field', 'source_key') + ]; + } + + /** + * 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('external_source_identity_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('external_source_identity_id'); + + $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/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/config/plugin.json b/app/plugins/OrcidSource/src/config/plugin.json new file mode 100644 index 000000000..79ddee3b6 --- /dev/null +++ b/app/plugins/OrcidSource/src/config/plugin.json @@ -0,0 +1,55 @@ +{ + "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" } + }, + "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" ] } + } + } + } + } +} \ 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..8d3b2ca6a --- /dev/null +++ b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc @@ -0,0 +1,59 @@ +Field->enableFormEditMode(); + ksort($vv_env_source_vars); + $previousKey = ''; + // Render the parsed variables +?> +

+ +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'external_identity_source_id', + 'fieldLabel' => __d('env_source', 'controller.EnvSources', [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..5f749c7f9 --- /dev/null +++ b/app/plugins/OrcidSource/templates/OrcidSources/fields.inc @@ -0,0 +1,95 @@ +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' => 'redirect_uri', + 'fieldOptions' => [ + 'readOnly' => true, + 'default' => $vv_inherited_scopes + ] + ]]); 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