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 ""; +} \ 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 +?> + + 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 + ]]); + } +}