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 970794dd0..400a9aa7b 100644 --- a/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po +++ b/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po @@ -25,11 +25,47 @@ msgid "controller.FileProvisioners" msgstr "{0,plural,=1{File Provisioner} other{File Provisioners}}" +msgid "enumeration.FileSourceFormatEnum.C3" +msgstr "CSV v3" + +msgid "error.filename.readable" +msgstr "The file \"{0}\" is not readable" + msgid "error.filename.writeable" msgstr "The file \"{0}\" is not writable" +msgid "error.header" +msgstr "Did not find CSV file header" + msgid "field.FileProvisioners.filename" msgstr "File Name" msgid "field.FileProvisioners.filename.desc" -msgstr "Full path to file to write to, which must exist and be writeable" \ No newline at end of file +msgstr "Full path to file to write to, which must exist and be writeable" + +msgid "field.FileSources.archivedir" +msgstr "Archive Directory" + +msgid "field.FileSources.archivedir.desc" +msgstr "If specified, a limited number of prior copies of the source file will be stored here" + +msgid "field.FileSources.filename" +msgstr "File Name" + +msgid "field.FileSources.filename.desc" +msgstr "Full path to file to read from, which must exist and be readable" + +msgid "field.FileSources.format" +msgstr "File Format" + +msgid "field.FileSources.threshold_warn" +msgstr "Warning Threshold" + +msgid "field.FileSources.threshold_warn.desc" +msgstr "If the number of changed records exceeds the specified percentage, a warning will be generated and processing will stop (requires Archive Directory)" + +msgid "field.FileSources.threshold_override" +msgstr "Warning Threshold Override" + +msgid "field.FileSources.threshold_override.desc" +msgstr "If set, the next Full sync will ignore the Warning Threshold" \ No newline at end of file diff --git a/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php b/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php new file mode 100644 index 000000000..bd8407232 --- /dev/null +++ b/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php @@ -0,0 +1,40 @@ + [ + 'FileSources.id' => 'asc' + ] + ]; +} diff --git a/app/availableplugins/FileConnector/src/Lib/Enum/FileSourceFormatEnum.php b/app/availableplugins/FileConnector/src/Lib/Enum/FileSourceFormatEnum.php new file mode 100644 index 000000000..0bf9a88ce --- /dev/null +++ b/app/availableplugins/FileConnector/src/Lib/Enum/FileSourceFormatEnum.php @@ -0,0 +1,41 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php index e8b7c7fa7..d9ccb0bdc 100644 --- a/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php +++ b/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php @@ -157,7 +157,7 @@ public function provision( public function ruleIsFileWriteable($entity, array $options): string|bool { if(!is_writable($entity->filename)) { - return __d('file_provisioner', 'error.filename.writeable', [$entity->filename]); + return __d('file_connector', 'error.filename.writeable', [$entity->filename]); } return true; diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php new file mode 100644 index 000000000..c50aa1136 --- /dev/null +++ b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php @@ -0,0 +1,525 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ExternalIdentitySources'); + + $this->setDisplayField('filename'); + + $this->setPrimaryLink(['external_identity_source_id']); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'formats' => [ + 'type' => 'enum', + 'class' => 'FileConnector.FileSourceFormatEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + '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'] + ] + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // The requested file must exist and be readable. + + $rules->add([$this, 'ruleIsFileReadable'], + 'isFileReadable', + ['errorField' => 'filename']); + +// XXX CFM-117 should we also check that the archive dir, if specified, is writeable? + + return $rules; + } + + /** + * Obtain the file field configuration. + * + * @since COmanage Registry v4.0.0 + * @return array Configuration array + */ + + protected function readFieldConfig( + \FileConnector\Model\Entity\FileSource $filesource + ): array { + if($this->fieldCfg) { + return $this->fieldCfg; + } + + // The only supported format is CSV3, so we don't currently need to check + // $this->pluginCfg['format'] + + $this->fieldCfg = []; + + // The field configuration is described in the first line of the file + $handle = fopen($filesource->filename, "r"); + + if(!$handle) { + throw new \RuntimeException(__d('file_connector', 'error.filename.readable', $file_source->filename)); + } + + // The first line is our configuration + $cfg = fgetcsv($handle); + + fclose($handle); + + if(empty($cfg)) { + throw new \RuntimeException(__d('error.header')); + } + + foreach($cfg as $i => $label) { + // Labels are of the forms described in the switch statement. + // Parse them out into the fieldcfg array. + + $bits = explode('.', $label, 5); + + switch(count($bits)) { + case 1: + // SORID (special case) + $this->fieldCfg[ $bits[0] ] = $i; + break; + case 2: + // external_identity.field + // ad_hoc_attributes.tag (attached to EI) + // related_model.field (not currently used) + $this->fieldCfg[ $bits[0] ][ $bits[1] ] = $i; + break; + case 3: + // related_models.type.field + // external_identity_roles.#.field (special case) + // Note we _no longer_ flip the order model/type/field + // (this is inverted from CSV v2) + // and identifier+login is no longer supported + if($bits[0] == 'external_identity_roles') { + // Store based on role + $this->fieldCfg[ $bits[0] ]['roles'][ $bits[1] ]['fields'][ $bits[2] ] = $i; + } else { + // Store based on type + $this->fieldCfg[ $bits[0] ]['types'][ $bits[1] ][ $bits[2] ] = $i; + } + break; + case 4: + // external_identity_roles.#.ad_hoc_attributes.tag (attached to EIRole) + $this->fieldCfg[ $bits[0] ]['roles'][ $bits[1] ]['related'][ $bits[2] ][ $bits[3] ] = $i; + break; + case 5: + // external_identity_roles.#.related_models.type.field + // Note these are keyed on an SOR Role ID + $this->fieldCfg[ $bits[0] ]['roles'][ $bits[1] ]['related'][ $bits[2] ]['types'][ $bits[3] ][ $bits[4] ] = $i; + break; + } + } + + return $this->fieldCfg; + } + + /** + * Convert a record from the FileSource data to a record suitable for + * construction of an Entity. readFieldConfig() must be called before + * this function. + * + * @since COmanage Registry v5.0.0 + * @param array $result FileSource record + * @return array Entity record (in array format) + */ + + protected function resultToEntityData(array $result): array { + // Build the External Identity as an array, then convert it to an entity. + // Unlike v4, backends need to insert the SORID (for consistency with the role ID) + $eidata = [ 'source_key' => $result[ $this->fieldCfg['SORID'] ] ]; + + // We copy whatever attributes the inbound file asserts for a given model, + // leaving it to the validation rules to worry about correctness. + + // Start with ExternalIdentity attributes (case 2) + if(!empty($this->fieldCfg['external_identity'])) { + foreach($this->fieldCfg['external_identity'] as $attr => $col) { + if(!empty($result[$col])) { + // Note we don't appear to need to convert date_of_birth manually, + // it appears to correctly marshal to a DateTime object + $eidata[$attr] = $result[$col]; + } + } + } + + // Walk through MVEAs (case 3) + foreach([ + 'addresses', + 'email_addresses', + 'identifiers', + 'names', + 'telephone_numbers', + 'urls' + ] as $model) { + if(!empty($this->fieldCfg[$model])) { + foreach(array_keys($this->fieldCfg[$model]['types']) as $type) { + $rdata = []; + + foreach($this->fieldCfg[$model]['types'][$type] as $attr => $col) { + if(!empty($result[$col])) { + $rdata[$attr] = $result[$col]; + } + } + + if(!empty($rdata)) { + // We found at least one field, so insert the type and the record + $rdata['type'] = $type; + + $eidata[$model][] = $rdata; + } + } + } + } + + // Make sure we have a Primary Name + $primaryNameSet = false; + + foreach($eidata['names'] as $n) { + if(isset($n['primary_name']) && $n['primary_name']) { + $primaryNameSet = true; + break; + } + } + + if(!$primaryNameSet) { + $eidata['names'][0]['primary_name'] = true; + } + + // Process Ad Hoc Attributes (case 2) + if(!empty($this->fieldCfg['ad_hoc_attributes'])) { + foreach($this->fieldCfg['ad_hoc_attributes'] as $tag => $col) { + if(!empty($result[$col])) { + $eidata['ad_hoc_attributes'][] = [ + 'tag' => $tag, + 'value' => $result[$col] + ]; + } + } + } + + // Handle External Identity Roles. This is similar to much of the above. + if(!empty($this->fieldCfg['external_identity_roles'])) { + foreach($this->fieldCfg['external_identity_roles']['roles'] as $roleId => $role) { + $eirdata = [ 'role_key' => $roleId ]; + + // Start with the EIR fields + foreach($role['fields'] as $attr => $col) { + if(!empty($result[$col])) { + $eirdata[$attr] = $result[$col]; + } + } + + // Next add the related models (case 5) + + foreach([ + 'addresses', + 'email_addresses', + 'telephone_numbers', + 'urls' + ] as $model) { + if(!empty($role['related'][$model])) { + foreach(array_keys($role['related'][$model]['types']) as $type) { + $rdata = []; + + foreach($role['related'][$model]['types'][$type] as $attr => $col) { + if(!empty($result[$col])) { + $rdata[$attr] = $result[$col]; + } + } + + if(!empty($rdata)) { + // We found at least one field, so insert the type and the record + $rdata['type'] = $type; + + $eirdata[$model][] = $rdata; + } + } + } + } + + // Finally process any Ad Hoc Attributes (case 4) + if(!empty($role['related']['ad_hoc_attributes'])) { + foreach($role['related']['ad_hoc_attributes'] as $tag => $col) { + if(!empty($result[$col])) { + $eirdata['ad_hoc_attributes'][] = [ + 'tag' => $tag, + 'value' => $result[$col] + ]; + } + } + } + + $eidata['external_identity_roles'][] = $eirdata; + } + } + + // XXX we're back to returning arrays rather than entities here because + // the validation rules get built even though validate = false + return $eidata; + } + + /** + * Retrieve a record from the External Identity Source. + * + * @since COmanage Registry v5.0.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 { + // Read the field configuration (for resultToEntity) + $this->readFieldConfig($source->file_source); + + $ret = [ + 'source_key' => $source_key + ]; + + // In v4 we did a field by field search, but v5 is free form. + + $handle = fopen($source->file_source->filename, "r"); + + if(!$handle) { + throw new \RuntimeException(__d('file_connector', 'error.filename.readable', [$source->file_source->filename])); + } + + // We simply walk through the file until we find the matching record. + // If there is more than one record, we'll return the first one we find. + + // The first line of a CSV v3 file is our configuration + fgetcsv($handle); + + while(($data = fgetcsv($handle)) !== false) { + if($data[0] == $source_key) { + // This is our record + + $ret['source_record'] = json_encode($data); + $ret['entity_data'] = $this->resultToEntityData($data); + + break; + } + } + + fclose($handle); + + if(!isset($ret['source_record'])) { + // We didn't find a record + throw new \InvalidArgumentException(__d('error', 'notfound', [$source_key])); + } + + return $ret; + } + + /** + * Application Rule to determine if the current entity is a readable file. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.0.0 + */ + + public function ruleIsFileReadable($entity, array $options): string|bool { + if(!is_readable($entity->filename)) { + return __d('file_connector', 'error.filename.readable', [$entity->filename]); + } + + return true; + } + + /** + * Search the External Identity Source. + * + * @since COmanage Registry v5.0.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 { + // Read the field configuration (for resultToEntity) + $this->readFieldConfig($source->file_source); + + $ret = []; + + // In v4 we did a field by field search, but v5 is free form. + + $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) { + // strtolower, previous behavior was full string only so dupe that + + $match = array_search(strtolower($searchAttrs['q']), array_map('strtolower', $data)); + + if($match !== false) { + // $match will be the CSV column that matched, but for now we ignore that + // since we just need to know that the row matched somewhere. Note the first + // column is always the SORID. + + $ret[ $data[0] ] = $this->resultToEntityData($data); + } + } + + fclose($handle); + + return $ret; + } + + /** + * Obtain the set of searchable attributes for this backend. + * + * @since COmanage Registry v5.0.0 + * @return array Array of searchable attributes and localized descriptions + */ + + public function searchableAttributes(): array { + // In v4 we accepted structured search attributes (name, email, etc), but + // with CSV v2 (the only currently supported format) it's not clear what + // the benefit of this is anymore, so for PE we switch to a simple search + // string. + + return [ + 'q' => __d('field', 'search.placeholder') + ]; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.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'); + + $this->registerStringValidation($validator, $schema, 'filename', true); + + $validator->add('format', [ + 'content' => ['rule' => ['inList', FileSourceFormatEnum::getConstValues()]] + ]); + $validator->notEmptyString('format'); + + $this->registerStringValidation($validator, $schema, 'archivedir', false); + + $validator->add('threshold_warn', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->add('threshold_warn', [ + 'range' => ['rule' => 'range', 0, 100] + ]); + $validator->allowEmptyString('threshold_warn'); + + $validator->add('threshold_override', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('threshold_override'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/FileConnector/src/config/plugin.json b/app/availableplugins/FileConnector/src/config/plugin.json index 29079f98d..977e900b2 100644 --- a/app/availableplugins/FileConnector/src/config/plugin.json +++ b/app/availableplugins/FileConnector/src/config/plugin.json @@ -2,6 +2,9 @@ "types": { "provisioner": [ "FileProvisioners" + ], + "source": [ + "FileSources" ] }, "schema": { @@ -10,11 +13,25 @@ "columns": { "id": {}, "provisioning_target_id": {}, - "filename": { "type": "string", "size": 128 } + "filename": { "type": "string", "size": 256 } }, "indexes": { "file_provisioners_i1": { "columns": [ "provisioning_target_id" ]} } + }, + "file_sources": { + "columns": { + "id": {}, + "external_identity_source_id": {}, + "filename": { "type": "string", "size": 256 }, + "format": { "type": "string", "size": 2 }, + "archivedir": { "type": "string", "size": 256 }, + "threshold_warn": { "type": "integer" }, + "threshold_override": { "type": "boolean" } + }, + "indexes": { + "file_sources_i1": { "columns": [ "external_identity_source_id" ] } + } } } } diff --git a/app/availableplugins/FileConnector/templates/FileSources/fields.inc b/app/availableplugins/FileConnector/templates/FileSources/fields.inc new file mode 100644 index 000000000..2380fe7bb --- /dev/null +++ b/app/availableplugins/FileConnector/templates/FileSources/fields.inc @@ -0,0 +1,39 @@ +Field->control('filename'); + + print $this->Field->control('format'); + + print $this->Field->control('archivedir'); + + print $this->Field->control('threshold_warn'); + + print $this->Field->control('threshold_override'); +} diff --git a/app/availableplugins/SqlConnector/src/Controller/SqlProvisionersController.php b/app/availableplugins/SqlConnector/src/Controller/SqlProvisionersController.php index 026e74a42..40b1ae658 100644 --- a/app/availableplugins/SqlConnector/src/Controller/SqlProvisionersController.php +++ b/app/availableplugins/SqlConnector/src/Controller/SqlProvisionersController.php @@ -47,14 +47,16 @@ class SqlProvisionersController extends StandardPluginController { public function reapply(string $id) { try { - $this->SqlProvisioners->applySchema((int)$id); + $sp = $this->SqlProvisioners->get((int)$id); + $this->SqlProvisioners->applySchema($sp->id); + $this->Flash->success(__d('sql_connector', 'result.reapply.ok')); } catch(\Exception $e) { $this->Flash->error($e->getMessage()); } - return $this->generateRedirect((int)$id); + return $this->generateRedirect($sp ?? null); } /** @@ -68,7 +70,8 @@ public function resync(string $id) { try { $cur_co = $this->getCO(); - $this->SqlProvisioners->syncReferenceData(id: $id); + $sp = $this->SqlProvisioners->get((int)$id); + $this->SqlProvisioners->syncReferenceData(id: $sp->id); $this->Flash->success(__d('sql_connector', 'result.resync.ok')); } @@ -76,6 +79,6 @@ public function resync(string $id) { $this->Flash->error($e->getMessage()); } - return $this->generateRedirect((int)$id); + return $this->generateRedirect($sp ?? null); } } diff --git a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php index df0a584a1..50f7b493f 100644 --- a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php +++ b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php @@ -332,13 +332,16 @@ public function applySchema($id) { */ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { - // Apply the database schema (PAR-SqlProvisioner-1) - $this->llog('rule', "PAR-SqlProvisioner-1 Applying database schema for SqlProvisioner " . $entity->id); - $this->applySchema($entity->id); - - // Populate or update the reference data (PAR-SqlProvisioner-2) - $this->llog('rule', "PAR-SqlProvisioner-2 Syncing reference data for SqlProvisioner " . $entity->id); - $this->syncReferenceData($entity->id); + // We may not have a Server configuration yet on first save + if(!empty($spcfg->server_id)) { + // Apply the database schema (PAR-SqlProvisioner-1) + $this->llog('rule', "PAR-SqlProvisioner-1 Applying database schema for SqlProvisioner " . $entity->id); + $this->applySchema($entity->id); + + // Populate or update the reference data (PAR-SqlProvisioner-2) + $this->llog('rule', "PAR-SqlProvisioner-2 Syncing reference data for SqlProvisioner " . $entity->id); + $this->syncReferenceData($entity->id); + } return true; } @@ -530,7 +533,7 @@ protected function syncEntity( * @param string $dataSource DataSource label */ - public function syncReferenceData($id, $dataSource='targetdb') { + public function syncReferenceData(int $id, string $dataSource='targetdb') { $spcfg = $this->get($id, ['contain' => ['ProvisioningTargets']]); $this->Servers->SqlServers->connect($spcfg->server_id, $dataSource); diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 05186b12a..bae9ccfdc 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -16,6 +16,8 @@ "description": { "type": "string", "size": 128 }, "external_identity_id": { "type": "integer", "foreignkey": { "table": "external_identities", "column": "id" } }, "external_identity_role_id": { "type": "integer", "foreignkey": { "table": "external_identity_roles", "column": "id" } }, + "external_identity_source_id": { "type": "integer", "foreignkey": { "table": "external_identity_sources", "column": "id" } }, + "frozen": { "type": "boolean" }, "group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, "id": { "type": "integer", "autoincrement": true, "primarykey": true }, "identifier_assignment_id": { "type": "integer", "foreignkey": { "table": "identifier_assignments", "column": "id" }, "notnull": true }, @@ -26,6 +28,7 @@ "person_role_id": { "type": "integer", "foreignkey": { "table": "person_roles", "column": "id" } }, "plugin": { "type": "string", "size": 80 }, "provisioning_target_id": { "type": "integer", "foreignkey": { "table": "provisioning_targets", "column": "id" }, "notnull": true }, + "reference_identifier": { "type": "string", "size": 40 }, "report_id": { "type": "integer", "foreignkey": { "table": "reports", "column": "id" }, "notnull": true }, "server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" }, "notnull": true }, "status": { "type": "string", "size": 2 }, @@ -202,40 +205,17 @@ } }, - "person_roles": { - "columns": { - "id": {}, - "person_id": { "notnull": true }, - "status": {}, - "ordr": {}, - "cou_id": {}, - "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "title": { "type": "string", "size": 128 }, - "organization": { "type": "string", "size": 128 }, - "department": { "type": "string", "size": 128 }, - "manager_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, - "sponsor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, - "valid_from": {}, - "valid_through": {} - }, - "indexes": { - "person_roles_i1": { "columns": [ "person_id" ] }, - "person_roles_i2": { "columns": [ "sponsor_person_id" ] }, - "person_roles_i3": { "columns": [ "cou_id" ] }, - "person_roles_i4": { "columns": [ "affiliation_type_id" ] }, - "person_roles_i5": { "columns": [ "manager_person_id" ] } - } - }, - "external_identities": { "columns": { "id": {}, "person_id": { "notnull": true }, + "source_key": { "type": "string", "size": 512 }, "status": {}, "date_of_birth": { "type": "date" } }, "indexes": { - "external_identities_i1": { "columns": [ "person_id" ] } + "external_identities_i1": { "columns": [ "person_id" ] }, + "external_identities_i2": { "columns": [ "source_key" ] } } }, @@ -243,6 +223,7 @@ "columns": { "id": {}, "external_identity_id": { "notnull": true }, + "role_key": { "type": "string", "size": 512 }, "status": {}, "ordr": {}, "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, @@ -260,6 +241,34 @@ } }, + "person_roles": { + "columns": { + "id": {}, + "person_id": { "notnull": true }, + "status": {}, + "ordr": {}, + "cou_id": {}, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "title": { "type": "string", "size": 128 }, + "organization": { "type": "string", "size": 128 }, + "department": { "type": "string", "size": 128 }, + "manager_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "sponsor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "valid_from": {}, + "valid_through": {}, + "source_external_identity_role_id": { "type": "integer", "foreignkey": { "table": "external_identity_roles", "column": "id" } }, + "frozen": {} + }, + "indexes": { + "person_roles_i1": { "columns": [ "person_id" ] }, + "person_roles_i2": { "columns": [ "sponsor_person_id" ] }, + "person_roles_i3": { "columns": [ "cou_id" ] }, + "person_roles_i4": { "columns": [ "affiliation_type_id" ] }, + "person_roles_i5": { "columns": [ "manager_person_id" ] }, + "person_roles_i6": { "columns": [ "source_external_identity_role_id" ] } + } + }, + "groups": { "columns": { "id": {}, @@ -589,6 +598,68 @@ "identifier_assignments_i2": { "needed": false, "columns": [ "email_address_type_id" ] }, "identifier_assignments_i3": { "needed": false, "columns": [ "identifier_type_id" ] } } + }, + + "pipelines": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "status": {}, + "match_strategy": { "type": "string", "size": 2 }, + "match_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "match_server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" } }, + "sync_status_on_delete": { "type": "string", "size": 2 }, + "sync_affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "sync_cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, + "sync_replace_cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, + "sync_identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } } + }, + "indexes": { + "pipelines_i1": { "columns": [ "co_id" ] }, + "pipelines_i2": { "needed": false, "columns": [ "match_type_id" ] }, + "pipelines_i3": { "needed": false, "columns": [ "match_server_id" ] }, + "pipelines_i4": { "needed": false, "columns": [ "sync_affiliation_type_id" ] }, + "pipelines_i5": { "needed": false, "columns": [ "sync_cou_id" ] }, + "pipelines_i6": { "needed": false, "columns": [ "sync_replace_cou_id" ] }, + "pipelines_i7": { "needed": false, "columns": [ "sync_identifier_type_id" ] } + } + }, + + "external_identity_sources": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {}, + "sor_label": { "type": "string", "size": 40 }, + "pipeline_id": { "type": "integer", "foreignkey": { "table": "pipelines", "column": "id" }, "notnull": true }, + "hash_source_record": { "type": "boolean" } + }, + "indexes": { + "external_identity_sources_i1": { "columns": [ "co_id" ] }, + "external_identity_sources_i2": { "columns": [ "sor_label"] }, + "external_identity_sources_i3": { "needed": false, "columns": [ "pipeline_id" ] } + } + }, + + "ext_identity_source_records": { + "comment": "This table should be called external_identity_source_records but then we exceed Cake's 61 character alias.field limit with ExternalIdentitySourceRecords.external_identity_source_record_id", + "columns": { + "id": {}, + "external_identity_source_id": { "type": "integer", "foreignkey": { "table": "external_identity_sources", "column": "id" }, "notnull": true }, + "source_key": { "type": "string", "size": 1024 }, + "source_record": { "type": "text" }, + "last_update": { "type": "datetime" }, + "external_identity_id": {}, + "reference_identifier": {} + }, + "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" ] } + } } }, diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index 8e7e2a3ae..5539ba39b 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -57,6 +57,12 @@ msgstr "{0,plural,=1{External Identity} other{External Identities}}" msgid "ExternalIdentityRoles" msgstr "{0,plural,=1{External Identity Role} other{External Identity Roles}}" +msgid "ExternalIdentitySources" +msgstr "{0,plural,=1{External Identity Source} other{External Identity Sources}}" + +msgid "ExtIdentitySourceRecords" +msgstr "{0,plural,=1{External Identity Source Record} other{External Identity Source Records}}" + msgid "GroupMembers" msgstr "{0,plural,=1{Group Member} other{Group Members}}" @@ -93,6 +99,9 @@ msgstr "{0,plural,=1{Person} other{People}}" msgid "PersonRoles" msgstr "{0,plural,=1{Person Role} other{Person Roles}}" +msgid "Pipelines" +msgstr "{0,plural,=1{Pipeline} other{Pipelines}}" + msgid "Pronouns" msgstr "{0,plural,=1{Pronoun Preference} other{Pronouns}}" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 45d21a71f..23ba920c9 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -36,6 +36,18 @@ msgstr "False" msgid "BooleanEnum.1" msgstr "True" +msgid "DeletedRoleStatusEnum.D" +msgstr "Archived" + +msgid "DeletedRoleStatusEnum.GP" +msgstr "Grace Period" + +msgid "DeletedRoleStatusEnum.S" +msgstr "Suspended" + +msgid "DeletedRoleStatusEnum.XP" +msgstr "Expired" + msgid "EduPersonAffiliationEnum.affiliate" msgstr "Affiliate" @@ -60,6 +72,27 @@ msgstr "Staff" msgid "EduPersonAffiliationEnum.student" msgstr "Student" +msgid "ExternalIdentityStatusEnum.A" +msgstr "Active" + +msgid "ExternalIdentityStatusEnum.D" +msgstr "Archived" + +msgid "ExternalIdentityStatusEnum.D2" +msgstr "Duplicate" + +msgid "ExternalIdentityStatusEnum.GP" +msgstr "Grace Period" + +msgid "ExternalIdentityStatusEnum.PS" +msgstr "Pending Activation" + +msgid "ExternalIdentityStatusEnum.S" +msgstr "Suspended" + +msgid "ExternalIdentityStatusEnum.XP" +msgstr "Expired" + msgid "GroupTypeEnum.MA" msgstr "Active Members" @@ -213,6 +246,18 @@ msgstr "Turkish (Türkçe)" msgid "LanguageEnum.ur" msgstr "Urdu (اُردُو)" +msgid "MatchStrategyEnum.EA" +msgstr "Email Address" + +msgid "MatchStrategyEnum.EX" +msgstr "External" + +msgid "MatchStrategyEnum.ID" +msgstr "Identifier" + +msgid "MatchStrategyEnum.NO" +msgstr "No Matching" + msgid "PermittedNameFieldsEnum.given,family" msgstr "Given, Family" @@ -354,6 +399,9 @@ msgstr "Pending Approval" msgid "StatusEnum.PC" msgstr "Pending Confirmation" +msgid "StatusEnum.PS" +msgstr "Pending Activation" + msgid "StatusEnum.S" msgstr "Suspended" @@ -372,6 +420,21 @@ msgstr "Active" msgid "SuspendableStatusEnum.S" msgstr "Suspended" +msgid "SyncModeEnum.F" +msgstr "Full" + +msgid "SyncModeEnum.M" +msgstr "Manual" + +msgid "SyncModeEnum.Q" +msgstr "Query" + +msgid "SyncModeEnum.U" +msgstr "Update" + +msgid "SyncModeEnum.X" +msgstr "Disabled" + msgid "TemplateableStatusEnum.A" msgstr "Active" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index cea0ec87c..fd393db38 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -104,6 +104,9 @@ msgstr "Ends at:" msgid "extension" msgstr "Extension" +msgid "frozen" +msgstr "Frozen" + msgid "id" msgstr "ID" @@ -134,6 +137,9 @@ msgstr "Full Name" msgid "language" msgstr "Language" +msgid "last_update" +msgstr "Last Update" + msgid "locality" msgstr "Locality" @@ -183,12 +189,18 @@ msgstr "Privileged" msgid "pronouns" msgstr "Preferred Pronouns" +msgid "reference_identifier" +msgstr "Reference Identifier" + msgid "remote_ip" msgstr "IP Address" msgid "required" msgstr "Required" +msgid "role_key" +msgstr "Role Key" + msgid "room" msgstr "Room" @@ -204,6 +216,15 @@ msgstr "Clear global search" msgid "search.placeholder" msgstr "Search..." +msgid "source" +msgstr "Source" + +msgid "source_key" +msgstr "Source Key" + +msgid "source_record" +msgstr "Source Record" + msgid "sponsor" msgstr "Sponsor" @@ -327,6 +348,9 @@ msgstr "Limit Global Search Scope" msgid "CoSettings.search_global_limited_models.desc" msgstr "If true, Global Search will only search Names, Email Addresses, and Identifiers. This may result in faster searches for larger deployments." +msgid "ExternalIdentitySources.source_record.desc" +msgstr "If the source record is empty, it likely indicates this record is no longer available from the datasource" + msgid "GroupMembers.source" msgstr "Membership Source" @@ -438,6 +462,42 @@ msgstr "Start Summary" msgid "Jobs.start_time" msgstr "Started" +msgid "Pipelines.match_strategy" +msgstr "Match Strategy" + +msgid "Pipelines.sync_affiliation_type_id" +msgstr "Person Role Affiliation" + +msgid "Pipelines.sync_affiliation_type_id.desc" +msgstr "If set, created Person Roles will be given this affiliation (not the affiliation of the External Identity)" + +msgid "Pipelines.sync_cou_id" +msgstr "Sync to COU" + +msgid "Pipelines.sync_identifier_type_id" +msgstr "Sync Identifier Type" + +msgid "Pipelines.sync_identifier_type_id.desc" +msgstr "For fields such as manager or sponsor, the inbound identifier type" + +msgid "Pipelines.sync_on_delete" +msgstr "Sync On Delete" + +msgid "Pipelines.sync_on_update" +msgstr "Sync On Update" + +msgid "Pipelines.sync_replace_cou_id" +msgstr "Replace Record in COU" + +msgid "Pipelines.sync_replace_cou_id.desc" +msgstr "If the Person has an existing Person Role in the specified Person Role will be deleted/expired" + +msgid "Pipelines.sync_status_on_delete" +msgstr "Role Status On Delete" + +msgid "Pipelines.sync_status_on_delete.desc" +msgstr "When the source record is no longer valid, the corresponding Person Role will be set to this status" + msgid "Plugins.plugin" msgstr "Plugin" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index f129fe850..7c215af73 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -39,17 +39,17 @@ msgstr "Please select the collaboration (CO) you wish to manage." msgid "entity.id" msgstr "ID: {0}" -msgid "pagination.format" -msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" +msgid "ExternalIdentities.source" +msgstr "This External Identity was created from {0} ({1})" -msgid "plugin.active" -msgstr "Active" +msgid "ExternalIdentitySources.retrieve" +msgstr "This is the current record available directly from the source. To view the latest record retrieved and cached by Registry, click View External Identity Source Record." -msgid "plugin.active.only" -msgstr "Active, Cannot Be Disabled" +msgid "ExternalIdentitySourceRecords.view" +msgstr "This is the latest record retrieved from the source, as cached by Registry. To view the current record directly from the source, select Retrieve from External Identity Source." -msgid "plugin.inactive" -msgstr "Inactive" +msgid "ExternalIdentitySources.search.attrs.none" +msgstr "The External Identity Source cannot be searched." msgid "global.attribute.modal" msgstr "Attribute Modal" @@ -68,3 +68,15 @@ msgstr "No value" msgid "global.visit.link" msgstr "Visit link" + +msgid "pagination.format" +msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" + +msgid "plugin.active" +msgstr "Active" + +msgid "plugin.active.only" +msgstr "Active, Cannot Be Disabled" + +msgid "plugin.inactive" +msgstr "Inactive" \ No newline at end of file diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index c157cd963..5dcc26e72 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -93,6 +93,15 @@ msgstr "Edit {0}" msgid "edit.ai" msgstr "Edit {0}" +msgid "ExternalIdentitySourceRecords.retrieve" +msgstr "Retrieve from External Identity Source" + +msgid "ExternalIdentitySources.search" +msgstr "Search Source" + +msgid "ExternalIdentitySources.sync" +msgstr "Sync Record to CO" + msgid "filter" msgstr "Filter" @@ -168,6 +177,9 @@ msgstr "Skip to main content" msgid "Cos.switch" msgstr "Switch To This CO" +msgid "unfreeze" +msgstr "Unfreeze" + msgid "view" msgstr "View" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index 7e8e4e907..6cd04094d 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -48,6 +48,9 @@ msgstr "{0} {1} Deleted: {2}" msgid "edited.mvea" msgstr "{0} {1} Edited: {2}" +msgid "ExternalIdentitySources.synced" +msgstr "External Identity Source sync complete" + msgid "Groups.added" msgstr "Group {0} created" @@ -102,6 +105,12 @@ msgstr "Job canceled by {0}" msgid "Jobs.registered" msgstr "Started via JobCommand by {0} (uid {1})" +msgid "People.added.pipeline" +msgstr "Created new Person via Pipeline {0} ({1}) using Source {2} ({3}) Key {4}" + +msgid "Pipelines.ei.added" +msgstr "Created new External Identity via Pipeline {0} ({1}) using Source {2} ({3}) Key {4}" + msgid "saved" msgstr "Saved" @@ -117,6 +126,9 @@ msgstr "No results found" msgid "search.result.found" msgstr "Found {0} results" +msgid "search.result.found.modelCount" +msgstr "{0} {1}" + msgid "search.result.id" msgstr "ID {0}" diff --git a/app/src/Controller/ApiV2Controller.php b/app/src/Controller/ApiV2Controller.php index 62c5a88a9..e5a39ccb1 100644 --- a/app/src/Controller/ApiV2Controller.php +++ b/app/src/Controller/ApiV2Controller.php @@ -208,9 +208,9 @@ public function edit($id) { $this->$modelsName->saveOrFail($obj); // Trigger provisioning, letting errors bubble up (AR-GMR-5) - if(method_exists($table, "requestProvisioning")) { + if(method_exists($this->$modelsName, "requestProvisioning")) { $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); - $table->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + $this->$modelsName->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); } // Let the view render diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php index eeb22f2df..01c2db4e0 100644 --- a/app/src/Controller/Component/BreadcrumbComponent.php +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -43,8 +43,10 @@ class BreadcrumbComponent extends Component protected $skipConfigPaths = []; // Don't render the parent links protected $skipParentPaths = []; - // Inject parent links + // Inject parent links (these render before the index link, if set) protected $injectParents = []; + // Inject title links (immediately before the title breadcrumb) + protected $injectTitleLinks = []; /** * Callback run prior to rendering the view. @@ -131,9 +133,40 @@ public function beforeRender(EventInterface $event) { } $controller->set('vv_bc_parents', $parents); + + $controller->set('vv_bc_title_links', $this->injectTitleLinks); } } + /** + * Inject a title link based on the display field of an entity into the breadcrumb set. + * + * @since COmanage Registry v5.0.0 + * @param Table $table Table for $entity + * @param Entity $entity Entity to generate title link for + * @param string $action Action to link to + * @param string $label If set, use this label instead of the entity's displayField + */ + + public function injectTitleLink( + $table, + $entity, + string $action='edit', + ?string $label=null + ) { + $displayField = $table->getDisplayField(); + + $this->injectTitleLinks[] = [ + 'target' => [ + 'plugin' => null, + 'controller' => $table->getTable(), + 'action' => $action, + $entity->id + ], + 'label' => $label ?: $entity->$displayField + ]; + } + /** * Inject the primary link into the breadcrumb path. * diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index 0e5ffb211..ffaebb9c5 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -80,6 +80,11 @@ public function artifacts() { 'icon' => 'assignment', 'controller' => 'jobs', 'action' => 'index' + ], + __d('controller', 'ExternalIdentitySourceRecords', [99]) => [ + 'icon' => 'assignment', + 'controller' => 'external_identity_source_records', + 'action' => 'index' ] ]; @@ -117,12 +122,21 @@ public function configuration() { 'controller' => 'cous', 'action' => 'index' ], - // XXX External Identity Sources should use "cloud_download" for the icon + __d('controller', 'ExternalIdentitySources', [99]) => [ + 'icon' => 'cloud_download', + 'controller' => 'external_identity_sources', + 'action' => 'index' + ], __d('controller', 'IdentifierAssignments', [99]) => [ 'icon' => 'badge', 'controller' => 'identifier_assignments', 'action' => 'index' ], + __d('controller', 'Pipelines', [99]) => [ + 'icon' => 'cable', + 'controller' => 'pipelines', + 'action' => 'index' + ], __d('controller', 'ProvisioningTargets', [99]) => [ 'icon' => 'cloud_upload', 'controller' => 'provisioning_targets', diff --git a/app/src/Controller/ExtIdentitySourceRecordsController.php b/app/src/Controller/ExtIdentitySourceRecordsController.php new file mode 100644 index 000000000..5f07920d1 --- /dev/null +++ b/app/src/Controller/ExtIdentitySourceRecordsController.php @@ -0,0 +1,42 @@ + [ + 'ExtIdentitySourceRecords.id' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/GroupsController.php b/app/src/Controller/GroupsController.php index 062a72f83..cbd9bae10 100644 --- a/app/src/Controller/GroupsController.php +++ b/app/src/Controller/GroupsController.php @@ -48,13 +48,16 @@ class GroupsController extends StandardController { public function reconcile(string $id) { try { - $this->Groups->reconcile((int)$id); + $group = $this->Groups->get((int)$id); + + $this->Groups->reconcile($group->id); + $this->Flash->success(__d('result', 'Groups.reconciled')); } catch(\Exception $e) { $this->Flash->error($e->getMessage()); } - - return $this->generateRedirect((int)$id); + + return $this->generateRedirect($group ?? null); } } \ No newline at end of file diff --git a/app/src/Controller/NamesController.php b/app/src/Controller/NamesController.php index b5a312b56..36084b4e7 100644 --- a/app/src/Controller/NamesController.php +++ b/app/src/Controller/NamesController.php @@ -92,7 +92,7 @@ public function primary(string $id) { catch(\Exception $e) { $this->Flash->error($e->getMessage()); } - - return $this->generateRedirect((int)$id); + + return $this->generateRedirect($obj ?? null); } } \ No newline at end of file diff --git a/app/src/Controller/PipelinesController.php b/app/src/Controller/PipelinesController.php new file mode 100644 index 000000000..bd841023f --- /dev/null +++ b/app/src/Controller/PipelinesController.php @@ -0,0 +1,41 @@ + [ + 'Pipelines.description' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index f6bc44c90..d264925d6 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -74,7 +74,7 @@ public function add() { return $this->instantiatePlugin($obj); } - return $this->generateRedirect($obj->id); + return $this->generateRedirect($obj); } $errors = $obj->getErrors(); @@ -173,7 +173,14 @@ public function beforeRender(\Cake\Event\EventInterface $event) { } $this->set('vv_template_path', $vv_template_path); - + + // Primarily of interest to detailed record views, if this attribute supports + // Pipeline sourcing (ie: has a source_foo_id field) set the name of the source + // foreign key into a view var since it's not always calculable. + if(method_exists($table, "sourceForeignKey")) { + $this->set('vv_source_fk', $table->sourceForeignKey()); + } + // Check to see if the model names a specific layout if(method_exists($table, "getLayout")) { $this->viewBuilder()->setLayout($table->getLayout()); @@ -339,7 +346,7 @@ public function edit(string $id) { $table->requestProvisioning(id: (int)$id, context: ProvisioningContextEnum::Automatic); } - return $this->generateRedirect((int)$id); + return $this->generateRedirect($saveObj); } $errors = $saveObj->getErrors(); @@ -358,9 +365,7 @@ public function edit(string $id) { catch(\Exception $e) { // findById throws Cake\Datasource\Exception\RecordNotFoundException $this->Flash->error($e->getMessage()); - // XXX This redirects to an Exception page because $id is not found. - // XXX A 404 with error would be better. - return $this->generateRedirect((int)$id); + return $this->generateRedirect(null); } $this->set('vv_obj', $obj); @@ -399,11 +404,11 @@ public function edit(string $id) { * Generate a redirect for a Standard Object operation. * * @since COmanage Registry v5.0.0 - * @param int $id ID of object to redirect to + * @param Entity $entity Entity to redirect to * @return \Cake\Http\Response */ - public function generateRedirect(?int $id) { + public function generateRedirect($entity) { $redirect = []; // By default we return to the index, but we'll also accept "self" or "primaryLink". @@ -418,16 +423,23 @@ public function generateRedirect(?int $id) { $redirectGoal = 'index'; } } - + if($redirectGoal == 'self' - && $id + && $entity && in_array($this->request->getParam('action'), ['add', 'edit'])) { - // Redirect to the edit view of the record just added - // (if the user has add permission, they probably have edit permission) + // We typically want to redirect to the edit view of the record, + // but in some cases (eg: if the record was just frozen) we want to + // redirect to "view" instead. + $readOnly = false; + + if(method_exists($entity, "isReadOnly")) { + $readOnly = $entity->isReadOnly(); + } + $redirect = [ - 'action' => 'edit', - $id + 'action' => $readOnly ? "view" : "edit", + $entity->id ]; } elseif($redirectGoal == 'pluggableLink' || $redirectGoal == 'primaryLink') { // pluggableLink and primaryLink do basically the same thing, except that @@ -778,6 +790,37 @@ public function provision($id) { return $this->redirect($redirect); } + /** + * Unfreeze a frozen record. + * + * @since COmanage Registry v5.0.0 + * @param string $id Entity ID + */ + + public function unfreeze($id) { + // $this->name = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + try { + // Pull the current record + $obj = $table->get((int)$id); + } + catch(\Exception $e) { + // findById throws Cake\Datasource\Exception\RecordNotFoundException + $this->Flash->error($e->getMessage()); + return $this->generateRedirect(null); + } + + // Normally we'd wrap this in a function on the table or entity, but + // it's such a simple change that it doesn't seem to be worth it atm. + $obj->frozen = false; + $table->save($obj); + + return $this->generateRedirect($obj); + } + /** * Handle a view action for a Standard object. * @@ -809,9 +852,7 @@ public function view($id = null) { catch(\Exception $e) { // findById throws Cake\Datasource\Exception\RecordNotFoundException $this->Flash->error($e->getMessage()); - // XXX This redirects to an Exception page because $id is not found. - // XXX A 404 with error would be better. - return $this->generateRedirect((int)$id); + return $this->generateRedirect(null); } $this->set('vv_obj', $obj); diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php index 8255d88df..293e9f9f2 100644 --- a/app/src/Lib/Enum/ActionEnum.php +++ b/app/src/Lib/Enum/ActionEnum.php @@ -46,4 +46,5 @@ class ActionEnum extends StandardEnum { const MVEADeleted = 'DMVE'; const MVEAEdited = 'EMVE'; const NamePrimary = 'PNAM'; + const PersonAddedPipeline = 'ACPL'; } \ No newline at end of file diff --git a/app/src/Lib/Enum/DeletedRoleStatusEnum.php b/app/src/Lib/Enum/DeletedRoleStatusEnum.php new file mode 100644 index 000000000..10a482045 --- /dev/null +++ b/app/src/Lib/Enum/DeletedRoleStatusEnum.php @@ -0,0 +1,38 @@ + $value) { + if((!isset($this->$field) && !empty($value)) // Value in $data but not $entity + || (isset($this->$field) && empty($value)) // Value in $entity but not $data + || (isset($this->$field) && $this->$field != $value)) { // Values don't match + // Not a match + $match = false; + break; + } + } + + return $match; + } + + /** + * Determine the source attribute foreign key (eg: source_name_id) for this entity. + * + * @since COmanage Registry v5.0.0 + * @return string Source Attribute column name + */ + + public function sourceAttributeName() { + // The class name is something like `\App\Model\Entity\TelephoneNumber', but we + // want telephone_number (lowercased). + $entityName = Inflector::underscore(substr(strrchr(get_class($this), '\\'),1)); + + return "source_" . $entityName . "_id"; + } +} diff --git a/app/src/Lib/Traits/HistoryTrait.php b/app/src/Lib/Traits/HistoryTrait.php index 95b678c63..0cd903a69 100644 --- a/app/src/Lib/Traits/HistoryTrait.php +++ b/app/src/Lib/Traits/HistoryTrait.php @@ -81,7 +81,9 @@ public function changesToString($entity): string { $newValue = $entity->get($field); - if(!empty($newValue)) { + if(!empty($newValue) + // get() appears to return related entities? + && is_string($newValue)) { if($field == 'type_id') { $newValue = $Types->getTypeLabel((int)$newValue); } @@ -98,6 +100,11 @@ public function changesToString($entity): string { $oldValue = $diff[$field]; $newValue = $entity->get($field); + // extractOriginalChanged will return associated models, which we skip + if(is_array($oldValue) || is_array($newValue)) { + continue; + } + if($field == 'type_id') { $oldValue = $Types->getTypeLabel((int)$diff[$field]); $newValue = $Types->getTypeLabel((int)$newValue); diff --git a/app/src/Lib/Traits/MVETrait.php b/app/src/Lib/Traits/MVETrait.php index d7e885341..bf3c5b66e 100644 --- a/app/src/Lib/Traits/MVETrait.php +++ b/app/src/Lib/Traits/MVETrait.php @@ -63,7 +63,7 @@ public function whereClause(): array { if(!empty($this->person_id)) { return [$this->getSource().'.person_id' => $this->person_id]; } elseif(!empty($this->external_identity_id)) { - return [$this->getSource().'.external_identity_id' => $entity->external_identity_id]; + return [$this->getSource().'.external_identity_id' => $this->external_identity_id]; } else { throw new \InvalidArgumentException(__d('error', 'notfound.person')); } diff --git a/app/src/Lib/Traits/PluggableModelTrait.php b/app/src/Lib/Traits/PluggableModelTrait.php index d8bb2eae9..b66b8bce5 100644 --- a/app/src/Lib/Traits/PluggableModelTrait.php +++ b/app/src/Lib/Traits/PluggableModelTrait.php @@ -115,6 +115,21 @@ public function pluginInUse(string $plugin): ResultSet { ->all(); } + /** + * Obtain the Plugin Model from an entity ID. + * + * @since COmanage Registry v5.0.0 + * @param int $id Entity ID + * @param array $options Options, as supported by get() + */ + + public function pluginModelForEntityId(int $id, array $options=[]) { + $entity = $this->get($id, $options); + $pModel = StringUtilities::pluginModel($entity->plugin); + + return $this->$pModel; + } + /** * Set up hasMany relations for instantiated plugin models. * diff --git a/app/src/Lib/Traits/ReadOnlyEntityTrait.php b/app/src/Lib/Traits/ReadOnlyEntityTrait.php index 592e07885..0673af91f 100644 --- a/app/src/Lib/Traits/ReadOnlyEntityTrait.php +++ b/app/src/Lib/Traits/ReadOnlyEntityTrait.php @@ -45,6 +45,11 @@ public function isReadOnly(): bool { return true; } + // Frozen attributes are treated as Read Only + if($this->frozen) { + return true; + } + // Records flagged as deleted or with a parent foreign key are read only // The class name is something like `\App\Model\Entity\PersonRole', but we just diff --git a/app/src/Lib/Traits/TableMetaTrait.php b/app/src/Lib/Traits/TableMetaTrait.php index 673de3046..e7590c0f7 100644 --- a/app/src/Lib/Traits/TableMetaTrait.php +++ b/app/src/Lib/Traits/TableMetaTrait.php @@ -31,43 +31,11 @@ use Cake\Utility\Inflector; use App\Lib\Enum\TableTypeEnum; +use App\Lib\Util\StringUtilities; trait TableMetaTrait { // What type of Table is this? private $tableType = null; - - /** - * Determine if this Table represents Registry artifacts. - * - * @since COmanage Registry v5.0.0 - * @return bool True if this Table represents artifact data, false otherwise - */ - - public function isArtifactTable() { - return $this->tableType === TableTypeEnum::Artifact; - } - - /** - * Determine if this Table represents Registry configuration. - * - * @since COmanage Registry v5.0.0 - * @return bool True if this Table represents Configuration data, false otherwise - */ - - public function isConfigurationTable() { - return $this->tableType === TableTypeEnum::Configuration; - } - - /** - * Set the type of this Table. - * - * @since COmanage Registry v5.0.0 - * @param TableTypeEnum $tableType Table Type - */ - - public function setTableType(string $tableType) { - $this->tableType = $tableType; - } /** * Filter metadata fields. @@ -93,7 +61,6 @@ protected function filterMetadataFields() { // Map the model (eg: Person) to the changelog key (person_id) $mfk = Inflector::underscore($modelName) . "_id"; - $meta_fields = [ ...$assc_keys, $mfk, @@ -107,14 +74,17 @@ protected function filterMetadataFields() { 'lft', // XXX For now i skip lft.rght column for tree structures 'rght', // 'parent_id', // todo: We need to filter using the parent_id. This should be an enumerator and should apply for all the models that use TreeBehavior - 'api_key' - // 'source_ad_hoc_attribute_id', - // 'source_address_id', - // 'source_email_address_id', - // 'source_identifier_id', - // 'source_name_id', - // 'source_external_identity_id', - // 'source_telephone_number_id', + 'api_key', + // XXX maybe replace this with a regex, source_*_id? + 'source_ad_hoc_attribute_id', + 'source_address_id', + 'source_email_address_id', + 'source_external_identity_id', + 'source_identifier_id', + 'source_name_id', + 'source_pronoun_id', + 'source_telephone_number_id', + 'source_url_id' ]; $newa = array(); @@ -130,4 +100,49 @@ protected function filterMetadataFields() { return $newa ?? []; } + + /** + * Determine if this Table represents Registry artifacts. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this Table represents artifact data, false otherwise + */ + + public function isArtifactTable() { + return $this->tableType === TableTypeEnum::Artifact; + } + + /** + * Determine if this Table represents Registry configuration. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this Table represents Configuration data, false otherwise + */ + + public function isConfigurationTable() { + return $this->tableType === TableTypeEnum::Configuration; + } + + /** + * Set the type of this Table. + * + * @since COmanage Registry v5.0.0 + * @param TableTypeEnum $tableType Table Type + */ + + public function setTableType(string $tableType) { + $this->tableType = $tableType; + } + + /** + * Determine the source foreign key attribute for this table, for tables that + * have Pipelined attributes from External Identities to People. + * + * @since COmanage Registry v5.0.0 + * @return string Source name field (eg: source_name_id) + */ + + public function sourceForeignKey(): string { + return "source_" . Inflector::underscore(StringUtilities::tableToEntityName($this)) . "_id"; + } } diff --git a/app/src/Lib/Util/SchemaManager.php b/app/src/Lib/Util/SchemaManager.php index 5534723cb..1819ecf86 100644 --- a/app/src/Lib/Util/SchemaManager.php +++ b/app/src/Lib/Util/SchemaManager.php @@ -245,6 +245,9 @@ protected function processSchema( $table->addForeignKeyConstraint($tablePrefix.$fkTable, [$mColumn], ['id'], [], $tablePrefix.$tName . "_" . $mColumn . "_fkey"); $table->addIndex([$mColumn], $tablePrefix.$tName . "_im" . $i++); } + + // MVEA tables also support frozen flags + $table->addColumn("frozen", "boolean", ['notnull' => false]); } if(isset($tCfg->indexes)) { @@ -268,8 +271,8 @@ protected function processSchema( } // (For Registry) If an attribute is "sourced" it is a CO Person attribute - // that is copied via a Pipeline from an Org Identity that was created from - // an Org Identity Source, so we need a foreign key into ourself. + // that is copied via a Pipeline from an External Identity that was created from + // an External Identity Source, so we need a foreign key into ourself. if(isset($tCfg->sourced) && $tCfg->sourced) { $sColumn = "source_" . $tablePrefix.\Cake\Utility\Inflector::singularize($tName) . "_id"; diff --git a/app/src/Model/Entity/AdHocAttribute.php b/app/src/Model/Entity/AdHocAttribute.php index e93f97d3f..66c1922b2 100644 --- a/app/src/Model/Entity/AdHocAttribute.php +++ b/app/src/Model/Entity/AdHocAttribute.php @@ -32,6 +32,7 @@ use Cake\ORM\Entity; class AdHocAttribute extends Entity { + use \App\Lib\Traits\EntityMetaTrait; use \App\Lib\Traits\ReadOnlyEntityTrait; use \App\Lib\Traits\MVETrait; diff --git a/app/src/Model/Entity/Address.php b/app/src/Model/Entity/Address.php index d990e8c0d..f58bc0d80 100644 --- a/app/src/Model/Entity/Address.php +++ b/app/src/Model/Entity/Address.php @@ -32,6 +32,7 @@ use Cake\ORM\Entity; class Address extends Entity { + use \App\Lib\Traits\EntityMetaTrait; use \App\Lib\Traits\ReadOnlyEntityTrait; use \App\Lib\Traits\MVETrait; diff --git a/app/src/Model/Entity/EmailAddress.php b/app/src/Model/Entity/EmailAddress.php index 85c03fd99..99640e435 100644 --- a/app/src/Model/Entity/EmailAddress.php +++ b/app/src/Model/Entity/EmailAddress.php @@ -32,6 +32,7 @@ use Cake\ORM\Entity; class EmailAddress extends Entity { + use \App\Lib\Traits\EntityMetaTrait; use \App\Lib\Traits\ReadOnlyEntityTrait; use \App\Lib\Traits\MVETrait; diff --git a/app/src/Model/Entity/ExtIdentitySourceRecord.php b/app/src/Model/Entity/ExtIdentitySourceRecord.php new file mode 100644 index 000000000..85dfc7783 --- /dev/null +++ b/app/src/Model/Entity/ExtIdentitySourceRecord.php @@ -0,0 +1,44 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/ExternalIdentitySource.php b/app/src/Model/Entity/ExternalIdentitySource.php new file mode 100644 index 000000000..13ac3780a --- /dev/null +++ b/app/src/Model/Entity/ExternalIdentitySource.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Identifier.php b/app/src/Model/Entity/Identifier.php index a86ba05d7..bc231646d 100644 --- a/app/src/Model/Entity/Identifier.php +++ b/app/src/Model/Entity/Identifier.php @@ -32,6 +32,7 @@ use Cake\ORM\Entity; class Identifier extends Entity { + use \App\Lib\Traits\EntityMetaTrait; use \App\Lib\Traits\ReadOnlyEntityTrait; use \App\Lib\Traits\MVETrait; diff --git a/app/src/Model/Entity/Name.php b/app/src/Model/Entity/Name.php index da742b5b6..c08e31093 100644 --- a/app/src/Model/Entity/Name.php +++ b/app/src/Model/Entity/Name.php @@ -32,6 +32,7 @@ use Cake\ORM\Entity; class Name extends Entity { + use \App\Lib\Traits\EntityMetaTrait; use \App\Lib\Traits\ReadOnlyEntityTrait; use \App\Lib\Traits\MVETrait; diff --git a/app/src/Model/Entity/Pipeline.php b/app/src/Model/Entity/Pipeline.php new file mode 100644 index 000000000..8df0fb96b --- /dev/null +++ b/app/src/Model/Entity/Pipeline.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Pronoun.php b/app/src/Model/Entity/Pronoun.php index e2825a8b4..872be9521 100644 --- a/app/src/Model/Entity/Pronoun.php +++ b/app/src/Model/Entity/Pronoun.php @@ -33,6 +33,7 @@ // Strictly speaking, this should probably be "Pronouns", but it's easier not to fight inflection class Pronoun extends Entity { + use \App\Lib\Traits\EntityMetaTrait; use \App\Lib\Traits\ReadOnlyEntityTrait; use \App\Lib\Traits\MVETrait; diff --git a/app/src/Model/Entity/TelephoneNumber.php b/app/src/Model/Entity/TelephoneNumber.php index 3ee0ad622..305cf34b6 100644 --- a/app/src/Model/Entity/TelephoneNumber.php +++ b/app/src/Model/Entity/TelephoneNumber.php @@ -32,6 +32,7 @@ use Cake\ORM\Entity; class TelephoneNumber extends Entity { + use \App\Lib\Traits\EntityMetaTrait; use \App\Lib\Traits\ReadOnlyEntityTrait; use \App\Lib\Traits\MVETrait; diff --git a/app/src/Model/Entity/Url.php b/app/src/Model/Entity/Url.php index 119f40442..d92eda483 100644 --- a/app/src/Model/Entity/Url.php +++ b/app/src/Model/Entity/Url.php @@ -32,6 +32,7 @@ use Cake\ORM\Entity; class Url extends Entity { + use \App\Lib\Traits\EntityMetaTrait; use \App\Lib\Traits\ReadOnlyEntityTrait; use \App\Lib\Traits\MVETrait; diff --git a/app/src/Model/Table/AdHocAttributesTable.php b/app/src/Model/Table/AdHocAttributesTable.php index f1becd357..0e02e5e35 100644 --- a/app/src/Model/Table/AdHocAttributesTable.php +++ b/app/src/Model/Table/AdHocAttributesTable.php @@ -79,14 +79,18 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); $this->setRequiresCO(true); $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], @@ -130,6 +134,11 @@ public function validationDefault(Validator $validator): Validator { $this->registerStringValidation($validator, $schema, 'value', false); + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + $validator->add('source_ad_hoc_attribute_id', [ 'content' => ['rule' => 'isInteger'] ]); diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index 254b39df3..6f84cabf1 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -96,6 +96,7 @@ public function initialize(array $config): void { $this->setRequiresCO(true); $this->setAcceptsCoId(true); $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); $this->setAutoViewVars([ 'languages' => [ @@ -113,8 +114,11 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], @@ -248,6 +252,11 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('type_id'); + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + $validator->add('source_address_id', [ 'content' => ['rule' => 'isInteger'] ]); diff --git a/app/src/Model/Table/CosTable.php b/app/src/Model/Table/CosTable.php index 56a16dc94..76b31bc57 100644 --- a/app/src/Model/Table/CosTable.php +++ b/app/src/Model/Table/CosTable.php @@ -80,12 +80,18 @@ public function initialize(array $config): void { $this->hasMany('Groups') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('IdentifierAssignments') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Jobs') ->setDependent(true) ->setCascadeCallbacks(true); $this->hasMany('People') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('Pipelines') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Reports') ->setDependent(true) ->setCascadeCallbacks(true); diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index bbb1d4316..b340696a5 100644 --- a/app/src/Model/Table/CousTable.php +++ b/app/src/Model/Table/CousTable.php @@ -79,6 +79,12 @@ public function initialize(array $config): void { ->setCascadeCallbacks(true); // AR-COU-1 A COU may not be deleted if it has any members. $this->hasMany('PersonRoles'); + $this->hasMany('SyncCouPipelines') + ->setClassName('Pipelines') + ->setForeignKey('sync_cou_id'); + $this->hasMany('SyncReplaceCouPipelines') + ->setClassName('Pipelines') + ->setForeignKey('sync_replace_cou_id'); $this->setDisplayField('name'); diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index a1e872c37..8b43242fb 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -95,6 +95,7 @@ public function initialize(array $config): void { $this->setAllowLookupPrimaryLink(['primary']); $this->setRequiresCO(true); $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); $this->setAutoViewVars([ 'types' => [ @@ -108,8 +109,11 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], @@ -189,6 +193,11 @@ public function validationDefault(Validator $validator): Validator { $this->registerStringValidation($validator, $schema, 'description', false); + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + $validator->add('source_email_address_id', [ 'content' => ['rule' => 'isInteger'] ]); diff --git a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php new file mode 100644 index 000000000..b55509804 --- /dev/null +++ b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php @@ -0,0 +1,131 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('ExternalIdentitySources'); + + $this->setDisplayField('source_key'); + + $this->setPrimaryLink(['external_identity_source_id']); + $this->setRequiresCO(true); + + $this->setViewContains([ + 'ExternalIdentitySources' + ]); + + $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, +// CFM-32 Update this permission when made available via the Artifacts menu + 'index' => false // ['platformAdmin', 'coAdmin'], + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('external_identity_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('external_identity_source_id'); + + $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 +// $this->registerStringValidation($validator, $schema, 'source_record', false); + + $validator->add('last_updane', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('last_update'); + + $validator->add('external_identity_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('external_identity_id'); + + $this->registerStringValidation($validator, $schema, 'reference_identifier', false); + + 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 986300d14..1b9390035 100644 --- a/app/src/Model/Table/ExternalIdentitiesTable.php +++ b/app/src/Model/Table/ExternalIdentitiesTable.php @@ -33,10 +33,11 @@ use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\Validation\Validator; -use \App\Lib\Enum\StatusEnum; +use \App\Lib\Enum\ExternalIdentityStatusEnum; class ExternalIdentitiesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; @@ -82,6 +83,9 @@ public function initialize(array $config): void { $this->hasMany('ExternalIdentityRoles') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('ExtIdentitySourceRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('HistoryRecords') ->setDependent(true) ->setCascadeCallbacks(true); @@ -120,8 +124,14 @@ public function initialize(array $config): void { 'TelephoneNumbers', 'Urls' ]); + $this->setIndexContains(['PrimaryName']); + $this->setViewContains([ + 'PrimaryName', + 'ExtIdentitySourceRecords' => ['ExternalIdentitySources'] + ]); + $this->setAutoViewVars([ 'statuses' => [ 'type' => 'enum', @@ -163,6 +173,22 @@ public function generateDisplayField(\App\Model\Entity\ExternalIdentity $entity) return $entity->primary_name->full_name; } + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + return true; + } + /** * Set validation rules. * @@ -176,9 +202,10 @@ public function validationDefault(Validator $validator): Validator { $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + $this->registerStringValidation($validator, $schema, 'source_key', true); + $validator->add('status', [ -// XXX what to do about the sync status? - 'content' => ['rule' => ['inList', StatusEnum::getConstValues()]] + 'content' => ['rule' => ['inList', ExternalIdentityStatusEnum::getConstValues()]] ]); $validator->notEmptyString('status'); diff --git a/app/src/Model/Table/ExternalIdentityRolesTable.php b/app/src/Model/Table/ExternalIdentityRolesTable.php index 299284fa6..b54eae789 100644 --- a/app/src/Model/Table/ExternalIdentityRolesTable.php +++ b/app/src/Model/Table/ExternalIdentityRolesTable.php @@ -33,12 +33,15 @@ use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\Validation\Validator; -use \App\Lib\Enum\StatusEnum; +use \App\Lib\Enum\ActionEnum; +use \App\Lib\Enum\ExternalIdentityStatusEnum; class ExternalIdentityRolesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + 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; @@ -75,6 +78,10 @@ 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); @@ -122,6 +129,39 @@ public function initialize(array $config): void { ]); } + /** + * Callback before model delete. + * + * @since COmanage Registry v5.0.0 + * @param CakeEventEvent $event The beforeDelete event + * @param $entity Entity + * @param ArrayObject $options Options + * @return boolean True on success + */ + + public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $options) { + // Is there a Person Role associated with this EI Role? + if(!empty($entity->id)) { + $prole = $this->PersonRoles->find() + ->where(['PersonRoles.source_external_identity_role_id' => $entity->id]) + ->first(); + + if(!empty($prole)) { + // Unset the foreign key to the source EI Role so we don't cascade + // deletes or otherwise mess things up. + + $this->llog('trace', "Removing link from PersonRole " . $prole->id . " to source ExternalIdentityRole " . $entity->id); + + $prole->source_external_identity_role_id = null; + $this->PersonRoles->saveOrFail($prole); + } + } + + $this->recordHistory(entity: $entity, action: ActionEnum::MVEADeleted); + + return true; + } + /** * Table specific logic to generate a display field. * @@ -142,6 +182,42 @@ public function generateDisplayField(\App\Model\Entity\ExternalIdentityRole $ent return (string)$entity->id; } + /** + * Define the table's implemented events. + * + * @since COmanage Registry v5.0.0 + */ + + public function implementedEvents(): array { + $events = parent::implementedEvents(); + + // We need to adjust our beforeDelete priority to run before ChangelogBehavior's. + $events['Model.beforeDelete'] = [ + 'callable' => 'beforeDelete', + 'priority' => 1 + ]; + + return $events; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + if(!$entity->deleted) { + $this->recordHistory($entity); + } + + return true; + } + /** * Set validation rules. * @@ -155,6 +231,8 @@ public function validationDefault(Validator $validator): Validator { $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + $this->registerStringValidation($validator, $schema, 'role_key', true); + $validator->add('affiliation_type_id', [ 'content' => ['rule' => 'isInteger'] ]); @@ -181,7 +259,7 @@ public function validationDefault(Validator $validator): Validator { $validator->allowEmptyString('valid_through'); $validator->add('status', [ - 'content' => ['rule' => ['inList', StatusEnum::getConstValues()]] + 'content' => ['rule' => ['inList', ExternalIdentityStatusEnum::getConstValues()]] ]); $validator->notEmptyString('status'); diff --git a/app/src/Model/Table/ExternalIdentitySourcesTable.php b/app/src/Model/Table/ExternalIdentitySourcesTable.php new file mode 100644 index 000000000..4cbabaa28 --- /dev/null +++ b/app/src/Model/Table/ExternalIdentitySourcesTable.php @@ -0,0 +1,230 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('Pipelines'); + + $this->hasMany('ExtIdentitySourceRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink(['co_id']); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['retrieve', 'search', 'sync']); + + $this->setAutoViewVars([ + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'source' + ], + 'pipelines' => [ + 'type' => 'select', + 'model' => 'Pipelines' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SyncModeEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'retrieve' => ['platformAdmin', 'coAdmin'], + 'search' => ['platformAdmin', 'coAdmin'], + 'sync' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + 'status' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Retrieve a record from an External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param int $id External Identity Source ID + * @param string $source_key EIS Backend Source Key + * @return array Array of source_key, source_record, and entity_data + */ + + public function retrieve(int $id, string $source_key): array { + // We want to pull the plugin configuration along with the EIS, to make + // the query simpler we contain all possible relations, which will + // usually only be a small number. + $source = $this->get($id, ['contain' => $this->getPluginRelations()]); + + $pModel = StringUtilities::pluginModel($source->plugin); + + return $this->$pModel->retrieve($source, $source_key); + } + + /** + * Search the External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param int $id External Identity Source ID + * @param array $searchAttrs Array of search attributes and values, as configured by searchAttributes() + * @return array Array of matching records + */ + + public function search(int $id, array $attrs): array { + // We want to pull the plugin configuration along with the EIS, to make + // the query simpler we contain all possible relations, which will + // usually only be a small number. + $source = $this->get($id, ['contain' => $this->getPluginRelations()]); + + $pModel = StringUtilities::pluginModel($source->plugin); + + return $this->$pModel->search($source, $attrs); + } + + /** + * Obtain the set of searchable attributes for this backend. + * + * @since COmanage Registry v5.0.0 + * @return array Array of searchable attributes and localized descriptions + */ + + public function searchableAttributes(int $id) { + $pModel = $this->pluginModelForEntityId($id); + + return $pModel->searchableAttributes(); + } + + /** + * Sync an External Identity from a Source to a Person via a Pipeline. + * + * @since COmanage Registry v5.0.0 + * @param int $id External Identity Source ID + * @param string $source_key EIS Backend Source Key + */ + + public function sync(int $id, string $source_key) { + // All work is actually handled by the Pipeline, but we need our configuration + // to know which Pipeline. + $eis = $this->get($id); + + // Also get the current record from the Backend, which might have been deleted + $eisBackendRecord = $this->retrieve($id, $source_key); + + $this->Pipelines->execute( + id: $eis->pipeline_id, + eisId: $id, + eisBackendRecord: $eisBackendRecord, + // Force the full Pipeline run even if the backend record didn't change + force: true + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SyncModeEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + $this->registerStringValidation($validator, $schema, 'sor_label', false); + + $validator->add('pipeline_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('pipeline_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index cf291eafb..56655a11b 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -29,6 +29,7 @@ namespace App\Model\Table; +use Cake\Event\EventInterface; use Cake\ORM\Table; use Cake\Validation\Validator; use \App\Lib\Enum\SuspendableStatusEnum; @@ -41,6 +42,7 @@ class IdentifiersTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; + use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; @@ -108,6 +110,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'group_id', 'person_id']); $this->setRequiresCO(true); $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); $this->setAutoViewVars([ 'types' => [ @@ -125,8 +128,11 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], @@ -139,6 +145,23 @@ public function initialize(array $config): void { ]); } + /** + * Callback before data is marshaled into an entity. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(empty($data['status'])) { + // Set a default status of Active if not otherwise set (eg: via EIS/Pipelines) + $data['status'] = SuspendableStatusEnum::Active; + } + } + /** * Callback after model save. * @@ -229,6 +252,11 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('status'); + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + $validator->add('source_identifier_id', [ 'content' => ['rule' => 'isInteger'] ]); diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index 30cc37c70..809b215e4 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -29,6 +29,7 @@ namespace App\Model\Table; +use \Cake\Event\EventInterface; use \Cake\ORM\Query; use \Cake\ORM\RulesChecker; use \Cake\ORM\Table; @@ -96,7 +97,7 @@ public function initialize(array $config): void { $this->setDisplayField('full_name'); $this->setPrimaryLink(['external_identity_id', 'person_id']); - $this->setAllowLookupPrimaryLink(['primary']); + $this->setAllowLookupPrimaryLink(['primary', 'unfreeze']); $this->setRequiresCO(true); $this->setAcceptsCoId(true); $this->setRedirectGoal('self'); @@ -118,8 +119,11 @@ public function initialize(array $config): void { 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], 'primary' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], @@ -128,6 +132,24 @@ public function initialize(array $config): void { ]); } + /** + * Callback before data is marshaled into an entity. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(!empty($data['source_name_id'])) { + // Source records may not assert primary name on the Person copy. +// XXX this implies an EIS name cannot be a primary name - document as an AR + $data['primary_name'] = false; + } + } + /** * Define business rules. * @@ -389,6 +411,11 @@ public function validationDefault(Validator $validator): Validator { $this->registerStringValidation($validator, $schema, 'display_name', false); + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + $validator->add('source_name_id', [ 'content' => ['rule' => 'isInteger'] ]); diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 066393201..7008dbe2a 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -73,6 +73,11 @@ public function initialize(array $config): void { $this->hasOne('PrimaryName') ->setClassName('Names') + // We have to explicitly set the foreign key here so that the relations + // ManagerPeople and SponsorPeople (in PersonRolesTable) get the correct + // foreign key into Names table when pulling data via contains (as in + // marshalProvisioningData(), below) + ->setForeignKey('person_id') ->setConditions(['PrimaryName.primary_name' => true]); $this->hasMany('Names') ->setDependent(true) @@ -92,9 +97,6 @@ public function initialize(array $config): void { $this->hasMany('GroupMembers') ->setDependent(true) ->setCascadeCallbacks(true); - $this->hasMany('GroupOwners') - ->setDependent(true) - ->setCascadeCallbacks(true); $this->hasMany('HistoryRecords') ->setDependent(true) ->setCascadeCallbacks(true); @@ -197,9 +199,16 @@ public function initialize(array $config): void { */ public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $options) { - // Note this callback successfully fires because ChangelogBehavior ignores - // hard deletes. See GroupsTable for an example of using implementedEvents() - // to change priorities. +// XXX we are effectively reimplementing expunge logic here, maybe move it to +// a new protected PeopleTable::expunge() function (called only from here)? + // If we were only dealing with hard delete, we wouldn't need implementedEvents() + // below, because ChangelogBehavior ignores hard deletes. + + // Whether soft or hard deleting, we need to remove Automatic Group Memberships + // before we delete the Person, or lookups performed while managing those + // group memberships will fail. + + $this->reconcileCoMembersGroupMemberships(entity: $entity, deleted: true); if(isset($options['useHardDelete']) && $options['useHardDelete'] @@ -216,6 +225,21 @@ public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $op [ 'sponsor_person_id' => null ], [ 'sponsor_person_id' => $entity->id ] ); + + // Manually delete any names, since the validation rules will fail on cascade. + $this->Names->deleteAll( + [ 'person_id' => $entity->id ] + ); + } else { + // Manually delete any names, since the validation rules will fail on cascade. + // Since this isn't a hard delete we can't use deleteAll since we need + // ChangelogBehavior to fire. + + $names = $this->Names->find()->where(['person_id' => $entity->id])->all(); + + foreach($names as $n) { + $this->Names->delete($n, ['checkRules' => false]); + } } return true; @@ -254,6 +278,24 @@ public function getMembers(int $coId): PaginatedSqlIterator { return new PaginatedSqlIterator($this, $conditions); } + /** + * Define the table's implemented events. + * + * @since COmanage Registry v5.0.0 + */ + + public function implementedEvents(): array { + $events = parent::implementedEvents(); + + // We need to adjust our beforeDelete priority to run before ChangelogBehavior's. + $events['Model.beforeDelete'] = [ + 'callable' => 'beforeDelete', + 'priority' => 1 + ]; + + return $events; + } + /** * Callback after model save. * @@ -270,7 +312,10 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour // XXX implement this eventually? //$provision = (isset($options['provision']) ? $options['provision'] : true); - $this->reconcileCoMembersGroupMemberships($entity); + if(!$entity->deleted) { + // If the entity was deleted we handled this in beforeDelete, above + $this->reconcileCoMembersGroupMemberships($entity); + } return true; } @@ -312,7 +357,6 @@ public function marshalProvisioningData(int $id): array { 'Urls' => [ 'Types' ] ], 'GroupMembers' => [ 'Groups' ], - 'GroupOwners' => [ 'Groups' ], 'Identifiers' => [ 'Types' ], 'Names' => [ 'Types' ], 'PersonRoles' => [ @@ -431,15 +475,20 @@ public function marshalProvisioningData(int $id): array { * @since COmanage Registry v5.0.0 * @param EntityInterface $entity Person Entity * @param bool $provision Whether to run provisioners + * @param bool $deleted Whether $entity should be treated as deleted * @throws InvalidArgumentException * @throws RuntimeException */ - public function reconcileCoMembersGroupMemberships(\Cake\Datasource\EntityInterface $entity, bool $provision=true) { + public function reconcileCoMembersGroupMemberships( + \Cake\Datasource\EntityInterface $entity, + bool $provision=true, + bool $deleted=false + ) { // This is similar to PersonRole::reconcileCouMembersGroupMemberships. - - $activeEligible = $entity->isActive(); - $allEligible = $entity->status != StatusEnum::Archived; + + $activeEligible = !$deleted && $entity->isActive(); + $allEligible = !$deleted && ($entity->status != StatusEnum::Archived); // Update the automatic CO groups $this->llog('rule', "AR-Person-1 Syncing membership in All Members Group for CO " . $entity->co_id . " for Person " . $entity->id . ", eligibility=" . $allEligible); diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php index 900f727ff..97035061c 100644 --- a/app/src/Model/Table/PersonRolesTable.php +++ b/app/src/Model/Table/PersonRolesTable.php @@ -96,6 +96,10 @@ public function initialize(array $config): void { ->setClassName('People') ->setForeignKey('sponsor_person_id') ->setProperty('sponsor_person'); + $this->belongsTo('SourceExternalIdentityRoles') + ->setClassName('ExternalIdentityRoles') + ->setForeignKey('source_external_identity_role_id') + ->setProperty('source_external_identity_role'); $this->belongsTo('Types') ->setForeignKey('affiliation_type_id') ->setProperty('affiliation_type'); @@ -118,7 +122,8 @@ public function initialize(array $config): void { $this->setPrimaryLink('person_id'); $this->setRequiresCO(true); $this->setRedirectGoal('self'); - + $this->setAllowLookupPrimaryLink(['unfreeze']); + $this->setEditContains([ 'Addresses', 'AdHocAttributes', @@ -131,7 +136,12 @@ public function initialize(array $config): void { }]], 'SponsorPeople' => ['Names' => ['queryBuilder' => function ($q) { return $q->where(['primary_name' => true]); - }]] + }]], + 'SourceExternalIdentityRoles' + ]); + + $this->setViewContains([ + 'SourceExternalIdentityRoles' ]); $this->setAutoViewVars([ @@ -156,8 +166,11 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], @@ -422,6 +435,19 @@ public function search(int $coId, string $q, int $limit) { ->all(); } + /** + * Determine the source foreign key attribute for this table, for tables that + * have Pipelined attributes from External Identities to People. + * + * @since COmanage Registry v5.0.0 + * @return string Source name field (eg: source_name_id) + */ + + public function sourceForeignKey(): string { + // PersonRoles doesn't follow the standard pattern + return "source_external_identity_role_id"; + } + /** * Set validation rules. * @@ -484,7 +510,17 @@ public function validationDefault(Validator $validator): Validator { 'content' => ['rule' => 'isInteger'] ]); $validator->allowEmptyString('ordr'); + + $validator->add('source_external_identity_role_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('source_external_identity_role_id'); + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php new file mode 100644 index 000000000..77264c9eb --- /dev/null +++ b/app/src/Model/Table/PipelinesTable.php @@ -0,0 +1,1406 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('MatchServers') + ->setClassName('Servers') + ->setForeignKey('match_server_id') + ->setProperty('match_server'); + $this->belongsTo('MatchTypes') + ->setClassName('Types') + ->setForeignKey('match_type_id') + ->setProperty('match_type'); + $this->belongsTo('SyncAffiliationTypes') + ->setClassName('Types') + ->setForeignKey('sync_affiliation_type_id') + ->setProperty('sync_affiliation_type'); + $this->belongsTo('SyncCous') + ->setClassName('Cous') + ->setForeignKey('sync_cou_id') + ->setProperty('sync_cou'); + $this->belongsTo('SyncReplaceCous') + ->setClassName('Cous') + ->setForeignKey('sync_replace_cou_id') + ->setProperty('sync_replace_cou'); + $this->belongsTo('SyncIdentifierTypes') + ->setClassName('Types') + ->setForeignKey('sync_identifier_type_id') + ->setProperty('sync_identifier_type'); + + $this->hasMany('ExternalIdentitySources'); + + $this->setDisplayField('description'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'matchStrategies' => [ + 'type' => 'enum', + 'class' => 'MatchStrategyEnum' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ], + 'syncAffiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ], + 'syncCous' => [ + 'type' => 'select', + 'model' => 'Cous' + ], + 'syncIdentifierTypes' => [ + 'type' => 'select', +// XXX We need to filter this to just Person Identifiers + 'model' => 'Types' + ], + 'syncReplaceCous' => [ + 'type' => 'select', + 'model' => 'Cous' + ], + // Just go with Cake's default pluralization + 'syncStatusOnDeletes' => [ + 'type' => 'enum', + 'class' => 'DeletedRoleStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Correlate an array of mapped backend record data (as returned by + * mapAttributesToCO) to an existing External Identity (as returned by get) + * by finding ID keys for related models. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentity $externalIdentity ExternalIdentity, including related models + * @param array $mapped Backend data, including related models + * @return array Backend data, with record keys + */ + + protected function correlateRecordKeys( + ExternalIdentity $externalIdentity, + array $mapped + ): array { + $ret = $mapped; + + // Unlike when syncing an External Identity to a Person, syncing an EIS Record + // to an External Identity doesn't have a key to map associated models in the + // EIS Record to the External Identity. (The exception being the role_key on + // the External Identity Role.) + + // While we could enforce a key as part of the EIS API, that might be tricky + // for some backends to implement (eg: an LdapSource record with two email + // addresses can't guarantee it will always retrieve them in the same order), + // and the only benefit of such a requirement would be that we could do an update + // rather than a delete and add. + + // Instead we try to map records in the Backend data to records in the + // External Identity data. We mostly iteratively loop over the various related + // models. This isn't necessarily the most efficient approach, but in most cases + // we're dealing with O(1) MVEA records. (eg: An EIS record will typically have + // 0, 1, or maybe 2 EmailAddresses attached to it.) + + // Note that our goal here is to identity attributes that _haven't_ changed. + // By finding a matching ID patchEntity() will know not to update the related + // model. If an attribute changes in the Backend record, we won't match it + // here and the old value will be deleted while the new value will be added. + + // Start with the ID of the External Identity itself. + $ret['id'] = $externalIdentity->id; + + // Next look through the External Identity's related models. + foreach([ + // related models need EntityMetaTrait + 'ad_hoc_attributes', + 'addresses', + 'email_addresses', + 'identifiers', + 'names', + 'pronouns', + 'telephone_numbers', + 'urls' + ] as $m) { + if(!empty($externalIdentity->$m) && !empty($ret[$m])) { + // There is at least one associated model of this type on the + // External Identity, and in the mapped Backend data + foreach($externalIdentity->$m as $rentity) { + // Check all mapped records for the same model + foreach($ret[$m] as $i => $mdata) { + if(!isset($ret[$m][$i]['id']) // We saw this one already + && $rentity->isProbablyThisArray($mdata)) { + // Insert the record ID + $ret[$m][$i]['id'] = $rentity->id; + break; // We can exit the inner loop, but not the outer one + } + } + } + + // And make sure each mapped Backend record has a parent record ID. + // We do this separately to catch any new records. + foreach(array_keys($ret[$m]) as $i) { + $ret[$m][$i]['external_identity_id'] = $externalIdentity->id; + } + } + } + + // Now map any External Identity Roles. We can use the role_key to help here. + if(!empty($externalIdentity->external_identity_roles) + && !empty($ret['external_identity_roles'])) { + foreach($externalIdentity->external_identity_roles as $roleentity) { + foreach($ret['external_identity_roles'] as $i => $rdata) { + if($roleentity->role_key == $rdata['role_key']) { + // Insert the record ID + $ret['external_identity_roles'][$i]['id'] = $roleentity->id; + break; // We can exit the inner loop, but not the outer one + } + } + + // Insert the parent record ID, again separately to catch any new records. + foreach(array_keys($ret['external_identity_roles']) as $i) { + $ret['external_identity_roles'][$i]['external_identity_id'] = $externalIdentity->id; + } + } + + // And finally any related models for the External Identity. We can skip this + // for new Roles since the related models are also going to be new (and + // therefore not have existing keys). For deleted Roles, when the Role itself + // is deleted the associated models will also be deleted (as dependencies) so + // we don't need to facilitate that here. + + foreach($externalIdentity->external_identity_roles as $roleentity) { + foreach($ret['external_identity_roles'] as $i => $rdata) { + foreach([ + // related models need EntityMetaTrait + 'ad_hoc_attributes', + 'addresses', + 'telephone_numbers' + ] as $m) { + if(!empty($roleentity->$m) + && !empty($ret['external_identity_roles'][$m])) { + // There is at least one associated model of this type on the + // External Identity Role, and in the mapped Backend data + foreach($roleentity->$m as $rentity) { + // Check all mapped records for the same model + foreach($ret['external_identity_roles'][$m] as $i => $mdata) { + if(!isset($ret['external_identity_roles'][$m][$i]['id']) // We saw this one already + && $rentity->isProbablyThisArray($mdata)) { + // Insert the record ID + $ret['external_identity_roles'][$m][$i]['id'] = $rentity->id; + break; // We can exit the inner loop, but not the outer ones + } + } + + // Insert the parent record ID, separately to catch any new records + foreach(array_keys($ret['external_identity_roles'][$m]) as $i) { + $ret['external_identity_roles'][$m][$i]['external_identity_role_id'] = $roleentity->id; + } + } + } + } + } + } + } + + return $ret; + } + + /** + * Create an initial Person record from an External Identity. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentitySource $eis External Identity Source + * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record + * @param array $eisAttributes Attributes provided by EIS Backend + * @return Person New Person entity + */ + + protected function createPersonFromEIS( + Pipeline $pipeline, + ExternalIdentitySource $eis, + ExtIdentitySourceRecord $eisRecord, + array $eisAttributes + ): Person { + // This is a skeletal Person record so that we can hang other entities + // from it (eg: External Identity). As such, we're just prepopulating + // some defaults but these values are NOT linked to the original source + // record and can be changed via other steps in the Pipeline, an + // administrator, or self service tooling. We also do not create roles + // or run identifier assignments, etc, that stuff happens later in the + // Pipeline. + + $mappedAttributes = $this->mapAttributesToCO($pipeline, $eisAttributes); + + $newPerson = [ + 'co_id' => $pipeline->co_id, + 'status' => StatusEnum::Pending + ]; + + if(!empty($mappedAttributes['date_of_birth'])) { + $newPerson['date_of_birth'] = $mappedAttributes['date_of_birth']; + } + + if(empty($mappedAttributes['names'][0])) { + throw new \InvalidArgumentException('At least one name is required for createPersonFromEIS'); + } + + // AR-Pipeline-1 If a Pipeline creates a new Person, the first Name + // returned by the External Identity Source backend will be used as + // the initial Primary Name for the new Person. + $newPerson['names'][] = $mappedAttributes['names'][0]; + // Force this to be the primary name just in case it wasn't set + $newPerson['names'][0]['primary_name'] = true; + + $entity = $this->Cos->People->newEntity($newPerson); + + $this->Cos->People->saveOrFail($entity); + + $this->Cos->People->recordHistory( + entity: $entity, + action: ActionEnum::PersonAddedPipeline, + comment: __d('result', + 'People.added.pipeline', + [$pipeline->description, + $pipeline->id, + $eis->description, + $eis->id, + $eisRecord->source_key]) + ); + + return $entity; + } + + /** + * Execute the specified Pipeline on the provided EIS data. + * + * @since COmanage Registry v5.0.0 + * @param int $id Pipeline ID + * @param int $eisId Exxternal Identity Source ID + * @param array $eisBackendRecord Record returned by EIS Backend + * @param bool $force Force the Pipeline to run all steps, even if no changes were detected + */ + + public function execute( + int $id, + int $eisId, + array $eisBackendRecord, + bool $force=false + ) { + // Start with our configuration(s) + $pipeline = $this->get($id); + $eis = $this->ExternalIdentitySources->get($eisId); + + // Start a Transaction + $cxn = $this->getConnection(); + $cxn->begin(); + + try { + $this->llog('trace', "Executing Pipeline $id for EIS $eisId source key " . $eisBackendRecord['source_key']); + + // (1) Create or update the ExtIdentitySourceRecord based on the + // data provided by the backend + $eisRecord = $this->manageEISRecord( + $pipeline, + $eis, + $eisBackendRecord['source_key'], + $eisBackendRecord['source_record'] + ); + + if(!$force && $eisRecord['status'] == 'unchanged') { + $this->llog('trace', "Record for EIS $eisId source key " . $eisBackendRecord['source_key'] . " is unchanged, stopping Pipeline"); + + $cxn->commit(); + $return; + } + + // (2) Match against an existing Person or create a new Person, in + // accordance with the Pipeline's Match Strategy + $person = $this->obtainPerson( + $pipeline, + $eis, + $eisRecord['record'], + $eisBackendRecord['entity_data'] + ); + + // (3) Create or update an External Identity based on the sync strategy + // and the backend attributes + $externalIdentity = $this->syncExternalIdentity( + $pipeline, + $person, + $eis, + $eisRecord['record'], + $eisBackendRecord['entity_data'] + ); + + // (4) Sync the External Identity attributes with the Person record + $person = $this->syncPerson( + $pipeline, + $externalIdentity, + $person + ); + + // (5) Assign Identifiers + + // We can basically ignore the results from assign() since we don't + // directly report them anywhere. + + $this->Cos->IdentifierAssignments->assign( + entityType: 'People', + entityId: $person->id, + provision: false, +// XXX should we pass this in when we have it? CFM-343 + actorPersonId: null + ); + + // (6) Update Person Status + + $person = $this->updatePersonStatus( + $pipeline, + $externalIdentity, + $person + ); + + // (7) Provision + + $this->Cos->People->requestProvisioning( + id: $person->id, + context: ProvisioningContextEnum::Automatic + ); + + $this->llog('trace', "Pipeline $id complete for EIS $eisId source key " . $eisBackendRecord['source_key']); + + $cxn->commit(); + } + catch(\Exception $e) { + $cxn->rollback(); + + $this->llog('error', "Pipeline $id for EIS $eisId source key " . $eisBackendRecord['source_key'] . " failed: " . $e->getMessage()); + + throw new \RuntimeException($e->getMessage()); + } + } + + /** + * Copy the data from an entity and filter metadata, returning an array + * suitable for creating a new entity. Related models are also removed. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to copy + * @return array Array of filtered entity data + */ + + protected function duplicateFilterEntityData($entity): array { + // There's some overlap with TableMetaTrait::filterMetadataFields... + + $newdata = $entity->toArray(); + + // This list is a combination of eliminating fields that create + // noise in change detection for History creation, as well as + // functional attributes that cause problems if set (eg: frozen). + unset( + $newdata['id'], + $newdata['external_identity_id'], + $newdata['actor_identifier'], + $newdata['created'], + $newdata['deleted'], + $newdata['frozen'], + $newdata['full_name'], + $newdata['modified'], + $newdata['primary_name'], + $newdata['revision'], + $newdata['role_key'], + $newdata['status'] + ); + + // This will remove anything that isn't stringy + return array_filter($newdata, 'is_scalar'); + } + + /** + * Pipeline step to create or update the External Identity Source Record. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentitySource $eis External Identity Source + * @param string $sourceKey Source Key + * @param string $sourceRecord Source Record + * @param array $eisBackendRecord Record returned by EIS Backend + * @return array ExtIdentitySourceRecord and change status + */ + + protected function manageEISRecord( + Pipeline $pipeline, + ExternalIdentitySource $eis, + string $sourceKey, + string $sourceRecord, + ): array { + $status = 'unknown'; + + // Do we already have an EISRecord for this source_key? + $eisRecord = $this->ExternalIdentitySources->ExtIdentitySourceRecords + ->find() + ->where([ + 'ExtIdentitySourceRecords.external_identity_source_id' => $eis->id, + 'ExtIdentitySourceRecords.source_key' => $sourceKey + ]) + ->contain(['ExternalIdentities' => 'People']) + ->first(); + + if($eisRecord) { + // 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 + // that construct the External Identity. + +// XXX update this to test hashed value, once implemented + if((empty($eisRecord->source_record) && !empty($sourceRecord)) + || (!empty($eisRecord->source_record) && empty($sourceRecord)) + || (!empty($eisRecord->source_record) && !empty($sourceRecord) + && $eisRecord->source_record != $sourceRecord)) { + // We have an update of some form or another, including, possibly, a delete + + $this->llog('trace', "Updating Record for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + + // XXX support hashing here + $eisRecord->source_record = $sourceRecord; + $eisRecord->last_update = date('Y-m-d H:i:s', time()); + + $status = 'updated'; + } else { + $this->llog('trace', "Record for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey unchanged"); + + $status = 'unchanged'; + } + } else { + // Insert a new record + $this->llog('trace', "Creating a new Record for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + + $eisRecord = $this->ExternalIdentitySources->ExtIdentitySourceRecords + ->newEntity([ + 'external_identity_source_id' => $eis->id, + 'source_key' => $sourceKey, +// XXX support hashing here + 'source_record' => $sourceRecord, + 'last_update' => date('Y-m-d H:i:s', time()) + ]); + + $status = 'new'; + } + + $this->ExternalIdentitySources->ExtIdentitySourceRecords->saveOrFail($eisRecord); + + // Because --force is implemented in the Pipeline, we need to return the + // $eisRecord regardless of whether or not it changed, and so we also need + // a status flag to indicate whether or not it was. + return [ + 'record' => $eisRecord, + 'status' => $status + ]; + } + + /** + * Map entity data returned from an EIS Backend to the CO. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param array $eisAttributes Attributes provided by EIS Backend + * @return array Attributes adjusted for the CO + */ + + protected function mapAttributesToCO( + Pipeline $pipeline, + array $eisAttributes + ): array { + // We explicitly list the valid models, which will effectively filter + // out any unsupported noise from the backend. (Unsupported attributes + // will be ignored on save or throw errors.) + + $ret = [ + // Make sure source_key is a string + 'source_key' => (string)$eisAttributes['source_key'], + 'status' => StatusEnum::Active + ]; + + if(!empty($eisAttributes['date_of_birth'])) { + // While we're here, make sure it's in YYYY-MM-DD format. This should fail + // if the inbound attribute is invalid. + $dob = \DateTimeImmutable::createFromFormat('Y-m-d', $eisAttributes['date_of_birth']); + + if($dob) { + $ret['date_of_birth'] = $dob->format('Y-m-d'); + } + } + + foreach([ + 'addresses', + 'email_addresses', + 'identifiers', + 'names', + 'pronouns', + 'telephone_numbers', + 'urls' + ] as $m) { + if(!empty($eisAttributes[$m])) { + foreach($eisAttributes[$m] as $attr) { + $copy = $attr; + + // Map the type string to a type ID. If we fail to map the string, + // log an error but keep going. + + try { + $copy['type_id'] = $this->Cos->Types + ->getTypeId( + $pipeline->co_id, + Inflector::camelize($m).".type", + $attr['type'] + ); + unset($copy['type']); + $ret[$m][] = $copy; + } + catch(\Exception $e) { + // If we can't find a type we can't insert this record + $this->llog('error', "Failed to map $attr type \"" . $attr['type'] . "\" to a valid Type ID for EIS record " . $eisAttributes['source_key'] . ", skipping"); + } + } + } + } + + // ad_hoc_attributes require no special handling + if(!empty($eisAttributes['ad_hoc_attributes'])) { + $ret['ad_hoc_attributes'] = $eisAttributes['ad_hoc_attributes']; + } + + if(!empty($eisAttributes['external_identity_roles'])) { + foreach($eisAttributes['external_identity_roles'] as $role) { + $rolecopy = []; + + // Start with the single value attributes + foreach($role as $attr => $val) { + if(is_array($val)) { + // This is a related model, skip it for now + continue; + } + + if($attr == 'role_key') { + // Make sure the role key is a string + $rolecopy['role_key'] = (string)$val; + } elseif($attr == 'affiliation') { + // Affiliation needs to be mapped + + $rolecopy['affiliation_type_id'] = $this->Cos->Types + ->getTypeId( + $pipeline->co_id, + 'PersonRoles.affiliation_type', + $val + ); + } else { +// XXX need to add sponsor/manager mapping CFM-33 + // Just copy the attribute + $rolecopy[$attr] = $val; + } + } + +// XXX need to revisit status management CFM-344 + $rolecopy['status'] = StatusEnum::Active; + + // If no affiliation type was provided by the backend, + // use the Pipeline's configuration + if(empty($rolecopy['affiliation_type_id'])) { + $rolecopy['affiliation_type_id'] = $pipeline->sync_affiliation_type_id; + } + + // Now handle related models + foreach([ + 'addresses', + 'telephone_numbers' + ] as $m) { + if(!empty($role[$m])) { + foreach($role[$m] as $attr) { + $copy = $attr; + + // Map the type string to a type ID. If we fail to map the string, + // log an error but keep going. + + try { + $copy['type_id'] = $this->Cos->Types + ->getTypeId( + $pipeline->co_id, + Inflector::camelize($m).".type", + $attr['type'] + ); + unset($copy['type']); + $ret[$m][] = $copy; + } + catch(\Exception $e) { + $this->llog('error', "Failed to map $attr type \"" . $attr['type'] . "\" to a valid Type ID for EIS role record " . $role['role_key'] . ", skipping"); + } + } + } + } + + // And just copy ad hoc attributes + if(!empty($role['ad_hoc_attributes'])) { + $rolecopy['ad_hoc_attributes'] = $role['ad_hoc_attributes']; + } + + $ret['external_identity_roles'][] = $rolecopy; + } + } + + return $ret; + } + + /** + * Pipeline step to obtain a Person associated with the $eisRecord, possibly + * by executing the Match Strategy. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentitySource $eis External Identity Source + * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record + * @param array $eisAttributes Attributes provided by EIS Backend + * @return Person Person, possibly newly created + */ + + protected function obtainPerson( + Pipeline $pipeline, + ExternalIdentitySource $eis, + ExtIdentitySourceRecord $eisRecord, + array $eisAttributes + ): Person { + // Shorthand... + $sourceKey = $eisRecord->source_key; + + // If the $eisRecord has an External Identity attached to it, there must + // also be a Person, and we can just return that. + + if(!empty($eisRecord->external_identity_id)) { + $this->llog('trace', "Using previously linked Person " . $eisRecord->external_identity->person->id . " for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + return $eisRecord->external_identity->person; + } + + // There isn't a Person associated with the request, run the configured + // Match Strategy to see if one exists + + $personId = null; + $referenceId = null; + + $this->llog('trace', "Using Match Strategy " . $pipeline->match_strategy . " for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + + switch($pipeline->match_strategy) { + case MatchStrategyEnum::EmailAddress: + case MatchStrategyEnum::External: +// XXX If we get a reference ID, attach it to the $eisRecord here CFM-33 + case MatchStrategyEnum::Identifier: + throw new \RuntimeException('NOT IMPLEMENTED'); + break; + case MatchStrategyEnum::NoMatching: + // No matching configured, so just fall through and create a new Person + break; + } + + if(!$personId) { + // We didn't find an existing Person, so create a new one + $this->llog('trace', "No existing Person found, creating new Person record for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + + $person = $this->createPersonFromEIS($pipeline, $eis, $eisRecord, $eisAttributes); + } + + return $person; + } + + /** + * Sync an External Identity Source Record to an External Identity. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param Person $person Person + * @param ExternalIdentitySource $eis External Identity Source + * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record + * @param array $eisAttributes Attributes provided by EIS Backend + * @return External Identity External Identity, possibly newly created + */ + + protected function syncExternalIdentity( + Pipeline $pipeline, + Person $person, + ExternalIdentitySource $eis, + ExtIdentitySourceRecord $eisRecord, + array $eisAttributes + ): ExternalIdentity { + if(empty($eisRecord->external_identity_id)) { + $this->llog('trace', "Creating new External Identity for Person " . $person->id . " from EIS " . $eis->description . " (" . $eis->id . ")"); + + // We can substantially just save the backend attributes, with a little + // bit of preprocessing + + $mapped = $this->mapAttributesToCO($pipeline, $eisAttributes); + + // We also need to add the Person ID + $mapped['person_id'] = $person->id; + + $entity = $this->Cos->People->ExternalIdentities->newEntity($mapped); + + $this->Cos->People->ExternalIdentities->saveOrFail($entity); + + // Update $eisRecord with the new external_entity_id + $eisRecord->external_identity_id = $entity->id; + $this->ExternalIdentitySources->ExtIdentitySourceRecords->saveOrFail($eisRecord); + + $this->Cos->People->ExternalIdentities->recordHistory( + entity: $entity, + action: ActionEnum::PersonAddedPipeline, + comment: __d('result', + 'Pipelines.ei.added', + [$pipeline->description, + $pipeline->id, + $eis->description, + $eis->id, + $eisRecord->source_key]) + ); + + return $entity; + } else { + $this->llog('trace', "Updating existing External Identity " . $eisRecord->external_identity_id . " for Person " . $person->id . " from EIS " . $eis->description . " (" . $eis->id . ")"); + + // Start by pulling the current ExternalIdentity with its associated models. + + $externalIdentity = $this->Cos->People->ExternalIdentities->get( + $eisRecord->external_identity_id, + ['contain' => [ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls', + 'ExternalIdentityRoles' => [ + 'AdHocAttributes', + 'Addresses', + 'TelephoneNumbers' + ] + ]] + ); + + // Map the current backend record... + $mapped = $this->mapAttributesToCO($pipeline, $eisAttributes, $externalIdentity); + // and try to correlate its record keys. + $mapped = $this->correlateRecordKeys($externalIdentity, $mapped); + + // To avoid complications with patching, we work with individual records, + // not associated models. + $externalIdentityEntity = $this->Cos->People->ExternalIdentities->get( + $eisRecord->external_identity_id + ); + + // Now start the actual diff check with the External Identity itself. + $this->Cos->People->ExternalIdentities->patchEntity( + $externalIdentityEntity, + // is_scalar will keep strings and ints but not arrays (or nulls) + array_filter($mapped, 'is_scalar') + ); + + if($externalIdentityEntity->isDirty()) { + $this->llog('trace', "External Identity " . $externalIdentityEntity->id . " updated"); + $this->Cos->People->ExternalIdentities->saveOrFail($externalIdentityEntity); + } + + // Walk through the top level associated models. + foreach([ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls', + 'ExternalIdentityRoles' + ] as $model) { + $amodel = Inflector::underscore($model); + + if(!empty($mapped[$amodel])) { + // We have one or more of this model in the mapped backend data, + // check for update vs insert. Delete is handled below. + + foreach($mapped[$amodel] as $arecord) { + if(!empty($arecord['id'])) { + // We successfully mapped the backend record to an entity, + // so this is an update. Find the entity retrieved above + // with the matching record ID. + + // Note that generally we _won't_ actually perform an update + // because if a backend record changes it won't successfully map + // to a record ID in correlateRecordKeys. (While this code will + // run, isDirty() will generally return false.) Instead, we'll + // add the "new" record (meaning the changed record) and + // delete the "old" record (meaning the database copy), + // resulting in a new ID being assigned. However, if we're + // ever able to add persistant record keys to the Backend + // interface this code should "just work". + + // We do rely on this block to process EIR related models. + + foreach($externalIdentity->$amodel as $aentity) { + if($aentity->id == $arecord['id']) { + // This is the record we mapped in the backend data + $this->Cos->People->ExternalIdentities->$model->patchEntity( + $aentity, + // We only need to filter out related models for + // ExternalIdentityRoles since the others don't have them + array_filter($arecord, 'is_scalar') + ); + + if($aentity->isDirty()) { + $this->Cos->People->ExternalIdentities->$model->saveOrFail($aentity); + $this->llog('trace', "Updated $model " . $aentity->id . " for External Identity " . $externalIdentityEntity->id); + } + + // ----- Process this model's related models ----- // + if($model == 'ExternalIdentityRoles') { + // We also need to sync the related models. This is just + // different enough that it's not worth trying to abstract + // this code as a separate function. + + foreach([ + 'Addresses', + 'AdHocAttributes', + 'TelephoneNumbers' + ] as $eirmodel) { + $aeirmodel = Inflector::underscore($eirmodel); + + if(!empty($arecord[$aeirmodel])) { + // We have one or more Role related model is the mapped + // backend data, check for update vs insert. + + foreach($arecord[$aeirmodel] as $aeirrecord) { + if(!empty($aeirrecord['id'])) { + // This is an update since we successfully mapped + // the backend entityrecord, but see note above + // about updates not really happening. + + foreach($aentity->$aeirmodel as $aeirentity) { + if($aeirentity->id == $aeirrecord['id']) { + // This is the record we want to work with + $this->Cos->People->ExternalIdentities->$model->$eirmodel->patchEntity( + $aeirentity, + $aeirrecord + ); + + if($aeirentity->isDirty()) { + $this->Cos->People->ExternalIdentities->$model->$eirmodel->saveOrFail($aeirentity); + $this->llog('trace', "Updated $eirmodel " . $aeirentity->id . " for $model " . $aentity->id); + } + + break; // $aeirentity + } + } + } else { + // This is the insertion of a new record to an + // _existing_ External Identity Role. + + $newentity = $this->Cos->People->ExternalIdentities->$model->$eirmodel->newEntity($aeirrecord); + + $this->Cos->People->ExternalIdentities->$model->$eirmodel->saveOrFail($newentity); + this->llog('trace', "Added $eirmodel " . $newentity->id . " for $model " . $aentity->id); + } + } + } + + // Handle deleted records attached to a _still existing_ + // External Identity Role. + + if(!empty($aentity->$aeirmodel)) { + foreach($aentity->$aeirmodel as $aeirentity) { + $found = Hash::extract($arecord[$aeirmodel], '{n}[id='.$aeirentity->id.']'); + + if(!$found) { + $this->llog('trace', "Deleted $eirmodel " . $aeirentity->id . " for $model " . $aentity->id); + $this->Cos->People->ExternalIdentities->$model->$eirmodel->deleteOrFail($aeirentity); + // Note deleted related models remain on the ExternalIdentity in case they + // are needed later in the Pipeline. + } + } + } + } + } + // ----- End of related model processing ----- // + + // Done processing existing parent record, break $aentity loop + break; + } + } + } else { + // This is the insertion of a new record. For ExternalIdentityRoles + // $arecord should include the associated models, so we don't need + // to do any special handling for them. + + $newentity = $this->Cos->People->ExternalIdentities->$model->newEntity($arecord); + + $this->Cos->People->ExternalIdentities->$model->saveOrFail($newentity); + $this->llog('trace', "Added $model " . $newentity->id . " for External Identity " . $externalIdentityEntity->id); + } + } + } + + // Now handled deleted records, for which we only need to check + // $externalIdentity not being empty. If $mapped is empty we'll + // simply remove all the related model entities from the + // $externalIdentity. + + // Deleting an ExternalIdentityRole will delete its associated + // model entities. + + if(!empty($externalIdentity->$amodel)) { + foreach($externalIdentity->$amodel as $aentity) { + $found = Hash::extract($mapped[$amodel], '{n}[id='.$aentity->id.']'); + + if(!$found) { + if($model == 'ExternalIdentityRoles') { + // We have to handle the link to PersonRoles a bit carefully. + // First, we'll set the status of the Role in accordance with the + // Pipeline configuration. We do this here because + // ExternalIdentityRolesTable::beforeDelete() will set the Person + // Role foreign key to null to avoid problems with cascading deletes, + // but then when we sync the Person record later we won't see this + // PersonRole since the foreign key was nulled out. + + // We don't set the foreign key to null here because we want it + // to be cleared regardless of how the ExternalIdentity was deleted. + // eg: If an admin deletes it, the delete should complete but there + // is no Pipeline context so the PersonRole status won't be updated. + + $prole = $this->Cos->People->PersonRoles->find() + ->where(['PersonRoles.source_external_identity_role_id' => $aentity->id]) + ->first(); + + if(!empty($prole)) { + // Update the status in accordance with the Pipeline configuratino + $this->llog('trace', "Updating status on PersonRole " . $prole->id . " to " . $pipeline->sync_status_on_delete . " following deletion of source ExternalIdentityRole " . $aentity->id); + + $prole->status = $pipeline->sync_status_on_delete; + $this->Cos->People->PersonRoles->saveOrFail($prole); + } + } + + $this->llog('trace', "Deleted $model " . $aentity->id . " for External Identity " . $externalIdentityEntity->id); + $this->Cos->People->ExternalIdentities->$model->deleteOrFail($aentity); + // Note deleted related models remain on the ExternalIdentity in case they + // are needed later in the Pipeline. + } + } + } + } + + // Note $externalIdentity may include deleted related models. + + return $externalIdentity; + } + } + + /** + * Sync an External Identity to a Person. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentity $externalIdentity External Identity + * @param Person $person Person + * @return Person Person + */ + + protected function syncPerson( + Pipeline $pipeline, + ExternalIdentity $externalIdentity, + Person $person + ): Person { + // Because ExternalIdentities belongTo People, we can assume we have at least + // a Person object here (it would have been created by obtainPerson if there + // wasn't one at the start of the process). + + // Start with the directly related models + + foreach([ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls' + ] as $model) { + $amodel = Inflector::underscore($model); + // sourcefk = eg source_name_id + $sourcefk = $this->Cos->People->$model->sourceForeignKey(); + + // Pull the current set of associated records for this model. + // We can filter down to those that came from _any_ source (ie + // the source attribute is not null), but filtering only those + // from _this_ source requires a JOIN that isn't really worth the + // effort. + + $curentities = $this->Cos->People->$model + ->find() + ->where([ + $model.'.person_id' => $person->id, + $model.'.'.$sourcefk." IS NOT" => null + ]) + ->all(); + + // Track which IDs we've seen to facilitate deletes. + $seenIds = []; + + if(!empty($externalIdentity->$amodel)) { + // Walk through the ExternalIdentity's entities, adding or updating as + // appropriate. + + foreach($externalIdentity->$amodel as $eientity) { + if($eientity->deleted) { + // We ignore entities flagged as deleted, we'll calculate deletions + // separately in case we need to fix manually mucked up data. + continue; + } + + // Convert the ExternalIdentity attribute to an array and filter it + $newdata = $this->duplicateFilterEntityData($eientity); + + // Add the foreign keys + $newdata[$sourcefk] = $eientity->id; + $newdata['person_id'] = $person->id; + + // Do we have a corresponding record on the Person? + $found = $curentities->firstMatch([$sourcefk => $eientity->id]); + + if($found) { + // There is an existing record, update it (if it changed) _unless_ + // the attribute record is frozen. + + $this->Cos->People->$model->patchEntity($found, $newdata); + + if($found->isDirty()) { + if(isset($found->frozen) && $found->frozen) { + $this->llog('trace', "Refusing to update frozen $model " . $found->id . " to Person from External Identity " . $externalIdentity->id); + } else { + $this->Cos->People->$model->saveOrFail($found); + $this->llog('trace', "Updated $model " . $found->id . " to Person from External Identity " . $externalIdentity->id); + } + } + } else { + // This is a new record. We have to convert the External Identity to + // an array to create the new Person entity anyway, so we use that + // as an opportunity to set the foreign key. + + // Note that certain application logic (eg: no primary names for + // EIS data copied to People) is implemented in beforeMarshal on + // the appropriate Table. + + // Default the new attribute to not frozen + $newdata['frozen'] = false; + + $newentity = $this->Cos->People->$model->newEntity($newdata); + $this->Cos->People->$model->saveOrFail($newentity); + + $this->llog('trace', "Added $model " . $newentity->id . " to Person from External Identity " . $externalIdentity->id); + } + } + + $seenIds[] = $eientity->id; + } + + // Now walk through the Person entities, and delete any that we didn't see. + // In theory we could make Cake do this automatically via a HasOne + // relation, but it's a bit tricky to make Cake handle relations within + // the same object correctly to cascade the delete. + + if(!empty($curentities)) { + foreach($curentities as $aentity) { + // $aentity is an entity attached to 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.']'); + + if(!$found) { + if(isset($aentity->frozen) && $aentity->frozen) { + $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); + $this->Cos->People->$model->deleteOrFail($aentity); + } + } + } + } + } + + // Next sync External Identity Roles to Person Roles. + // **Be careful to note the different terminology for EIR vs PR** + // Track which person roles we've seen to remove any deleted ones. + $seenRoleIds = []; + + if(!empty($externalIdentity->external_identity_roles)) { + $sourcefk = 'source_external_identity_role_id'; + + // Pull the current Person Roles + $curentities = $this->Cos->People->PersonRoles + ->find() + ->where([ + 'PersonRoles.person_id' => $person->id, + "PersonRoles.$sourcefk IS NOT" => null + ]) + ->all(); + + foreach($externalIdentity->external_identity_roles as $eirentity) { + if($eirentity->deleted) { + // We ignore entities flagged as deleted, we'll calculate deletions + // separately in case we need to fix manually mucked up data. + continue; + } + + // Convert the ExternalIdentityRole to an array and filter it + $newdata = $this->duplicateFilterEntityData($eirentity); + + // Insert foreign keys + $newdata[$sourcefk] = $eirentity->id; + $newdata['person_id'] = $person->id; + + // And set the COU, if configured. Currently all Roles from a given + // External Identity sync to the same COU. + if(!empty($pipeline->sync_cou_id)) { + $newdata['cou_id'] = $pipeline->sync_cou_id; + } + + // duplicateFilterEntityData() will remove status, but we need to + // set it back (if asserted) or set a default (if not). + $newdata['status'] = $eirentity->status ?? StatusEnum::Pending; + + // Do we have a corresponding record on the Person? + $found = $curentities->firstMatch([$sourcefk => $eirentity->id]); + + if($found) { + // There is an existing record, update it (if it changed) _unless_ + // the role record is frozen. + + $this->Cos->People->PersonRoles->patchEntity($found, $newdata); + + if($found->isDirty()) { + if(isset($found->frozen) && $found->frozen) { + $this->llog('trace', "Refusing to update frozen $model " . $found->id . " to Person from External Identity " . $externalIdentity->id); + } else { + $this->Cos->People->PersonRoles->saveOrFail($found); + $this->llog('trace', "Updated PersonRole " . $found->id . " to Person from External Identity " . $externalIdentity->id); + } + } + } else { + // Default the new attribute to not frozen + $newdata['frozen'] = false; + + $newentity = $this->Cos->People->PersonRoles->newEntity($newdata); + $this->Cos->People->PersonRoles->saveOrFail($newentity); + + $this->llog('trace', "Added PersonRole " . $newentity->id . " to Person from External Identity " . $externalIdentity->id); + } + + $seenRoleIds[] = $eirentity->id; + } + + // For any roles we didn't see, we don't actually delete them, instead + // we set them to the configured status. This allows Expiration Policies + // to be applied, and also allows us to reactive a role if it comes back + // with the same Role Key. + + if(!empty($curentities->person_roles)) { + foreach($curentities->person_roles as $currole) { + if(!in_array($seenRoleIds, $curentities->currole->id)) { + // XXX want to delete person role $curentities->currole->id CFM-33 + } + } + } + } + + return $person; + } + + /** + * Update Person status upon completion of Pipeline syncing. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentity $externalIdentity External Identity + * @param Person $person Person + * @return Person Person + */ + + protected function updatePersonStatus( + Pipeline $pipeline, + ExternalIdentity $externalIdentity, + Person $person + ): Person { + // Role status is set during syncPerson, so all we need to do is update + // the Person status, and then only if the current status is Pending. + + if($person->status == StatusEnum::Pending) { + $person->status = StatusEnum::Active; + + $this->Cos->People->saveOrFail($person, ['associated' => false]); + $this->llog('trace', "Pipeline " . $pipeline->id . " updating Person " . $person->id . " status to Active"); + } + + return $person; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('match_strategy', [ + 'content' => ['rule' => ['inList', MatchStrategyEnum::getConstValues()]] + ]); + $validator->notEmptyString('match_strategy'); + + $validator->add('match_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('match_type_id'); + + $validator->add('match_server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('match_server_id'); + + $validator->add('sync_affiliation_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('sync_affiliation_type_id'); + + $validator->add('sync_cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('sync_cou_id'); + + $validator->add('sync_replace_cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('sync_replace_cou_id'); + + $validator->add('sync_status_on_delete', [ + 'content' => ['rule' => ['inList', DeletedRoleStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('sync_status_on_delete'); + + $validator->add('sync_identifier_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('sync_identifier_type_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PronounsTable.php b/app/src/Model/Table/PronounsTable.php index 5cd0f78c7..b380170d0 100644 --- a/app/src/Model/Table/PronounsTable.php +++ b/app/src/Model/Table/PronounsTable.php @@ -89,6 +89,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'person_id']); $this->setRequiresCO(true); $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); $this->setAutoViewVars([ 'languages' => [ @@ -106,8 +107,11 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], @@ -159,6 +163,11 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('language'); + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + $validator->add('source_pronoun_id', [ 'content' => ['rule' => 'isInteger'] ]); diff --git a/app/src/Model/Table/ServersTable.php b/app/src/Model/Table/ServersTable.php index 6c70d8156..3980bb804 100644 --- a/app/src/Model/Table/ServersTable.php +++ b/app/src/Model/Table/ServersTable.php @@ -64,6 +64,12 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); + // In general, we don't want to propagate deletes of a Server to its + // hasMany dependents since we want to throw an error for the administrator + // first. (For deleting a CO, the dependent objects should be deleted first.) + $this->hasMany('Pipelines') + ->setForeignKey('match_server_id'); + // XXX Note this will bind to (eg) CoreServer but not (eg) SqlProvisioner $this->setPluginRelations(); diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index f43329d05..c18ccbe7d 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -96,6 +96,7 @@ public function initialize(array $config): void { $this->setRequiresCO(true); $this->setAcceptsCoId(true); $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); $this->setAutoViewVars([ 'types' => [ @@ -109,8 +110,11 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], @@ -199,6 +203,11 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('type_id'); + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + $validator->add('source_telephone_number_id', [ 'content' => [ 'rule' => 'isInteger' ] ]); diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php index 2f763c65f..532d7e5a9 100644 --- a/app/src/Model/Table/TypesTable.php +++ b/app/src/Model/Table/TypesTable.php @@ -94,6 +94,15 @@ public function initialize(array $config): void { $this->hasMany('Names'); $this->hasMany('PersonRoles') ->setForeignKey('affiliation_type_id'); + $this->hasMany('PipelineMatchTypes') + ->setClassName('Pipelines') + ->setForeignKey('match_type_id'); + $this->hasMany('PipelineSyncAffiliationTypes') + ->setClassName('Pipelines') + ->setForeignKey('sync_affiliation_type_id'); + $this->hasMany('PipelineSyncIdentifierTypes') + ->setClassName('Pipelines') + ->setForeignKey('sync_identifier_type_id'); $this->hasMany('Pronouns'); $this->hasMany('TelephoneNumbers'); $this->hasMany('Urls'); diff --git a/app/src/Model/Table/UrlsTable.php b/app/src/Model/Table/UrlsTable.php index 9729aa0bd..9c4fe052f 100644 --- a/app/src/Model/Table/UrlsTable.php +++ b/app/src/Model/Table/UrlsTable.php @@ -89,6 +89,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'person_id']); $this->setRequiresCO(true); $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); $this->setAutoViewVars([ 'types' => [ @@ -102,8 +103,11 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], @@ -175,6 +179,11 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('type_id'); + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + $validator->add('source_url_id', [ 'content' => ['rule' => 'isInteger'] ]); diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index b9f31c759..632ade5d7 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -158,6 +158,23 @@ public function control(string $fieldName, $labelText); } + // If an attribute is frozen, inject a special link to unfreeze it, since + // the attribute is read only and the admin can't simply uncheck the setting + if($fieldName == 'frozen' && $vv_obj->frozen) { + return $this->statusControl($fieldName, + __d('field', 'frozen'), + [ + 'label' => __d('operation', 'unfreeze'), + 'url' => [ + 'plugin' => null, + 'controller' => StringUtilities::entityToClassname($vv_obj), + 'action' => 'unfreeze', + $vv_obj->id + ] + ], + $labelText); + } + // Required fields are usually determined by the model validator, but for // related models the view (currently) has to pass the field as required in // $options. For fields of the form model.0.field, if $options['required'] @@ -464,7 +481,58 @@ protected function formNameDiv(string $fieldName, string $labelText=null, string ' . ($desc ? '
' . $desc . '
' : "") .' '; } + + /** + * Emit a source control for an MVEA that has a source_foo_id field pointing + * to an External Identity attribute. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to emit control for + * @return string Source HTML + */ + // XXX docblock - emit control for MVEA that has a source_foo_id + public function sourceControl($entity): string { + // eg: Identifiers + $modelName = StringUtilities::entityToClassName($entity); + // eg: source_identifier_id, or source_external_identity_role_id + $sourceFK = $this->getView()->get('vv_source_fk'); + // eg: source_identifier - we need to construct this from the $sourceFK + $sourceEntityName = substr($sourceFK, 0, strlen($sourceFK)-3); + // In most cases $sourceModelName = $modelName, but not for PersonRoles + $sourceModelName = substr(StringUtilities::foreignKeyToClassName($sourceFK), 6); + + $linkHtml = ""; + + if(!empty($entity->$sourceFK)) { + $linkHtml = $this->Html->Link( + title: __d('controller', $sourceModelName, [1]), + url: [ + 'controller' => $sourceModelName, + 'action' => 'view', + $entity->$sourceFK + ] + ) . ", " . + $this->Html->Link( + title: __d('controller', 'ExternalIdentities', [1]), + url: [ + 'controller' => 'external_identities', + 'action' => 'view', + $entity->$sourceEntityName->external_identity_id + ] + ); + } + + return $this->startLine() + . $this->formNameDiv( + fieldName: $sourceFK, + labelText: __d('field', 'source'), + fieldType: 'string' + ) + . $linkHtml + . $this->endLine(); + } + /** * Generate a status control (a read only status with an optional link button). * diff --git a/app/templates/AdHocAttributes/fields.inc b/app/templates/AdHocAttributes/fields.inc index 6a66210f0..b1f57e918 100644 --- a/app/templates/AdHocAttributes/fields.inc +++ b/app/templates/AdHocAttributes/fields.inc @@ -25,9 +25,12 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does not currently support read-only -if($vv_action == 'add' || $vv_action == 'edit') { +if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { print $this->Field->control('tag'); print $this->Field->control('value'); + + print $this->Field->control('frozen'); + + print $this->Field->sourceControl($vv_obj); } diff --git a/app/templates/Addresses/fields.inc b/app/templates/Addresses/fields.inc index 63c9a8703..757f13db9 100644 --- a/app/templates/Addresses/fields.inc +++ b/app/templates/Addresses/fields.inc @@ -25,8 +25,7 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does not currently support read-only -if($vv_action == 'add' || $vv_action == 'edit') { +if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { // Dynamic required fields is automatically handled by FormHelper via the // validation rules @@ -47,4 +46,8 @@ if($vv_action == 'add' || $vv_action == 'edit') { print $this->Field->control('language'); print $this->Field->control('description'); + + print $this->Field->control('frozen'); + + print $this->Field->sourceControl($vv_obj); } diff --git a/app/templates/EmailAddresses/fields.inc b/app/templates/EmailAddresses/fields.inc index d5790a145..7676ffb3e 100644 --- a/app/templates/EmailAddresses/fields.inc +++ b/app/templates/EmailAddresses/fields.inc @@ -25,8 +25,7 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only -if($vv_action == 'add' || $vv_action == 'edit') { +if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { print $this->Field->control('mail'); print $this->Field->control('type_id', ['default' => $vv_default_type]); @@ -35,4 +34,8 @@ if($vv_action == 'add' || $vv_action == 'edit') { // XXX CFM-129 need to implement verification print $this->Field->control('verified', ['readonly' => true]); + + print $this->Field->control('frozen'); + + print $this->Field->sourceControl($vv_obj); } diff --git a/app/templates/ExtIdentitySourceRecords/fields.inc b/app/templates/ExtIdentitySourceRecords/fields.inc new file mode 100644 index 000000000..68be3473a --- /dev/null +++ b/app/templates/ExtIdentitySourceRecords/fields.inc @@ -0,0 +1,76 @@ + + + + +Field->control('source_key'); + + print $this->Field->control('last_update'); + + print $this->Field->control('reference_identifier'); + + print $this->Field->control(fieldName: 'source_record', cssClass: 'source-record'); +} diff --git a/app/templates/ExternalIdentities/fields.inc b/app/templates/ExternalIdentities/fields.inc index 45904c2a1..06c5d1636 100644 --- a/app/templates/ExternalIdentities/fields.inc +++ b/app/templates/ExternalIdentities/fields.inc @@ -25,9 +25,31 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does not currently support read-only -if($vv_action == 'add' || $vv_action == 'edit') { -// XXX sync status? +// This view will not support add/edit +if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { + print __d( + 'information', + 'ExternalIdentities.source', + [ + $this->Html->link( + $vv_obj->ext_identity_source_records[0]->external_identity_source->description, + [ + 'controller' => 'external-identity-sources', + 'action' => 'edit', + $vv_obj->ext_identity_source_records[0]->external_identity_source->id + ] + ), + $this->Html->link( + __d('operation', 'view.a', [__d('controller', 'ExtIdentitySourceRecords', 1)]), + [ + 'controller' => 'ext-identity-source-records', + 'action' => 'view', + $vv_obj->ext_identity_source_records[0]->id + ] + ) + ] + ); + print $this->Field->control('status', ['empty' => false]); print $this->Field->dateControl('date_of_birth', \App\Lib\Enum\DateTypeEnum::DateOnly); diff --git a/app/templates/ExternalIdentitySources/columns.inc b/app/templates/ExternalIdentitySources/columns.inc new file mode 100644 index 000000000..27fde5877 --- /dev/null +++ b/app/templates/ExternalIdentitySources/columns.inc @@ -0,0 +1,69 @@ + [ + 'type' => 'link', + 'sortable' => true + ], + 'plugin' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'SyncModeEnum', + 'sortable' => true + ] +]; + +// $rowActions appear as row-level menu items in the index view gear icon +$rowActions = [ + [ + 'action' => 'search', + 'label' => __d('operation', 'ExternalIdentitySources.search'), + 'icon' => 'search' + ], + [ + 'action' => 'edit', + 'label' => __d('operation', 'edit.a', [__d('controller', 'ExternalIdentitySources', 1)]), + 'icon' => 'edit' + ], + [ + '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/ExternalIdentitySources/fields.inc b/app/templates/ExternalIdentitySources/fields.inc new file mode 100644 index 000000000..c9f6d8019 --- /dev/null +++ b/app/templates/ExternalIdentitySources/fields.inc @@ -0,0 +1,39 @@ + +Field->control('description'); + + print $this->Field->control('status', + ['default' => \App\Lib\Enum\SyncModeEnum::Disabled]); + + print $this->Field->control('plugin'); + + print $this->Field->control('pipeline_id'); +} diff --git a/app/templates/ExternalIdentitySources/retrieve.php b/app/templates/ExternalIdentitySources/retrieve.php new file mode 100644 index 000000000..cebe36a39 --- /dev/null +++ b/app/templates/ExternalIdentitySources/retrieve.php @@ -0,0 +1,251 @@ + + +
+
+

+
+
+ + +
+ Flash->render() ?> + + + + Alert->alert($b, 'warning') ?> + + + + + + Alert->alert($b, 'warning') ?> + + +
+ + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + \n"; + print "\n"; + print "\n"; + print "\n"; + print "\n"; + } + } + } + ?> + + \n"; + print "\n"; + print "\n"; + print "\n"; + print "\n"; + } + } + ?> + + \n"; + print "\n"; + print "\n"; + + print "\n"; + print "\n"; + print "\n"; + print "\n"; + print "\n"; + + foreach(array_keys($role) as $field) { + if($field == 'role_key' || is_array($role[$field])) continue; + + print "\n"; + print "\n"; + print "\n"; + print "\n"; + print "\n"; + } + } + } + ?> + + \n"; + print "\n"; + print "\n"; + print "\n"; + print "\n"; + } + } + } + ?> + + \n"; + print "\n"; + print "\n"; + print "\n"; + print "\n"; + } + } + ?> + + + + + + + +
description; ?>
+
    + $value) { + if($field == 'type') continue; + + print "
  • " . $field . ": " . $value . "
  • \n"; + } + ?> +
+
" . __d('controller', Inflector::camelize($model), 1) . "" . ($m['type'] ?? "") . "
    \n"; + foreach($m as $field => $value) { + if($field == 'type') continue; + + print "
  • " . $field . ": " . $value . "
  • \n"; + } + print "
" . __d('controller', 'AdHocAttributes', 1) . "" . $aha['tag'] . "" . $aha['value'] . "
" . __d('controller', 'ExternalIdentityRoles', 1) . "
" . __d('field', 'role_key') . "" . $role['role_key'] . "
" . __d('field', $field) . "" . $role[$field] . "
" . __d('controller', Inflector::camelize($model), 1) . "" . ($m['type'] ?? "") . "
    \n"; + foreach($m as $field => $value) { + if($field == 'type') continue; + + print "
  • " . $field . ": " . $value . "
  • \n"; + } + print "
" . __d('controller', 'AdHocAttributes', 1) . "" . $aha['tag'] . "" . $aha['value'] . "
+
+ +
+ + + +
+
+
diff --git a/app/templates/ExternalIdentitySources/search.php b/app/templates/ExternalIdentitySources/search.php new file mode 100644 index 000000000..1dc20feee --- /dev/null +++ b/app/templates/ExternalIdentitySources/search.php @@ -0,0 +1,145 @@ + + +
+
+

+
+
+ + +
+ Flash->render() ?> + + + + Alert->alert($b, 'warning') ?> + + + + + + Alert->alert($b, 'warning') ?> + + +
+ + + +
+ info +
+ +
+
+ + + + + + +

+ +

+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ Html->link( + $r['source_key'], + [ + 'action' => 'retrieve', + $this->request->getParam('pass')[0], + '?' => ['source_key' => $r['source_key']] + ] + ); ?> + + +
+
+ + diff --git a/app/templates/Identifiers/fields.inc b/app/templates/Identifiers/fields.inc index 17f7ea380..532f29865 100644 --- a/app/templates/Identifiers/fields.inc +++ b/app/templates/Identifiers/fields.inc @@ -25,8 +25,7 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does not currently support read-only -if($vv_action == 'add' || $vv_action == 'edit') { +if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { print $this->Field->control('identifier'); print $this->Field->control('type_id', ['default' => $vv_default_type]); @@ -39,4 +38,8 @@ if($vv_action == 'add' || $vv_action == 'edit') { } print $this->Field->control('status', ['empty' => false]); + + print $this->Field->control('frozen'); + + print $this->Field->sourceControl($vv_obj); } diff --git a/app/templates/Names/fields.inc b/app/templates/Names/fields.inc index 275f26e33..3b16e5804 100644 --- a/app/templates/Names/fields.inc +++ b/app/templates/Names/fields.inc @@ -25,8 +25,7 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does not currently support read-only -if($vv_action == 'add' || $vv_action == 'edit') { +if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { // Dynamic required fields is automatically handled by FormHelper via the // validation rules, but we need to manually check permitted fields. @@ -46,4 +45,8 @@ if($vv_action == 'add' || $vv_action == 'edit') { // the new primary_name is, but we do allow this name to become primary // because afterSave will unset the old one. print $this->Field->control('primary_name', ['readonly' => $vv_obj->primary_name]); + + print $this->Field->control('frozen'); + + print $this->Field->sourceControl($vv_obj); } diff --git a/app/templates/PersonRoles/fields.inc b/app/templates/PersonRoles/fields.inc index c8fa75cef..4748921f3 100644 --- a/app/templates/PersonRoles/fields.inc +++ b/app/templates/PersonRoles/fields.inc @@ -25,8 +25,7 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does not currently support read-only -if($vv_action == 'add' || $vv_action == 'edit') { +if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { print $this->Field->control('cou_id'); print $this->Field->control('affiliation_type_id', labelText: __d('field', 'affiliation')); @@ -60,4 +59,8 @@ if($vv_action == 'add' || $vv_action == 'edit') { print $this->Field->dateControl('valid_from'); print $this->Field->dateControl('valid_through'); + + print $this->Field->control('frozen'); + + print $this->Field->sourceControl($vv_obj); } diff --git a/app/templates/Pipelines/columns.inc b/app/templates/Pipelines/columns.inc new file mode 100644 index 000000000..b8234409a --- /dev/null +++ b/app/templates/Pipelines/columns.inc @@ -0,0 +1,43 @@ + [ + 'type' => 'link', + 'sortable' => true + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum', + 'sortable' => true + ] +]; + +// TODO: develop $bulkActions. For now, use a placeholder. +$bulkActions = [ + 'delete' => true +]; \ No newline at end of file diff --git a/app/templates/Pipelines/fields.inc b/app/templates/Pipelines/fields.inc new file mode 100644 index 000000000..0c52fc3ec --- /dev/null +++ b/app/templates/Pipelines/fields.inc @@ -0,0 +1,59 @@ + +Field->control('description'); + + print $this->Field->control('status'); + + // Match Strategy + print $this->Field->control('match_strategy'); + + // print $this->Field->control('match_type_id'); + + // print $this->Field->control('match_server_id'); + + // Sync Strategy +// print $this->Field->control('sync_on_update'); + +// print $this->Field->control('sync_on_delete'); + + print $this->Field->control('sync_status_on_delete', ['empty' => false]); + + print $this->Field->control('sync_cou_id'); + + print $this->Field->control('sync_replace_cou_id'); + + print $this->Field->control('sync_affiliation_type_id'); + + print $this->Field->control('sync_identifier_type_id'); + + // Connections +//XXX +} diff --git a/app/templates/Pronouns/fields.inc b/app/templates/Pronouns/fields.inc index 26d64e1a4..a5361fc5e 100644 --- a/app/templates/Pronouns/fields.inc +++ b/app/templates/Pronouns/fields.inc @@ -25,11 +25,14 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only -if($vv_action == 'add' || $vv_action == 'edit') { +if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { print $this->Field->control('pronouns'); print $this->Field->control('type_id', ['default' => $vv_default_type]); print $this->Field->control('language'); + + print $this->Field->control('frozen'); + + print $this->Field->sourceControl($vv_obj); } diff --git a/app/templates/TelephoneNumbers/fields.inc b/app/templates/TelephoneNumbers/fields.inc index 058b2172a..2f772914d 100644 --- a/app/templates/TelephoneNumbers/fields.inc +++ b/app/templates/TelephoneNumbers/fields.inc @@ -25,8 +25,7 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does not currently support read-only -if($vv_action == 'add' || $vv_action == 'edit') { +if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { // Dynamic required fields is automatically handled by FormHelper via the // validation rules, but we need to manually check permitted fields. @@ -39,4 +38,8 @@ if($vv_action == 'add' || $vv_action == 'edit') { print $this->Field->control('type_id', ['default' => $vv_default_type]); print $this->Field->control('description'); + + print $this->Field->control('frozen'); + + print $this->Field->sourceControl($vv_obj); } diff --git a/app/templates/Urls/fields.inc b/app/templates/Urls/fields.inc index f3d442389..93ca41f36 100644 --- a/app/templates/Urls/fields.inc +++ b/app/templates/Urls/fields.inc @@ -25,11 +25,14 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only -if($vv_action == 'add' || $vv_action == 'edit') { +if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { print $this->Field->control('url'); print $this->Field->control('type_id', ['default' => $vv_default_type]); print $this->Field->control('description'); + + print $this->Field->control('frozen'); + + print $this->Field->sourceControl($vv_obj); } diff --git a/app/templates/element/breadcrumbs.php b/app/templates/element/breadcrumbs.php index fdbb4ba46..ee449b68d 100644 --- a/app/templates/element/breadcrumbs.php +++ b/app/templates/element/breadcrumbs.php @@ -77,6 +77,16 @@ } } +// Insert any title links immediately before the page title +if(!empty($vv_bc_title_links)) { + foreach($vv_bc_title_links as $tbc) { + $this->Breadcrumbs->add( + $tbc['label'], + $tbc['target'] + ); + } +} + // Insert the page title if(!empty($vv_title)) { $this->Breadcrumbs->add(