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
+?>
+
= __d('env_source', 'information.review') ?>
+
+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