diff --git a/app/plugins/EnvSource/README.md b/app/plugins/EnvSource/README.md
new file mode 100644
index 000000000..0a7c3212c
--- /dev/null
+++ b/app/plugins/EnvSource/README.md
@@ -0,0 +1,11 @@
+# EnvSource 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/env-source
+```
diff --git a/app/plugins/EnvSource/composer.json b/app/plugins/EnvSource/composer.json
new file mode 100644
index 000000000..8afc6a65c
--- /dev/null
+++ b/app/plugins/EnvSource/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "your-name-here/env-source",
+ "description": "EnvSource plugin for CakePHP",
+ "type": "cakephp-plugin",
+ "license": "MIT",
+ "require": {
+ "php": ">=7.2",
+ "cakephp/cakephp": "4.5.*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5 || ^9.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "EnvSource\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "EnvSource\\Test\\": "tests/",
+ "Cake\\Test\\": "vendor/cakephp/cakephp/tests/"
+ }
+ }
+}
diff --git a/app/plugins/EnvSource/phpunit.xml.dist b/app/plugins/EnvSource/phpunit.xml.dist
new file mode 100644
index 000000000..83d2293ac
--- /dev/null
+++ b/app/plugins/EnvSource/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+ tests/TestCase/
+
+
+
+
+
+
+
+
+
+
+ src/
+
+
+
diff --git a/app/plugins/EnvSource/resources/locales/en_US/env_source.po b/app/plugins/EnvSource/resources/locales/en_US/env_source.po
new file mode 100644
index 000000000..2cf309708
--- /dev/null
+++ b/app/plugins/EnvSource/resources/locales/en_US/env_source.po
@@ -0,0 +1,173 @@
+# COmanage Registry Localizations (env_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.1.0
+# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+
+msgid "controller.EnvSourceCollectors"
+msgstr "{0,plural,=1{Env Source Collector} other{Env Source Collectors}}"
+
+msgid "controller.EnvSources"
+msgstr "{0,plural,=1{Env Source} other{Env Sources}}"
+
+msgid "controller.PetitionEnvIdentities"
+msgstr "{0,plural,=1{Petition Env Identity} other{Petition Env Identities}}"
+
+msgid "enumeration.EnvSourceSpModeEnum.O"
+msgstr "Other"
+
+msgid "enumeration.EnvSourceSpModeEnum.SH"
+msgstr "Shibboleth"
+
+msgid "enumeration.EnvSourceSpModeEnum.SS"
+msgstr "SimpleSamlPhp"
+
+msgid "error.lookaside_file"
+msgstr "Lookaside File {0} could not be parsed"
+
+msgid "error.source_key"
+msgstr "Source Key (env_identifier_sourcekey) not found in attributes"
+
+msgid "error.source_key.duplicate"
+msgstr "Source Key {0} is already attached to External Identity {1}"
+
+msgid "field.EnvSources.address_type_id"
+msgstr "Address Type"
+
+msgid "field.EnvSources.default_affiliation_type_id"
+msgstr "Default Affiliation Type"
+
+msgid "field.EnvSources.email_address_type_id"
+msgstr "Email Address Type"
+
+msgid "field.EnvSources.env_address_street"
+msgstr "Address - Street"
+
+msgid "field.EnvSources.env_address_locality"
+msgstr "Address - Locality"
+
+msgid "field.EnvSources.env_address_state"
+msgstr "Address - State"
+
+msgid "field.EnvSources.env_address_postalcode"
+msgstr "Address - Postal Code"
+
+msgid "field.EnvSources.env_address_country"
+msgstr "Address - Country"
+
+msgid "field.EnvSources.env_affiliation"
+msgstr "Affiliation"
+
+msgid "field.EnvSources.env_department"
+msgstr "Department"
+
+msgid "field.EnvSources.env_identifier_eppn"
+msgstr "Identifier (ePPN)"
+
+msgid "field.EnvSources.env_identifier_eptid"
+msgstr "Identifier (ePTID)"
+
+msgid "field.EnvSources.env_identifier_epuid"
+msgstr "Identifier (ePUID)"
+
+msgid "field.EnvSources.env_identifier_network"
+msgstr "Identifier (Network)"
+
+msgid "field.EnvSources.env_identifier_oidcsub"
+msgstr "Identifier (OIDC sub)"
+
+msgid "field.EnvSources.env_identifier_samlpairwiseid"
+msgstr "Identifier (SAML pairwise-id)"
+
+msgid "field.EnvSources.env_identifier_samlsubjectid"
+msgstr "Identifier (SAML subject-id)"
+
+msgid "field.EnvSources.env_identifier_sourcekey"
+msgstr "Identifier (Source Key)"
+
+msgid "field.EnvSources.env_identifier_sourcekey.desc"
+msgstr "This must be set to an environment variable holding a unique identifier for each authenticated user"
+
+msgid "field.EnvSources.env_mail"
+msgstr "Email"
+
+msgid "field.EnvSources.env_name_honorific"
+msgstr "Name - Honorific"
+
+msgid "field.EnvSources.env_name_given"
+msgstr "Name - Given"
+
+msgid "field.EnvSources.env_name_middle"
+msgstr "Name - Middle"
+
+msgid "field.EnvSources.env_name_family"
+msgstr "Name - Family"
+
+msgid "field.EnvSources.env_name_suffix"
+msgstr "Name - Suffix"
+
+msgid "field.EnvSources.env_organization"
+msgstr "Organization"
+
+msgid "field.EnvSources.env_telephone_number"
+msgstr "Telephone Number"
+
+msgid "field.EnvSources.env_title"
+msgstr "Title"
+
+msgid "field.EnvSources.lookaside_file"
+msgstr "Lookaside File"
+
+msgid "field.EnvSources.lookaside_file.desc"
+msgstr "Path to lookaside file, intended for testing only"
+
+msgid "field.EnvSources.redirect_on_duplicate"
+msgstr "Redirect on Duplicate"
+
+msgid "field.EnvSources.sp_mode"
+msgstr "Web Server Service Provider"
+
+msgid "field.EnvSources.sync_on_login"
+msgstr "Sync on Login"
+
+msgid "field.EnvSources.sync_on_login.desc"
+msgstr "Refresh Env Source attributes when the Identifier associated with the External Identity is used to authenticate to the platform"
+
+msgid "field.EnvSources.name_type_id"
+msgstr "Name Type"
+
+msgid "field.EnvSources.telephone_number_type_id"
+msgstr "Telephone Number Type"
+
+msgid "information.header.map"
+msgstr "Environment Variable Map"
+
+msgid "information.review"
+msgstr "This is the information received from your IdP. Please review, and if you identify any discrepencies please contact your Petitioner before proceeding."
+
+msgid "result.env.saved"
+msgstr "Env Attributes recorded"
+
+msgid "result.env.saved.login"
+msgstr "Env Attributes updated at login"
+
+msgid "result.pipeline.status"
+msgstr "Pipeline completed with status {0}"
\ No newline at end of file
diff --git a/app/plugins/EnvSource/src/Controller/AppController.php b/app/plugins/EnvSource/src/Controller/AppController.php
new file mode 100644
index 000000000..3b436ed0d
--- /dev/null
+++ b/app/plugins/EnvSource/src/Controller/AppController.php
@@ -0,0 +1,10 @@
+ [
+ 'EnvSourceCollectors.id' => 'asc'
+ ]
+ ];
+
+ /**
+ * Dispatch an Enrollment Flow Step.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param string $id Env Source Collector ID
+ */
+
+ public function dispatch(string $id) {
+ $petition = $this->getPetition();
+
+ // The $link also has the Enrollment Flow Step ID
+ // $link = $this->getPrimaryLink(true);
+
+ // Pull our configuration
+
+ $envSource = $this->EnvSourceCollectors->get((int)$id, ['contain' => ['ExternalIdentitySources' => 'EnvSources']]);
+
+ try {
+ $vars = $this->EnvSourceCollectors->parse($envSource->external_identity_source->env_source);
+
+ $this->set('vv_env_source_vars', $vars);
+
+ if($this->request->is(['post', 'put'])) {
+ // We'll upsert the collected attributes. Generally this should always be an insert,
+ // but we could imagine a scenario where an admin reruns the step to change the
+ // collected identity. Or maybe if the enrollee just hits the back button.
+
+ $this->EnvSourceCollectors->upsert(
+ id: (int)$id,
+ petitionId: $petition->id,
+ attributes: $vars
+ );
+
+ // On success, indicate the step is completed and generate a redirect
+ // to the next step
+
+ return $this->finishStep(
+ enrollmentFlowStepId: $envSource->enrollment_flow_step_id,
+ petitionId: $petition->id,
+ comment: __d('env_source', 'result.env.saved')
+ );
+ }
+ }
+ catch(\OverflowException $e) {
+ // The requested Source Key is already attached to an External Identity, so we throw
+ // an error now rather than wait until finalization
+
+ // Flag the Petition as a duplicate
+ $Petitions = TableRegistry::getTableLocator()->get("Petitions");
+
+ // The exception from upsert will have a bit more detail than the generic
+ // flagDuplicate() message, so we'll stuff that into the Petition History
+ $Petitions->PetitionHistoryRecords->record(
+ petitionId: $petition->id,
+ enrollmentFlowStepId: $envSource->enrollment_flow_step_id,
+ action: PetitionActionEnum::FlaggedDuplicate,
+ comment: $e->getMessage()
+ );
+
+ $Petitions->flagDuplicate($petition->id, $envSource->enrollment_flow_step_id);
+
+ // Redirect to configured URL or default location
+ if(!empty($envSource->external_identity_source->env_source->redirect_on_duplicate)) {
+ // Use the EnvSource specific redirect URL
+ return $this->redirect($envSource->external_identity_source->env_source->redirect_on_duplicate);
+ } else {
+ // Redirect to the default Duplicate Landing URL for this CO
+ $coId = $envSource->external_identity_source->co_id;
+
+ return $this->redirect("/$coId/duplicate-landing");
+ }
+ }
+ catch(\Exception $e) {
+ $this->Flash->error($e->getMessage());
+ }
+
+ // Fall through and let the form render
+
+ $this->render('/Standard/dispatch');
+ }
+
+ /**
+ * Display information about this Step.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param string $id Attribute Collector ID
+ */
+
+ public function display(string $id) {
+ $petition = $this->getPetition();
+
+ $this->set('vv_petition_env_identities', $this->EnvSourceCollectors
+ ->PetitionEnvIdentities
+ ->find()
+ ->where(['PetitionEnvIdentities.petition_id' => $petition->id])
+ ->contain(['EnvSourceIdentities'])
+ ->firstOrFail());
+ }
+
+ /**
+ * Indicate whether this Controller will handle some or all authnz.
+ *
+ * @since COmanage Registry v5.1.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/EnvSource/src/Controller/EnvSourceDetoursController.php b/app/plugins/EnvSource/src/Controller/EnvSourceDetoursController.php
new file mode 100644
index 000000000..dbd5ffa7b
--- /dev/null
+++ b/app/plugins/EnvSource/src/Controller/EnvSourceDetoursController.php
@@ -0,0 +1,70 @@
+ [
+ 'EnvSourceDetours.id' => 'asc'
+ ]
+ ];
+
+ /**
+ * Handle a post login action.
+ *
+ * @since COmanage Registry v5.1.0
+ */
+
+ public function postlogin() {
+ $request = $this->getRequest();
+ $session = $request->getSession();
+
+ $detourId = $request->getQuery("detour_id");
+
+ // We need to register CoIdEventListener and pass it to refresh since
+ // certain models (Addresses, etc) require CO ID context for validation
+ // (and we don't require a CO for Detours).
+ $CoIdEventListener = new CoIdEventListener();
+ EventManager::instance()->on($CoIdEventListener);
+
+ $this->EnvSourceDetours->refresh(
+ detourId: (int)$detourId,
+ sourceKey: $session->read('Auth.external.user'),
+ coidListener: $CoIdEventListener
+ );
+
+ return $this->finishDetour();
+ }
+}
diff --git a/app/plugins/EnvSource/src/Controller/EnvSourcesController.php b/app/plugins/EnvSource/src/Controller/EnvSourcesController.php
new file mode 100644
index 000000000..663448a4a
--- /dev/null
+++ b/app/plugins/EnvSource/src/Controller/EnvSourcesController.php
@@ -0,0 +1,40 @@
+ [
+ 'EnvSources.id' => 'asc'
+ ]
+ ];
+}
diff --git a/app/plugins/EnvSource/src/EnvSourcePlugin.php b/app/plugins/EnvSource/src/EnvSourcePlugin.php
new file mode 100644
index 000000000..8c6359dc1
--- /dev/null
+++ b/app/plugins/EnvSource/src/EnvSourcePlugin.php
@@ -0,0 +1,93 @@
+plugin(
+ 'EnvSource',
+ ['path' => '/env-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/EnvSource/src/Lib/Enum/EnvSourceSpModeEnum.php b/app/plugins/EnvSource/src/Lib/Enum/EnvSourceSpModeEnum.php
new file mode 100644
index 000000000..4f6d3fb43
--- /dev/null
+++ b/app/plugins/EnvSource/src/Lib/Enum/EnvSourceSpModeEnum.php
@@ -0,0 +1,38 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/EnvSource/src/Model/Entity/EnvSourceCollector.php b/app/plugins/EnvSource/src/Model/Entity/EnvSourceCollector.php
new file mode 100644
index 000000000..91989da2c
--- /dev/null
+++ b/app/plugins/EnvSource/src/Model/Entity/EnvSourceCollector.php
@@ -0,0 +1,51 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/EnvSource/src/Model/Entity/EnvSourceDetour.php b/app/plugins/EnvSource/src/Model/Entity/EnvSourceDetour.php
new file mode 100644
index 000000000..5b8717dc7
--- /dev/null
+++ b/app/plugins/EnvSource/src/Model/Entity/EnvSourceDetour.php
@@ -0,0 +1,51 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/EnvSource/src/Model/Entity/EnvSourceIdentity.php b/app/plugins/EnvSource/src/Model/Entity/EnvSourceIdentity.php
new file mode 100644
index 000000000..2873ca37c
--- /dev/null
+++ b/app/plugins/EnvSource/src/Model/Entity/EnvSourceIdentity.php
@@ -0,0 +1,49 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/EnvSource/src/Model/Entity/PetitionEnvIdentity.php b/app/plugins/EnvSource/src/Model/Entity/PetitionEnvIdentity.php
new file mode 100644
index 000000000..beb919bfc
--- /dev/null
+++ b/app/plugins/EnvSource/src/Model/Entity/PetitionEnvIdentity.php
@@ -0,0 +1,49 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/EnvSource/src/Model/Table/EnvSourceCollectorsTable.php b/app/plugins/EnvSource/src/Model/Table/EnvSourceCollectorsTable.php
new file mode 100644
index 000000000..3741d559c
--- /dev/null
+++ b/app/plugins/EnvSource/src/Model/Table/EnvSourceCollectorsTable.php
@@ -0,0 +1,445 @@
+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']);
+
+ $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 = [];
+
+ // All available variables
+ $src = [];
+
+ if(!empty($envSource->lookaside_file)) {
+ // The lookaside file is for debugging purposes. If the file is specified but not found,
+ // we throw an error to prevent unintended configurations.
+
+ $src = parse_ini_file($envSource->lookaside_file);
+
+ if(!$src) {
+ throw new \InvalidArgumentException(__d('env_source', 'error.lookaside_file', [$envSource->lookaside_file]));
+ }
+ } else {
+ // The set of available vars is available via getenv()
+ $src = getenv();
+ }
+
+ // 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
+ && !empty($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->upsert(
+ 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->upsert(
+ 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/EnvSource/src/Model/Table/EnvSourceDetoursTable.php b/app/plugins/EnvSource/src/Model/Table/EnvSourceDetoursTable.php
new file mode 100644
index 000000000..f4e09b259
--- /dev/null
+++ b/app/plugins/EnvSource/src/Model/Table/EnvSourceDetoursTable.php
@@ -0,0 +1,224 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('TrafficDetours');
+
+ $this->setDisplayField('id');
+
+ $this->setPrimaryLink(['traffic_detour_id']);
+// XXX Move this to parent model? (and add all possible values)
+ $this->setAllowEmptyPrimaryLink(['postlogin']);
+ $this->setRequiresCO(false);
+
+ // We want to update attributes after login
+ $this->setSupportedDetourContext('postlogin');
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => false, // Delete the pluggable object instead
+ 'edit' => ['platformAdmin'],
+ 'view' => ['platformAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => false, //['platformAdmin'],
+ 'index' => ['platformAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Refresh Env Source attributes associated with the authenticated identifier.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param int $detourId Traffic Detour ID
+ * @param string $sourceKey Source Key
+ * @param CoIdEventListener $coidListener CoIdEventListener
+ */
+
+ public function refresh(
+ int $detourId,
+ string $sourceKey,
+ CoIdEventListener $coidListener
+ ) {
+ // While a given Source Key can only be associated with one External Identity
+ // within a CO, it _can_ be associated with multiple External Identities across
+ // multiple COs.
+
+ // Start by pulling all Env Source Identities with this Source Key.
+ // (This is the cache of Env Source data that is ultimately read by the
+ // retrieve() during the Pipeline.)
+
+ $EnvSourceIdentities = TableRegistry::getTableLocator()->get('EnvSource.EnvSourceIdentities');
+ $EnvSourceCollectors = TableRegistry::getTableLocator()->get('EnvSource.EnvSourceCollectors');
+ $ExternalIdentitySources = TableRegistry::getTableLocator()->get('ExternalIdentitySources');
+ $ExtIdentitySourceRecords = TableRegistry::getTableLocator()->get('ExtIdentitySourceRecords');
+
+ $esis = $EnvSourceIdentities->find()
+ ->where(['EnvSourceIdentities.source_key' => $sourceKey])
+ ->contain(['EnvSources'])
+ ->all();
+
+ if(!empty($esis)) {
+ $this->llog('trace', "Found " . $esis->count() . " EnvSourceIdentities for source key " . $sourceKey);
+
+ foreach($esis as $esi) {
+ // debug($esi);
+
+ if($esi->env_source->sync_on_login) {
+ // Use EnvSourceCollectorsTable to parse the attributes
+ $attrs = $EnvSourceCollectors->parse($esi->env_source);
+
+ if(!empty($attrs['env_identifier_sourcekey']
+ && $attrs['env_identifier_sourcekey'] === $sourceKey)) {
+ // Update the EnvSourceIdentity (strictly speaking this should always
+ // be an update since we just pulled the matching record...)
+
+ $esi->env_attributes = json_encode($attrs);
+
+ try {
+ $EnvSourceIdentities->saveOrFail($esi, ['associated' => false]);
+
+ // Because we operate outside of a CO context, we need to manually update
+ // the CO for the Models that require it for validation purposes. We do
+ // this via CoIdEventListener so we don't have to enumerate the list of
+ // supported models.
+ $coId = $ExternalIdentitySources->findCoForRecord($esi->env_source->external_identity_source_id);
+
+ $coidListener->updateCoId($coId);
+
+ // Resync the External Identity (rerun the Pipeline)
+
+ $status = $ExternalIdentitySources->sync(
+ id: $esi->env_source->external_identity_source_id,
+ sourceKey: $sourceKey,
+ // Unlike our initial run during Enrollment, we _do_ want provisioning to
+ // run here. (This will also run Identifier Assignment, but in general
+ // identifiers should have already been assigned.)
+ syncOnly: false
+ );
+
+ $this->llog('trace', "Sync of Env Source ID " . $esi->env_source->id . " Source Key " . $sourceKey . " completed: " . $status);
+
+ if($status == 'updated') {
+ // Record a History Record, but in order to do that we need to map the $sourceKey
+ // (which was done in the sync process but not bubbled up)
+
+ $eisr = $ExtIdentitySourceRecords->find()
+ ->where([
+ 'ExtIdentitySourceRecords.external_identity_source_id' => $esi->env_source->external_identity_source_id,
+ 'ExtIdentitySourceRecords.source_key' => $sourceKey
+ ])
+ ->contain(['ExternalIdentities'])
+ ->firstOrFail();
+
+ $ExtIdentitySourceRecords->ExternalIdentities
+ ->recordHistory(
+ entity: $eisr->external_identity,
+ action: ActionEnum::ExternalIdentityLoginUpdate,
+ comment: __d('env_source', 'result.env.saved.login')
+ );
+ }
+ }
+ catch(\Exception $e) {
+ $this->llog('error', "Sync of Env Source ID " . $esi->env_source->id . " Source Key " . $sourceKey . " failed: " . $e->getMessage());
+ }
+ } else {
+ // We shouldn't get here since we just did a find based on $sourceKey
+ $this->llog('error', "Source Key mismatch for Env Source ID " . $esi->env_source->id . " Source Key " . $sourceKey);
+ }
+ } else {
+ $this->llog('trace', "sync_on_login disabled for Env Source ID " . $esi->env_source->id);
+ }
+ }
+ }
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ * @throws InvalidArgumentException
+ * @throws RecordNotFoundException
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('traffic_detour_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('traffic_detour_id');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/plugins/EnvSource/src/Model/Table/EnvSourceIdentitiesTable.php b/app/plugins/EnvSource/src/Model/Table/EnvSourceIdentitiesTable.php
new file mode 100644
index 000000000..13b9d8114
--- /dev/null
+++ b/app/plugins/EnvSource/src/Model/Table/EnvSourceIdentitiesTable.php
@@ -0,0 +1,111 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact);
+
+ // Define associations
+ $this->belongsTo('EnvSource.EnvSources');
+ // $this->belongsTo('Petition');
+
+ $this->hasMany('EnvSource.PetitionEnvIdentities')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
+
+ $this->setDisplayField('source_key');
+
+ $this->setPrimaryLink('EnvSource.env_source_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' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * 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('env_source_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('env_source_id');
+
+ $this->registerStringValidation($validator, $schema, 'source_key', true);
+
+ $this->registerStringValidation($validator, $schema, 'env_attributes', false);
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/EnvSource/src/Model/Table/EnvSourcesTable.php b/app/plugins/EnvSource/src/Model/Table/EnvSourcesTable.php
new file mode 100644
index 000000000..cdd8c2b5b
--- /dev/null
+++ b/app/plugins/EnvSource/src/Model/Table/EnvSourcesTable.php
@@ -0,0 +1,494 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('ExternalIdentitySources');
+ $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('EnvSource.EnvSourceIdentities')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
+
+ $this->setDisplayField('id');
+
+ $this->setPrimaryLink(['external_identity_source_id']);
+ $this->setRequiresCO(true);
+
+ $this->setAutoViewVars([
+ 'addressTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'Addresses.type'
+ ],
+ 'defaultAffiliationTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'PersonRoles.affiliation_type'
+ ],
+ 'duplicateModes' => [
+ 'type' => 'enum',
+ 'class' => 'EnvSource.EnvSourceDuplicateModeEnum'
+ ],
+ 'emailAddressTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'EmailAddresses.type'
+ ],
+ 'nameTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'Names.type'
+ ],
+ 'spModes' => [
+ 'type' => 'enum',
+ 'class' => 'EnvSource.EnvSourceSpModeEnum'
+ ],
+ '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']
+ ]
+ ]);
+ }
+
+ /**
+ * Obtain the set of changed records from the source database.
+ *
+ * @since COmanage Registry v5.1.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.1.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 EnvSource data to a record suitable for
+ * construction of an Entity. This call is for use with Relational Mode.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param EnvSource $EnvSource EnvSource configuration entity
+ * @param array $result Array of Env attributes
+ * @return array Entity record (in array format)
+ */
+
+ protected function resultToEntityData(
+ \EnvSource\Model\Entity\EnvSource $EnvSource,
+ array $result
+ ): array {
+ // We don't need most of the $EnvSource configuation since EnvSourceCollector::parse
+ // already mapped the variable names for us. We do need to know the sp_mode for parsing
+ // multiple values, and also we need the types.
+
+ // 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($EnvSource->name_type_id)
+ ];
+
+ foreach([
+ 'env_affiliation' => 'affiliation',
+ 'env_department' => 'department',
+ 'env_organization' => 'organization',
+ 'env_title' => 'title'
+ ] as $v => $f) {
+ if(!empty($result[$v])) {
+ $role[$f] = $result[$v];
+ }
+ }
+
+ $eidata['external_identity_roles'][] = $role;
+
+ // XXX Name should probably honor CO Settings?
+ $name = [
+ 'type' => $this->NameTypes->getTypeLabel($EnvSource->name_type_id)
+ ];
+
+ foreach([
+ 'env_name_honorific' => 'honorific',
+ 'env_name_given' => 'given',
+ 'env_name_middle' => 'middle',
+ 'env_name_family' => 'family',
+ 'env_name_suffix' => 'suffix'
+ ] as $v => $f) {
+ if(!empty($result[$v])) {
+ $name[$f] = $result[$v];
+ }
+ }
+
+ $eidata['names'][] = $name;
+
+ // XXX Address should probably honor CO Settings?
+ $address = [
+ 'type' => $this->AddressTypes->getTypeLabel($EnvSource->address_type_id)
+ ];
+
+ foreach([
+ 'env_address_street' => 'street',
+ 'env_address_locality' => 'locality',
+ 'env_address_state' => 'state',
+ 'env_address_postal_code' => 'postalcode',
+ 'env_address_country' => 'country'
+ ] as $v => $f) {
+ if(!empty($result[$v])) {
+ $address[$f] = $result[$v];
+ }
+ }
+
+ if(count(array_keys($address)) > 1) {
+ // We have a field other than type, so add it to the result
+
+ $eidata['addresses'][] = $address;
+ }
+
+ // Email Address
+ if(!empty($result['env_mail'])) {
+ $mails = [];
+
+ // We accept multiple values if supported by the configured SP software.
+
+ switch($EnvSource->sp_mode) {
+ case EnvSourceSpModeEnum::Shibboleth:
+ $mails = explode(";", $result['env_mail']);
+ break;
+ case EnvSourceSpModeEnum::SimpleSamlPhp:
+ $mails = explode(",", $result['env_mail']);
+ break;
+ default:
+ // We dont' try to tokenize the string
+ $mails = [ $result['env_mail' ]];
+ break;
+ }
+
+ foreach($mails as $m) {
+ $eidata['email_addresses'][] = [
+ 'mail' => $m,
+ 'type' => $this->EmailAddressTypes->getTypeLabel($EnvSource->email_address_type_id),
+ // We treat externally asserted email addresses as not verified,
+ // but this can be overridden in the Pipeline configuration.
+ // Note voPersonVerifiedEmail is capable of transmitted verified status,
+ // so in theory we could define a configuration to check for that.
+ 'verified' => false
+ ];
+ }
+ }
+
+ // Walk through all defined Identifiers
+ foreach([
+ 'env_identifier_eppn' => 'eppn',
+ 'env_identifier_eptid' => 'eptid',
+ 'env_identifier_epuid' => 'epuid',
+ 'env_identifier_network' => 'network',
+ 'env_identifier_oidcsub' => 'oidcsub',
+ 'env_identifier_samlpairwiseid' => 'samlpairwiseid',
+ 'env_identifier_samlsubjectid' => 'samlsubjectid'
+ // We don't include source_key (sorid) because the Pipeline will automatically insert it
+ ] as $v => $t) {
+ // Because we're in an External Identity context, we don't need to map the
+ // type strings to IDs (that happens in the Pipeline)
+
+ if(!empty($result[$v])) {
+ $eidata['identifiers'][] = [
+ 'identifier' => $result[$v],
+ 'type' => $t
+ ];
+ }
+ }
+
+ // Telephone Number
+ if(!empty($result['env_telephone_number'])) {
+ $eidata['telephone_numbers'][] = [
+ 'number' => $result['env_telephone_number'],
+ 'type' => $this->TelephoneNumberTypes->getTypeLabel($EnvSource->telephone_number_type_id)
+ ];
+ }
+
+ return $eidata;
+ }
+
+ /**
+ * Retrieve a record from the External Identity Source.
+ *
+ * @since COmanage Registry v5.1.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 {
+ $entity = $this->EnvSourceIdentities->find()
+ ->where([
+ 'env_source_id' => $source->env_source->id,
+ 'source_key' => $source_key
+ ])
+ ->first();
+
+ if($entity) {
+ return [
+ 'source_key' => $entity->source_key,
+ 'source_record' => $entity->env_attributes,
+ 'entity_data' => $this->resultToEntityData($source->env_source, json_decode($entity->env_attributes, true))
+ ];
+ } else {
+ throw new \InvalidArgumentException(__d('error', 'notfound', [$source_key]));
+ }
+
+ return [];
+ }
+
+ /**
+ * Search the External Identity Source.
+ *
+ * @since COmanage Registry v5.1.0
+ * @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
+ * @throws InvalidArgumentException
+ */
+
+ public function search(
+ \App\Model\Entity\ExternalIdentitySource $source,
+ array $searchAttrs
+ ): array {
+ // We currently only support retrieving based on Source Key
+ $ret = [];
+
+ try {
+ $record = $this->retrieve($source, $searchAttrs['source_key']);
+
+ $ret[ $record['source_key'] ] = $record['entity_data'];
+
+ return $ret;
+ }
+ catch(\InvalidArgumentException $e) {
+ // Source Key not found in table
+ return $ret;
+ }
+ }
+
+ /**
+ * Obtain the set of searchable attributes for this backend.
+ *
+ * @since COmanage Registry v5.1.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.1.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('redirect_on_duplicate', [
+ 'content' => ['rule' => 'url']
+ ]);
+ $validator->allowEmptyString('redirect_on_duplicate');
+
+ $validator->add('sp_mode', [
+ 'content' => ['rule' => ['inList', EnvSourceSpModeEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('sp_mode');
+
+ $validator->add('sync_on_login', [
+ 'content' => ['rule' => 'boolean']
+ ]);
+ $validator->allowEmptyString('sync_on_login');
+
+ // These are all required even if the deployer doesn't intend to populate the
+ // corresponding Env fields
+
+ foreach([
+ '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);
+ }
+
+ // For simplicity, we don't require any specific fields to be populated except SORID,
+ // though depending on the CO configuration some fields may effectively be required.
+
+ $this->registerStringValidation($validator, $schema, 'env_identifier_sourcekey', true);
+
+ foreach([
+ 'env_address_street',
+ 'env_address_locality',
+ 'env_address_state',
+ 'env_address_postalcode',
+ 'env_address_country',
+ 'env_affiliation',
+ 'env_department',
+ 'env_identifier_eppn',
+ 'env_identifier_eptid',
+ 'env_identifier_epuid',
+ 'env_identifier_network',
+ 'env_identifier_oidcsub',
+ 'env_identifier_samlpairwiseid',
+ 'env_identifier_samlsubjectid',
+ 'env_mail',
+ 'env_name_honorific',
+ 'env_name_given',
+ 'env_name_middle',
+ 'env_name_family',
+ 'env_name_suffix',
+ 'env_organization',
+ 'env_telephone_number',
+ 'env_title'
+ ] as $field) {
+ $this->registerStringValidation($validator, $schema, $field, false);
+ }
+
+ $this->registerStringValidation($validator, $schema, 'lookaside_file', false);
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/plugins/EnvSource/src/Model/Table/PetitionEnvIdentitiesTable.php b/app/plugins/EnvSource/src/Model/Table/PetitionEnvIdentitiesTable.php
new file mode 100644
index 000000000..803d0e40d
--- /dev/null
+++ b/app/plugins/EnvSource/src/Model/Table/PetitionEnvIdentitiesTable.php
@@ -0,0 +1,115 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact);
+
+ // Define associations
+ // We need this relation so we can collect multiple identities in the same flow
+ $this->belongsTo('EnvSource.EnvSourceCollectors');
+ $this->belongsTo('EnvSource.EnvSourceIdentities');
+ $this->belongsTo('Petitions');
+
+ $this->setDisplayField('id');
+
+ $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' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * 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('petition_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('petition_id');
+
+ $validator->add('env_source_collector_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('env_source_collector_id');
+
+ $validator->add('env_source_identity_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('env_source_identity_id');
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/EnvSource/src/config/plugin.json b/app/plugins/EnvSource/src/config/plugin.json
new file mode 100644
index 000000000..0b8fd35bf
--- /dev/null
+++ b/app/plugins/EnvSource/src/config/plugin.json
@@ -0,0 +1,104 @@
+{
+ "types": {
+ "enroller": [
+ "EnvSourceCollectors"
+ ],
+ "source": [
+ "EnvSources"
+ ],
+ "traffic": [
+ "EnvSourceDetours"
+ ]
+ },
+ "schema": {
+ "tables": {
+ "env_source_collectors": {
+ "columns": {
+ "id": {},
+ "enrollment_flow_step_id": {},
+ "external_identity_source_id": {}
+ },
+ "indexes": {
+ "env_source_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] },
+ "env_source_collectors_i2": { "needed": false, "columns": [ "external_identity_source_id" ] }
+ }
+ },
+ "env_sources": {
+ "columns": {
+ "id": {},
+ "external_identity_source_id": {},
+ "redirect_on_duplicate": { "type": "url" },
+ "sp_mode": { "type": "enum" },
+ "sync_on_login": { "type": "boolean"},
+ "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" } },
+ "env_address_street": { "type": "string", "size": "80" },
+ "env_address_locality": { "type": "string", "size": "80" },
+ "env_address_state": { "type": "string", "size": "80" },
+ "env_address_postalcode": { "type": "string", "size": "80" },
+ "env_address_country": { "type": "string", "size": "80" },
+ "env_affiliation": { "type": "string", "size": "80" },
+ "env_department": { "type": "string", "size": "80" },
+ "env_identifier_eppn": { "type": "string", "size": "80" },
+ "env_identifier_eptid": { "type": "string", "size": "80" },
+ "env_identifier_epuid": { "type": "string", "size": "80" },
+ "env_identifier_network": { "type": "string", "size": "80" },
+ "env_identifier_oidcsub": { "type": "string", "size": "80" },
+ "env_identifier_samlpairwiseid": { "type": "string", "size": "80" },
+ "env_identifier_samlsubjectid": { "type": "string", "size": "80" },
+ "env_identifier_sourcekey": { "type": "string", "size": "80" },
+ "env_mail": { "type": "string", "size": "80" },
+ "env_name_honorific": { "type": "string", "size": "80" },
+ "env_name_given": { "type": "string", "size": "80" },
+ "env_name_middle": { "type": "string", "size": "80" },
+ "env_name_family": { "type": "string", "size": "80" },
+ "env_name_suffix": { "type": "string", "size": "80" },
+ "env_organization": { "type": "string", "size": "80" },
+ "env_telephone_number": { "type": "string", "size": "80" },
+ "env_title": { "type": "string", "size": "80" },
+ "lookaside_file": { "type": "path" }
+ },
+ "indexes": {
+ "env_sources_i1": { "columns": [ "external_identity_source_id" ] }
+ }
+ },
+ "env_source_identities": {
+ "columns": {
+ "id": {},
+ "env_source_id": { "type": "integer", "foreignkey": { "table": "env_sources", "column": "id" }},
+ "source_key": { "type": "string", "size": 1024 },
+ "env_attributes": { "type": "text" }
+ },
+ "indexes": {
+ "env_source_identities_i1": { "columns": [ "source_key" ] },
+ "env_source_identities_i2": { "needed": false, "columns": [ "env_source_id" ] }
+ }
+ },
+ "env_source_detours": {
+ "columns": {
+ "id": {},
+ "traffic_detour_id": {}
+ },
+ "indexes": {
+ "env_source_detours_i1": { "needed": false, "columns": [ "traffic_detour_id" ] }
+ }
+ },
+ "petition_env_identities": {
+ "columns": {
+ "id": {},
+ "petition_id": {},
+ "env_source_collector_id": { "type": "integer", "foreignkey": { "table": "env_source_collectors", "column": "id" }},
+ "env_source_identity_id": { "type": "integer", "foreignkey": { "table": "env_source_identities", "column": "id" }}
+ },
+ "indexes": {
+ "petition_env_identities_i1": { "columns": [ "petition_id" ] },
+ "petition_env_identities_i2": { "needed": false, "columns": [ "env_source_collector_id" ] },
+ "petition_env_identities_i3": { "needed": false, "columns": [ "env_source_identity_id" ] }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/plugins/EnvSource/templates/EnvSourceCollectors/dispatch.inc b/app/plugins/EnvSource/templates/EnvSourceCollectors/dispatch.inc
new file mode 100644
index 000000000..5630e4e17
--- /dev/null
+++ b/app/plugins/EnvSource/templates/EnvSourceCollectors/dispatch.inc
@@ -0,0 +1,46 @@
+Field->enableFormEditMode();
+
+ // Render the parsed variables
+
+ print "
" . __d('env_source', 'information.review') . "
";
+
+ print "";
+
+ foreach($vv_env_source_vars as $k => $v) {
+ print "- " . __d('env_source', 'field.EnvSources.'.$k) . ": " . $v . "
\n";
+ }
+
+ print "
";
+}
\ No newline at end of file
diff --git a/app/plugins/EnvSource/templates/EnvSourceCollectors/fields.inc b/app/plugins/EnvSource/templates/EnvSourceCollectors/fields.inc
new file mode 100644
index 000000000..713b393ba
--- /dev/null
+++ b/app/plugins/EnvSource/templates/EnvSourceCollectors/fields.inc
@@ -0,0 +1,36 @@
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'external_identity_source_id',
+ 'fieldLabel' => __d('env_source', 'controller.EnvSources', [1])
+ ]
+ ]);
+}
diff --git a/app/plugins/EnvSource/templates/EnvSourceDetours/fields.inc b/app/plugins/EnvSource/templates/EnvSourceDetours/fields.inc
new file mode 100644
index 000000000..5b59be197
--- /dev/null
+++ b/app/plugins/EnvSource/templates/EnvSourceDetours/fields.inc
@@ -0,0 +1,29 @@
+
+
+
\ No newline at end of file
diff --git a/app/plugins/EnvSource/templates/EnvSources/fields-nav.inc b/app/plugins/EnvSource/templates/EnvSources/fields-nav.inc
new file mode 100644
index 000000000..e174c7b99
--- /dev/null
+++ b/app/plugins/EnvSource/templates/EnvSources/fields-nav.inc
@@ -0,0 +1,31 @@
+ 'plugin',
+ 'active' => 'plugin'
+ ];
\ No newline at end of file
diff --git a/app/plugins/EnvSource/templates/EnvSources/fields.inc b/app/plugins/EnvSource/templates/EnvSources/fields.inc
new file mode 100644
index 000000000..54fb0f882
--- /dev/null
+++ b/app/plugins/EnvSource/templates/EnvSources/fields.inc
@@ -0,0 +1,115 @@
+
+
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field
+ ]
+ ]);
+ }
+
+ print "" . __d('env_source', 'information.header.map') . "
\n";
+
+ $defaultNames = [
+ 'env_identifier_sourcekey' => 'ENV_SOURCE_KEY',
+ 'env_address_street' => 'ENV_STREET',
+ 'env_address_locality' => 'ENV_LOCALITY',
+ 'env_address_state' => 'ENV_STATE',
+ 'env_address_postalcode' => 'ENV_POSTALCODE',
+ 'env_address_country' => 'ENV_COUNTRY',
+ 'env_affiliation' => 'ENV_AFFILIATION',
+ 'env_department' => 'ENV_DEPARTMENT',
+ 'env_identifier_eppn' => 'ENV_EPPN',
+ 'env_identifier_eptid' => 'ENV_EPTID',
+ 'env_identifier_epuid' => 'ENV_EPUID',
+ 'env_identifier_network' => 'ENV_NETWORK',
+ 'env_identifier_oidcsub' => 'ENV_OIDCSUB',
+ 'env_identifier_samlpairwiseid' => 'ENV_SAMLPAIRWISE',
+ 'env_identifier_samlsubjectid' => 'ENV_SAMLSUBJECT',
+ 'env_mail' => 'ENV_MAIL',
+ 'env_name_honorific' => 'ENV_HONORIFIC',
+ 'env_name_given' => 'ENV_GIVEN',
+ 'env_name_middle' => 'ENV_MIDDLE',
+ 'env_name_family' => 'ENV_FAMILY',
+ 'env_name_suffix' => 'ENV_SUFFIX',
+ 'env_organization' => 'ENV_ORGANIZATION',
+ 'env_telephone_number' => 'ENV_TELEPHONE',
+ 'env_title' => 'ENV_TITLE'
+ ];
+
+ foreach([
+ 'env_identifier_sourcekey',
+ 'env_address_street',
+ 'env_address_locality',
+ 'env_address_state',
+ 'env_address_postalcode',
+ 'env_address_country',
+ 'env_affiliation',
+ 'env_department',
+ 'env_identifier_eppn',
+ 'env_identifier_eptid',
+ 'env_identifier_epuid',
+ 'env_identifier_network',
+ 'env_identifier_oidcsub',
+ 'env_identifier_samlpairwiseid',
+ 'env_identifier_samlsubjectid',
+ 'env_mail',
+ 'env_name_honorific',
+ 'env_name_given',
+ 'env_name_middle',
+ 'env_name_family',
+ 'env_name_suffix',
+ 'env_organization',
+ 'env_telephone_number',
+ 'env_title'
+ ] as $field) {
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field,
+ 'fieldOptions' => [
+ 'default' => $defaultNames[$field]
+ ]
+ ]
+ ]);
+ }
+}
diff --git a/app/plugins/EnvSource/templates/element/petition/envSourceCollectorsStep.php b/app/plugins/EnvSource/templates/element/petition/envSourceCollectorsStep.php
new file mode 100644
index 000000000..7f2f16ea7
--- /dev/null
+++ b/app/plugins/EnvSource/templates/element/petition/envSourceCollectorsStep.php
@@ -0,0 +1,13 @@
+env_source_identity->env_attributes);
+
+ // XXX this needs to be refactored
+?>
+
+
+ - Env Source Identity ID: = $vv_petition_env_identities->env_source_identity->id ?>
+ - Source Key: = $vv_petition_env_identities->env_source_identity->source_key ?>
+ $v): ?>
+ - = __d('env_source', 'field.EnvSources.'.$k) . ": " . $v ?>
+
+
diff --git a/app/plugins/EnvSource/tests/bootstrap.php b/app/plugins/EnvSource/tests/bootstrap.php
new file mode 100644
index 000000000..f064d887e
--- /dev/null
+++ b/app/plugins/EnvSource/tests/bootstrap.php
@@ -0,0 +1,55 @@
+loadSqlFiles('tests/schema.sql', 'test');
diff --git a/app/plugins/EnvSource/tests/schema.sql b/app/plugins/EnvSource/tests/schema.sql
new file mode 100644
index 000000000..d90c34fe5
--- /dev/null
+++ b/app/plugins/EnvSource/tests/schema.sql
@@ -0,0 +1 @@
+-- Test database schema for EnvSource
diff --git a/app/plugins/EnvSource/webroot/.gitkeep b/app/plugins/EnvSource/webroot/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/src/Command/UpgradeCommand.php b/app/src/Command/UpgradeCommand.php
new file mode 100644
index 000000000..cd221e48f
--- /dev/null
+++ b/app/src/Command/UpgradeCommand.php
@@ -0,0 +1,341 @@
+ 1.1.0, then 1.1.0 -> 1.2.0). Without the blocker, an upgrade from
+ // 1.0.0 to 1.2.0 is permitted.
+
+ // A typical scenario for blocking is when a pre### step must run after an
+ // earlier version's post### step. Because we don't have the capability
+ // to run database updates on a per-release basis, we run all relevant pre
+ // steps, then the database update, then all relevant post update steps.
+ // So if (eg) the admin is upgrading from 1.0.0 past 1.1.0 to 1.2.0 and there
+ // are no blockers, the order of operations is 1.1.0-pre, 1.2.0-pre, database,
+ // 1.1.0-post, 1.2.0-post.
+
+ // Make sure to keep this list in order so we can walk the array rather than
+ // compare version strings. You must specify the 'block' parameter. If you flag
+ // a version as blocking, be sure to document why.
+
+ // As of v5, pre and post are now a list of tasks instead of a single function
+
+ protected $versions = [
+ "5.0.0" => [
+ 'block' => false
+ ],
+ "5.1.0" => [
+ 'block' => false,
+ 'post' => ['installMostlyStaticPages']
+ ]
+ ];
+
+ /**
+ * Register command specific options.
+ *
+ * @param ConsoleOptionParser $parser Console Option Parser
+ * @return ConsoleOptionParser Console Option Parser
+ * @since COmanage Registry v5.1.0
+ */
+
+ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
+ {
+ $parser->addOption(
+ 'forcecurrent',
+ [
+ 'short' => 'f',
+ 'help' => __d('command', 'opt.upgrade.forcecurrent'),
+ 'required' => false,
+ 'boolean' => true
+ ]
+ )->addOption(
+ 'skipdatabase',
+ [
+ 'short' => 'D',
+ 'help' => __d('command', 'opt.upgrade.skipdatabase'),
+ 'required' => false,
+ 'boolean' => true
+ ]
+ )->addOption(
+ 'skipvalidation',
+ [
+ 'short' => 'X',
+ 'help' => __d('command', 'opt.upgrade.skipvalidation'),
+ 'required' => false,
+ 'boolean' => true
+ ]
+ )->addOption(
+ 'task',
+ [
+ 'short' => 't',
+ 'help' => __d('command', 'opt.upgrade.task'),
+ 'required' => false
+ ]
+ )->addOption(
+ 'version',
+ [
+ 'help' => __d('command', 'opt.upgrade.version'),
+ 'required' => false
+ ]
+ );
+
+ return $parser;
+ }
+
+ /**
+ * Execute the Setup Command.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param Arguments $args Command Arguments
+ * @param ConsoleIo $io Console IO
+ */
+
+ public function execute(Arguments $args, ConsoleIo $io)
+ {
+ global $argv;
+
+ $this->io = $io;
+
+ // Are we being asked to run a specific task?
+ $task = $args->getOption('task');
+
+ if(!empty($task)) {
+ $this->dispatch($task);
+ } else {
+ // We're running the standard update logic
+
+ // Pull current (PHP code) version
+ $targetVersion = null;
+
+ if(!empty($args->getArgumentAt(0))) {
+ // Use requested target version
+ $targetVersion = $args->getArgumentAt(0);
+ } else {
+ // Read the current release from the VERSION file
+ $targetVersion = rtrim(file_get_contents(CONFIG . DS . "VERSION"));
+ }
+
+ $MetaTable = $this->getTableLocator()->get('Meta');
+
+ $currentVersion = $MetaTable->getUpgradeVersion();
+
+ $this->io->out(__d('information', 'ug.version.current', [$currentVersion]));
+ $this->io->out(__d('information', 'ug.version.target', [$targetVersion]));
+
+ if(!$args->getOption('skipvalidation')) {
+ // Validate the version path
+
+ try {
+ $this->validateVersions($currentVersion, $targetVersion);
+ }
+ catch(Exception $e) {
+ $this->out($e->getMessage());
+ return;
+ }
+ }
+
+ // Run appropriate pre-database steps
+
+ $fromFound = false;
+
+ foreach($this->versions as $version => $params) {
+ if($version == $currentVersion) {
+ // Note we don't actually want to run the steps for $currentVersion
+ $fromFound = true;
+ continue;
+ }
+
+ if(!$fromFound) {
+ // We haven't reached the from version yet
+ continue;
+ }
+
+ if(!empty($params['pre'])) {
+ $this->io->out(__d('information', 'ug.tasks.pre', [$version]));
+
+ foreach($params['pre'] as $task) {
+ $this->dispatch($task);
+ }
+ }
+
+ if($version == $targetVersion) {
+ // We're done
+ break;
+ }
+ }
+
+ if(!$args->getOption('skipdatabase')) {
+ // Call database command
+ $this->executeCommand(DatabaseCommand::class);
+ }
+
+ // Run appropriate post-database steps
+
+ $fromFound = false;
+
+ foreach($this->versions as $version => $params) {
+ if($version == $currentVersion) {
+ // Note we don't actually want to run the steps for $currentVersion
+ $fromFound = true;
+ continue;
+ }
+
+ if(!$fromFound) {
+ // We haven't reached the from version yet
+ continue;
+ }
+
+ if(!empty($params['post'])) {
+ $this->io->out(__d('information', 'ug.tasks.post', [$version]));
+
+ foreach($params['post'] as $task) {
+ $this->dispatch($task);
+ }
+ }
+
+ if($version == $targetVersion) {
+ // We're done
+ break;
+ }
+ }
+
+ // Now that we're done, update the current version
+ $MetaTable->setUpgradeVersion($targetVersion);
+ }
+ }
+
+ /**
+ * Dispatch a task over each CO on the platform.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param string $task Task to dispatch
+ */
+
+ protected function dispatch(string $task) {
+ if(method_exists($this, $task)) {
+ // Pull the set of COs. We'll generally apply changes to _all_ COs, even if
+ // they're Suspended or Templates.
+
+ $CosTable = $this->getTableLocator()->get('Cos');
+
+ $cos = $CosTable->find()->all();
+
+ foreach($cos as $co) {
+ $this->io->out(__d('information', 'ug.'.$task, [$co->id]));
+ $this->$task($co->id);
+ }
+
+ $this->io->out(__d('result', 'ug.task.done', [$task]));
+ } else {
+ $this->io->err(__d('error', 'ug.task.unknown', [$task]));
+ }
+ }
+
+ /**
+ * Update the default set of Mostly Static Pages.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param int $coId CO ID
+ */
+
+ protected function installMostlyStaticPages(int $coId) {
+ $MspsTable = $this->getTableLocator()->get('MostlyStaticPages');
+
+ $MspsTable->addDefaults($coId);
+ }
+
+ /**
+ * Validate the requested from and to versions.
+ *
+ * @since COmanage Registry v0.9.4
+ * @param string $from "From" version (current database)
+ * @param string $to "To" version (current codebase)
+ * @return bool true if the requested range is valid
+ * @throws InvalidArgumentException
+ */
+
+ protected function validateVersions(string $from, string $to): bool {
+ // First make sure these are valid versions
+
+ if(!array_key_exists($from, $this->versions)) {
+ throw new \InvalidArgumentException(__d('error', 'ug.version.unknown', [$from]));
+ }
+
+ if(!array_key_exists($to, $this->versions)) {
+ throw new \InvalidArgumentException(__d('error', 'ug.version.unknown', [$to]));
+ }
+
+ // If $from and $to are the same, nothing to do.
+
+ if($from == $to) {
+ throw new \InvalidArgumentException(__d('error', 'ug.version.same'));
+ }
+
+ // Walk through the version array and check our version path
+
+ $fromFound = false;
+
+ foreach($this->versions as $version => $params) {
+ $blocks = $params['block'];
+
+ if($version == $from) {
+ $fromFound = true;
+ } elseif($version == $to) {
+ if(!$fromFound) {
+ // Can't downgrade ($from must preceed $to)
+ throw new \InvalidArgumentException(__d('error', 'ug.version.order'));
+ } else {
+ // We're good to go
+ break;
+ }
+ } else {
+ if($fromFound && $blocks) {
+ // We can't pass a blocker version
+ throw new \InvalidArgumentException(__d('error', 'ug.version.blocked', [$version]));
+ }
+ }
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/app/src/Controller/StandardDetourController.php b/app/src/Controller/StandardDetourController.php
new file mode 100644
index 000000000..b822a911b
--- /dev/null
+++ b/app/src/Controller/StandardDetourController.php
@@ -0,0 +1,66 @@
+redirect([
+ 'plugin' => null,
+ 'controller' => 'traffic',
+ 'action' => 'process-login',
+ '?' => ['done' => $this->request->getQuery("detour_id")]
+ ]);
+ }
+
+ /**
+ * Indicate whether this Controller will handle some or all authnz.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param EventInterface $event Cake event, ie: from beforeFilter
+ * @return string "no", "open", "authz", or "yes"
+ */
+
+ public function willHandleAuth(\Cake\Event\EventInterface $event): string {
+ return 'yes';
+ }
+}
\ No newline at end of file
diff --git a/app/src/Controller/TrafficDetoursController.php b/app/src/Controller/TrafficDetoursController.php
new file mode 100644
index 000000000..d7d5200a9
--- /dev/null
+++ b/app/src/Controller/TrafficDetoursController.php
@@ -0,0 +1,41 @@
+ [
+ 'TrafficDetours.description' => 'asc'
+ ]
+ ];
+}
\ No newline at end of file
diff --git a/app/src/Lib/Events/ActorEventListener.php b/app/src/Lib/Events/ActorEventListener.php
new file mode 100644
index 000000000..e7264f608
--- /dev/null
+++ b/app/src/Lib/Events/ActorEventListener.php
@@ -0,0 +1,113 @@
+RegistryAuth = $Auth;
+ }
+
+ /**
+ * Before marshal event listener.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param EventInterface $event Event
+ * @param ArrayObject $data Object data, in array format
+ * @param ArrayObject $options Entity save options
+ */
+
+ public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) {
+ // Tweak the options so HistoryRecords and similar models can see who performed the save
+
+ if($this->RegistryAuth) {
+ $options['actor'] = $this->RegistryAuth->getAuthenticatedUser();
+ $options['apiuser'] = $this->RegistryAuth->isApiUser();
+
+ // We don't autocalculate the actor_person_id here because most models won't need it
+ }
+ }
+
+ /**
+ * Before save event listener.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param Event $event Cake Event
+ * @param EntityInterface $entity Entity subject of the event (ie: object to be saved)
+ * @param ArrayObject $options Save options
+ */
+
+ public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options) {
+ // Tweak the options so ChangelogBehavior can see who performed the save
+
+ if($this->RegistryAuth) {
+ $options['actor'] = $this->RegistryAuth->getAuthenticatedUser();
+ $options['apiuser'] = $this->RegistryAuth->isApiUser();
+ }
+ }
+
+ /**
+ * Define the list of implemented events.
+ *
+ * @since COmanage Registry v5.0.0
+ * @return array Array of implemented events and associated configuration.
+ */
+
+ public function implementedEvents(): array {
+ return [
+ 'Model.beforeMarshal' => [
+ 'callable' => 'beforeMarshal',
+ // We need this beforeMarshal to run before the *HistoryRecords beforeMarshal
+ 'priority' => -100
+ ],
+ 'Model.beforeSave' => [
+ 'callable' => 'beforeSave',
+ // We need this beforeSave to run before the ChangelogBehavior beforeSave
+ 'priority' => -100
+ ]
+ ];
+ }
+}
diff --git a/app/src/Lib/Traits/CopyTrait.php b/app/src/Lib/Traits/CopyTrait.php
new file mode 100644
index 000000000..3b8fc30c2
--- /dev/null
+++ b/app/src/Lib/Traits/CopyTrait.php
@@ -0,0 +1,92 @@
+find()->where(['id' => $id]);
+
+ if(!empty($related)) {
+ $query = $query->contain($related);
+ }
+
+ $source = $query->firstOrFail();
+
+ // Convert to an array, which is what we need to create the new entities,
+ // and filter out the metadata fields.
+
+ $copy = $this->filterMetadataForCopy($this, $source, $related);
+
+ // Rename the "name" field, if present.
+
+ if(!empty($copy['name'])) {
+ $copy['name'] = __d('field', 'copy-a', [$copy['name']]);
+ }
+
+ // Convert the array into a new entity. We disable validation since it's possible
+ // validation rules changed since the original was persisted (either via code
+ // changes or configuration) but we'll honor the original since at some point it
+ // saved successfully.
+
+ $obj = $this->newEntity($copy, ['associated' => $related, 'validated' => false]);
+
+ // Pluggable models are NOT turned into entity heres (they remain as arrays),
+ // presumably because the ORM doesn't know how to create the table since the
+ // related model list is NOT in plugin.format. As a workaround,
+ // PluggableModelTrait::afterMarshal creates the new entities.
+
+ // Finally save, along with the associated models. By default Cake will save one
+ // level of associations, but we want to save anything in $related. We'll skip rules
+ // checking here for the same reason we skipped validation.
+
+ $this->saveOrFail($obj, ['associated' => $related, 'checkRules' => false]);
+
+ return $obj;
+ }
+}
diff --git a/app/src/Lib/Traits/TrafficDetourTrait.php b/app/src/Lib/Traits/TrafficDetourTrait.php
new file mode 100644
index 000000000..fcbd8e847
--- /dev/null
+++ b/app/src/Lib/Traits/TrafficDetourTrait.php
@@ -0,0 +1,67 @@
+ false,
+ 'login' => false,
+ 'postlogin' => false,
+ 'logout' => false
+ ];
+
+ /**
+ * Determine if this plugin supports the specified Traffic Detour context.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param string $context Traffic Detour context
+ * @return bool true if the specified context is supported, false otherwise
+ */
+
+ public function supportsDetourContext(string $context): bool {
+ return $this->supportedDetours[$context];
+ }
+
+ /**
+ * Assert that the plugin supports the specified Traffic Detour context.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param string $context Traffic Detour context
+ */
+
+ public function setSupportedDetourContext(string $context) {
+ if(!isset($this->supportedDetours[$context])) {
+ throw new \InvalidArgumentException(__d('error', 'unknown', [$context]));
+ }
+
+ $this->supportedDetours[$context] = true;
+ }
+}
diff --git a/app/src/Model/Entity/TrafficDetour.php b/app/src/Model/Entity/TrafficDetour.php
new file mode 100644
index 000000000..05d551543
--- /dev/null
+++ b/app/src/Model/Entity/TrafficDetour.php
@@ -0,0 +1,42 @@
+ true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/TrafficDetoursTable.php b/app/src/Model/Table/TrafficDetoursTable.php
new file mode 100644
index 000000000..596680a11
--- /dev/null
+++ b/app/src/Model/Table/TrafficDetoursTable.php
@@ -0,0 +1,164 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Orderable');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ $this->setPluginRelations();
+
+ $this->setDisplayField('description');
+
+ $this->setRequiresCO(false);
+
+ $this->setAutoViewVars([
+ 'plugins' => [
+ 'type' => 'plugin',
+ 'pluginType' => 'traffic'
+ ],
+ 'statuses' => [
+ 'type' => 'enum',
+ 'class' => 'SuspendableStatusEnum'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'configure' => ['platformAdmin'],
+ 'delete' => ['platformAdmin'],
+ 'edit' => ['platformAdmin'],
+ 'view' => ['platformAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => ['platformAdmin'],
+ 'index' => ['platformAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Determine the next Traffic Detour to process, given the last Traffic Detour to run.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param int $lastDetourId ID of last Traffic Detour to run, or 0 if none
+ * @return TrafficDetour The next Traffic Detour to process, or null if none left
+ */
+
+ public function calculateNextDetour(string $context, int $lastDetourId=0): ?TrafficDetour {
+ // Start by pulling the configured set of Traffic Detours
+
+ $detours = $this->find()
+ ->where(['status' => SuspendableStatusEnum::Active])
+ ->order(['TrafficDetours.ordr' => 'ASC'])
+ ->all();
+
+ // Should we return the next Traffic Detour? If we haven't seen any yet then we
+ // want to return the first one.
+ $returnNext = ($lastDetourId == 0);
+
+ if(!empty($detours)) {
+ foreach($detours as $detour) {
+ if($returnNext) {
+ // Only return this detour if it supports the requested context
+ $PluginTable = TableRegistry::getTableLocator()->get($detour->plugin);
+
+ if($PluginTable->supportsDetourContext($context)) {
+ return $detour;
+ }
+ // else keep trying
+ } elseif($detour->id == $lastDetourId) {
+ $returnNext = true;
+ }
+ }
+ }
+
+ // We're done, or nothing to return
+ return null;
+ }
+
+ /**
+ * 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();
+
+ $this->registerStringValidation($validator, $schema, 'description', true);
+
+ $validator->add('status', [
+ 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('status');
+
+ $this->registerStringValidation($validator, $schema, 'plugin', true);
+
+ $validator->add('ordr', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('ordr');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/templates/TrafficDetours/columns.inc b/app/templates/TrafficDetours/columns.inc
new file mode 100644
index 000000000..55003e341
--- /dev/null
+++ b/app/templates/TrafficDetours/columns.inc
@@ -0,0 +1,63 @@
+ [
+ 'type' => 'link',
+ 'sortable' => true
+ ],
+ 'plugin' => [
+ 'type' => 'echo',
+ 'sortable' => true
+ ],
+ 'ordr' => [
+ 'type' => 'echo',
+ 'sortable' => true
+ ],
+ 'status' => [
+ 'type' => 'enum',
+ 'class' => 'SuspendableStatusEnum',
+ 'sortable' => true
+ ]
+];
+
+// $rowActions appear as row-level menu items in the index view gear icon
+$rowActions = [
+ [
+ 'action' => 'configure',
+ 'label' => __d('operation', 'configure.plugin'),
+ 'icon' => 'electrical_services'
+ ]
+];
+
+/*
+// When the $bulkActions variable exists in a columns.inc config, the "Bulk edit" switch will appear in the index.
+$bulkActions = [
+ // TODO: develop bulk actions. For now, use a placeholder.
+ 'delete' => true
+];
+*/
\ No newline at end of file
diff --git a/app/templates/TrafficDetours/fields.inc b/app/templates/TrafficDetours/fields.inc
new file mode 100644
index 000000000..b6c0a72e3
--- /dev/null
+++ b/app/templates/TrafficDetours/fields.inc
@@ -0,0 +1,41 @@
+
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field
+ ]]);
+ }
+}