diff --git a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php index bf1f64e33..e9d2a6efa 100644 --- a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php +++ b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php @@ -337,7 +337,9 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour // Also, When the SQL Provisioner is deleted, neither the // database schema nor reference data is touched (PAR-SqlProvisioner-4). - if(!empty($entity->server_id) && !$entity->deleted) { + // Similarly, we skip this when cloning. + if(!empty($entity->server_id) && !$entity->deleted + && (!isset($options['clone']) || !$options['clone'])) { // Apply the database schema (PAR-SqlProvisioner-1) $this->llog('rule', "PAR-SqlProvisioner-1 Applying database schema for SqlProvisioner " . $entity->id); $this->applySchema($entity->id); @@ -572,7 +574,7 @@ public function syncReferenceData(int $id, string $dataSource='targetdb') { $options = [ 'table' => $spcfg->table_prefix . $m['table'], - 'alias' => $m['name'] . $SqlProvisioner->id, + 'alias' => $m['name'] . $id, 'connection' => ConnectionManager::get($dataSource) ]; diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index fa2eee611..ab1323074 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -103,7 +103,8 @@ "types_i1": { "columns": [ "co_id" ] }, "types_i2": { "columns": [ "co_id", "attribute" ] }, "types_i3": { "columns": [ "co_id", "attribute", "value" ] } - } + }, + "clonable": true }, "servers": { @@ -116,7 +117,8 @@ }, "indexes": { "servers_i1": { "columns": [ "co_id" ] } - } + }, + "clonable": true }, "co_settings": { @@ -195,7 +197,8 @@ "indexes": { "api_users_i1": { "columns": [ "co_id" ] }, "api_users_i2": { "columns": [ "username" ] } - } + }, + "clonable": true }, "cous": { @@ -210,6 +213,7 @@ "cous_i2": { "columns": [ "name" ] }, "cous_i3": { "columns": [ "co_id", "name" ] } }, + "clonable": true, "tree": true }, @@ -323,6 +327,7 @@ "groups_i5": { "needed": false, "columns": [ "cou_id" ]}, "groups_i6": { "needed": false, "columns": [ "owners_group_id" ]} }, + "clonable": true, "tree": true }, @@ -443,7 +448,8 @@ "indexes": { "provisioning_targets_i1": { "columns": [ "co_id" ]}, "provisioning_targets_i2": { "needed": false, "columns": [ "provisioning_group_id" ] } - } + }, + "clonable": true }, "provisioning_history_records": { @@ -812,7 +818,8 @@ "identifier_assignments_i1": { "columns": [ "co_id" ] }, "identifier_assignments_i2": { "needed": false, "columns": [ "email_address_type_id" ] }, "identifier_assignments_i3": { "needed": false, "columns": [ "identifier_type_id" ] } - } + }, + "clonable": true }, "pipelines": { @@ -841,7 +848,8 @@ "pipelines_i6": { "needed": false, "columns": [ "sync_replace_cou_id" ] }, "pipelines_i7": { "needed": false, "columns": [ "sync_identifier_type_id" ] }, "pipelines_i8": { "needed": false, "columns": [ "match_identifier_type_id" ] } - } + }, + "clonable": true }, "flanges": { @@ -877,7 +885,8 @@ "external_identity_sources_i1": { "columns": [ "co_id" ] }, "external_identity_sources_i2": { "columns": [ "sor_label"] }, "external_identity_sources_i3": { "needed": false, "columns": [ "pipeline_id" ] } - } + }, + "clonable": true }, "ext_identity_source_records": { diff --git a/app/resources/locales/en_US/command.po b/app/resources/locales/en_US/command.po index 635bd06b6..27b33c0ec 100644 --- a/app/resources/locales/en_US/command.po +++ b/app/resources/locales/en_US/command.po @@ -81,6 +81,27 @@ msgstr "Given Name of initial platform administrator" msgid "opt.admin-username" msgstr "Username of initial platform administrator" +msgid "opt.clone.all" +msgstr "Clone all available objects" + +msgid "opt.clone.co_id" +msgstr "Source CO ID" + +msgid "opt.clone.cri" +msgstr "Clone all available objects with the specified CRI" + +msgid "opt.clone.model" +msgstr "Clone all available objects of the specified Model" + +msgid "opt.clone.target_co_id" +msgstr "Target CO ID" + +msgid "opt.clone.target_server_id" +msgstr "Target Server ID" + +msgid "opt.clone.uuid" +msgstr "Clone the object with the specified UUID" + msgid "opt.co_id" msgstr "CO ID" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index e1aca5131..9c3511818 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -93,6 +93,9 @@ msgstr "{0} already exists with this name" msgid "exists.GroupMember" msgstr "{0} is already a member of Group {1}" +msgid "exists.uuid" +msgstr "A record already exists with this UUID ({0} ID {1})" + msgid "data.Load" msgstr "Failed to Load Data" @@ -430,6 +433,9 @@ msgstr "Type {0} is in use and cannot be deleted" msgid "Types.isdefault" msgstr "Type {0} is in use as a default (via CO Settings)" +msgid "Types.unique" +msgstr "The requested value {0} is already in use by Type ID {1}" + msgid "unknown" msgstr "Unknown value \"{0}\"" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 3ac1739bd..e67622d8f 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -89,6 +89,9 @@ msgstr "Country Code" msgid "created" msgstr "Created" +msgid "cri" +msgstr "Change Request Identifier" + msgid "datepicker.am" msgstr "AM" @@ -122,6 +125,9 @@ msgstr "Description" msgid "display_name" msgstr "Display Name" +msgid "do_not_clone" +msgstr "Do Not Clone" + msgid "edupersonaffiliation" msgstr "eduPersonAffiliation" @@ -342,6 +348,9 @@ msgstr "URL" msgid "username" msgstr "Username" +msgid "uuid" +msgstr "UUID" + msgid "valid_from" msgstr "Valid From" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index ababf8c7a..9fdfece85 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -186,6 +186,12 @@ msgstr "Current version: {0}" msgid "ug.version.target" msgstr "Target version: {0}" +msgid "ug.tasks.assignUuids" +msgstr "Assigning UUIDs (this may take a while for larger deployments)" + +msgid "ug.tasks.assignUuids.count" +msgstr "{0} records for {1}..." + msgid "ug.tasks.buildGroupTree" msgstr "Recovering Group tree" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index c4480d72e..cd4e14b4f 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -54,6 +54,9 @@ msgstr "Authenticator Unlocked" msgid "Authenticators.unlocked-a" msgstr "Authenticator {0} Unlocked" +msgid "clone.actor" +msgstr "Cloned from CO {0}" + msgid "copied" msgstr "Copied" @@ -266,7 +269,7 @@ msgid "search.none" msgstr "No results found" msgid "search.result.found" -msgstr "Found {0} results" +msgstr "{0,plural,=0{No matches found} =1{Found 1 match} other{Found # matches}}" msgid "search.result.found.modelCount" msgstr "{0} {1}" diff --git a/app/src/Command/CloneCommand.php b/app/src/Command/CloneCommand.php new file mode 100644 index 000000000..2ad415067 --- /dev/null +++ b/app/src/Command/CloneCommand.php @@ -0,0 +1,617 @@ +addOption( + 'co_id', + [ + 'required' => true, + 'short' => 'c', + 'help' => __d('command', 'opt.clone.co_id') + ] + )->addOption( + 'target_co_id', + [ + 'required' => true, + 'short' => 't', + 'help' => __d('command', 'opt.clone.target_co_id') + ] + )->addOption( + 'target_server_id', + [ + 'required' => false, // If false, use default datasource + 'short' => 's', + 'help' => __d('command', 'opt.clone.target_server_id') + ] + )->addOption( + 'all', + [ + 'required' => false, + 'short' => 'a', + 'boolean' => true, + 'help' => __d('command', 'opt.clone.all') // This must be explicitly requested + ] + )->addOption( + 'cri', + [ + 'required' => false, + 'short' => 'r', + 'boolean' => true, + 'help' => __d('command', 'opt.clone.cri') + ] + )->addOption( + 'model', + [ + 'required' => false, + 'short' => 'm', + 'boolean' => true, + 'help' => __d('command', 'opt.clone.model') // clonable model to copy all of + ] + )->addOption( + 'uuid', + [ + 'required' => false, + 'short' => 'u', + 'boolean' => true, + 'help' => __d('command', 'opt.clone.uuid') + ] + ); + + return $parser; + } + + /** + * Execute the Database Command. + * + * @since COmanage Registry v5.2.0 + * @param Arguments $args Command Arguments + * @param ConsoleIo $io Console IO + * @throws RuntimeException + */ + + public function execute(Arguments $args, ConsoleIo $io) { + $this->io = $io; + + // By default, the target database is the same as the source (ie: cloning from one + // CO to another on the same platform), but if -t is specified we'll set up a + // connection to that one as the target. This assumes that the SqlServer plugin + // is enabled, but it's a core plugin so it should be there by default. + $targetDS = 'default'; + + if(!empty($args->getOption('target_server_id'))) { + // Create a new "remote" datasource for writing the target records. + // Note the command line flag is "target" but we use the connection name + // "remote" to clarify that it's a different datasource. + + $targetDS = 'remote'; + + $SqlServer = TableRegistry::getTableLocator()->get('CoreServer.SqlServers'); + + $SqlServer->connect((int)$args->getOption('target_server_id'), $targetDS); + } + + if($args->getBooleanOption('all')) { + $this->cloneAll( + (int)$args->getOption('co_id'), + (int)$args->getOption('target_co_id'), + $targetDS + ); + } elseif($args->getBooleanOption('cri')) { + foreach($args->getArguments() as $cri) { + $this->cloneByCri( + $cri, + (int)$args->getOption('co_id'), + (int)$args->getOption('target_co_id'), + $targetDS + ); + } + } elseif($args->getBooleanOption('model')) { + foreach($args->getArguments() as $model) { + $this->cloneByModel( + $model, + (int)$args->getOption('co_id'), + (int)$args->getOption('target_co_id'), + $targetDS + ); + } + } elseif($args->getBooleanOption('uuid')) { + foreach($args->getArguments() as $uuid) { + $this->cloneByUuid( + $uuid, + (int)$args->getOption('co_id'), + (int)$args->getOption('target_co_id'), + $targetDS + ); + } + } + } + + /** + * Clone all available objects in a CO. + * + * @since COmanage Registry v5.2.0 + * @param int $sourceCoId Source CO ID (in "default" Datasource) + * @param int $targetCoId Target CO ID + * @param string $targetDataSource Target Dataource + * @throws InvalidArgumentException + */ + + public function cloneAll( + int $sourceCoId, + int $targetCoId, + string $targetDataSource='default' + ) { + $clonableModels = SearchUtilities::getClonableModels(); + + foreach($clonableModels as $m) { + $this->cloneByModel($m, $sourceCoId, $targetCoId, $targetDataSource); + } + } + + /** + * Clone an object via CRI. + * + * @since COmanage Registry v5.2.0 + * @param string $cri Change Request Identifier + * @param int $sourceCoId Source CO ID (in "default" Datasource) + * @param int $targetCoId Target CO ID + * @param string $targetDataSource Target Dataource + * @throws InvalidArgumentException + */ + + public function cloneByCri( + string $cri, + int $sourceCoId, + int $targetCoId, + string $targetDataSource='default' + ) { + $this->io->out("Searching for CRI " . $cri); + + $results = SearchUtilities::criSearch($sourceCoId, $cri); + + foreach($results as $class => $entities) { + $this->io->out("Found " . $entities->count() . " " . $class . " records to clone"); + + foreach($entities as $e) { + $this->cloneEntity($class, $e->id, $sourceCoId, $targetCoId, $targetDataSource); + } + } + } + + /** + * Clone all objects of a specific model. + * + * @since COmanage Registry v5.2.0 + * @param string $className Model class name in Table format (eg: "People") + * @param int $sourceCoId Source CO ID (in "default" Datasource) + * @param int $targetCoId Target CO ID + * @param string $targetDataSource Target Dataource + * @throws InvalidArgumentException + */ + + public function cloneByModel( + string $className, + int $sourceCoId, + int $targetCoId, + string $targetDataSource='default' + ) { + if(!in_array($className, SearchUtilities::getClonableModels())) { + $this->io->err($className . " is not a clonable model"); + return; + } + + // We handle Types specially + if($className == 'Types') { + $this->cloneTypes($sourceCoId, $targetCoId, $targetDataSource); + } else { + // Pull all records for this model within the CO. We use PaginatedSqlIterator + // because certain models can have large numbers of records (eg People). + + $Table = TableRegistry::getTableLocator()->get($className); + + // Clonable model must FK directly to CO + $iterator = new PaginatedSqlIterator($Table, ['co_id' => $sourceCoId]); + + $this->io->out($iterator->count() . " records available for " . $className); + + foreach($iterator as $entity) { + $this->cloneEntity($className, $entity->id, $sourceCoId, $targetCoId, $targetDataSource); + } + } + } + + /** + * Clone an object via UUID. + * + * @since COmanage Registry v5.2.0 + * @param string $uuid UUID for source object + * @param int $sourceCoId Source CO ID (in "default" Datasource) + * @param int $targetCoId Target CO ID + * @param string $targetDataSource Target Dataource + * @throws InvalidArgumentException + */ + + public function cloneByUuid( + string $uuid, + int $sourceCoId, + int $targetCoId, + string $targetDataSource='default' + ) { + $this->io->out("Searching for UUID " . $uuid); + + $result = SearchUtilities::uuidSearch($sourceCoId, $uuid); + + if(!$result) { + $this->io->err(__d('error', 'notfound', [$uuid])); + return; + } + + $this->cloneEntity($result['class'], $result['entity']->id, $sourceCoId, $targetCoId, $targetDataSource); + } + + /** + * Clone a single entity. + * + * @since COmanage Registry v5.2.0 + * @param string $className Class Name, in Table format (ie: "People") + * @param int $id Entity ID + * @param int $sourceCoId Source CO ID (in "default" Datasource) + * @param int $targetCoId Target CO ID + * @param string $targetDataSource Target Dataource + * @throws InvalidArgumentException + */ + + protected function cloneEntity( + string $className, + int $id, + int $sourceCoId, + int $targetCoId, + string $targetDataSource + ) { + // First check if we've already processed this entity. + if(isset($this->seenEntities[$className][$id])) { + $this->io->out("$className $id already processed, skipping"); + return; + } + + // If for some reason we fail, we won't try again the second time + $this->seenEntities[$className][$id] = time(); + + // In general, we're going to have to pull each record twice. The various cloneBy* + // calls will do a high level search to find records to clone, but then we need to + // re-find() the entity in case its table wants to pull associated models or perform + // other callbacks. + + // We wrap everything in a try/catch so an individual error doesn't cause the whole + // process to abort. + + // Target database connection, for transacation management + $tcxn = null; + + try { + $Table = TableRegistry::getTableLocator()->get($className); + + $related = []; + + // The clonable table can declare related models to be cloned with it + if(method_exists($Table, "getCloneRelations")) { + $related = $Table->getCloneRelations(); + } + + $query = $Table->find()->where([$className.'.id' => $id]); + + if(!empty($related)) { + $query = $query->contain($related); + } + + $original = $query->firstOrFail(); + + if(isset($original->do_not_clone) && $original->do_not_clone) { + $this->io->out($original->uuid . ": Do Not Clone is set on original object, skipping " . $className . " " . $id); + return; + } + + // This callback shouldn't do any work, it's just a pre-flight check + if(method_exists($Table, "checkCloneDependencies")) { + try { + $Table->checkCloneDependencies($original, $targetDataSource); + } + catch(\Exception $e) { + $this->io->out($original->uuid . ": checkCloneDependencies failed, skipping " . $className . " " . $id . ": " . $e->getMessage()); + return; + } + } + + $this->io->out($original->uuid . ": Cloning " . $className . " " . $id + . " from CO " . $sourceCoId . " to CO " . $targetCoId); + + // Clone any predecessor objects first. There is a default implementations in + // ClonableTrait that should cover most scenarios, so we don't need to check + // if it exists on the table + $uuids = $Table->getClonePredecessors($original); + + if(!empty($uuids)) { + $this->io->out("-- Processing Predecessor Entities --"); + + foreach($uuids as $uuid) { + $this->cloneByUuid($uuid, $sourceCoId, $targetCoId, $targetDataSource); + } + + $this->io->out("-- Finished Processing Predecessor Entities --"); + } + + $TargetTable = TableUtilities::getTableWithDataSource( + tableName: $className, + connectionName: $targetDataSource + ); + + // We start a transaction on the _target_ table, since we're just performing + // reads on the source table. (In theory we could get a read lock...) + + $tcxn = $TargetTable->getConnection(); + $tcxn->begin(); + + // Convert to an array, which is what we need to create the new entities, + // and filter out the metadata fields. + + $copy = $TargetTable->filterMetadataForCopy($TargetTable, $original, $related); + + // Replace the CO ID. Clonable model must FK directly to CO. + $copy['co_id'] = $targetCoId; + + // Check to see if there is an existing entity in the target already. We're effectively + // doing an upsert, but spread out over multiple steps in order to allow table specific + // callbocks to manipulate the prepared entity. + + $query = $TargetTable->find()->where(['co_id' => $targetCoId, 'uuid' => $original->uuid]); + + $targetRelated = $related; + + if(!empty($targetRelated)) { + // We need to convert $related to use the same prefix that getTableWithDataSource uses. + // While we're here, rekey $copy as well. + + if($targetDataSource != 'default') { + $prefix = \Cake\Utility\Inflector::camelize($targetDataSource); + + $fn = function(string $s) use ($prefix): string { + return $prefix . $s; + }; + + $targetRelated = array_map($fn, $related); + + // This is similar to what we do for related models, below + + foreach($related as $r) { + // eg: http_servers + $pluralSource = Inflector::underscore($r); + // eg: remote_http_servers + $pluralTarget = $targetDataSource . "_" . $pluralSource; + + if(!empty($copy[$pluralSource])) { + $copy[$pluralTarget] = $copy[$pluralSource]; + unset($copy[$pluralSource]); + } + + $singularSource = Inflector::singularize($pluralSource); + $singularTarget = Inflector::singularize($pluralTarget); + + if(!empty($copy[$singularSource])) { + $copy[$singularTarget] = $copy[$singularSource]; + unset($copy[$singularSource]); + } + } + } + + $query = $query->contain($targetRelated); + } + + $clone = $query->first(); + + if($clone) { + // We also honor do_not_clone being set in the target CO + if(isset($clone->do_not_clone) && $clone->do_not_clone) { + $this->io->out($clone->uuid . ": Do Not Clone is set on the cloned object, skipping " . $className . " " . $id); + return; + } + + // Patch the record, related models should be correctly handled by Cake + + $TargetTable->patchEntity($clone, $copy, ['validate' => false]); + } else { + // Convert the array into a new entity. We disable validation since it's possible + // validation rules changed since the original was persisted (either via code + // changes or configuration) but we'll honor the original since at some point it + // saved successfully. + + $entityOptions = [ + 'associated' => $targetRelated, + // For comparison, we do not disable rules checking below + 'validate' => false + ]; + + $clone = $TargetTable->newEntity($copy, $entityOptions); + } + + // If $clone has foreign keys to other tables, they now point to the wrong CO. + // Since this is a general problem, we fix it here rather than requiring each + // model to resolve its own keys. Note the implication is that the foreign key + // target models have been cloned already, either by getClonePredecessors or + // by the admin having cloned all entities of the target model already, and + // more specifically that foreign key targets can be resolved via UUID lookup. + + // This is defined by default in ClonableTrait + $clone = $TargetTable->fixCloneForeignKeys($original, $clone, $targetCoId, $targetDataSource); + + if(method_exists($TargetTable, "prepareClone")) { + $clone = $TargetTable->prepareClone($original, $clone, $targetDataSource); + } + + // Prepare for the save + + if($targetDataSource != 'default' && !empty($related)) { + // We need to rekey the related models, and we need to check both singular (hasOne) + // and plural (hasMany). + + foreach($related as $r) { + // eg: http_servers + $pluralSource = Inflector::underscore($r); + // eg: remote_http_servers + $pluralTarget = $targetDataSource . "_" . $pluralSource; + + if(!empty($clone->$pluralSource)) { + $clone->$pluralTarget = $clone->$pluralSource; + unset($clone->$pluralSource); + } + + $singularSource = Inflector::singularize($pluralSource); + $singularTarget = Inflector::singularize($pluralTarget); + + if(!empty($clone->$singularSource)) { + $clone->$singularTarget = $clone->$singularSource; + unset($clone->$singularSource); + } + } + } + + // Since we're managing the entity, we can skip the UUID duplication check + $clone->_uuidCloned = true; + + // By default Cake will save one level of associations, but we want to save + // anything in $related. Unlike validation above, we want rule check to run + // so (eg) uniqueness checks can be enforced. + $TargetTable->saveOrFail($clone, [ + 'associated' => $targetRelated, + // 'checkRules' => false, + 'actor' => __d('result', 'clone.actor', [$sourceCoId]), + 'clone' => true + ]); + + $tcxn->commit(); + } + catch(\Exception $e) { + $this->io->err($e->getMessage()); + + if($tcxn) { + $tcxn->rollback(); + } + } + } + + /** + * Clone all Types. + * + * @since COmanage Registry v5.2.0 + * @param int $sourceCoId Source CO ID (in "default" Datasource) + * @param int $targetCoId Target CO ID + * @param string $targetDataSource Target Dataource + * @throws InvalidArgumentException + */ + + protected function cloneTypes( + int $sourceCoId, + int $targetCoId, + string $targetDataSource + ) { + // Because we handle Types by mapping database value to/from primary key, the UUID + // is less important, and the administrator can decide to clone Types or just make + // sure the requisite database values are defined on both sides. When syncing by + // models, we'll handle all Types by database value, and as a side effect we'll + // sync the UUIDs. + + $Types = TableRegistry::getTableLocator()->get('Types'); + + $sourceTypes = $Types->find()->where(['co_id' => $sourceCoId])->all(); + + $TargetTypes = TableUtilities::getTableWithDataSource( + tableName: 'Types', + connectionName: $targetDataSource + ); + + foreach($sourceTypes as $t) { + if(isset($t->do_not_clone) && $t->do_not_clone) { + $this->io->out($t->uuid . ": Do Not Clone is set, skipping Type " . $t->id); + continue; + } + + $this->io->out($t->uuid . ": Cloning Type " . $t->id + . " from CO " . $sourceCoId . " to CO " . $targetCoId); + + // We can just use UpsertTrait since we're not relying on UUID + $targetData = $TargetTypes->filterMetadataForCopy($TargetTypes, $t); + + $targetData['co_id'] = $targetCoId; + + try { + $TargetTypes->upsertOrFail( + data: $targetData, + whereClause: [ + 'co_id' => $targetCoId, + 'attribute' => $t->attribute, + 'value' => $t->value, + ], + options: ['actor' => __d('result', 'clone.actor', [$sourceCoId])] + ); + } + catch(\Exception $e) { + $this->io->err($e->getMessage()); + } + } + } +} diff --git a/app/src/Command/UpgradeCommand.php b/app/src/Command/UpgradeCommand.php index c3dfe8022..df7365f09 100644 --- a/app/src/Command/UpgradeCommand.php +++ b/app/src/Command/UpgradeCommand.php @@ -35,6 +35,8 @@ use Cake\Console\ConsoleOptionParser; use Cake\Datasource\ConnectionManager; use \App\Lib\Enum\GroupTypeEnum; +use \App\Lib\Util\PaginatedSqlIterator; +use \App\Lib\Util\SearchUtilities; class UpgradeCommand extends BaseCommand { @@ -77,6 +79,7 @@ class UpgradeCommand extends BaseCommand 'checkGroupNames' ], 'post' => [ + 'asssignUuids', 'buildGroupTree', 'createDefaultGroups', 'installMostlyStaticPages' @@ -88,6 +91,7 @@ class UpgradeCommand extends BaseCommand // to make them easier to use regardless of context (pre/post/manual). protected $taskParams = [ + 'assignUuids' => ['global' => true], 'buildGroupTree' => ['global' => true], 'checkGroupNames' => ['global' => true], 'createDefaultGroups' => ['perCO' => true, 'perCOU' => true], @@ -336,6 +340,44 @@ protected function dispatch(string $task) { } } + /** + * Assign UUIDs for existing duplicatable objects. + * + * @since COmanage Registry v5.2.0 + */ + + protected function assignUuids() { + // Because UUIDs are globally unique, we don't have to assign them on a per CO basis. + + foreach(SearchUtilities::getClonableModels() as $m) { + // We basically have to walk all records of each model. We use PaginatedSqlIterator + // for all queries for consistency, although only a small number (People, mostly) + // will really need it. This will also guarantee all records get a UUID, even those + // that might be created while this task is running. + + $Table = $this->getTableLocator()->get($m); + + $iterator = new PaginatedSqlIterator($Table); + + $this->io->out(__d('information', 'ug.tasks.assignUuids.count', [$iterator->count(), $m])); + + foreach($iterator as $entity) { + if(!$entity->uuid) { + $entity->uuid = \Cake\Utility\Text::uuid(); + + try { + $Table->saveOrFail($entity); + } + catch(\Exception $e) { + $this->io->err($m . " " . $entity->id . ": " . $e->getMessage()); + } + + // No need to provision + } + } + } + } + /** * Establish tree metadata for Groups. * diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index 2be4c2bfb..506444343 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -35,7 +35,7 @@ use Cake\ORM\TableRegistry; use Cake\Utility\Hash; use Cake\Utility\Inflector; -//use \App\Lib\Enum\PermissionEnum; +use \App\Lib\Util\SearchUtilities; class DashboardsController extends StandardController { /** @@ -286,77 +286,6 @@ public function dashboard(?int $id=null) { */ public function search() { - /* To add a new backend to search: - * (1) Implement $model->search($id, $q, $limit) - * (2) Add the model to $models here, and define which roles can query it - * (3) Update documentation at https://spaces.at.internet2.edu/pages/viewpage.action?pageId=243078053 - */ - - $models = [ - 'Addresses' => [ - 'parent' => ['People' => 'person_id', 'PersonRoles' => 'person_role_id'], - 'roles' => ['platformAdmin', 'coAdmin'], - 'displayField' => 'street', - 'searchLimited' => false - ], - 'EmailAddresses' => [ - 'parent' => ['People' => 'person_id'], - 'roles' => ['platformAdmin', 'coAdmin'], - 'displayField' => 'mail', - 'searchLimited' => true - ], - 'Groups' => [ - 'parent' => ['Cos' => 'co_id'], - 'roles' => ['platformAdmin', 'coAdmin'], - 'displayField' => 'name', - 'searchLimited' => false - ], - 'Identifiers' => [ - 'parent' => ['Groups' => 'group_id', 'People' => 'person_id'], - 'roles' => ['platformAdmin', 'coAdmin'], - 'displayField' => 'identifier', - 'searchLimited' => true - ], - 'Names' => [ - 'parent' => ['People' => 'person_id'], - 'roles' => ['platformAdmin', 'coAdmin'], - 'displayField' => 'full_name', - 'searchLimited' => true - ], - 'PersonRoles' => [ - 'parent' => ['People' => 'person_id'], - 'roles' => ['platformAdmin', 'coAdmin'], - 'displayField' => 'title', - 'searchLimited' => false - ], - 'TelephoneNumbers' => [ - 'parent' => ['People' => 'person_id', 'PersonRoles' => 'person_role_id'], - 'roles' => ['platformAdmin', 'coAdmin'], - 'displayField' => 'number', - 'searchLimited' => false - ], - 'Urls' => [ - 'parent' => ['People' => 'person_id'], - 'roles' => ['platformAdmin', 'coAdmin'], - 'displayField' => 'url', - 'searchLimited' => false - ] - ]; - - $this->set('vv_supported_models', $models); - - // XXX inject plugins here - - // $results tracks the per-model backend results - $results = [ - 'Cos' => [], - 'Groups' => [], - 'People' => [] - ]; - - // XXX Still need to implement this (see also CFM-126) - $roles = []; - // Gather our search string. $q = ''; if(!empty($this->request->getData('q'))) { @@ -366,64 +295,15 @@ public function search() { // Only process the request if we have a string of non-space characters if(!empty($q)) { + $results = SearchUtilities::globalSearch($this->getCOID(), $q); - // Pull our search configuration - $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); - - $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); - - $searchLimit = $settings->search_global_limit; - - foreach(array_keys($models) as $m) { - // If we're in limited search mode, we don't search all models - if($settings->search_global_limited_models - && !$models[$m]['searchLimited']) { - continue; - } - - $authorized = true; // XXX dynamically calculate this - - $table = $this->getTableLocator()->get($m); - - $searchResults = $table->search(coId: $this->getCOID(), - q: $q, - limit: $searchLimit); - - // For models with a parent other than Co, we aggregate the results to the parent - // model, but track what the matching model was. We key on the foreign key to the parent - // to also unique-ify the results while we're here. - - foreach($searchResults as $r) { - // Some tables support multiple parent models (eg: Identifiers), so we walk through - // the possibilities to see which one matched - foreach($models[$m]['parent'] as $pmodel => $pkey) { - if(!empty($r->$pkey)) { - if($m == 'Groups') { - // We special case Groups since (unlike People) they can match on both the - // primary model (Groups::name) or associated models (Identifiers::identifier). - // We force any Groups matches into the parent key format. - $results['Groups'][$r->id]['Groups'] = $r; - } elseif($pmodel == 'Cos') { - // This will look something like $results['Cos']['Departments'][] = $entity - $results[$pmodel][$m][] = $r; - } elseif($pmodel == 'PersonRoles') { - // Although we matched on a PersonRole we're really interested in the Person - $results['People'][$r->person_role->person_id][$m] = $r->person_role; - } else { - // Note we're also keying on the matched model, so this will look something like - // $results['People'][123]['Names'] = $entity - $results[$pmodel][$r->$pkey][$m] = $r; - } - } - } - } - } - - if(count($results['Cos']) + count($results['Groups']) + count($results['People']) >= $searchLimit) { + if($results['limitReached']) { $this->Flash->information(__d('result', 'search.limit')); } } + $this->set('vv_supported_models', SearchUtilities::getGlobalSearchModels()); + // It's a single match if there is a single person or person role result, // or if there is a single result overall, redirect to that result. if((count($results['Cos']) == 0 @@ -457,6 +337,20 @@ public function search() { ]); // XXX handle plugins + } elseif($results['uuid']) { + // There is an exact match on uuid, redirect there + + $this->Flash->information(__d('result', + 'search.exact', + [filter_var($this->request->getData('q'), FILTER_SANITIZE_SPECIAL_CHARS), + 'uuid'])); + + return $this->redirect([ + 'controller' => Inflector::dasherize($results['uuid']['class']), + 'action' => 'edit', + $results['uuid']['entity']->id + ]); + } elseif(!empty($results['cri'])) { } elseif(count($results['Cos']) + count($results['People']) + count($results['Groups']) == 0) { diff --git a/app/src/Lib/Traits/ClonableTrait.php b/app/src/Lib/Traits/ClonableTrait.php new file mode 100644 index 000000000..bc01956cb --- /dev/null +++ b/app/src/Lib/Traits/ClonableTrait.php @@ -0,0 +1,180 @@ +associations()->getByType('BelongsTo') as $assn) { + if($assn->getClassName() == 'Cos') { + // We don't clone CO + continue; + } + + $aForeignKey = $assn->getForeignKey(); + + if(!empty($original->$aForeignKey)) { + // We have a non-empty value for this foreign key. Get the object and add its + // UUID to the list. + + // We're the Target Table, so we need to get a handle to the Source Table + $SourceTable = TableUtilities::getTableWithDataSource( + tableName: $assn->getClassName(), + connectionName: 'default' + ); + + $originalForeignEntity = $SourceTable->get($original->$aForeignKey); + + // We don't need to check for dupes because CloneCommand will track which + // entities it has already cloned + $ret[] = $originalForeignEntity->uuid; + } + } + + return $ret; + } + + /** + * Obtain a record by UUID. + * + * @since COmanage Registry v5.2.0 + * @param string $uuid UUID + * @param int $coId CO ID + * @return EntityInterface Entity + * @throws \Cake\Datasource\Exception\RecordNotFoundException + */ + + public function getByUuid(string $uuid, int $coId) { + // Clonable model must FK directly to CO + return $this->find()->where(['uuid' => $uuid, 'co_id' => $coId])->firstOrFail(); + } + + /** + * Prepare an entity for cloning. + * + * @since COmanage Registry v5.2.0 + * @param EntityInterface $original Original entity + * @param EntityInterface $clone Clone (not yet saved) + * @param string $dataSource DataSource connection name + * @return EntityInterface Clone, updated as necessary + */ + + public function prepareClone( + EntityInterface $original, + EntityInterface $clone, + string $dataSource + ): EntityInterface { + // If a parent_id is set, we need to map the _source_ parent to its UUID, then + // find the same UUID on the target, then replace the foreign key. + + if(!empty($original->parent_id)) { + // We're the Target Table, so we need to get a handle to the Source Table + $SourceTable = TableUtilities::getTableWithDataSource( + tableName: StringUtilities::entityToClassName($original), + connectionName: 'default' + ); + + $originalParent = $SourceTable->get($original->parent_id); + + // We're the Target Table, query using the UUID we just found + $cloneParent = $this->getByUuid($originalParent->uuid, $originalParent->co_id); + + // Update the foreign key + $clone->parent_id = $cloneParent->id; + } + + return $clone; + } + + /** + * Application Rule to determine if the entity's UUID is unique within the CO + * across all clonable objects, not just those of the same type. + * + * @since COmanage Registyr v5.2.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleUuidUnique($entity, $options) { + // This is somewhat weird given that UUIDs are supposed to be unique, + // but we're addressing the use case of an administrator manually assigning + // the same UUID to two different objects, which is an error state. + + // ClonableBehavior::beforeMarshal will set a flag if it generated a UUID + // so we know we can skip this (expensive) check. + if($entity->has('_uuidGenerated') && $entity->_uuidGenerated) { + return true; + } + + // CloneCommand will set a flag for records it is processing for the same reason. + if($entity->has('_uuidCloned') && $entity->_uuidCloned) { + return true; + } + + // If the UUID is unchanged we don't need to check it. + if(!$entity->isDirty('uuid')) { + return true; + } + + // If we make it here the admin set the UUID, so we do need to check. + + // Clonable model must FK directly to CO + $result = SearchUtilities::uuidSearch($entity->co_id, $entity->uuid); + + if(!empty($result['entity']) && !empty($entity->id) && ($entity->id != $result['entity']->id)) { + return __d('error', 'exists.uuid', [$result['class'], $result['entity']->id]); + } + + return true; + } +} diff --git a/app/src/Lib/Traits/PluggableModelTrait.php b/app/src/Lib/Traits/PluggableModelTrait.php index 513506aed..cba0868fd 100644 --- a/app/src/Lib/Traits/PluggableModelTrait.php +++ b/app/src/Lib/Traits/PluggableModelTrait.php @@ -34,6 +34,7 @@ use Cake\Utility\Inflector; use App\Lib\Util\StringUtilities; +use App\Lib\Util\TableUtilities; trait PluggableModelTrait { // The set of plugin entry point models used in configurations for this model @@ -80,6 +81,71 @@ public function afterMarshal( } } + /** + * Check for any dependencies that must be in place before cloning begins. + * + * @since COmanage Registry v5.2.0 + * @param EntityInterface $original Original entity + * @param string $targetDataSource Target DataSource connection name + */ + + public function checkCloneDependencies( + EntityInterface $original, + string $targetDataSource='default' + ) { + // Verify the plugin in use is active in the target database. If we're on the same + // datasource (ie: on the same platform) then the Plugin set is by definition the same, + // so we only need to perform this check when the target datasource is different. + + if(!empty($original->plugin) && $targetDataSource != 'default') { + $TargetPlugins = TableUtilities::getTableWithDataSource( + tableName: "Plugins", + connectionName: $targetDataSource + ); + + // $TargetPlugins = TableUtilities::getTableFromRegistry(alias: $options['alias'], options: $options); + + // We need the physical plugin name + $pluginName = StringUtilities::pluginPlugin($original->plugin); + + // Just running find() will be sufficient for now, though the error may not be obvious + $TargetPlugins->find() + ->where([ + 'plugin' => $pluginName, + 'status' => SuspendableStatusEnum::Active + ]) + ->firstOrFail(); + } + } + + /** + * Get the set of related models that are to be cloned along with this one. + * + * @since COmanage Registry v5.2.0 + * @return array Array of models, in contain() format + */ + + public function getCloneRelations(): array { + $ret = []; + + foreach($this->_pluginModels as $entryPoint) { + $PluginTable = TableRegistry::getTableLocator()->get($entryPoint); + +// XXX hasOne? + $hasMany = $PluginTable->associations()->getByType('hasMany'); + + if(!empty($hasMany)) { + foreach($hasMany as $h) { + $ret[StringUtilities::pluginModel($entryPoint)][] = $h->getName(); + } + } else { + $ret[] = StringUtilities::pluginModel($entryPoint); + } + } + + return $ret; + } + /** * Determine the plugin type used by this Pluggable Model. This is the lowercased * singular prefix of the Pluggable Model Table name. eg: For "ReportsTable" the @@ -179,30 +245,37 @@ public function pluginModelForEntityId(int $id, array $options=[]) { */ protected function setPluginRelations() { - // To determine which plugin models are instantiated, we'll query the configuration - // for this pluggable model. We only need to do this once per plugin model, not - // once per instantiation. - - $models = $this->find() - ->select(['id', 'plugin']) - ->distinct(['plugin']) - ->all(); - - foreach($models as $m) { - if(empty($m->plugin) || !strstr($m->plugin, '.')) { - // This plugin is not valid. We could filter this in the find() using a - // where() clause, but checking here allows us to emit a warning. - - $this->llog('error', "Ignoring invalid plugin '" . $m->plugin . "' found in " . $this->getTable() . " record " . $m->id); - continue; - } + // We originally queried the configurations for the pluggable model to see which + // plugins were in use, but that doesn't work when cloning, when the Target CO + // is empty and has no active plugins. The alternate approach is to look at each + // Plugin and query it for available plugins, and it turns out we already have + // utility functions that will do that for us... + + // Under certain circumstances (eg: CloneCommand) we may not be using the + // default datasource + $datasource = $this->getConnection()->configName(); + + $Plugins = TableUtilities::getTableWithDataSource( + tableName: "Plugins", + connectionName: $datasource + ); + + $models = $Plugins->getActivePluginModels($this->getPluggableModelType()); + + foreach($models as $plugin) { // Derive association alias from "Plugin.Model" - [$pluginName, $modelAlias] = explode('.', $m->plugin, 2); + [$pluginName, $modelAlias] = explode('.', $plugin, 2); + + if($datasource != 'default') { + // Add the aliasPrefix + + $modelAlias = Inflector::camelize($datasource) . $modelAlias; + } if ($this->associations()->has($modelAlias)) { // Association already defined elsewhere; don't rebind - $this->llog('debug', "Association '{$modelAlias}' already exists, skipping plugin relation '{$m->plugin}'"); + $this->llog('debug', "Association '{$modelAlias}' already exists, skipping plugin relation '{$plugin}'"); continue; } @@ -210,14 +283,35 @@ protected function setPluginRelations() { // with the instantiated plugin configuration. eg: One instance // of a Server has exactly one SqlServer associated with it. // Bind by alias and explicitly set the className. - $this->hasOne($modelAlias) - ->setClassName($m->plugin) + + // We also explicitly set the foreign key because creating a table alias (as for example + // done by CloneCommand) will create a default foreign key of the alias (eg: target_server_id) + // instead of the physical table name. + + $assn = $this->hasOne($modelAlias) + ->setClassName($plugin) ->setDependent(true) + ->setForeignKey(StringUtilities::tableToForeignKey($this)) ->setCascadeCallbacks(true); + + if($datasource != 'default') { + // We can't just set the connection on getTarget or we'll clobber the datasource. + // We have to create a new Table attached to the alternate datasource. + // (Strictly speaking we don't need to test for default, in which case we'd just + // re-set the same target table that hasOne would have used by default.) + + $targetTable = TableUtilities::getTableWithDataSource( + // aliasPrefix: Inflector::camelize($datasource), // XXX was Remote?` + tableName: $plugin, + connectionName: $datasource + ); + + $assn->setTarget($targetTable); + } // Cache the list of entry points that we found (avoid duplicates) - if (!in_array($m->plugin, $this->_pluginModels, true)) { - $this->_pluginModels[] = $m->plugin; + if (!in_array($plugin, $this->_pluginModels, true)) { + $this->_pluginModels[] = $plugin; } } diff --git a/app/src/Lib/Traits/TableMetaTrait.php b/app/src/Lib/Traits/TableMetaTrait.php index bc8d341d6..741155217 100644 --- a/app/src/Lib/Traits/TableMetaTrait.php +++ b/app/src/Lib/Traits/TableMetaTrait.php @@ -29,10 +29,12 @@ namespace App\Lib\Traits; +use Cake\Datasource\EntityInterface; use Cake\ORM\TableRegistry; use Cake\Utility\Inflector; use App\Lib\Enum\TableTypeEnum; use App\Lib\Util\StringUtilities; +use App\Lib\Util\TableUtilities; trait TableMetaTrait { // What type of Table is this? @@ -48,7 +50,7 @@ trait TableMetaTrait { * @return array Array of filtered attributes */ - protected function filterMetadataForCopy( + public function filterMetadataForCopy( \Cake\ORM\Table $table, \Cake\Datasource\EntityInterface $entity, array $related=[] @@ -56,7 +58,7 @@ protected function filterMetadataForCopy( // XXX There is overlap with Petitions::duplicateFilterEntityData and // TableMetaTrait::filterMetadataFields (used mostly for UI stuff), // should maybe refactor these. filterMetadata() is more based on the Petitions one -// See also CFM-442 +// See also CFM-442 and CFM-480 $ret = []; @@ -117,7 +119,13 @@ protected function filterMetadataForCopy( // For pluggable models, get the plugin table from the entity configuration $t = TableRegistry::getTableLocator()->get($entity->plugin); } else { - $t = TableRegistry::getTableLocator()->get($v); + if(!empty($entity->$m[0])) { + // hasMany Relation with at least one entity populated. Use the entity to get + // the appropriate table to make sure we handle plugins correctly. + $t = TableRegistry::getTableLocator()->get($entity->$m[0]->getSource()); + } else { + $t = TableRegistry::getTableLocator()->get($v); + } } if(is_array($entity->$m)) { @@ -138,7 +146,7 @@ protected function filterMetadataForCopy( // $m1 is the singular version (enrollment_flow_step) $m1 = Inflector::singularize($m); // $t is the Table for $k - if(!empty($entity->plugin) && StringUtilities::pluginModel($entity->plugin) == $v) { + if(!empty($entity->plugin) && StringUtilities::pluginModel($entity->plugin) == $k) { // For pluggable models, get the plugin table from the entity configuration $t = TableRegistry::getTableLocator()->get($entity->plugin); } else { @@ -245,6 +253,127 @@ protected function filterMetadataFields() { return $newa ?? []; } + /** + * Update the foreign keys in $clone to point to the correct entities in the target CO. + * This function is here and not in ClonableTrait in order to be available for + * related models that are not themselves directly Clonable. + * + * @since COmanage Registry v5.2.0 + * @param EntityInterface $original Original entity + * @param EntityInterface $clone Clone (not yet saved) + * @param int $targetCoId CO ID for target + * @param string $dataSource Target DataSource connection name + * @return EntityInterface Clone, updated as necessary + */ + + public function fixCloneForeignKeys( + EntityInterface $original, + EntityInterface $clone, + int $targetCoId, + string $dataSource + ): EntityInterface { + // To figure out the set of foreign keys for a table we start by getting its + // belongsTo assocations. (We're only interested in assocations where the foreign + // key is defined in this table.) + + // The Tables for $original and $clone should already be in the TableRegistry, + // so we don't need to specially query for them (via TableUtilities) + $OriginalTable = TableRegistry::getTableLocator()->get($original->getSource()); + + foreach($OriginalTable->associations()->getByType('BelongsTo') as $assn) { + if(in_array($assn->getForeignKey(), $OriginalTable->getPrimaryLinks())) { + // Skip the primary key for this table + continue; + } + + $aForeignKey = $assn->getForeignKey(); + + if(!empty($original->$aForeignKey)) { + // We have a non-empty value for this foreign key. We need to map the _source_ + // FK to its UUID, then find the same UUID on the target, then replace the FK. + + $clone->$aForeignKey = $OriginalTable->mapForeignKey( + $assn, + $original->$aForeignKey, + $targetCoId, + $dataSource + ); + } + } + + // Now handle any related models that might be riding along. Not all related models + // are necessarily populated in $original, so we'll need to check for that. + + foreach($OriginalTable->associations()->getByType(['hasOne', 'hasMany']) as $rassn) { + // We use the property name to find the sub-entity + $property = $rassn->getProperty(); + + if(!empty($original->$property)) { + // We have a non-empty related entity, eg $server->match_sever + + if(is_array($original->$property)) { + // hasMany - This is annoying because we can't directly correlate each original + // entity to each cloned entity. This is a similar problem to Pipeline processing, + // so we use the same solution, which is isProbablyThisArray(). + + $fixed = []; + + foreach($original->$property as $rorig) { + // Walk the clones until we find a match + foreach($clone->$property as $rclone) { + // $rclone might be an array or it might be an entity. When Cake marshals + // an array into an entity, it sometimes leaves subrelations as arrays + // apparently at least in some cases those provided by plugins since it + // can't resolve the entity to a table. We actually need both formats here + // since isProbablyThisArray() expects an array, while fixCloneForeignKeys + // expects an entity. + + if(is_array($rclone)) { + // Use the original table to find the source name, but use the target + // datasource to get the table handle. + $TargetTable = TableUtilities::getTableWithDataSource( + tableName: $rorig->getSource(), + connectionName: $dataSource + ); + + $rarray = $rclone; + $rentity = $TargetTable->newEntity($rclone); + } else { + $rarray = $rclone->toArray(); + $rentity = $rclone; + } + + // The reason this will work is because we haven't fixed the foreign keys yet. + // If we did, this mismatch would cause isProbablyThisArray to always return + // false. + if($rorig->isProbablyThisArray($rclone)) { + $fixed[] = $this->fixCloneForeignKeys( + $rorig, + $rentity, + $targetCoId, + $dataSource + ); + } + } + } + + $clone->$property = $fixed; + } else { + // hasOne + + $clone->$property = $this->fixCloneForeignKeys( + $original->$property, + $clone->$property, + $targetCoId, + $dataSource + ); + } + } + } + + return $clone; + } + /** * Determine if this Table represents Registry artifacts. * @@ -266,6 +395,43 @@ public function isArtifactTable() { public function isConfigurationTable() { return $this->tableType === TableTypeEnum::Configuration; } + + /** + * Map a foreign key based on UUID lookup. + * + * @since COmanage Registry v5.2.0 + * @param Association $assn Association being examined + * @param int $originalFK Foreign key value of original associated entity + * @param int $targetCoId CO ID for target + * @param string $targetDataSource Data source to use for target lookup + * @return int ID of corresponding target entity + */ + + public function mapForeignKey( + \Cake\ORM\Association $assn, + int $originalFK, + int $targetCoId, + string $targetDataSource + ): int { + $SourceTable = TableUtilities::getTableWithDataSource( + tableName: $assn->getClassName(), + connectionName: 'default' + ); + + $originalForeignEntity = $SourceTable->get($originalFK); + + // Query the Target Table using the UUID we just found. + // This will throw an Exception if the UUID is not found, which is fine + // because it means we can't resolve the link and so we can't clone. + $TargetTable = TableUtilities::getTableWithDataSource( + tableName: $assn->getClassName(), + connectionName: $targetDataSource + ); + + $cloneForeignEntity = $TargetTable->getByUuid($originalForeignEntity->uuid, $targetCoId); + + return $cloneForeignEntity->id; + } /** * Set the type of this Table. diff --git a/app/src/Lib/Traits/UpsertTrait.php b/app/src/Lib/Traits/UpsertTrait.php index da834ba53..95debdedc 100644 --- a/app/src/Lib/Traits/UpsertTrait.php +++ b/app/src/Lib/Traits/UpsertTrait.php @@ -37,6 +37,7 @@ trait UpsertTrait { * @param array $data Data to persist * @param array $whereClause Conditions to search for current entity * @param bool $orFail If true, use saveOrFail() instead of save() + * @param array $options Options for save * @return Cake\Datasource\EntityInterface|false Persisted entity, or false on failure * @throws Cake\ORM\Exception\PersistenceFailedException * @throws Cake\ORM\Exception\RolledbackTransactionException @@ -45,7 +46,8 @@ trait UpsertTrait { public function upsert( array $data, array $whereClause, - bool $orFail=false + bool $orFail=false, + array $options=[] ): \Cake\Datasource\EntityInterface|false { // First check if we have an entity matching $whereClause $entity = $this->find() @@ -70,7 +72,7 @@ public function upsert( // isNew() or getOriginal() can't be used to determine what happened. $entity->setHidden(['_upsertStatus']); - return $orFail ? $this->saveOrFail($entity) : $this->save($entity); + return $orFail ? $this->saveOrFail($entity, $options) : $this->save($entity, $options); } /** @@ -79,14 +81,16 @@ public function upsert( * @since COmanage Registry v5.1.0 * @param array $data Data to persist * @param array $whereClause Conditions to search for current entity + * @param array $options Options for save * @return Cake\Datasource\EntityInterface|false Persisted entity, or false on failure * @throws Cake\ORM\Exception\PersistenceFailedException */ public function upsertOrFail( array $data, - array $whereClause + array $whereClause, + array $options ): \Cake\Datasource\EntityInterface { - return $this->upsert($data, $whereClause, true); + return $this->upsert($data, $whereClause, true, $options); } } diff --git a/app/src/Lib/Traits/ValidationTrait.php b/app/src/Lib/Traits/ValidationTrait.php index b6ff096c5..9bc7836f4 100644 --- a/app/src/Lib/Traits/ValidationTrait.php +++ b/app/src/Lib/Traits/ValidationTrait.php @@ -35,6 +35,34 @@ use Cake\Validation\Validator; trait ValidationTrait { + /** + * Register validation rules for tables implementing ClonableBehavior. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Cake Validator + * @param TableSchemaInterface $schema Cake Schema + * @return Validator Cake Validator + */ + + public function registerClonableValidation( + Validator $validator, + TableSchemaInterface $schema, + ): Validator { + $this->registerStringValidation($validator, $schema, 'cri', false, '', false); + + $validator->add('do_not_clone', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('do_not_clone'); + + $validator->add('uuid', [ + 'content' => ['rule' => 'uuid'] + ]); + $validator->allowEmptyString('uuid'); + + return $validator; + } + /** * Register validation rules for the primary link key(s) associated with this table. * diff --git a/app/src/Lib/Util/SchemaManager.php b/app/src/Lib/Util/SchemaManager.php index ceaddbf4e..60acaf4fa 100644 --- a/app/src/Lib/Util/SchemaManager.php +++ b/app/src/Lib/Util/SchemaManager.php @@ -249,6 +249,25 @@ protected function processSchema( } } + if(isset($tCfg->clonable) && $tCfg->clonable) { + // Duplicatable objects get uuid and cri fields + + // The DBAL "guid" type will map to the Postgres "uuid" type, and Postgres + // will automatically create an optimal index for it. The column must allow + // nulls because historical records (including changelog archives) will not + // have UUIDs, and there may be cases where skeletal records are created + // before an object is fully active. + $table->addColumn("uuid", "guid", ['notnull' => false]); + $table->addIndex(["uuid"], $tablePrefix.$tName."_id1"); + + // While cri consists of deployer specific values, we want it searchable + $table->addColumn("cri", "string", ['notnull' => false]); + $table->addIndex(["cri"], $tablePrefix.$tName."_id2"); + + // Flag indicating + $table->addColumn("do_not_clone", "boolean", ['notnull' => false]); + } + // (For Registry) If MVEA models are specified, emit the appropriate // columns and indexes. MVEA attributes must be added before indexes, in // case the table has composite indexes referencing MVEA columns. diff --git a/app/src/Lib/Util/SearchUtilities.php b/app/src/Lib/Util/SearchUtilities.php new file mode 100644 index 000000000..d9f4126be --- /dev/null +++ b/app/src/Lib/Util/SearchUtilities.php @@ -0,0 +1,291 @@ +search($id, $q, $limit) + // (2) Add the model to $models here, and define which roles can query it + // (3) Update documentation at https://spaces.at.internet2.edu/pages/viewpage.action?pageId=243078053 + + static protected $globalSearchModels = [ + 'Addresses' => [ + 'parent' => ['People' => 'person_id', 'PersonRoles' => 'person_role_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'street', + 'searchLimited' => false + ], + 'EmailAddresses' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'mail', + 'searchLimited' => true + ], + 'Groups' => [ + 'parent' => ['Cos' => 'co_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'name', + 'searchLimited' => false + ], + 'Identifiers' => [ + 'parent' => ['Groups' => 'group_id', 'People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'identifier', + 'searchLimited' => true + ], + 'Names' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'full_name', + 'searchLimited' => true + ], + 'PersonRoles' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'title', + 'searchLimited' => false + ], + 'TelephoneNumbers' => [ + 'parent' => ['People' => 'person_id', 'PersonRoles' => 'person_role_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'number', + 'searchLimited' => false + ], + 'Urls' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'url', + 'searchLimited' => false + ] + ]; + + /** + * Perform a search across all objects that support Change Request Identifiers. + * + * @since COmanage Registry v5.2.0 + * @param int $coId CO ID + * @param string $cri Change Request Identifier + * @return array Array of arrays of matching Entities, keyed on Entity class + * (an empty array will be returned if no matches are found) + */ + + public static function criSearch(int $coId, string $cri): array { + $ret = []; + + // Unlike uuidSearch, criSearch can find multiple records + + foreach(self::$clonableModels as $m) { + $Table = TableRegistry::getTableLocator()->get($m); + + // Clonable model must FK directly to CO + $entities = $Table->find()->where(['co_id' => $coId, 'cri' => $cri])->all(); + + if($entities) { + $ret[$m] = $entities; + } + } + + return $ret; + } + + /** + * Obtain the set of clonable models, which support CRI and UUID searching. + * + * @since COmanage Registry v5.2.0 + * @return array Array of clonable models + */ + + public static function getClonableModels(): array { + return self::$clonableModels; + } + + /** + * Obtain the set of supported models for Global Search, and their associated + * metadata. + * + * @since COmanage Registry v5.2.0 + * @return array Array of Global Search models and metadata + */ + + public static function getGlobalSearchModels(): array { + // XXX inject plugins here CFM-109 + + return self::$globalSearchModels; + } + + /** + * Perform a "Global" Search, ie: a search from the main search bar. + * + * @since COmanage Registry v5.2.0 + * @param int $coId CO ID + * @param string $q Query string + * @return array Array of search results, sorted by searchable model + */ + + public static function globalSearch(int $coId, string $q): array { + // $results tracks the per-model backend results + $results = [ + 'Cos' => [], + 'Groups' => [], + 'People' => [], + // If we matched on a UUID + 'uuid' => null, + // If we matched on a CRN + 'crn' => [], + // If we reached our search limit + 'limitReached' => false + ]; + + // Pull our search configuration + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $coId])->firstOrFail(); + + $searchLimit = $settings->search_global_limit; + + // We call the function rather than use the array directly for when we add + // plugin support (CFM-109) + $models = self::getGlobalSearchModels(); + + // Search the Global Search models + foreach(array_keys($models) as $m) { + // If we're in limited search mode, we don't search all models + if($settings->search_global_limited_models + && !$models[$m]['searchLimited']) { + continue; + } + + $authorized = true; + + $Table = TableRegistry::getTableLocator()->get($m); + + $searchResults = $Table->search(coId: $coId, q: $q, limit: $searchLimit); + + // For models with a parent other than Co, we aggregate the results to the parent + // model, but track what the matching model was. We key on the foreign key to the parent + // to also unique-ify the results while we're here. + + foreach($searchResults as $r) { + // Some tables support multiple parent models (eg: Identifiers), so we walk through + // the possibilities to see which one matched + foreach($models[$m]['parent'] as $pmodel => $pkey) { + if(!empty($r->$pkey)) { + if($m == 'Groups') { + // We special case Groups since (unlike People) they can match on both the + // primary model (Groups::name) or associated models (Identifiers::identifier). + // We force any Groups matches into the parent key format. + $results['Groups'][$r->id]['Groups'] = $r; + } elseif($pmodel == 'Cos') { + // This will look something like $results['Cos']['Departments'][] = $entity + $results[$pmodel][$m][] = $r; + } elseif($pmodel == 'PersonRoles') { + // Although we matched on a PersonRole we're really interested in the Person + $results['People'][$r->person_role->person_id][$m] = $r->person_role; + } else { + // Note we're also keying on the matched model, so this will look something like + // $results['People'][123]['Names'] = $entity + $results[$pmodel][$r->$pkey][$m] = $r; + } + } + } + } + + if(count($results['Cos']) + count($results['Groups']) + count($results['People']) >= $searchLimit) { + $results['limitReached'] = true; + break; + } + } + + // Additionally, search the clonable models. We do this regardless of whether + // the search limit was reached, since they are exempt from the search limit. + + // If $q looks like a UUID, search on UUIDs + if(\Cake\Validation\Validation::uuid($q)) { + $results['uuid'] = self::uuidSearch($coId, $q); + } + + // Search on CRI + $results['cri'] = self::criSearch($coId, $q); + + return $results; + } + + /** + * Perform a search across all objects that support UUIDs. + * + * @since COmanage Registry v5.2.0 + * @param int $coId CO ID + * @param string $uuid UUID + * @return ?array Array of 'class' and 'entity' for the matching record, if found + */ + + public static function uuidSearch(int $coId, string $uuid): ?array { + $ret = null; + + // In theory there should only be one entity in a CO with a given UUID + // (though the same entity could exist in multiple COs), so we simply + // return the first match we find. We could slightly optimize this by + // searching models with larger numbers of records first (ie: search + // People before Authenticators), but at the end of the day this is going + // to be a somewhat expensive call to make. + + foreach(self::$clonableModels as $m) { + $Table = TableRegistry::getTableLocator()->get($m); + + // Clonable model must FK directly to CO + $entity = $Table->find()->where(['co_id' => $coId, 'uuid' => $uuid])->first(); + + if($entity) { + return [ + 'class' => $m, + 'entity' => $entity + ]; + } + } + + return $ret; + } +} \ No newline at end of file diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index 60b9f5227..43bb5c661 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -213,6 +213,7 @@ public static function entityToClassName($entity): string { * @since COmanage Registry v5.0.0 * @param Entity $entity Entity * @return string Foreign key name + * @todo Merge with entityToPluginClassName */ public static function entityToForeignKey($entity): string { @@ -222,6 +223,31 @@ public static function entityToForeignKey($entity): string { return Inflector::underscore(Inflector::singularize(substr($classPath, strrpos($classPath, '\\')+1))) . "_id"; } + /** + * Determine the class basename of a Cake Entity. + * + * @since COmanage Registry v5.2.0 + * @param Entity $entity Entity + * @return string Entity Class Basename, potentially in Plugin notation (Plugin.Model) + * @todo Merge with entityToClassName (some code that calls that function can't handle plugin notation) + */ + + public static function entityToPluginClassName($entity): string { + // $classPath will be something like App\Model\Entity\Name, but we want to return "Names". + // We also support plugins, if the first component is _not_ App, we'll prefix it. + + $bits = explode("\\", get_class($entity)); + + $model = Inflector::pluralize($bits[3]); + + if($bits[0] == 'App') { + return $model; + } else { + // Plugin + return $bits[0] . "." . $model; + } + } + /** * Construct the title, supertitle and subtitle for a given Model and action * diff --git a/app/src/Lib/Util/TableUtilities.php b/app/src/Lib/Util/TableUtilities.php index 5862871de..a38cb6299 100644 --- a/app/src/Lib/Util/TableUtilities.php +++ b/app/src/Lib/Util/TableUtilities.php @@ -35,6 +35,52 @@ use Cake\Utility\Inflector; class TableUtilities { + /** + * Dynamically obtain a Table model from the Table Registry based on the requested + * datasource connection name. If the requested connection is 'default' the Table alias + * will be the requested Table name (eg "People" for "People"). For any other connection, + * the connection name will be CamelCased and prefixed to create the Table alias + * (eg "RemotePeople" for "People"). For Plugins, the Plugin name will be removed and + * the Plugin model used as described (eg "RemoteWidgets" for "MyPlugin.Widgehs"). + * + * @since COmanage Registry v5.2.0 + * @param string $tableName Table name, in the usual format (including Plugin.Models) + * @param string $connectionName Name of database connection to use + * @param array $options Additional options to pass to TableLocator + * @return Table + */ + + public static function getTableWithDataSource( + string $tableName, + string $connectionName, + array $options=[] + ): Table { + if($connectionName == 'default') { + // We can simply pass through the request + + return self::getTableFromRegistry($tableName, $options); + } + + // Start with the prefix (eg: "Remote") + $modelName = Inflector::camelize($connectionName); + + if(str_contains($tableName, '.')) { + // now (eg) SqlServers + $modelName .= StringUtilities::PluginModel($tableName); + } else { + // eg, "People" or "CoreServer.SqlServers" + $modelName .= $tableName; + } + + $mergedOptions = $options; + + $mergedOptions['alias'] = $modelName; + $mergedOptions['className'] = $tableName; + $mergedOptions['connectionName'] = $connectionName; + + return self::getTableFromRegistry($modelName, $mergedOptions); + } + /** * Dynamically create a Table model via the Table Registry. * @@ -60,6 +106,32 @@ public static function getTableFromRegistry(string $alias, array $options): Tabl return $Locator->get($alias, $options); } } + + /** + * Take an array of associations (as used for contains()) and normalize them + * for easier handling. Specifically, all relations will always have a child array, + * though it may be an empty array. + * + * @since COmanage Registry v5.2.0 + * @param array $related Array of associations in contain() format + * @return array Array in normalized format. + */ + + public static function normalizeAssociationArray(array $related): array { + $ret = []; + + foreach($related as $k => $v) { + if(is_int($k)) { + // Simple relation, give it an empty set of related children + $ret[$v] = []; + } elseif(is_array($v)) { + // Pass through the array, but we need to recurse over its elements + $ret[$k] = self::normalizeAssociationArray($v); + } + } + + return $ret; + } /** * Traverse backwards through model associations starting from a primary link. diff --git a/app/src/Model/Behavior/ClonableBehavior.php b/app/src/Model/Behavior/ClonableBehavior.php new file mode 100644 index 000000000..ff7909661 --- /dev/null +++ b/app/src/Model/Behavior/ClonableBehavior.php @@ -0,0 +1,57 @@ +group_type, - [ - GroupTypeEnum::ActiveMembers, - GroupTypeEnum::Admins, - GroupTypeEnum::AllMembers, - GroupTypeEnum::Approvers, - GroupTypeEnum::Owners - ]); + // For all intents and purposes, System groups are those that aren't Standard Groups. + return $this->group_type != GroupTypeEnum::Standard; } /** diff --git a/app/src/Model/Table/ApiUsersTable.php b/app/src/Model/Table/ApiUsersTable.php index ddc3226d6..d9861d87c 100644 --- a/app/src/Model/Table/ApiUsersTable.php +++ b/app/src/Model/Table/ApiUsersTable.php @@ -32,6 +32,7 @@ use ArrayObject; use Authentication\PasswordHasher\FallbackPasswordHasher; use Cake\Chronos\Chronos; +use Cake\Datasource\EntityInterface; use Cake\Event\EventInterface; use Cake\ORM\RulesChecker; use Cake\ORM\Table; @@ -43,6 +44,7 @@ class ApiUsersTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\ClonableTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; @@ -57,8 +59,8 @@ class ApiUsersTable extends Table { */ public function initialize(array $config): void { - // Timestamp behavior handles created/modified updates $this->addBehavior('Changelog'); + $this->addBehavior('Clonable'); $this->addBehavior('Timestamp'); $this->addBehavior('Timezone'); @@ -114,6 +116,9 @@ public function initialize(array $config): void { public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options) { + // AR-APIUser-3 For namespacing purposes, API Users are named with a prefix consisting + // of the string co_#. + if (isset($data['username'])) { $data['username'] = "co_" . $data['co_id'] . "." . $data['username']; } @@ -128,19 +133,18 @@ public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObj */ public function buildRules(RulesChecker $rules): RulesChecker { - // We don't want to perform the uniqueness check until after then namespacing - // check in order to avoid information leakage. This requires more complicated - // rule building. - - $rules->add(function($entity, $options) use($rules) { - - // AR-ApiUser-3 API usernames must be unique across the entire platform. - $rule = $rules->isUnique(['username'], __d('error', 'exists', [__d('controller', 'ApiUsers', [1])])); - - return $rule($entity, $options); - }, - 'isUsernameValid', - ['errorField' => 'username']); + // AR-ApiUser-3 API usernames must be unique across the entire platform. + $rules->add( + $rules->isUnique(['username']), + 'usernameUnique', + ['errorField' => 'username', + 'message' => __d('error', 'exists', [__d('controller', 'ApiUsers', [1])])] + ); + + // AR-GMR-6 The same UUID cannot be assigned to multiple objects within the same CO. + $rules->add([$this, 'ruleUuidUnique'], + 'uuidUnique', + ['errorField' => 'uuid']); return $rules; } @@ -175,8 +179,6 @@ public function generateKey(int $id) { * @throws InvalidArgumentException */ -// public function getUserPrivilege(string $username): mixed { -// mixed requires PHP 8 public function getUserPrivilege(string $username) { $apiUser = $this->find()->where(['username' => $username])->contain('Cos')->first(); @@ -192,6 +194,40 @@ public function getUserPrivilege(string $username) { return false; } + + /** + * Prepare an entity for cloning. + * + * @since COmanage Registry v5.2.0 + * @param EntityInterface $original Original entity + * @param EntityInterface $clone Clone (not yet saved) + * @param string $dataSource DataSource connection name + * @return EntityInterface Clone, updated as necessary + */ + + public function prepareClone( + EntityInterface $original, + EntityInterface $clone, + string $dataSource + ): EntityInterface { + // beforeMarshal will inject the co_id prefix, but it will prefix the old CO prefix, + // and we'll end up with something like co_x.co_y.username. We'll fix that here, + // because beforeMarshal shouldn't have to deal with the otherwise unsupported + // concept of moving an entity across a CO. + + // We can simply throw away the middle bit + $bits = explode('.', $clone->username, 3); + + $clone->username = $bits[0] . '.' . $bits[2]; + + // Because we don't ordinarily allow API Keys to be set on entity creation + // (see ApiUser.php) we manually copy the key. Because ApiUser defines a setter + // to hash the key, we need to use set() to disable setters. + + $clone->set('api_key', $original->api_key, ['setter' => false]); + + return $clone; + } /** * Validate an API Key. @@ -321,6 +357,8 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('remote_ip'); + $this->registerClonableValidation($validator, $schema); + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index a694bec0f..105031672 100644 --- a/app/src/Model/Table/CousTable.php +++ b/app/src/Model/Table/CousTable.php @@ -29,19 +29,23 @@ namespace App\Model\Table; -use App\Lib\Enum\StatusEnum; use Cake\Database\Expression\QueryExpression; +use Cake\Datasource\EntityInterface; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\Validation\Validator; +use \App\Lib\Enum\StatusEnum; use \App\Lib\Enum\ProvisioningEligibilityEnum; +use \App\Lib\Util\StringUtilities; +use \App\Lib\Util\TableUtilities; class CousTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\ChangelogBehaviorTrait; + use \App\Lib\Traits\ClonableTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; @@ -61,6 +65,7 @@ class CousTable extends Table { public function initialize(array $config): void { // Timestamp behavior handles created/modified updates $this->addBehavior('Changelog'); + $this->addBehavior('Clonable'); $this->addBehavior('Log'); $this->addBehavior('Timestamp'); $this->addBehavior('Tree'); @@ -142,7 +147,7 @@ public function initialize(array $config): void { */ public function buildRules(RulesChecker $rules): RulesChecker { - // AR-CO-3 Two COUs within the same CO cannot share the same name + // AR-COU-3 Two COUs within the same CO cannot share the same name $rules->add($rules->isUnique(['name', 'co_id'], __d('error', 'exists', [__d('controller', 'Cous', [1])]))); // This is not an Application Rule per se, but the parent_id must be a valid @@ -151,6 +156,11 @@ public function buildRules(RulesChecker $rules): RulesChecker { 'potentialParent', ['errorField' => 'parent_id']); + // AR-GMR-6 The same UUID cannot be assigned to multiple objects within the same CO. + $rules->add([$this, 'ruleUuidUnique'], + 'uuidUnique', + ['errorField' => 'uuid']); + return $rules; } @@ -262,6 +272,8 @@ public function validationDefault(Validator $validator): Validator { 'content' => ['rule' => 'isInteger'] ]); $validator->allowEmptyString('rght'); + + $this->registerClonableValidation($validator, $schema); return $validator; } diff --git a/app/src/Model/Table/ExternalIdentitySourcesTable.php b/app/src/Model/Table/ExternalIdentitySourcesTable.php index 76a1b0089..42837ecf3 100644 --- a/app/src/Model/Table/ExternalIdentitySourcesTable.php +++ b/app/src/Model/Table/ExternalIdentitySourcesTable.php @@ -41,6 +41,7 @@ class ExternalIdentitySourcesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\ChangelogBehaviorTrait; + use \App\Lib\Traits\ClonableTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; @@ -63,6 +64,7 @@ class ExternalIdentitySourcesTable extends Table { public function initialize(array $config): void { // Timestamp behavior handles created/modified updates $this->addBehavior('Changelog'); + $this->addBehavior('Clonable'); $this->addBehavior('Log'); $this->addBehavior('Timestamp'); @@ -189,6 +191,23 @@ public function annul(int $id, string $sourceKey) { ); } + /** + * Define business rules. + * + * @since COmanage Registry v5.2.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-GMR-6 The same UUID cannot be assigned to multiple objects within the same CO. + $rules->add([$this, 'ruleUuidUnique'], + 'uuidUnique', + ['errorField' => 'uuid']); + + return $rules; + } + /** * Obtain the changelist from the backend, if supported. * @@ -422,6 +441,8 @@ public function validationDefault(Validator $validator): Validator { 'content' => ['rule' => ['boolean']] ]); $validator->allowEmptyString('suppress_noop_logs'); + + $this->registerClonableValidation($validator, $schema); return $validator; } diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index f13f14a26..b8b268f7d 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -46,6 +46,7 @@ class GroupsTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\ChangelogBehaviorTrait; + use \App\Lib\Traits\ClonableTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\LabeledLogTrait; @@ -71,6 +72,7 @@ class GroupsTable extends Table { public function initialize(array $config): void { // Timestamp behavior handles created/modified updates $this->addBehavior('Changelog'); + $this->addBehavior('Clonable'); $this->addBehavior('Log'); $this->addBehavior('Timestamp'); $this->addBehavior('Tree'); @@ -428,8 +430,16 @@ public function afterMarshal( public function beforeDelete(EventInterface $event, $entity, \ArrayObject $options) { // AR-Group-8 When a Group is deleted, its corresponding Owners Group is also deleted. if(!empty($entity->owners_group_id)) { - $ownersGroup = $this->get($entity->owners_group_id); - $this->delete($ownersGroup); + // When we're deleting via cascade (eg: deleting a COU, which cascades to the COU + // specific Groups) we can't control the order we're called in, and the Owners Group + // might be deleted before main Group. We'll simply ignore the error if we can't + // find the owners' group. + + $ownersGroup = $this->find()->where(['id' => $entity->owners_group_id])->first(); + + if($ownersGroup) { + $this->delete($ownersGroup); + } // We leave the foreign key in place on $entity in case someone decides // to look at the archived data. @@ -506,6 +516,11 @@ public function buildRules(RulesChecker $rules): RulesChecker { $rules->add([$this, 'rulePotentialParent'], 'potentialParent', ['errorField' => 'parent_id']); + + // AR-GMR-6 The same UUID cannot be assigned to multiple objects within the same CO. + $rules->add([$this, 'ruleUuidUnique'], + 'uuidUnique', + ['errorField' => 'uuid']); return $rules; } @@ -597,6 +612,27 @@ public function getAdminGroupId(int $coId): int { return $g->id; } + /** + * Check for any dependencies that must be in place before cloning begins. + * + * @since COmanage Registry v5.2.0 + * @param EntityInterface $original Original entity + * @param string $targetDataSource Target DataSource connection name + */ + + public function checkCloneDependencies( + EntityInterface $original, + string $targetDataSource='default' + ) { + // As a first pass, we only sync Standard Groups. + + if($original->isSystem()) { + // This string isn't internationalized because it is intended to render + // in CloneCommand output + throw new \InvalidArgumentException("Group " . $original->id . " is a system group, skipping..."); + } + } + /** * Obtain the fully qualified name for the Group, which will include all * parent names, separated by colons. diff --git a/app/src/Model/Table/IdentifierAssignmentsTable.php b/app/src/Model/Table/IdentifierAssignmentsTable.php index 29257a1aa..c1a905c8c 100644 --- a/app/src/Model/Table/IdentifierAssignmentsTable.php +++ b/app/src/Model/Table/IdentifierAssignmentsTable.php @@ -43,6 +43,7 @@ class IdentifierAssignmentsTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\ChangelogBehaviorTrait; + use \App\Lib\Traits\ClonableTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; @@ -66,6 +67,7 @@ class IdentifierAssignmentsTable extends Table { public function initialize(array $config): void { // Timestamp behavior handles created/modified updates $this->addBehavior('Changelog'); + $this->addBehavior('Clonable'); $this->addBehavior('Log'); $this->addBehavior('Orderable'); $this->addBehavior('Timestamp'); @@ -390,6 +392,11 @@ public function buildRules(RulesChecker $rules): RulesChecker { $rules->add([$this, 'ruleWhichType'], 'targetType', ['errorField' => 'identifier_type_id']); + + // AR-GMR-6 The same UUID cannot be assigned to multiple objects within the same CO. + $rules->add([$this, 'ruleUuidUnique'], + 'uuidUnique', + ['errorField' => 'uuid']); return $rules; } @@ -541,6 +548,8 @@ public function validationDefault(Validator $validator): Validator { 'content' => ['rule' => 'isInteger'] ]); $validator->allowEmptyString('ordr'); + + $this->registerClonableValidation($validator, $schema); return $validator; } diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php index f9c2afa54..184374fcf 100644 --- a/app/src/Model/Table/PipelinesTable.php +++ b/app/src/Model/Table/PipelinesTable.php @@ -54,6 +54,7 @@ class PipelinesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\ChangelogBehaviorTrait; + use \App\Lib\Traits\ClonableTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; @@ -72,6 +73,7 @@ class PipelinesTable extends Table { public function initialize(array $config): void { // Timestamp behavior handles created/modified updates $this->addBehavior('Changelog'); + $this->addBehavior('Clonable'); $this->addBehavior('Log'); $this->addBehavior('Timestamp'); @@ -182,6 +184,23 @@ public function initialize(array $config): void { ] ]); } + + /** + * Define business rules. + * + * @since COmanage Registry v5.2.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-GMR-6 The same UUID cannot be assigned to multiple objects within the same CO. + $rules->add([$this, 'ruleUuidUnique'], + 'uuidUnique', + ['errorField' => 'uuid']); + + return $rules; + } /** * Correlate an array of mapped backend record data (as returned by @@ -2565,6 +2584,8 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('sync_verify_email_addresses'); + $this->registerClonableValidation($validator, $schema); + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/PluginsTable.php b/app/src/Model/Table/PluginsTable.php index 6bc7824c3..7903fa390 100644 --- a/app/src/Model/Table/PluginsTable.php +++ b/app/src/Model/Table/PluginsTable.php @@ -211,7 +211,8 @@ public function deactivate(int $id): bool { */ public function findActive(Query $query): Query { - return $query->where(['Plugins.status' => SuspendableStatusEnum::Active]) + // We might be called (eg) RemotePlugins via CloneCommand + return $query->where([$this->getAlias().'.status' => SuspendableStatusEnum::Active]) ->orderBy(['plugin' => 'ASC']); } diff --git a/app/src/Model/Table/ProvisioningTargetsTable.php b/app/src/Model/Table/ProvisioningTargetsTable.php index c118d9e83..c1ba4b9c1 100644 --- a/app/src/Model/Table/ProvisioningTargetsTable.php +++ b/app/src/Model/Table/ProvisioningTargetsTable.php @@ -43,6 +43,7 @@ class ProvisioningTargetsTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\ChangelogBehaviorTrait; + use \App\Lib\Traits\ClonableTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; @@ -61,6 +62,7 @@ class ProvisioningTargetsTable extends Table { public function initialize(array $config): void { // Timestamp behavior handles created/modified updates $this->addBehavior('Changelog'); + $this->addBehavior('Clonable'); $this->addBehavior('Log'); $this->addBehavior('Orderable'); $this->addBehavior('Timestamp'); @@ -122,6 +124,23 @@ public function initialize(array $config): void { ]); } + /** + * Define business rules. + * + * @since COmanage Registry v5.2.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-GMR-6 The same UUID cannot be assigned to multiple objects within the same CO. + $rules->add([$this, 'ruleUuidUnique'], + 'uuidUnique', + ['errorField' => 'uuid']); + + return $rules; + } + /** * Invoke provisioning. This function is intended to be called via ProvisionableTrait. * @@ -343,6 +362,8 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('ordr'); + $this->registerClonableValidation($validator, $schema); + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/ServersTable.php b/app/src/Model/Table/ServersTable.php index 678b20bb1..76c6d3a0c 100644 --- a/app/src/Model/Table/ServersTable.php +++ b/app/src/Model/Table/ServersTable.php @@ -29,16 +29,22 @@ namespace App\Model\Table; +use Cake\Datasource\ConnectionManager; +use Cake\Datasource\EntityInterface; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\Validation\Validator; + use App\Lib\Enum\SuspendableStatusEnum; +use App\Lib\Util\StringUtilities; +use App\Lib\Util\TableUtilities; class ServersTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\ChangelogBehaviorTrait; + use \App\Lib\Traits\ClonableTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PluggableModelTrait; @@ -56,6 +62,7 @@ class ServersTable extends Table { public function initialize(array $config): void { // Timestamp behavior handles created/modified updates $this->addBehavior('Changelog'); + $this->addBehavior('Clonable'); $this->addBehavior('Log'); $this->addBehavior('Timestamp'); @@ -74,7 +81,7 @@ public function initialize(array $config): void { $this->hasMany('Pipelines') ->setForeignKey('match_server_id'); - $this->setPluginRelations(); + $this->setPluginRelations(); $this->setDisplayField('description'); @@ -124,6 +131,11 @@ public function buildRules(RulesChecker $rules): RulesChecker { 'serverInUse', ['errorField' => 'status']); + // AR-GMR-6 The same UUID cannot be assigned to multiple objects within the same CO. + $rules->add([$this, 'ruleUuidUnique'], + 'uuidUnique', + ['errorField' => 'uuid']); + return $rules; } @@ -166,6 +178,8 @@ public function validationDefault(Validator $validator): Validator { $validator->notEmptyString('status'); $this->registerStringValidation($validator, $schema, 'plugin', true); + + $this->registerClonableValidation($validator, $schema); return $validator; } diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php index 0ad07782e..81278227b 100644 --- a/app/src/Model/Table/TypesTable.php +++ b/app/src/Model/Table/TypesTable.php @@ -42,11 +42,13 @@ class TypesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\ClonableTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\SearchFilterTrait; use \App\Lib\Traits\TableMetaTrait; + use \App\Lib\Traits\UpsertTrait; use \App\Lib\Traits\ValidationTrait; // XXX note not all models are implemented yet... @@ -79,6 +81,7 @@ class TypesTable extends Table { public function initialize(array $config): void { // Timestamp behavior handles created/modified updates $this->addBehavior('Changelog'); + $this->addBehavior('Clonable'); $this->addBehavior('Log'); $this->addBehavior('Timestamp'); @@ -235,6 +238,16 @@ public function buildRules(RulesChecker $rules): RulesChecker { 'typeInUse', ['errorField' => 'type_id']); + // AR-Type-4 The Database Values for a given Attribute Type must be unique within the CO + $rules->add([$this, 'ruleTypeUnique'], + 'typeUnique', + ['errorField' => 'type_id']); + + // AR-GMR-6 The same UUID cannot be assigned to multiple objects within the same CO. + $rules->add([$this, 'ruleUuidUnique'], + 'uuidUnique', + ['errorField' => 'uuid']); + return $rules; } @@ -365,6 +378,36 @@ public function ruleTypeInUse($entity, $options) { if($this->typeIsDefault($entity)) { return __d('error', 'Types.isdefault', [$entity->value]); } + + return true; + } + + /** + * Determine if the provided Type is unique within the CO. + * + * @since COmanage Registry v5.2.0 + * @param Type $entity Type + * @return bool true if the Type is unique, false otherwise + */ + + public function ruleTypeUnique($entity, $options) { + // We require the database value to be unique for the given attribute within the CO + // in order for type lookups (eg getTypeId()) to deterministically map to the same record. + // Note we don't specifically enforce case insensitive checks, so it's possible to + // have two different types called "foo" and "Foo", which may or may not be desirable. + + try { + $match = $this->getTypeId($entity->co_id, $entity->attribute, $entity->value); + + if(isset($entity->id) && $match == $entity->id) { + // We found our own record + } else { + return __d('error', 'Types.unique', [$entity->value, $match]); + } + } + catch(\Exception $e) { + // We want the reverse logic, ie if we don't find a record we're good + } return true; } @@ -415,6 +458,8 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('status'); + $this->registerClonableValidation($validator, $schema); + return $validator; } } \ No newline at end of file diff --git a/app/templates/ApiUsers/fields.inc b/app/templates/ApiUsers/fields.inc index cddb22c1b..def26c533 100644 --- a/app/templates/ApiUsers/fields.inc +++ b/app/templates/ApiUsers/fields.inc @@ -93,4 +93,6 @@ if($vv_action == 'add' || $vv_action == 'edit') { 'fieldName' => 'privileged', // boolean ] ]); + + print $this->element('clonable'); } diff --git a/app/templates/Cous/fields.inc b/app/templates/Cous/fields.inc index 60089c412..a6307e69a 100644 --- a/app/templates/Cous/fields.inc +++ b/app/templates/Cous/fields.inc @@ -42,4 +42,6 @@ if($vv_action == 'add' || $vv_action == 'edit') { ] ]); } + + print $this->element('clonable'); } diff --git a/app/templates/Dashboards/search.php b/app/templates/Dashboards/search.php index fdb036d45..cb597602d 100644 --- a/app/templates/Dashboards/search.php +++ b/app/templates/Dashboards/search.php @@ -25,6 +25,8 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ + use Cake\Utility\Hash; + $options = [ 'type' => 'post', 'url' => [ @@ -37,9 +39,22 @@ $resultsCount = 0; // Count only People and Groups for now. Other models can come later. - foreach(['People', 'Groups'] as $pm) { + foreach(['People', 'Groups', 'Cos'] as $pm) { $resultsCount += count($vv_results[$pm]); - } + } + + if($vv_results['uuid']) { + // It's unlikely we'll get here with a uuid in the search results since uuids will + // generally result in an exact match, which will cause a redirect to the result + + $resultsCount++; + } + + if(!empty($vv_results['cri'])) { + foreach($vv_results['cri'] as $m => $rs) { + $resultsCount += $rs->count(); + } + } ?>
@@ -85,7 +100,7 @@
- $pm): ?> + $pm): ?>
+ +
+
    + + + \Cake\Utility\Inflector::dasherize($model), + 'action' => 'edit', + $match->id + ]; + +// XXX meh... + $displayString = $model . " " . $match->id; + $matchInfo = $match->modified; + ?> + +
  • + +
    + +
    +
    + +
    +
    +
  • + + +
+
+ +

diff --git a/app/templates/ExternalIdentitySources/fields.inc b/app/templates/ExternalIdentitySources/fields.inc index f0a7fb645..9fa41023b 100644 --- a/app/templates/ExternalIdentitySources/fields.inc +++ b/app/templates/ExternalIdentitySources/fields.inc @@ -49,4 +49,6 @@ if($vv_action == 'add' || $vv_action == 'edit') { } print $this->element('form/listItem', $params); } + + print $this->element('clonable'); } diff --git a/app/templates/Groups/fields.inc b/app/templates/Groups/fields.inc index 9281be407..fcd7c6691 100644 --- a/app/templates/Groups/fields.inc +++ b/app/templates/Groups/fields.inc @@ -89,3 +89,5 @@ if($vv_action != 'add') { ]); } } + +print $this->element('clonable'); \ No newline at end of file diff --git a/app/templates/IdentifierAssignments/fields.inc b/app/templates/IdentifierAssignments/fields.inc index 93ca8e381..6f94bbe53 100644 --- a/app/templates/IdentifierAssignments/fields.inc +++ b/app/templates/IdentifierAssignments/fields.inc @@ -124,4 +124,6 @@ if($vv_action == 'add' || $vv_action == 'edit') { 'fieldName' => $field, ]]); } + + print $this->element('clonable'); } \ No newline at end of file diff --git a/app/templates/Pipelines/fields.inc b/app/templates/Pipelines/fields.inc index 349767fb4..713168c45 100644 --- a/app/templates/Pipelines/fields.inc +++ b/app/templates/Pipelines/fields.inc @@ -108,6 +108,8 @@ if($vv_action == 'add' || $vv_action == 'edit') { ]); } + print $this->element('clonable'); + // Connections //XXX } diff --git a/app/templates/ProvisioningTargets/fields.inc b/app/templates/ProvisioningTargets/fields.inc index 1e3b025de..be73e7171 100644 --- a/app/templates/ProvisioningTargets/fields.inc +++ b/app/templates/ProvisioningTargets/fields.inc @@ -86,4 +86,6 @@ if($vv_action == 'add' || $vv_action == 'edit') { 'arguments' => [ 'fieldName' => 'ordr' ]]); + + print $this->element('clonable'); } diff --git a/app/templates/Servers/fields.inc b/app/templates/Servers/fields.inc index 76054295d..cce7c41b8 100644 --- a/app/templates/Servers/fields.inc +++ b/app/templates/Servers/fields.inc @@ -44,4 +44,5 @@ if($vv_action == 'add' || $vv_action == 'edit') { ] ]); + print $this->element('clonable'); } diff --git a/app/templates/Standard/api/v2/json/index.php b/app/templates/Standard/api/v2/json/index.php index e1e1a76e9..18e67193b 100644 --- a/app/templates/Standard/api/v2/json/index.php +++ b/app/templates/Standard/api/v2/json/index.php @@ -40,7 +40,21 @@ $responseMeta['pageCount'] = $this->Paginator->total(); } -$metaAttrs = ['created', 'modified', 'revision', 'deleted', 'actor_identifier']; +$metaAttrs = [ + // Timestamp + 'created', + 'modified', + // Changelog + 'revision', + 'deleted', + 'actor_identifier', + // Tree It's unclear we should even return these + 'lft', + 'rght', + // CFM-127 Duplicatable + 'uuid', + 'crn' +]; // Inflect the table name to get the changelog parent record key $pkey = \Cake\Utility\Inflector::singularize($vv_table_name) . "_id"; diff --git a/app/templates/Types/columns.inc b/app/templates/Types/columns.inc index fbbc8a4f3..e436d2be4 100644 --- a/app/templates/Types/columns.inc +++ b/app/templates/Types/columns.inc @@ -30,6 +30,10 @@ $indexColumns = [ 'type' => 'link', 'sortable' => true ], + 'value' => [ + 'type' => 'echo', + 'sortable' => true + ], 'attribute' => [ 'type' => 'echo', 'sortable' => true diff --git a/app/templates/Types/fields.inc b/app/templates/Types/fields.inc index f90568fd6..fe6d2fb91 100644 --- a/app/templates/Types/fields.inc +++ b/app/templates/Types/fields.inc @@ -72,4 +72,6 @@ if($vv_action == 'add' || $vv_action == 'edit') { 'fieldName' => $field ]]); } + + print $this->element('clonable'); } diff --git a/app/templates/element/clonable.php b/app/templates/element/clonable.php new file mode 100644 index 000000000..157b84e77 --- /dev/null +++ b/app/templates/element/clonable.php @@ -0,0 +1,36 @@ +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field + ] + ]); +}