diff --git a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php index a2ac41f02..1712c158f 100644 --- a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php +++ b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php @@ -182,34 +182,56 @@ protected function mapApiToRegistry(string $model, array $attributes): array { * Remove a record from the External Identity Source. * * @since COmanage Registry v5.0.0 - * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration - * @param string $sorId API System of Record ID - * @return bool True on success + * @param int $id Api Source ID + * @param string $sorLabel System of Record Label + * @param string $sorId API System of Record ID + * @return bool True on success * @throws RecordNotFoundException */ - public function remove( - \App\Model\Entity\ExternalIdentitySource $source, - string $sorId - ): array { + public function remove(int $id, string $sorLabel, string $sorId): bool { // We call this remove() so as not to interfere with the default table::delete(). + $apiSource = $this->get($id, ['contain' => ['ExternalIdentitySources']]); + + // Pull our configuration + $apiSource = $this->get($id, ['contain' => ['ExternalIdentitySources']]); + + // Like upsert(), we don't really need $sorLabel, but we check it for + // consistency with upsert() (which also doesn't really need it). + + if(empty($apiSource->external_identity_source->sor_label) + || $apiSource->external_identity_source->sor_label != $sorLabel) { + throw new \InvalidArgumentException("Requested SOR Label $sorLabel does not match configuration"); + } // Remove the ApiSourceRecord for this $source_key from the cache - $apiSourceRecord = $this->ApiSourceRecords->find() - ->where([ - 'api_source_id' => $source->api_source->id, - 'source_key' => $sorId - ]) - ->firstOrFail(); + try { + // Start a Transaction + $cxn = $this->getConnection(); + $cxn->begin(); + + $apiSourceRecord = $this->ApiSourceRecords->find() + ->where([ + 'api_source_id' => $id, + 'source_key' => $sorId + ]) + ->firstOrFail(); - $this->ApiSourceRecords->delete($apiSourceRecord); + $this->ApiSourceRecords->delete($apiSourceRecord); + + // Run sync + $this->ExternalIdentitySources->sync($apiSource->external_identity_source_id, $sorId); - // Run sync -// XXX do we need some sort of return value to pass back in the API response? - $this->ExternalIdentitySources->sync($source->id, $sorId); + $cxn->commit(); - return true; + return true; + } + catch(\Exception $e) { + $cxn->rollback(); + + throw $e; + } } @@ -397,8 +419,8 @@ public function upsert( // Pull our configuration $apiSource = $this->get($id, ['contain' => ['ExternalIdentitySources']]); - // Strictly speaking we don't need $sorid since we know which configuration - // to use from the ApiSource ID, and $sorlabel might not be unique across COs + // Strictly speaking we don't need $sorLabel since we know which configuration + // to use from the ApiSource ID, and $sorLabel might not be unique across COs // in a multi-tenant environment. Eventually we could support multiple // Systems of Record within the same ApiSource configuration, but for now // we just make sure $sorLabel matches the configuration and throw an error diff --git a/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po b/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po index 400a9aa7b..5569bfd6f 100644 --- a/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po +++ b/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po @@ -37,6 +37,9 @@ msgstr "The file \"{0}\" is not writable" msgid "error.header" msgstr "Did not find CSV file header" +msgid "error.header.sorid" +msgstr "Did not find SORID as first defined column, check file header definition" + msgid "field.FileProvisioners.filename" msgstr "File Name" diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php index 0e26f851a..8029d6961 100644 --- a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php +++ b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php @@ -131,6 +131,42 @@ public function buildRules(RulesChecker $rules): RulesChecker { return $rules; } + /** + * 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 { + $ret = []; + + $handle = fopen($source->file_source->filename, "r"); + + if(!$handle) { + throw new \RuntimeException(__d('file_connector', 'error.filename.readable', [$source->file_source->filename])); + } + + // The first line of a CSV v3 file is our configuration + fgetcsv($handle); + + while(($data = fgetcsv($handle)) !== false) { + // The source key is always the first field in each line + + $ret[] = $data[0]; + } + + fclose($handle); + + // It's not clear we really need to sort the array, but why not... + sort($ret); + + return $ret; + } + /** * Obtain the file field configuration. * @@ -175,6 +211,10 @@ protected function readFieldConfig( switch(count($bits)) { case 1: // SORID (special case) + // While we're here check to make sure the field is as expected + if($bits[0] != 'SORID') { + throw new \RuntimeException(__d('file_connector', 'error.header.sorid')); + } $this->fieldCfg[ $bits[0] ] = $i; break; case 2: diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 28b763bf9..bcbd0fcfe 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -870,12 +870,14 @@ "source_record": { "type": "text" }, "last_update": { "type": "datetime" }, "external_identity_id": {}, - "reference_identifier": {} + "reference_identifier": {}, + "adopted_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } } }, "indexes": { "ext_identity_sources_records_i1": { "columns": [ "external_identity_source_id" ] }, "ext_identity_sources_records_i2": { "columns": [ "external_identity_id" ] }, - "ext_identity_sources_records_i3": { "columns": [ "external_identity_source_id", "source_key" ] } + "ext_identity_sources_records_i3": { "columns": [ "external_identity_source_id", "source_key" ] }, + "ext_identity_sources_records_i4": { "needed": false, "columns": [ "adopted_person_id" ] } } }, diff --git a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po index 461f50a14..f333a118d 100644 --- a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po +++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po @@ -110,7 +110,7 @@ msgid "information.EmailVerifiers.sending" msgstr "Sending" msgid "information.EmailVerifiers.success" -msgstr "New Code Submitted!" +msgstr "New code sent" msgid "information.EmailVerifiers.abort" msgstr "Abort" diff --git a/app/plugins/CoreJob/resources/locales/en_US/core_job.po b/app/plugins/CoreJob/resources/locales/en_US/core_job.po index 4deabe791..0598a407b 100644 --- a/app/plugins/CoreJob/resources/locales/en_US/core_job.po +++ b/app/plugins/CoreJob/resources/locales/en_US/core_job.po @@ -25,6 +25,12 @@ msgid "error.co_id" msgstr "Requested {0} entity {1} is not in CO {2}" +msgid "opt.adopter.external_identity_source_id" +msgstr "External Identity Source ID" + +msgid "opt.adopter.source_keys" +msgstr "Source Keys to process, comma separated (requires external_identity_source_id)" + msgid "opt.assigner.context" msgstr "Identifier Assignment context" @@ -49,6 +55,27 @@ msgstr "If true, force records to process even if no changes have been detected" msgid "opt.sync.source_keys" msgstr "Source Keys to process, comma separated (requires external_identity_source_id)" +msgid "Adopter.error.adopted" +msgstr "Record has already been adopted as Person {0}" + +msgid "Adopter.error.status" +msgstr "EIS must be disabled before adoption process is run" + +msgid "Adopter.error.synced" +msgstr "Record has not been synced (no corresponding External Identity) and so cannot be adopted" + +msgid "Adopter.finish_summary.count" +msgstr "Adopter Finished ({0} records adopted, {1} errors)" + +msgid "Adopter.result.adopted" +msgstr "Adopted External Identity {0} as Person {1}" + +msgid "Adopter.start_summary.eis" +msgstr "Adopting all records from EIS {0}" + +msgid "Adopter.start_summary.keys" +msgstr "Adopting {0} record(s) from EIS {1}" + msgid "Assigner.cancel_summary" msgstr "Job canceled after reviewing {0} entities and assigning {1} Identifier(s)" diff --git a/app/plugins/CoreJob/src/Lib/Jobs/AdopterJob.php b/app/plugins/CoreJob/src/Lib/Jobs/AdopterJob.php new file mode 100644 index 000000000..d4224593d --- /dev/null +++ b/app/plugins/CoreJob/src/Lib/Jobs/AdopterJob.php @@ -0,0 +1,169 @@ + [ + 'help' => __d('core_job', 'opt.adopter.external_identity_source_id'), + 'type' => 'fk', + 'required' => true + ], + 'source_keys' => [ + 'help' => __d('core_job', 'opt.adopter.source_keys'), + 'type' => 'string', + 'required' => false + ] + ]; + } + + /** + * Run the requested Job. + * + * @since COmanage Registry v5.0.0 + * @param JobsTable $JobsTable Jobs table, for updating the Job status + * @param JobHistoryRecordsTable $JobHistoryRecordsTable Job History Records table, for recording additional history + * @param Job $job Job entity + * @param array $parameters Parameters for this Job + */ + + public function run( + \App\Model\Table\JobsTable $JobsTable, + \App\Model\Table\JobHistoryRecordsTable $JobHistoryRecordsTable, + \App\Model\Entity\Job $job, + array $parameters + ) { + $count = 0; // Count of records successfully processed + $errors = 0; // Count of records that had errors + $todo = []; // The set of source keys to process + + // Pull the EIS configuration + $EISTable = TableRegistry::getTableLocator()->get('ExternalIdentitySources'); + $EITable = TableRegistry::getTableLocator()->get('ExternalIdentities'); + + $eis = $EISTable->get($parameters['external_identity_source_id']); + + // The EIS must be disabled to prevent conflicts (and to encourage the admin to + // consider whether it should be enabled after the adoption process runs.) + + if($eis->status != SyncModeEnum::Disabled) { + throw new \InvalidArgumentException(__d('core_job', 'Adopter.error.status')); + } + + if(!empty($parameters['source_keys'])) { + $todo = explode(',', $parameters['source_keys']); + + $JobsTable->start( + job: $job, + summary: __d('core_job', 'Adopter.start_summary.keys', [count($todo), $eis->id]) + ); + } else { + // Note inventory() loads all records into memory, so we might have issues with + // extremely large datasets. + $todo = $EISTable->inventory($eis->id); + + $JobsTable->start( + job: $job, + summary: __d('core_job', 'Adopter.start_summary.eis', [$eis->id]) + ); + } + + $this->llog('trace', "Adopting " . count($todo) . " record(s) from EIS " + . $eis->description . " (job " . $job->id . ")"); + + foreach($todo as $sourceKey) { + try { + // We need to map the $sourceKey to an External Identity, which we do via + // the EIS Record. + + $eisrecord = $EISTable->ExtIdentitySourceRecords->find() + ->where([ + 'external_identity_source_id' => $eis->id, + 'source_key' => $sourceKey + ]) + ->firstOrFail(); + + if(!empty($eisrecord->adopted_person_id)) { + throw new \InvalidArgumentException(__d('core_job', 'Adopter.error.adopted', [$eisrecord->adopted_person_id])); + } + + if(empty($eisrecord->external_identity_id)) { + throw new \InvalidArgumentException(__d('core_job', 'Adopter.error.synced')); + } + + $this->llog('trace', "Mapped source key $sourceKey to External Identity " . $eisrecord->external_identity_id); + + $personId = $EITable->adopt($eisrecord->external_identity_id); + + $this->llog('trace', "Adopted External Identity " . $eisrecord->external_identity_id . " as Person " . $personId); + + $JobHistoryRecordsTable->record( + jobId: $job->id, + recordKey: $sourceKey, + comment: __d('core_job', 'Adopter.result.adopted', [$eisrecord->external_identity_id, $personId]), + status: JobStatusEnum::Complete + ); + + $count++; + } + catch(\Exception $e) { + $this->llog('trace', "$sourceKey could not be adopted: " . $e->getMessage()); + + $JobHistoryRecordsTable->record( + jobId: $job->id, + recordKey: $sourceKey, + comment: $e->getMessage(), + status: JobStatusEnum::Failed + ); + + $errors++; + } + } + + $JobsTable->finish(job: $job, summary: __d('core_job', 'Adopter.finish_summary.count', [$count, $errors])); + } +} \ No newline at end of file diff --git a/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php b/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php index 60bc1f2d3..c762cdcb0 100644 --- a/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php +++ b/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php @@ -106,11 +106,11 @@ protected function fullSync() { return; } - // Now perform the actual sync. Start by pulling the list of known source keys. - // We maintain this in memory as a simple hash since this _should_ fit within the - // memory requirements of our larger expected deployments, and it's significantly - // simpler to perform diff calculations this way. This might need to be refactored - // at some point... + // Now perform the actual sync. Start by pulling the list of known source keys, + // ie those that have already been synced at least once. We maintain this in memory + // as a simple hash since this _should_ fit within the memory requirements of our + // larger expected deployments, and it's significantly simpler to perform diff + // calculations this way. This might need to be refactored at some point... $knownKeys = $this->runContext->EISTable->getKnownSourceKeys($this->runContext->eis->id); @@ -184,7 +184,14 @@ protected function fullSync() { // and processing any records the plugin reported that we didn't know about. if($this->runContext->eis->status == SyncModeEnum::Full) { - $allKeys = $this->runContext->EISTable->inventory($this->runContext->eis->id); + try { + $allKeys = $this->runContext->EISTable->inventory($this->runContext->eis->id); + } + catch(\Exception $e) { + $this->llog('error', $e->getMessage()); + + throw $e; + } if($allKeys === false) { $this->llog('error', "EIS " . $this->runContext->eis->description @@ -194,6 +201,10 @@ protected function fullSync() { $newKeys = array_diff($allKeys, $knownKeys); + $this->llog('trace', "EIS " . $this->runContext->eis->description . " reported " + . count($allKeys) . " available source key(s), " + . count($newKeys) . " new"); + foreach($newKeys as $sourceKey) { $this->llog('trace', "EIS " . $this->runContext->eis->description . " processing new entry $sourceKey"); diff --git a/app/plugins/CoreJob/src/config/plugin.json b/app/plugins/CoreJob/src/config/plugin.json index c23f5b520..307e6a147 100644 --- a/app/plugins/CoreJob/src/config/plugin.json +++ b/app/plugins/CoreJob/src/config/plugin.json @@ -1,6 +1,7 @@ { "types": { "job": [ + "AdopterJob", "AssignerJob", "ProvisionerJob", "SyncJob" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 9d504dd80..6456faffb 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -135,6 +135,9 @@ msgstr "COU Person" msgid "ExternalIdentityStatusEnum.A" msgstr "Active" +msgid "ExternalIdentityStatusEnum.AD" +msgstr "Adopted" + msgid "ExternalIdentityStatusEnum.D" msgstr "Archived" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index 7f0408c5b..4f95c6329 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -133,6 +133,12 @@ msgstr "Enrollment Flow Step {0} is transitioning Actor Types but does not have msgid "EnrollmentFlowSteps.none" msgstr "This Enrollment Flow has no Active steps and so cannot be run" +msgid "ExternalIdentities.relink.frozen" +msgstr "External Identity cannot be relinked due to Frozen Attribute {0} {1} on original Person" + +msgid "ExternalIdentitySources.annul.person_id" +msgstr "There is no adopted Person ID associated with this Source Key, and so it cannot be annuled" + msgid "GroupNestings.active" msgstr "Group {0} is not active and so cannot be nested" @@ -331,6 +337,9 @@ msgstr "An Email Address for the Enrollee is required by this Enrollment Flow" msgid "Petitions.status.finalizing" msgstr "Petition {0} is not in Finalizing status" +msgid "Pipelines.eis.record.adopted" +msgstr "Source Key {0} has been adopted by Person ID {1} and is no longer eligible for syncing" + msgid "Pipelines.plugin.notimpl" msgstr "Pipeline plugin does not implement {0}" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index f24603317..e39ff2fe7 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -465,6 +465,9 @@ msgstr "Suppress No-op Logs" msgid "ExternalIdentitySources.suppress_noop_logs.desc" msgstr "Do not record Job History Records for records that were unchanged or not processed" +msgid "ExtIdentitySourceRecords.adopted_person_id" +msgstr "Adopted Person" + msgid "GroupMembers.source" msgstr "Membership Source" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index 5340a5013..408e7187b 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -60,6 +60,9 @@ msgstr "Enrollment Steps" msgid "ExternalIdentities.source" msgstr "This External Identity was created from {0}." +msgid "ExternalIdentitySources.adopted" +msgstr "This record has been adopted by Person ID {0} and is no longer eligible for syncing." + msgid "ExternalIdentitySources.records" msgstr "Source Records" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 908a83da1..65f4d6b07 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -51,6 +51,9 @@ msgstr "Add Attribute" msgid "add.member" msgstr "Add member: " +msgid "adopt" +msgstr "Adopt" + msgid "apply" msgstr "Apply" @@ -111,6 +114,12 @@ msgstr "Confirm" msgid "confirm.generic" msgstr "Are you sure you want to confirm this action?" +msgid "confirm.no" +msgstr "No" + +msgid "confirm.yes" +msgstr "Yes" + msgid "configure.a" msgstr "Configure {0}" @@ -168,9 +177,18 @@ msgstr "Edit Role {0}" msgid "EmailAddresses.verify.force" msgstr "Force Verify" +msgid "ExternalIdentities.adopt.confirm" +msgstr "Are you sure you want to adopt record {0} and disconnect it from {1}?" + msgid "ExternalIdentitySourceRecords.retrieve" msgstr "Retrieve from External Identity Source" +msgid "ExternalIdentitySources.annul" +msgstr "Annul Adoption" + +msgid "ExternalIdentitySources.annul.confirm" +msgstr "Are you sure you want to annul the adoption of record {0}?" + msgid "ExternalIdentitySources.search" msgstr "Search Source" @@ -198,6 +216,9 @@ msgstr "Assign Identifiers" msgid "identifiers.assign.confirm" msgstr "Are you sure you want to assign identifiers to this record ({0})?" +msgid "Jobs.cancel.confirm" +msgstr "Are you sure you want to cancel Job {0}?" + msgid "last" msgstr "Last" @@ -261,6 +282,12 @@ msgstr "Reconcile" msgid "reconcile.confirm" msgstr "Are you sure you want to reconcile this group ({0})?" +msgid "relink" +msgstr "Relink" + +msgid "relink.a" +msgstr "Relink {0}" + msgid "remove" msgstr "Remove" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index c02348d32..783986b96 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -72,6 +72,15 @@ msgstr "Email Address {0} force verified" msgid "EmailAddresses.verify.trust" msgstr "Email Address {0} verified by trusted source {1}" +msgid "ExternalIdentities.adopted" +msgstr "External Identity {0} Adopted" + +msgid "ExternalIdentities.relinked" +msgstr "External Identity {0} Relinked to Person {1}" + +msgid "ExternalIdentities.relinked.from" +msgstr "External Identity Relinked from Person {0}" + msgid "ExternalIdentities.status.recalculated" msgstr "External Identity status recalculated from {0} to {1}" @@ -175,6 +184,9 @@ msgstr "Created new Person via Pipeline {0} ({1}) using Source {2} ({3}) Key {4} msgid "People.status.recalculated" msgstr "Person status recalculated from {0} to {1}" +msgid "PersonRoles.relinked.from" +msgstr "Person Role moved from Person {0}" + msgid "PersonRoles.status.recalculated" msgstr "Person Role status recalculated from {0} to {1}" diff --git a/app/src/Controller/ExternalIdentitiesController.php b/app/src/Controller/ExternalIdentitiesController.php index e1ab7db68..3fb9427ab 100644 --- a/app/src/Controller/ExternalIdentitiesController.php +++ b/app/src/Controller/ExternalIdentitiesController.php @@ -31,6 +31,7 @@ use Cake\Event\EventInterface; use Cake\Http\Response; +use Cake\ORM\TableRegistry; // Use extend MVEAController for breadcrumb rendering. ExternalIdentities is // sort of an MVEA, so maybe it makes sense to treat it as such. @@ -45,6 +46,34 @@ class ExternalIdentitiesController extends MVEAController { ] ]; + /** + * Adopt an External Identity. + * + * @since COmanage Registry v5.1.0 + * @param string $id External Identity ID + */ + + public function adopt(string $id) { + try { + $personId = $this->ExternalIdentities->adopt((int)$id); + + $this->Flash->success(__d('result', 'ExternalIdentities.adopted', [$id])); + + // Redirect to the Person that adopted this External Identity + + return $this->redirect([ + 'controller' => 'people', + 'action' => 'edit', + $personId + ]); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + + return $this->generateRedirect($this->ExternalIdentities->get((int)$id)); + } + } + /** * Callback run prior to the request render. * @@ -67,4 +96,44 @@ public function beforeRender(EventInterface $event) { return parent::beforeRender($event); } -} \ No newline at end of file + + /** + * Relink an External Identity. + * + * @since COmanage Registry v5.1.0 + * @param string $id External Identity ID + */ + + public function relink(string $id) { + if($this->request->is('post')) { + $reqData = $this->getRequest()->getData(); + + if(!empty($reqData['target_person_id'])) { + try { + $Pipelines = TableRegistry::getTableLocator()->get('Pipelines'); + + $Pipelines->relink((int)$id, (int)$reqData['target_person_id']); + + $this->Flash->success(__d('result', 'ExternalIdentities.relinked', [$id, $reqData['target_person_id']])); + + // Redirect to the External Identity + return $this->redirect([ + 'controller' => 'external-identities', + 'action' => 'view', + $id + ]); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } else { + $this->Flash->error(__d('error', 'notprov', ['target_person_id'])); + } + } + + // Fall through to the view to render a People Picker + + $this->set('vv_title', __d('operation', 'relink.a', [__d('controller', 'ExternalIdentities', [1])])); + + $this->set('vv_external_identity', $this->ExternalIdentities->get((int)$id, ['contain' => 'Names'])); + }} \ No newline at end of file diff --git a/app/src/Controller/ExternalIdentitySourcesController.php b/app/src/Controller/ExternalIdentitySourcesController.php index 3ba47ae14..73ecd8604 100644 --- a/app/src/Controller/ExternalIdentitySourcesController.php +++ b/app/src/Controller/ExternalIdentitySourcesController.php @@ -42,6 +42,28 @@ class ExternalIdentitySourcesController extends StandardPluggableController { ] ]; + /** + * Annul an External Identity adoption. + * + * @since COmanage Registry v5.1.0 + * @param string $id External Identity Source ID + */ + + public function annul(string $id) { + try { + $source_key = $this->request->getQuery('source_key'); + + $this->ExternalIdentitySources->annul((int)$id, $source_key); + + $this->Flash->success(__d('result', 'ExternalIdentitySources.synced')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect(null); + } + /** * Callback run prior to the request action. * diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php index 65c38e173..9b5012df4 100644 --- a/app/src/Lib/Enum/ActionEnum.php +++ b/app/src/Lib/Enum/ActionEnum.php @@ -36,7 +36,9 @@ class ActionEnum extends StandardEnum { const EmailForceVerified = 'EMFV'; const EmailVerified = 'EMLV'; const EmailVerifyCodeSent = 'EMLS'; + const ExternalIdentityAdopted = 'EOIA'; const ExternalIdentityLoginUpdate = 'EOIE'; + const ExternalIdentityRelinked = 'LEOI'; const GroupAdded = 'ACGR'; const GroupDeleted = 'DCGR'; const GroupEdited = 'ECGR'; @@ -59,5 +61,6 @@ class ActionEnum extends StandardEnum { const PersonMatchedPipeline = 'MCPL'; const PersonPipelineComplete = 'CCPL'; const PersonPipelineStarted = 'SCPL'; + const PersonRoleRelinked = 'LCPR'; const PersonStatusRecalculated = 'RCPS'; } \ No newline at end of file diff --git a/app/src/Lib/Enum/ExternalIdentityStatusEnum.php b/app/src/Lib/Enum/ExternalIdentityStatusEnum.php index 9052667d2..f380b6807 100644 --- a/app/src/Lib/Enum/ExternalIdentityStatusEnum.php +++ b/app/src/Lib/Enum/ExternalIdentityStatusEnum.php @@ -31,6 +31,7 @@ class ExternalIdentityStatusEnum extends StandardEnum { const Active = 'A'; + const Adopted = 'AD'; const Archived = 'D'; const Deleted = 'X'; const Duplicate = 'D2'; @@ -60,6 +61,8 @@ public static function rank(string $status): int { // Finally, we generally don't want Deleted or Duplicate unless all roles are deleted or duplicates self::Archived => 2, + // Adopted and Archived are functionally the same thing + self::Adopted => 2, // "Deleted" is managed by Registry, not the EIS backend, but we'll basically treat // it the same as Archived self::Deleted => 2, diff --git a/app/src/Lib/Events/RuleBuilderEventListener.php b/app/src/Lib/Events/RuleBuilderEventListener.php index 15adc442f..a600c7e43 100644 --- a/app/src/Lib/Events/RuleBuilderEventListener.php +++ b/app/src/Lib/Events/RuleBuilderEventListener.php @@ -177,7 +177,7 @@ public function ruleFreezePrimaryLink(EntityInterface $entity, array $options) { if($want != $have && ($want === NULL || $have === NULL)) { // GMR-3 - $this->llog('error', "GMR-3 The Primary Link key cannot be changed once set, changing " . $table->getAlias() . " record " . $entity->id . " " . $options['errorField'] . " from " . $have . " to " . $want . " is not allowed"); + $this->llog('error', "GMR-3 The Primary Link key cannot be changed once set, changing " . $table->getAlias() . " record " . $entity->id . " " . $options['errorField'] . " from " . ($have ?? "null") . " to " . ($want ?? "null") . " is not allowed"); return __d('error', 'primary_link.frozen'); } @@ -212,7 +212,7 @@ public function ruleFreezePrimaryLink(EntityInterface $entity, array $options) { public function ruleValidateCO(EntityInterface $entity, array $options) { // GMR-2 Foreign keys from one entity to another cannot cross COs. // The logic here requires an "anchor" that cannot change, which is the - // primary link, which is enforce by ruleFreezePrimaryLink (which verifies + // primary link, which is enforced by ruleFreezePrimaryLink (which verifies // that the primary object cannot be altered). // The field to check is (confusingly) $options['errorField']. diff --git a/app/src/Model/Entity/ExtIdentitySourceRecord.php b/app/src/Model/Entity/ExtIdentitySourceRecord.php index 85dfc7783..46edcb702 100644 --- a/app/src/Model/Entity/ExtIdentitySourceRecord.php +++ b/app/src/Model/Entity/ExtIdentitySourceRecord.php @@ -34,6 +34,7 @@ // This should be ExternalIdentitySourceRecord but then alias.field assembly // exceeds Cake's 61 character limit class ExtIdentitySourceRecord extends Entity { + use \App\Lib\Traits\EntityMetaTrait; use \App\Lib\Traits\ReadOnlyEntityTrait; protected $_accessible = [ diff --git a/app/src/Model/Table/AdHocAttributesTable.php b/app/src/Model/Table/AdHocAttributesTable.php index f6b2adfab..c7ad2c1e1 100644 --- a/app/src/Model/Table/AdHocAttributesTable.php +++ b/app/src/Model/Table/AdHocAttributesTable.php @@ -70,7 +70,11 @@ public function initialize(array $config): void { ->setClassName('AdHocAttributes') ->setForeignKey('source_ad_hoc_attribute_id') ->setProperty('source_ad_hoc_attribute'); - + $this->hasMany('PipelinedAdHocAttributes') + ->setClassName('AdHocAttributes') + ->setForeignKey('source_ad_hoc_attribute_id') + ->setProperty('pipelined_ad_hoc_attribute'); + $this->setDisplayField('tag'); $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index 674ed42dc..72cde2371 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -88,7 +88,11 @@ public function initialize(array $config): void { ->setClassName('Addresses') ->setForeignKey('source_address_id') ->setProperty('source_address'); - + $this->hasMany('PipelinedAddresses') + ->setClassName('Addresses') + ->setForeignKey('source_address_id') + ->setProperty('pipelined_address'); + $this->setDisplayField('street'); $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index 7db4fdeaa..05a572f67 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -93,7 +93,10 @@ public function initialize(array $config): void { ->setClassName('EmailAddresses') ->setForeignKey('source_email_address_id') ->setProperty('source_email_address'); - + $this->hasMany('PipelinedEmailAddresses') + ->setClassName('EmailAddresses') + ->setForeignKey('source_email_address_id') + ->setProperty('pipelined_email_address'); $this->hasOne('Verifications') ->setDependent(true) ->setCascadeCallbacks(true); diff --git a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php index e81ad0aa9..b9a8fda8a 100644 --- a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php +++ b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php @@ -70,7 +70,10 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('ExternalIdentities'); $this->belongsTo('ExternalIdentitySources'); - + $this->belongsTo('AdoptedPerson') + ->setClassName('People') + ->setForeignKey('adopted_person_id') + ->setProperty('adopted_person'); $this->setDisplayField('source_key'); $this->setPrimaryLink(['external_identity_source_id', 'external_identity_id']); @@ -85,8 +88,9 @@ public function initialize(array $config): void { ]); $this->setViewContains([ + 'AdoptedPerson' => ['PrimaryName'], 'ExternalIdentitySources', - 'ExternalIdentities' => ['Names', 'People' => ['PrimaryName']], + 'ExternalIdentities' => ['Names', 'People' => ['PrimaryName']] ]); /* // XXX This doesn't seem to correlate to what actually renders? @@ -166,10 +170,13 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('external_identity_source_id'); + // Note that adopting an EIS Record briefly creates a second EIS Record for the same + // source key, so we shouldn't try to enforce uniqueness of the source key here. + // (See ExternalIdentitiesTable::adopt.) $this->registerStringValidation($validator, $schema, 'source_key', true); // Since source_record comes from upstream, it's not clear that we should -// enforce any validation on it +// enforce any validation on it. // $this->registerStringValidation($validator, $schema, 'source_record', false); $validator->add('last_updane', [ @@ -184,6 +191,11 @@ public function validationDefault(Validator $validator): Validator { $this->registerStringValidation($validator, $schema, 'reference_identifier', false); + $validator->add('adopted_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('adopted_person_id'); + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/ExternalIdentitiesTable.php b/app/src/Model/Table/ExternalIdentitiesTable.php index 3cd797d23..c3f91fad0 100644 --- a/app/src/Model/Table/ExternalIdentitiesTable.php +++ b/app/src/Model/Table/ExternalIdentitiesTable.php @@ -32,6 +32,7 @@ use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; +use Cake\Utility\Inflector; use Cake\Validation\Validator; use \App\Lib\Enum\ActionEnum; use \App\Lib\Enum\ExternalIdentityStatusEnum; @@ -41,6 +42,7 @@ class ExternalIdentitiesTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\QueryModificationTrait; @@ -113,6 +115,7 @@ public function initialize(array $config): void { $this->setPrimaryLink('person_id'); $this->setRequiresCO(true); $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['adopt', 'relink']); $this->setEditContains([ 'Addresses', @@ -187,8 +190,12 @@ public function initialize(array $config): void { // See also CFM-126 // XXX need to add couAdmin, eventually 'entity' => [ + // Note the inverse operation for adoption, annulment, is handled by + // External Identity Sources since there is no longer an External Identity + 'adopt' => ['platformAdmin', 'coAdmin'], 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'relink' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin', 'selfMember'] ], // Actions that operate over a table (ie: do not require an $id) @@ -217,6 +224,210 @@ public function initialize(array $config): void { ] ]); } + + /** + * Adopt an External Identity. + * + * @since COmanage Registry v5.1.0 + * @param int $id External Identity ID + * @return int Person ID + */ + + public function adopt(int $id): int { + // Adoption is the process of converting a record that came from an External Identity + // Source to a native Registry record. This process involves several steps. While we could + // probably rely on Cake's transaction, we explicitly create one here to be clearer. + + $cxn = $this->getConnection(); + $cxn->begin(); + + try { + // Start by pulling the External Identity and related models + + $related = [ + // For the External Identity itself, we pull the directly related MVEAs + // as an easy way to walk to the Pipelined attributes, which are what we + // actually want to update + 'Addresses' => ['PipelinedAddresses'], + 'AdHocAttributes' => ['PipelinedAdHocAttributes'], + 'EmailAddresses' => ['PipelinedEmailAddresses'], + 'Identifiers' => ['PipelinedIdentifiers'], + 'Names' => ['PipelinedNames'], + 'Pronouns' => ['PipelinedPronouns'], + 'TelephoneNumbers' => ['PipelinedTelephoneNumbers'], + 'Urls' => ['PipelinedUrls'], + // For the attached External Identity Roles, we want to pull the associated + // Person Roles, but we still pull the EIR MVEAs and walk to their Pipelined + // attributes (rather than query the Person Role attributes directly) because + // an admin might have added additional MVEAs to the Person Role, and we need + // to distinguish the ones that came from this External Identity. + 'ExternalIdentityRoles' => [ + 'Addresses' => ['PipelinedAddresses'], + 'AdHocAttributes' => ['PipelinedAdHocAttributes'], + 'TelephoneNumbers' => ['PipelinedTelephoneNumbers'], + 'PersonRoles' // These are the Pipelined Roles + ] + ]; + + $externalIdentity = $this->get($id, ['contain' => $related]); + + // For each of the top level related models, walk to the Pipelined record on the Person + // and unset the source_ key. + + foreach(array_keys($related) as $modelName) { + if($modelName == 'ExternalIdentityRoles') continue; // We'll handle these separately + + // The name in related entity format, eg email_addresses + $entities = Inflector::underscore($modelName); + // The pipelined name in related entity format, eg pipelined_email_address + $pentity = "pipelined_" . Inflector::singularize($entities); + // The singular model name, eg EmailAddress + $sModelName = Inflector::singularize($modelName); + + $soridTypeId = null; + + if($modelName == 'Identifiers') { + // AR-ExternalIdentity-2 When an External Identity is adopted, the Source Key + // Identifier is deleted from the adopting Person. + + // As a special case, we _delete_ rather than unlink the Source Key on the + // Person Record, since it doesn't make sense to keep that anymore. To do + // this, we need the type id. + + $soridTypeId = $this->Identifiers->Types->getTypeId( + coId: $this->calculateCoForRecord($externalIdentity), + attribute: 'Identifiers.type', + value: 'sorid' + ); + } + + if(!empty($externalIdentity->$entities)) { + // $e is the MVEA entity attached to the External Identity + foreach($externalIdentity->$entities as $e) { + if(!empty($e->$pentity)) { + // The name of the source field, eg source_email_address_id + $sourceField = $e->sourceAttributeName(); + + // $p is the Pipelined copy of $e + foreach($e->$pentity as $p) { + if($soridTypeId && $p->type_id == $soridTypeId) { + // Special case for Source Key / sorid + + $this->llog('trace', "Deleting Source Key Identifier " . $p->id . " from Person " . $externalIdentity->person_id); + + $this->Identifiers->delete($p); + } elseif($e->id == $p->$sourceField) { + $this->llog('trace', "Unlinking Person " . $externalIdentity->person_id . " $sModelName " . $p->id . " from External Identity " . $externalIdentity->id . " $sModelName " . $e->id); + + $p->$sourceField = null; + $this->$modelName->saveOrFail($p); + } + } + } + } + } + } + + // For each EIR, do the same, including for the Person Role itself. + if(!empty($externalIdentity->external_identity_roles)) { + foreach($externalIdentity->external_identity_roles as $eirole) { + // The Person Role created from this EI Role + $prole = $eirole->pipelined_person_role; + + foreach(array_keys($related['ExternalIdentityRoles']) as $modelName) { + if(is_int($modelName)) continue; // This is PersonRoles, which we'll handle these separately + + // The name in related entity format, eg email_addresses + $entities = Inflector::underscore($modelName); + // The pipelined name in related entity format, eg pipelined_email_address + $pentity = "pipelined_" . Inflector::singularize($entities); + // The singular model name, eg EmailAddress + $sModelName = Inflector::singularize($modelName); + + if(!empty($eirole->$entities)) { + // $e is the MVEA entity attached to the External Identity Role + foreach($eirole->$entities as $e) { + if(!empty($e->$pentity)) { + // The name of the source field, eg source_email_address_id + $sourceField = $e->sourceAttributeName(); + + // $p is the Pipelined copy of $e, and is attached to the Person Role ($prole), + // though we retrieved it via the EI Role + foreach($e->$pentity as $p) { + if($e->id == $p->$sourceField) { + $this->llog('trace', "Unlinking Person Role " . $prole->id . " $sModelName " . $p->id . " from External Identity Role " . $eirole->id . " $sModelName " . $e->id); + + $p->$sourceField = null; + $this->$modelName->saveOrFail($p); + } + } + } + } + } + } + + $this->llog('trace', "Unlinking Person Role " . $prole->id . " from External Identity Role " . $eirole->id); + + $prole->source_external_identity_role_id = null; + $this->ExternalIdentityRoles->PersonRoles->saveOrFail($prole); + } + } + + // Store the adopted Person ID in the External Identity Source Record so the record + // can't be re-synced (AR-ExternalIdentity-1). We also need to unlink the External + // Identity so we don't delete the EIS Record via cascade, below. + + $eisrecord = $this->ExtIdentitySourceRecords->find() + ->where(['external_identity_id' => $externalIdentity->id]) + ->firstOrFail(); + + // AR-GMR-3 prevents us from changing a primary link key, so we can't just do this: + // $eisrecord->adopted_person_id = $externalIdentity->person_id; + // $eisrecord->external_identity_id = null; + // So instead we delete the current record and create a new one. + // (The deletion is actually handled by the cascade from External Identity, below.) + + // Because we are creating a new record the "create" time will likely be _after_ the + // "last update" time, which is a little unintuitive, however it is technically correct + // so we don't try to override the create time of the new EIS Record. (The archived + // values will still be available in the database for context.) + + $neweisdata = $this->filterMetadataForCopy( + table: $this->ExtIdentitySourceRecords->getTarget(), + entity: $eisrecord + ); + + $neweisdata['external_identity_id'] = null; + $neweisdata['adopted_person_id'] = $externalIdentity->person_id; + // filterMetadataForCopy will pick the "wrong" primary key, so we need to repopulate it + $neweisdata['external_identity_source_id'] = $eisrecord->external_identity_source_id; + + $neweisentity = $this->ExtIdentitySourceRecords->newEntity($neweisdata); + + $this->llog('trace', "Replacing ExtIdentitySourceRecord " . $eisrecord->id . " for source key " . $eisrecord->source_key . " to be adopted by Person " . $externalIdentity->person_id); + $this->ExtIdentitySourceRecords->saveOrFail($neweisentity); + + // Create a History Record + $this->recordHistory( + entity: $externalIdentity, + action: ActionEnum::ExternalIdentityAdopted, + comment: __d('result', 'ExternalIdentities.adopted', [$externalIdentity->id]) + ); + + // Finally, delete the External Identity and its related models + $this->llog('trace', "Deleting External Identity " . $externalIdentity->id); + $this->delete($externalIdentity); + + $cxn->commit(); + + return $externalIdentity->person_id; + } + catch(\Exception $e) { + $cxn->rollback(); + + throw $e; + } + } /** * Callback before model delete. diff --git a/app/src/Model/Table/ExternalIdentityRolesTable.php b/app/src/Model/Table/ExternalIdentityRolesTable.php index eb6a2c899..88c012386 100644 --- a/app/src/Model/Table/ExternalIdentityRolesTable.php +++ b/app/src/Model/Table/ExternalIdentityRolesTable.php @@ -80,16 +80,16 @@ public function initialize(array $config): void { $this->hasMany('AdHocAttributes') ->setDependent(true) ->setCascadeCallbacks(true); - $this->hasMany('PersonRoles') - ->setForeignKey('source_external_identity_role_id') - ->setProperty('source_external_identity_role'); - // We don't want these to cascade deletes, see beforeDelete() $this->hasMany('TelephoneNumbers') ->setDependent(true) ->setCascadeCallbacks(true); $this->hasMany('HistoryRecords') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasOne('PersonRoles') + ->setForeignKey('source_external_identity_role_id') + ->setProperty('pipelined_person_role'); + // We don't want these to cascade deletes, see beforeDelete() $this->setDisplayField('title'); diff --git a/app/src/Model/Table/ExternalIdentitySourcesTable.php b/app/src/Model/Table/ExternalIdentitySourcesTable.php index 2bc1eb7f8..8eef5d03b 100644 --- a/app/src/Model/Table/ExternalIdentitySourcesTable.php +++ b/app/src/Model/Table/ExternalIdentitySourcesTable.php @@ -84,7 +84,7 @@ public function initialize(array $config): void { $this->setRequiresCO(true); // We need to calculate the redirect URL for sync ourselves (in the controller) $this->setRedirectGoal('special', 'sync'); - $this->setAllowLookupPrimaryLink(['retrieve', 'search', 'sync']); + $this->setAllowLookupPrimaryLink(['annul', 'retrieve', 'search', 'sync']); $this->setAutoViewVars([ 'plugins' => [ @@ -121,6 +121,7 @@ public function initialize(array $config): void { $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ + 'annul' => ['platformAdmin', 'coAdmin'], 'configure' => ['platformAdmin', 'coAdmin'], 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], @@ -143,6 +144,51 @@ public function initialize(array $config): void { ]); } + /** + * Annul the adoption of an External Identity. + * + * @since COmanage Registry v5.1.0 + * @param int $id External Identity Source ID + * @param string $sourceKey Source Key + * @return string Record status, as per sync() + */ + + public function annul(int $id, string $sourceKey) { + // Annulment is not exactly a reversal of an adoption, since we can't 100% be sure + // of which attributes on the Person record we should "relink" to the External Identity, + // so we effectively create a duplicate set of attributes and rely on the administrator + // to clean up the record. (In theory we could reconstruct which attributes were originally + // created from the EIS by examining changelog metadata, but if the attributes were + // subsequently modified it gets messy...) + + // We basically just need to delete the existing EIS Record and let the Pipeline + // create a new one, but first we copy the Peron ID to pass to the Pipeline. + + $eisrecord = $this->ExtIdentitySourceRecords + ->find() + ->where([ + 'external_identity_source_id' => $id, + 'source_key' => $sourceKey + ]) + ->firstOrFail(); + + if(empty($eisrecord->adopted_person_id)) { + throw new \InvalidArgumentException(__d('error', 'ExternalIdentitySources.annul.person_id')); + } + + $targetPersonId = $eisrecord->adopted_person_id; + + $this->llog('trace', "Deleting ExtIdentitySourceRecord " . $eisrecord->id . " for source key " . $eisrecord->source_key . " in preparation for annulment and resync to Person " . $targetPersonId); + + $this->ExtIdentitySourceRecords->delete($eisrecord); + + return $this->sync( + id: $id, + sourceKey: $sourceKey, + personId: $targetPersonId + ); + } + /** * Obtain the changelist from the backend, if supported. * diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index ff3db63ab..052e29705 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -102,6 +102,10 @@ public function initialize(array $config): void { ->setClassName('Identifiers') ->setForeignKey('source_identifier_id') ->setProperty('source_identifier'); + $this->hasMany('PipelinedIdentifiers') + ->setClassName('Identifiers') + ->setForeignKey('source_identifier_id') + ->setProperty('pipelined_identifier'); $this->setDisplayField('identifier'); @@ -262,12 +266,15 @@ public function lookupPersonByLogin(int $coId, string $identifier): int { */ public function ruleUniqueIdentifier($entity, $options) { - // Uniqueness constraints only apply to People and Groups + // Uniqueness constraints only apply to People and Groups, and only those that + // are not synced from an External Identity. (ie: we permit duplicates from + // External Identity Sources.) // In v4 we created a txn to ensure consistency, but it looks like Cake actually // starts a transaction, so it appears we don't need to do that here. - if(!empty($entity->person_id) || !empty($entity->group_id)) { + if((!empty($entity->person_id) || !empty($entity->group_id)) + && empty($entity->source_identifier_id)) { if($entity->isNew() || $entity->isDirty('identifier') || $entity->isDirty('type_id')) { diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index 85e797e41..5f2b22acb 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -91,6 +91,10 @@ public function initialize(array $config): void { ->setClassName('Names') ->setForeignKey('source_name_id') ->setProperty('source_name'); + $this->hasMany('PipelinedNames') + ->setClassName('Names') + ->setForeignKey('source_name_id') + ->setProperty('pipelined_name'); $this->setDisplayField('full_name'); diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index d89307555..cce7f4456 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -100,6 +100,11 @@ public function initialize(array $config): void { $this->hasMany('ExternalIdentities') ->setDependent(true) ->setCascadeCallbacks(true); + // For "adopted" External Identities + $this->hasMany('ExtIdentitySourceRecords') + ->setForeignKey('adopted_person_id') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('GroupMembers') ->setDependent(true) ->setCascadeCallbacks(true); diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php index f6fd1ffef..66eb1b112 100644 --- a/app/src/Model/Table/PipelinesTable.php +++ b/app/src/Model/Table/PipelinesTable.php @@ -641,7 +641,7 @@ public function execute( $person = $this->syncPerson( $pipeline, $eis, - isset($externalIdentity->id) ? $externalIdentity->id : null, + $externalIdentity->id ?? null, $person ); @@ -744,6 +744,16 @@ protected function manageEISRecord( ->first(); if($eisRecord) { + // AR-ExternalIdentity-1 An External Identity that has been adopted cannot be + // resynced from the External Identity Source, unless the adoption is annulled. + + if(!empty($eisRecord->adopted_person_id)) { + // If there is an adopted_person_id set abort, as no further syncing is permitted + + $this->llog('rule', "AR-ExternalIdentity-1 Rejecting request to update adopted record for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + throw new \InvalidArgumentException(__d('error', 'Pipelines.eis.record.adopted', [$sourceKey, $eisRecord->adopted_person_id])); + } + // Update the record as needed, but only if the source record changed. // We consider any aspect of the source record changing to mark the // EIS record as changed, even if it's not material to the attributes @@ -1091,6 +1101,145 @@ protected function obtainPerson( ]; } + /** + * Relink an External Identity to a new Person. + * + * @since COmanage Registry v5.1.0 + * @param int $externalIdentityId External Identity ID + * @param int $targetPersonId Person ID to move External Identity to + * @throws Exception + */ + + public function relink( + int $externalIdentityId, + int $targetPersonId + ) { + $cxn = $this->getConnection(); + $cxn->begin(); + + // We need the Source Key, EIS ID, and Pipeline ID. The easiest way to get everything + // is via the EIS Record. + + try { + // We use FOR UPDATE to create read locks on the data we're processing to prevent + // a concurrent sync job (or other manual process) from causing problems. + // Unfortunately, the way epilog() works is incompatible with the OUTER JOIN + // syntax that Cake uses to construct the contain() clauses, so we need to retrieve + // the associated models separately. + + // Note that we are intentionally just reading the cached record. If an admin wants + // to perform a sync as well, they can do that separately. + + $eisRecord = $this->ExternalIdentitySources + ->ExtIdentitySourceRecords + ->find() + ->where(['ExtIdentitySourceRecords.external_identity_id' => $externalIdentityId]) + /*->contain(['ExternalIdentitySources'])*/ + ->epilog('FOR UPDATE') + ->firstOrFail(); + + $eis = $this->ExternalIdentitySources->get( + $eisRecord->external_identity_source_id, + ['contain' => 'Pipelines'] + ); + + $ei = $this->Cos->People->ExternalIdentities->get( + $eisRecord->external_identity_id, + ['contain' => [ + 'ExternalIdentityRoles' => 'PersonRoles', + 'People' + ]] + ); + + // To start the relinking, tell syncPerson to update the source Person with a + // null External Identity ID, which normally means the External Identity was deleted. + + $origPerson = $this->syncPerson( + $eis->pipeline, + $eis, + null, + $ei->person, + $ei->id + ); + + // Next reassign the External Identity to the new target Person. + + // GMR-1 will prevent $targetPersonId from being in a different CO than the one that + // $externalIdentityId is currently linked to, so we don't need to explicitly check + // that here. + + $origPersonId = $ei->person_id; + $ei->person_id = $targetPersonId; + + // Make sure not to save the Person we retrieved in the original query + $this->Cos->People->ExternalIdentities->saveOrFail($ei, ['associated' => false]); + + $this->Cos->People->ExternalIdentities->recordHistory( + entity: $ei, + action: ActionEnum::ExternalIdentityRelinked, + comment: __d('result', 'ExternalIdentities.relinked.from', [$origPersonId]) + ); + + // Also add history to the original Person + + $this->Cos->People->recordHistory( + entity: $ei->person, + action: ActionEnum::ExternalIdentityRelinked, + comment: __d('result', 'ExternalIdentities.relinked', [$ei->id, $targetPersonId]) + ); + + // Next we have to manually reassign the Person Roles. Normally deleting an + // External Identity would cascade to the EI Roles, which would update the + // associated Person Role status via beforeDelete. However, we're not actually + // deleting the EI, we're changing the foreign key. The EI Roles will move + // automatically since their parent key is the EI, not the Person, but the + // associated Person Roles need to be dealt with. + + // Note we are specifically _moving_ the Person Roles, we are not creating new ones + // on the target Person. The idea here is that a relinking operation is a correction + // of a bad action, and so it doesn't make sense to leave "expired" (or whatever) + // Person Roles behind on the original Person record. + + // AR-ExternalIdentity-3 When an External Identity is relinked, any associated + // Person Roles will be moved from the original Person to the target Person. + + foreach($ei->external_identity_roles as $eir) { + if(!empty($eir->pipelined_person_role)) { + $this->llog('rule', "AR-ExternalIdentity-3 Moving Person Role " + . $eir->pipelined_person_role->id + . " to Person " . $targetPersonId); + + $eir->pipelined_person_role->person_id = $targetPersonId; + + $this->Cos->People->PersonRoles->saveOrFail($eir->pipelined_person_role, ['associated' => false]); + + $this->Cos->People->PersonRoles->recordHistory( + entity: $eir->pipelined_person_role, + action: ActionEnum::PersonRoleRelinked, + comment: __d('result', 'PersonRoles.relinked.from', [$origPersonId]) + ); + } + } + + // Run the Pipeline again, this time we the new target Person + + $targetPerson = $this->Cos->People->get($targetPersonId); + + $targetPerson = $this->syncPerson( + $eis->pipeline, + $eis, + $externalIdentityId, + $targetPerson + ); + + $cxn->commit(); + } + catch(\Exception $e) { + $cxn->rollback(); + throw $e; + } + } + /** * Search for an existing Person using an attribute provided in the EIS Record. * @@ -1556,23 +1705,27 @@ protected function syncExternalIdentity( * Sync an External Identity to a Person. * * @since COmanage Registry v5.0.0 - * @param Pipeline $pipeline Pipeline - * @param ExternalIdentitySource $eis External Identity Source - * @param int $externalIdentityId External Identity ID - * @param Person $person Person - * @return Person Person + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentitySource $eis External Identity Source + * @param int $externalIdentityId External Identity ID + * @param Person $person Person + * @param int $unlinkedExternalIdentityId If running after unlinking an EI, the former EI ID + * @return Person Person */ protected function syncPerson( Pipeline $pipeline, ExternalIdentitySource $eis, ?int $externalIdentityId, - Person $person + Person $person, + ?int $unlinkedExternalIdentityId=null ): Person { // We re-pull the External Identity to account for any changes that might have // been processed by syncExternalIdentity. Note if the ID is null, the External // Identity was deleted. + // Note any models added here also need to be added to ExternalIdentitiesTable::adopt(). + if($externalIdentityId) { $externalIdentity = $this->Cos->People->ExternalIdentities->get( $externalIdentityId, @@ -1792,6 +1945,13 @@ protected function syncPerson( // to another EI associated with the Person); we search through the // source attributes for one with a corresponding source key ID. $found = Hash::extract($externalIdentity[$amodel], '{n}[id='.$aentity->$sourcefk.']'); + } elseif($unlinkedExternalIdentityId + && ($aentity->$sourceEntity->external_identity_id == $unlinkedExternalIdentityId)) { + // This attribute was sourced from an External Identity that we are unlinking + // (presumably in the process of moving it to another Person). Treat this + // attribute as _not_ found. + + $found = false; } else { // This doesn't belong to our current External Identity, so flag it as // "found" so we don't delete it @@ -1804,6 +1964,18 @@ protected function syncPerson( if(!$found) { if(isset($aentity->frozen) && $aentity->frozen) { + if($unlinkedExternalIdentityId + && ($aentity->$sourceEntity->external_identity_id == $unlinkedExternalIdentityId)) { + // AR-ExternalIdentity-4 An External Identity cannot be relinked if any of the + // attributes Pipelined to the Person record are frozen. + $this->llog('rule', "AR-ExternalIdentity-4 An External Identity cannot be relinked if any of the attributes Pipelined to the Person record are frozen (External Identity " . $unlinkedExternalIdentityId . ", $model " . $aentity->$sourceEntity->id); + throw new \RuntimeException(__d( + 'error', + 'ExternalIdentities.relink.frozen', + [$model, $aentity->$sourceEntity->id]) + ); + } + $this->llog('trace', "Refusing to delete frozen $model " . $aentity->id . " on Person from External Identity " . $externalIdentity->id); } else { $this->llog('trace', "Deleted $model " . $aentity->id . " for Person " . $person->id); @@ -2044,6 +2216,9 @@ protected function syncPerson( // key to no longer point to the source EIR, so we wouldn't see the PR // at all. // - A manually deleted EIR would behave similarly. + + // Note that updating of the Person Role status when the External Identity Role + // is deleted is handled by ExternalIdentityRolesTable::beforeDelete(). /* if(!empty($curentities->person_roles)) { foreach($curentities->person_roles as $currole) { diff --git a/app/src/Model/Table/PronounsTable.php b/app/src/Model/Table/PronounsTable.php index 796b7a82d..8e03ff219 100644 --- a/app/src/Model/Table/PronounsTable.php +++ b/app/src/Model/Table/PronounsTable.php @@ -80,7 +80,11 @@ public function initialize(array $config): void { ->setClassName('Pronouns') ->setForeignKey('source_pronoun_id') ->setProperty('source_pronoun'); - + $this->hasMany('PipelinedPronouns') + ->setClassName('Pronouns') + ->setForeignKey('source_pronoun_id') + ->setProperty('pipelined_pronoun'); + $this->setDisplayField('pronouns'); $this->setPrimaryLink(['external_identity_id', 'person_id']); diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index 63a6d79d9..fb73d950c 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -87,6 +87,10 @@ public function initialize(array $config): void { ->setClassName('TelephoneNumbers') ->setForeignKey('source_telephone_number_id') ->setProperty('source_telephone_number'); + $this->hasMany('PipelinedTelephoneNumbers') + ->setClassName('TelephoneNumbers') + ->setForeignKey('source_telephone_number_id') + ->setProperty('pipelined_telephone_number'); $this->setDisplayField('number'); diff --git a/app/src/Model/Table/UrlsTable.php b/app/src/Model/Table/UrlsTable.php index 2f57710ab..6e4b60580 100644 --- a/app/src/Model/Table/UrlsTable.php +++ b/app/src/Model/Table/UrlsTable.php @@ -83,6 +83,10 @@ public function initialize(array $config): void { ->setClassName('Urls') ->setForeignKey('source_url_id') ->setProperty('source_url'); + $this->hasMany('PipelinedUrls') + ->setClassName('Urls') + ->setForeignKey('source_url_id') + ->setProperty('pipelined_url'); $this->setDisplayField('url'); diff --git a/app/templates/ExtIdentitySourceRecords/columns.inc b/app/templates/ExtIdentitySourceRecords/columns.inc index bdb686ca0..f9b1f0a1b 100644 --- a/app/templates/ExtIdentitySourceRecords/columns.inc +++ b/app/templates/ExtIdentitySourceRecords/columns.inc @@ -31,16 +31,16 @@ $indexColumns = [ 'type' => 'link', 'sortable' => true ], + 'source_key' => [ + 'type' => 'echo', + 'sortable' => true + ], 'external_identity_source_id' => [ 'type' => 'relatedLink', 'model' => 'external_identity_source', 'field' => 'description', 'sortable' => true ], - 'source_key' => [ - 'type' => 'echo', - 'sortable' => true - ], 'last_update' => [ 'type' => 'datetime' ] diff --git a/app/templates/ExtIdentitySourceRecords/fields.inc b/app/templates/ExtIdentitySourceRecords/fields.inc index 32d77ab24..c92535642 100644 --- a/app/templates/ExtIdentitySourceRecords/fields.inc +++ b/app/templates/ExtIdentitySourceRecords/fields.inc @@ -63,19 +63,37 @@ if($vv_action == 'view') { ] ]); - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'external_identity_id', - 'status' => $vv_obj->external_identity->names[0]->full_name, - 'link' => [ - 'url' => [ - 'controller' => 'external_identities', - 'action' => 'edit', - $vv_obj->external_identity->id + if(!empty($vv_obj->adopted_person_id)) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'adopted_person_id', + 'status' => $vv_obj->adopted_person->primary_name->full_name, + 'link' => [ + 'url' => [ + 'controller' => 'people', + 'action' => 'edit', + $vv_obj->adopted_person->id + ] ] ] - ] - ]); + ]); + } + + if(!empty($vv_obj->external_identity_id)) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'external_identity_id', + 'status' => $vv_obj->external_identity->names[0]->full_name, + 'link' => [ + 'url' => [ + 'controller' => 'external_identities', + 'action' => 'edit', + $vv_obj->external_identity->id + ] + ] + ] + ]); + } foreach([ 'source_key', diff --git a/app/templates/ExternalIdentities/fields-nav.inc b/app/templates/ExternalIdentities/fields-nav.inc index a8900d347..5e339771e 100644 --- a/app/templates/ExternalIdentities/fields-nav.inc +++ b/app/templates/ExternalIdentities/fields-nav.inc @@ -72,6 +72,36 @@ if($vv_obj?->ext_identity_source_record?->id !== null) { $vv_obj->ext_identity_source_record->id ] ]; + + $topLinks[] = [ + 'icon' => 'stroller', + 'order' => 'Default', + 'label' => __d('operation', 'adopt'), + 'link' => [ + 'action' => 'adopt', + $vv_obj->id + ], + 'confirm' => [ + 'dg_body_txt' => __d( + 'operation', + 'ExternalIdentities.adopt.confirm', + [ + $vv_obj->ext_identity_source_record->source_key, + $vv_obj->ext_identity_source_record->external_identity_source->description, + ]), + 'dg_confirm_btn' => __d('operation', 'adopt') + ] + ]; + + $topLinks[] = [ + 'icon' => 'move', + 'order' => 'Default', + 'label' => __d('operation', 'relink'), + 'link' => [ + 'action' => 'relink', + $vv_obj->id + ] + ]; } // $addMenuLinks is also given slightly different treatment from the typical $topLinks found in most views: diff --git a/app/templates/ExternalIdentities/relink.php b/app/templates/ExternalIdentities/relink.php new file mode 100644 index 000000000..2ca6c2c6b --- /dev/null +++ b/app/templates/ExternalIdentities/relink.php @@ -0,0 +1,49 @@ + + +