diff --git a/app/availableplugins/FileConnector/README.md b/app/availableplugins/FileConnector/README.md new file mode 100644 index 000000000..8bb5edeb3 --- /dev/null +++ b/app/availableplugins/FileConnector/README.md @@ -0,0 +1,11 @@ +# FileProvisioner plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/file-provisioner +``` diff --git a/app/availableplugins/FileConnector/composer.json b/app/availableplugins/FileConnector/composer.json new file mode 100644 index 000000000..740d46354 --- /dev/null +++ b/app/availableplugins/FileConnector/composer.json @@ -0,0 +1,24 @@ +{ + "name": "comanage-registry/file-provisioner", + "description": "FileConnector plugin for COmanage Registry", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "FileConnector\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "FileConnector\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/availableplugins/FileConnector/phpunit.xml.dist b/app/availableplugins/FileConnector/phpunit.xml.dist new file mode 100644 index 000000000..9fb2429d3 --- /dev/null +++ b/app/availableplugins/FileConnector/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po b/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po new file mode 100644 index 000000000..970794dd0 --- /dev/null +++ b/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po @@ -0,0 +1,35 @@ +# COmanage Registry Localizations (file_provisioner domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.FileProvisioners" +msgstr "{0,plural,=1{File Provisioner} other{File Provisioners}}" + +msgid "error.filename.writeable" +msgstr "The file \"{0}\" is not writable" + +msgid "field.FileProvisioners.filename" +msgstr "File Name" + +msgid "field.FileProvisioners.filename.desc" +msgstr "Full path to file to write to, which must exist and be writeable" \ No newline at end of file diff --git a/app/availableplugins/FileConnector/src/Controller/AppController.php b/app/availableplugins/FileConnector/src/Controller/AppController.php new file mode 100644 index 000000000..d1cf9843f --- /dev/null +++ b/app/availableplugins/FileConnector/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'FileProvisioners.id' => 'asc' + ] + ]; +} diff --git a/app/availableplugins/FileConnector/src/FileConnectorPlugin.php b/app/availableplugins/FileConnector/src/FileConnectorPlugin.php new file mode 100644 index 000000000..c3e445196 --- /dev/null +++ b/app/availableplugins/FileConnector/src/FileConnectorPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'FileConnector', + ['path' => '/file-connector'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/availableplugins/FileConnector/src/Model/Entity/FileProvisioner.php b/app/availableplugins/FileConnector/src/Model/Entity/FileProvisioner.php new file mode 100644 index 000000000..36fc9b3e3 --- /dev/null +++ b/app/availableplugins/FileConnector/src/Model/Entity/FileProvisioner.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php new file mode 100644 index 000000000..e8b7c7fa7 --- /dev/null +++ b/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php @@ -0,0 +1,188 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ProvisioningTargets'); + + $this->setDisplayField('filename'); + + $this->setPrimaryLink(['provisioning_target_id']); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + + $this->setProvisionableModels([ + 'People', + 'Groups' + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // The requested file must exist and be writeable. + + $rules->add([$this, 'ruleIsFileWriteable'], + 'isFileWriteable', + ['errorField' => 'filename']); + + return $rules; + } + + /** + * Provision object data to the provisioning target. + * + * @since COmanage Registry v5.0.0 + * @param SqlProvisioner $provisioningTarget FileProvisioner configuration + * @param string $className Class name of primary object being provisioned + * @param object $data Provisioning data in Entity format (eg: \App\Model\Entity\Person) + * @param ProvisioningEligibilityEnum $eligibility Provisioning Eligibility Enum + * @return array Array of status, comment, and optional identifier + */ + + public function provision( + \FileProvisioner\Model\Entity\FileProvisioner $provisioningTarget, + string $entityName, + object $data, + string $eligibility + ): array { + // Default output is an empty record + $output = [ 'id' => $data->id ]; + + if($eligibility == ProvisioningEligibilityEnum::Eligible) { + $output = $data; + } + + if(file_put_contents( + filename: $provisioningTarget->filename, + data: json_encode($output, JSON_INVALID_UTF8_SUBSTITUTE) . "\n", + flags: FILE_APPEND + ) === false) { + throw new \RuntimeException("Write to " . $provisioningTarget->filename . " failed"); + } + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => "Wrote 1 record to file", + 'identifier' => null + ]; + } + + /** + * Application Rule to determine if the current entity is a writeable file. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.0.0 + */ + + public function ruleIsFileWriteable($entity, array $options): string|bool { + if(!is_writable($entity->filename)) { + return __d('file_provisioner', 'error.filename.writeable', [$entity->filename]); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('provisioning_target_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('provisioning_target_id'); + + $this->registerStringValidation($validator, $schema, 'filename', true); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/FileConnector/src/config/plugin.json b/app/availableplugins/FileConnector/src/config/plugin.json new file mode 100644 index 000000000..29079f98d --- /dev/null +++ b/app/availableplugins/FileConnector/src/config/plugin.json @@ -0,0 +1,21 @@ +{ + "types": { + "provisioner": [ + "FileProvisioners" + ] + }, + "schema": { + "tables": { + "file_provisioners": { + "columns": { + "id": {}, + "provisioning_target_id": {}, + "filename": { "type": "string", "size": 128 } + }, + "indexes": { + "file_provisioners_i1": { "columns": [ "provisioning_target_id" ]} + } + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/FileConnector/templates/FileProvisioners/fields.inc b/app/availableplugins/FileConnector/templates/FileProvisioners/fields.inc new file mode 100644 index 000000000..4980a687a --- /dev/null +++ b/app/availableplugins/FileConnector/templates/FileProvisioners/fields.inc @@ -0,0 +1,31 @@ +Field->control('filename'); +} diff --git a/app/availableplugins/FileConnector/tests/bootstrap.php b/app/availableplugins/FileConnector/tests/bootstrap.php new file mode 100644 index 000000000..040ecb580 --- /dev/null +++ b/app/availableplugins/FileConnector/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/availableplugins/FileConnector/tests/schema.sql b/app/availableplugins/FileConnector/tests/schema.sql new file mode 100644 index 000000000..ab2df5081 --- /dev/null +++ b/app/availableplugins/FileConnector/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for FileProvisioner diff --git a/app/availableplugins/FileConnector/webroot/.gitkeep b/app/availableplugins/FileConnector/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/availableplugins/SqlConnector/README.md b/app/availableplugins/SqlConnector/README.md new file mode 100644 index 000000000..06ab084d6 --- /dev/null +++ b/app/availableplugins/SqlConnector/README.md @@ -0,0 +1,11 @@ +# SqlConnector plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/sql-connector +``` diff --git a/app/availableplugins/SqlConnector/composer.json b/app/availableplugins/SqlConnector/composer.json new file mode 100644 index 000000000..25e5666fe --- /dev/null +++ b/app/availableplugins/SqlConnector/composer.json @@ -0,0 +1,24 @@ +{ + "name": "comanage-registry/sql-connector", + "description": "SqlConnector plugin for COmanage Registry", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "SqlConnector\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SqlConnector\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/availableplugins/SqlConnector/phpunit.xml.dist b/app/availableplugins/SqlConnector/phpunit.xml.dist new file mode 100644 index 000000000..ed4972e70 --- /dev/null +++ b/app/availableplugins/SqlConnector/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/availableplugins/SqlConnector/resources/locales/en_US/sql_connector.po b/app/availableplugins/SqlConnector/resources/locales/en_US/sql_connector.po new file mode 100644 index 000000000..ceb0662fa --- /dev/null +++ b/app/availableplugins/SqlConnector/resources/locales/en_US/sql_connector.po @@ -0,0 +1,59 @@ +# COmanage Registry Localizations (sql_connector domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.SqlProvisioners" +msgstr "{0,plural,=1{SQL Provisioner} other{SQL Provisioners}}" + +msgid "error.table_prefix" +msgstr "Table Name Prefix must be alphanumeric and end with an underscore" + +msgid "field.SqlProvisioners.table_prefix" +msgstr "Table Name Prefix" + +msgid "field.SqlProvisioners.table_prefix.desc" +msgstr "Prefix used when constructing table names, must be alphanumeric and end with an underscore (_)" + +msgid "operation.reapply" +msgstr "Reapply Target Database Schema" + +msgid "operation.resync" +msgstr "Resync Reference Data" + +msgid "result.prov.added" +msgstr "New record published" + +msgid "result.prov.deleted" +msgstr "Record deleted" + +msgid "result.prov.ineligible" +msgstr "Record is not eligible for provisioning" + +msgid "result.prov.updated" +msgstr "Record updated" + +msgid "result.reapply.ok" +msgstr "Schema Reapplied" + +msgid "result.resync.ok" +msgstr "Reference Data Synced" \ No newline at end of file diff --git a/app/availableplugins/SqlConnector/src/Controller/AppController.php b/app/availableplugins/SqlConnector/src/Controller/AppController.php new file mode 100644 index 000000000..42c5e1b94 --- /dev/null +++ b/app/availableplugins/SqlConnector/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'SqlProvisioners.id' => 'asc' + ] + ]; + + /** + * Reapply the target database schema. + * + * @since COmanage Registry v5.0.0 + * @param string $id SqlProvisioner ID + */ + + public function reapply(string $id) { + try { + $this->SqlProvisioners->applySchema((int)$id); + $this->Flash->success(__d('sql_connector', 'result.reapply.ok')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect((int)$id); + } + + /** + * Reapply all Reference Data, including Groups. + * + * @since COmanage Registry v5.0.0 + * @param string $id SqlProvisioner ID + */ + + public function resync(string $id) { + try { + $cur_co = $this->getCO(); + + $this->SqlProvisioners->syncReferenceData(id: $id); + + $this->Flash->success(__d('sql_connector', 'result.resync.ok')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect((int)$id); + } +} diff --git a/app/availableplugins/SqlConnector/src/Model/Entity/SqlProvisioner.php b/app/availableplugins/SqlConnector/src/Model/Entity/SqlProvisioner.php new file mode 100644 index 000000000..5d5ff2247 --- /dev/null +++ b/app/availableplugins/SqlConnector/src/Model/Entity/SqlProvisioner.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php new file mode 100644 index 000000000..2cebb53af --- /dev/null +++ b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php @@ -0,0 +1,754 @@ + [ + 'table' => 'people', + 'name' => 'SpPeople', + 'source' => 'People', + 'source_table' => 'people', + 'related' => [ + 'AdHocAttributes', + 'Addresses', + 'EmailAddresses', + 'ExternalIdentities', + 'GroupMembers', + 'Identifiers', + 'Names', + 'PersonRoles', + 'Pronouns', + 'TelephoneNumbers', + 'Urls' + ] + ], + 'Groups' => [ + 'table' => 'groups', + 'name' => 'SpGroups', + 'source' => 'Groups', + 'source_table' => 'groups', + 'related' => [ + 'GroupMembers' + ] + ] + ]; + + // Secondary models that are provisioned along with one or more other models + protected $secondaryModels = [ + 'AdHocAttributes' => [ + 'table' => 'ad_hoc_attributes', + 'name' => 'SpAdHocAttributes', + 'source' => 'AdHocAttributes', + 'source_table' => 'ad_hoc_attributes', + 'related' => [] + ], + 'Addresses' => [ + 'table' => 'addresses', + 'name' => 'SpAddresses', + 'source' => 'Addresses', + 'source_table' => 'addresses', + 'related' => [] + ], + 'EmailAddresses' => [ + 'table' => 'email_addresses', + 'name' => 'SpEmailAddresses', + 'source' => 'EmailAddresses', + 'source_table' => 'email_addresses', + 'related' => [] + ], + 'ExternalIdentities' => [ + 'table' => 'external_identities', + 'name' => 'SpExternalIdentities', + 'source' => 'ExternalIdentities', + 'source_table' => 'external_identities', + 'related' => [ + 'AdHocAttributes', + 'Addresses', + 'EmailAddresses', + 'ExternalIdentityRoles', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls' + ] + ], + 'ExternalIdentityRoles' => [ + 'table' => 'external_identity_roles', + 'name' => 'SpExternalIdentityRoles', + 'source' => 'ExternalIdentityRoles', + 'source_table' => 'external_identity_roles', + 'related' => [ + 'AdHocAttributes', + 'Addresses', + 'TelephoneNumbers' + ] + ], + 'GroupMembers' => [ + 'table' => 'group_members', + 'name' => 'SpGroupMembers', + 'source' => 'GroupMembers', + 'source_table' => 'group_members', + 'related' => [] + ], +// XXX Not implementing GroupOwners pending resolution of CO-2508 + 'Identifiers' => [ + 'table' => 'identifiers', + 'name' => 'SpIdentifiers', + 'source' => 'Identifiers', + 'source_table' => 'identifiers', + 'related' => [] + ], + 'Names' => [ + 'table' => 'names', + 'name' => 'SpNames', + 'source' => 'Names', + 'source_table' => 'names', + 'related' => [] + ], + 'PersonRoles' => [ + 'table' => 'person_roles', + 'name' => 'SpPersonRoles', + 'source' => 'PersonRoles', + 'source_table' => 'person_roles', + 'related' => [ + 'AdHocAttributes', + 'Addresses', + 'TelephoneNumbers' + ] + ], + 'Pronouns' => [ + 'table' => 'pronouns', + 'name' => 'SpPronouns', + 'source' => 'Pronouns', + 'source_table' => 'pronouns', + 'related' => [] + ], + 'TelephoneNumbers' => [ + 'table' => 'telephone_numbers', + 'name' => 'SpTelephoneNumbers', + 'source' => 'TelephoneNumbers', + 'source_table' => 'telephone_numbers', + 'related' => [] + ], + 'Urls' => [ + 'table' => 'urls', + 'name' => 'SpUrls', + 'source' => 'Urls', + 'source_table' => 'urls', + 'related' => [] + ] + ]; + + // Models holding reference data + protected $referenceModels = [ + 'Cous' => [ + 'table' => 'cous', + 'name' => 'SpCous', + 'source' => 'Cous', + 'source_table' => 'cous', +// XXX Note as of right now syncReferenceData doesn't look at 'related' +// - if we need it to, that'll break Groups + 'related' => [] + ], + 'Types' => [ + 'table' => 'types', + 'name' => 'SpTypes', + 'source' => 'Types', + 'source_table' => 'types', + 'related' => [] + ] +/* XXX not yet implemented + [ + 'table' => 'co_terms_and_conditions', + // Ordinarily we'd call this SpCoTermsAndConditions, but it's not worth + // fighting cake's inflector + 'name' => 'SpCoTermsAndCondition', + 'source' => 'CoTermsAndConditions', + 'source_table' => 'co_terms_and_conditions' + ], + [ + 'table' => 'org_identity_sources', + 'name' => 'SpOrgIdentitySource', + 'source' => 'OrgIdentitySource', + 'source_table' => 'org_identity_sources' + ]*/ + ]; + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // Timestamp behavior handles created/modified updates + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ProvisioningTargets'); + $this->belongsTo('Servers'); + + $this->setDisplayField('server_id'); + + $this->setPrimaryLink(['provisioning_target_id']); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['reapply', 'resync']); + + $this->setAutoViewVars([ + 'servers' => [ + 'type' => 'select', + 'model' => 'Servers', + 'where' => ['plugin' => 'CoreServer.SqlServers'] + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'reapply' => ['platformAdmin', 'coAdmin'], + 'resync' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + + $this->setProvisionableModels( + array_merge( + array_keys($this->referenceModels), + array_keys($this->primaryModels) + ) + ); + } + + /** + * Apply the Target Database Schema. + * + * @since COmanage Registry v5.0.0 + * @param integer $id SQL Provisioner ID + * @throws InvalidArgumentException + * @throws RuntimeException + */ + + public function applySchema($id) { + // In order to apply the schema, we need to find the underlying + // SqlConnector configuration. There should only be (at most) one... + + $Plugins = TableRegistry::getTableLocator()->get('Plugins'); + + $targetSchema = $Plugins->getPluginConfig(plugin: "SqlConnector", key: "target-schema"); + + if(empty($targetSchema)) { + throw new \RuntimeException("Could not find SqlProvisioner target schema definition"); + } + + // Pull our configuration + + $spcfg = $this->get($id); + + $this->Servers->SqlServers->connect($spcfg->server_id, 'targetdb'); + + $SchemaManager = new SchemaManager(connection: 'targetdb'); + + $SchemaManager->applySchemaObject( + schemaObject: $targetSchema, + tablePrefix: $spcfg->table_prefix + ); + + return true; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + // Apply the database schema (PAR-SqlProvisioner-1) + $this->llog('rule', "PAR-SqlProvisioner-1 Applying database schema for SqlProvisioner " . $entity->id); + $this->applySchema($entity->id); + + // Populate or update the reference data (PAR-SqlProvisioner-2) + $this->llog('rule', "PAR-SqlProvisioner-2 Syncing reference data for SqlProvisioner " . $entity->id); + $this->syncReferenceData($entity->id); + + return true; + } + + /** + * Provision object data to the provisioning target. + * + * @since COmanage Registry v5.0.0 + * @param SqlProvisioner $provisioningTarget SqlProvisioner configuration + * @param string $className Class name of primary object being provisioned + * @param object $data Provisioning data in Entity format (eg: \App\Model\Entity\Person) + * @param ProvisioningEligibilityEnum $eligibility Provisioning Eligibility Enum + * @return array Array of status, comment, and optional identifier + */ + + public function provision( + \SqlConnector\Model\Entity\SqlProvisioner $provisioningTarget, + string $className, + object $data, // $data is currently only \App\Model\Entity\Person, but that might change + string $eligibility + ): array { + // Connect to the target database + $this->Servers->SqlServers->connect($provisioningTarget->server_id, 'targetdb'); + + return $this->syncEntity( + $provisioningTarget, + $entityName, + $data, + $eligibility + ); + } + + /** + * Sync an entity to the target database schema. + * + * @since COmanage Registry v5.0.0 + * @param SqlProvisioner $SqlProvisioner SqlProvisioner configuration + * @param string $entityName Entity name of primary object being provisioned + * @param object $data Provisioning data in Entity format (eg: \App\Model\Entity\Person) + * @param ProvisioningEligibilityEnum $eligibility Provisioning Eligibility Enum + * @param string $dataSource Datasource to provision to + * @return array Array of status, comment, and optional identifier + */ + + protected function syncEntity( + \SqlConnector\Model\Entity\SqlProvisioner $SqlProvisioner, + string $entityName, + $data, + string $eligibility, + string $dataSource='targetdb'): array { + // Find the model config, which may vary depending on the type of entity. + // We don't check secondaryModels because those aren't directly provisioned. + $mconfig = $this->primaryModels[$entityName] + ?? ($this->referenceModels[$entityName] ?? null); + + if(!$mconfig) { + throw new \RuntimeException("Model configuration for $entityName not defined"); + } + + // Pull the current target record +// XXX similar code in syncReferenceData, refactor? + $options = [ + 'table' => $SqlProvisioner->table_prefix . $mconfig['table'], + 'alias' => $mconfig['name'], + 'connection' => ConnectionManager::get($dataSource) + ]; + + $SpTable = TableRegistry::get(alias: $mconfig['name'], options: $options); + + try { + $curEntity = $SpTable->get($data->id); + + if($eligibility == ProvisioningEligibilityEnum::Eligible) { + // We have a currently provisioned record and the subject is Eligible, + // patch it with $data and try saving. + $patchedEntity = $SpTable->patchEntity($curEntity, $data->toArray(), ['validate' => false]); + + $SpTable->saveOrFail( + $patchedEntity, + [ + 'validate' => false, + 'checkRules' => false + ] + ); + + if(!empty($mconfig['related'])) { + // Process related models + foreach($mconfig['related'] as $rmodel) { + $this->syncRelatedEntities( + SqlProvisioner: $SqlProvisioner, + parentEntityName: $entityName, + relatedEntityName: $rmodel, + parentData: $data, + eligibility: $eligibility, + dataSource: $dataSource + ); + } + } + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('sql_connector', 'result.prov.updated'), + 'identifier' => null + ]; + } else { + // The subject record is deleted or otherwise Ineligible, remove the + // current entity. Remove the related models before the entity. + + if(!empty($mconfig['related'])) { + // Process related models + foreach($mconfig['related'] as $rmodel) { + $this->syncRelatedEntities( + SqlProvisioner: $SqlProvisioner, + parentEntityName: $entityName, + relatedEntityName: $rmodel, + parentData: $data, + eligibility: $eligibility, + dataSource: $dataSource + ); + } + } + + $SpTable->delete($curEntity); + + return [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('sql_connector', 'result.prov.deleted'), + 'identifier' => null + ]; + } + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + // The record is not yet in the SP table (probably a new record) + if($eligibility == ProvisioningEligibilityEnum::Eligible) { + // The subject is eligible, so provision the record + $newEntity = $SpTable->newEntity($data->toArray(), ['validate' => false]); + + $SpTable->saveOrFail( + $newEntity, + [ + 'validate' => false, + 'checkRules' => false + ] + ); + + if(!empty($mconfig['related'])) { + // Process related models + foreach($mconfig['related'] as $rmodel) { + $this->syncRelatedEntities( + SqlProvisioner: $SqlProvisioner, + parentEntityName: $entityName, + relatedEntityName: $rmodel, + parentData: $data, + eligibility: $eligibility, + dataSource: $dataSource + ); + } + } + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('sql_connector', 'result.prov.added'), + 'identifier' => null + ]; + } else { + // The subject record is deleted or otherwise Ineligible, nothing to do + + return [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('sql_connector', 'result.prov.ineligible'), + 'identifier' => null + ]; + } + } + catch(\Exception $e) { + return [ + 'status' => ProvisioningStatusEnum::Unknown, + 'comment' => $e->getMessage(), + 'identifier' => null + ]; + } + } + + /** + * Synchronize reference data to the target database. + * + * @since COmanage Registry v5.0.0 + * @param int $id SQL Provisioner ID + * @param string $dataSource DataSource label + */ + + public function syncReferenceData($id, $dataSource='targetdb') { + $spcfg = $this->get($id, ['contain' => ['ProvisioningTargets']]); + + $this->Servers->SqlServers->connect($spcfg->server_id, $dataSource); + + // We treat Groups as Reference Models since they may be referred to + // by other entities. We do NOT sync Group Members here, just the Groups. + + foreach( + // PAR-SqlProvisioner-3 When Reference Data is resynced, Groups are also resynced. + array_merge($this->referenceModels, ['Groups' => $this->primaryModels['Groups']]) + as $mname => $m + ) { + // First construct the model reflecting the target database + + $options = [ + 'table' => $spcfg->table_prefix . $m['table'], + 'alias' => $m['name'], + 'connection' => ConnectionManager::get($dataSource) + ]; + + $SpTable = TableRegistry::get(alias: $m['name'], options: $options); + + // Next get the source table model + +// XXX don't we need to use the "plugin" datasource here and elsewhere? +// (test with job shell - maybe this is an RFE for Reprovision All) + $SrcTable = TableRegistry::get($m['source']); + + // Pull the source records and then sync them to the target table. + // We expect reference data to be no larger than O(100) or maybe + // O(1000) so we don't bother with PaginatedSqlIterator here. + + $srcRecords = []; + + foreach($SrcTable->find() + ->where(['co_id' => $spcfg->provisioning_target->co_id]) + ->toArray() as $r) { + // We shouldn't have to manually convert the entities to arrays + // but toArray() is returning an array of objects instead of an + // array of arrays... (and we only need this because the second + // parameter to patchEntities expects an array since it's typically + // used to process form data) + + // We key on record ID for use in delete, below + $srcRecords[$r->id] = $r->toArray(); + } + + // Pull the current target records + $curRecords = $SpTable->find()->all(); + + // Patch the target with the source. Note this will handle add and + // insert correctly, but will ignore any records from $curRecords that + // are not in $srcRecords. + $patchedRecords = $SpTable->patchEntities($curRecords, $srcRecords, ['validate' => false]); + + $SpTable->saveManyOrFail($patchedRecords, ['validate' => false, 'checkRules' => false]); + + // patchEntities will handle inserts and updates, but not deletes. + + $toDelete = []; + + foreach($curRecords as $c) { + if(!isset($srcRecords[$c->id])) { + $toDelete[] = $c; + } + } + + if(!empty($toDelete)) { + $SpTable->deleteMany($toDelete); + } + } + } + + /** + * Sync related entities to the target database schema. + * + * @since COmanage Registry v5.0.0 + * @param SqlProvisioner $SqlProvisioner SqlProvisioner configuration + * @param string $parentEntityName Entity name of primary object being provisioned + * @param string $relatedEntityName Entity name of related object being provisioned + * @param object $parentData Provisioning data in Entity format (eg: \App\Model\Entity\Person) for parent + * @param ProvisioningEligibilityEnum $eligibility Provisioning Eligibility Enum + * @param string $dataSource Datasource to provision to + */ + + protected function syncRelatedEntities( + \SqlConnector\Model\Entity\SqlProvisioner $SqlProvisioner, + string $parentEntityName, + string $relatedEntityName, + $parentData, + string $eligibility, + string $dataSource='targetdb') { + // eg: person_id + $parentFk = StringUtilities::entityToForeignKey($parentData); + // eg: names + $relatedTable = Inflector::tableize($relatedEntityName); + + // $parentData will have the "new" values for the related model, + // we need to pull the current values from the SP tables + + $mconfig = $this->secondaryModels[$relatedEntityName]; + + if(!$mconfig) { + throw new \RuntimeException("Model configuration for $relatedEntityName not defined"); + } + + $options = [ + 'table' => $SqlProvisioner->table_prefix . $mconfig['table'], + 'alias' => $mconfig['name'], + 'connection' => ConnectionManager::get($dataSource) + ]; + + $SpTable = TableRegistry::get(alias: $mconfig['name'], options: $options); + + // We have the source values, but we need to convert them to arrays + // for patchEntities + $srcEntities = []; + + foreach($parentData->$relatedTable as $r) { + $srcEntities[$r->id] = $r->toArray(); + } + + // Pull the current provisioned data + + $curEntities = $SpTable->find() + ->where([$parentFk => $parentData->id]) + ->all(); + + if($eligibility == ProvisioningEligibilityEnum::Eligible) { + // Patch the target with the source. Note this will handle add and + // insert correctly, but will ignore any records from $curEntities that + // are not in $srcEntities. + $patchedEntities = $SpTable->patchEntities($curEntities, $srcEntities, ['validate' => false]); + + $SpTable->saveManyOrFail($patchedEntities, ['validate' => false, 'checkRules' => false]); + } else { + // Delete all currently provisioned entries, which will force by + // clearing $srcEntities + + $srcEntities = []; + } + + // Sync any related entities. We need to do this after save for Eligible + // records (above) and before delition of ineligible records (below). + // We have to do this once per instance of the parent related model. + // eg: If we're currently syncing parent model People and related model + // PersonRoles, we need to syncRelatedEntities on PersonRoles once for + // _each_ roles attached to the Person. + + if(!empty($mconfig['related'])) { + // Process related models + foreach($mconfig['related'] as $rmodel) { + if(!empty($parentData->$relatedTable)) { + foreach($parentData->$relatedTable as $rmdata) { + $this->syncRelatedEntities( + SqlProvisioner: $SqlProvisioner, + parentEntityName: $relatedEntityName, + relatedEntityName: $rmodel, + parentData: $rmdata, + eligibility: $eligibility, + dataSource: $dataSource + ); + } + } + } + } + + // Delete any dropped related entities + + $toDelete = []; + + foreach($curEntities as $c) { + if(!isset($srcEntities[$c->id])) { + $toDelete[] = $c; + } + } + + if(!empty($toDelete)) { + $SpTable->deleteMany($toDelete); + } + + // We don't currently return errors up the stack, should we? + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('provisioning_target_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('provisioning_target_id'); + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('server_id'); + + $this->registerStringValidation($validator, $schema, 'table_prefix', true); + + // Table prefixes must be alphanumeric and end in an underscore + $validator->add('table_prefix', [ + 'format' => [ + 'rule' => function ($value, $context) { + return (preg_match('/[\w]+_/', $value) ? true : __d('sql_connector', 'error.table_prefix')); + } + ] + ]); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/SqlConnector/src/SqlConnectorPlugin.php b/app/availableplugins/SqlConnector/src/SqlConnectorPlugin.php new file mode 100644 index 000000000..a0bcdf884 --- /dev/null +++ b/app/availableplugins/SqlConnector/src/SqlConnectorPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'SqlConnector', + ['path' => '/sql-connector'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/availableplugins/SqlConnector/src/config/plugin.json b/app/availableplugins/SqlConnector/src/config/plugin.json new file mode 100644 index 000000000..04a3c185c --- /dev/null +++ b/app/availableplugins/SqlConnector/src/config/plugin.json @@ -0,0 +1,304 @@ +{ + "types": { + "provisioner": [ + "SqlProvisioners" + ] + }, + "schema": { + "tables": { + "sql_provisioners": { + "columns": { + "id": {}, + "provisioning_target_id": {}, + "server_id": { "notnull": false }, + "table_prefix": { "type": "string", "size": 32 } + }, + "indexes": { + "sql_provisioners_i1": { "columns": [ "provisioning_target_id" ]} + } + } + } + }, + "target-schema": { + "not-yet-implemented-tables": { + "terms_and_conditions": "CFM-200", + "external_identity_sources": { + "JIRA": "CFM-265", + "fk from": [ "names" ] + } + }, + + "tables": { + "types": { + "columns": { + "id": {}, + "attribute": { "type": "string", "size": 32, "notnull": true }, + "display_name": { "type": "string", "size": 64, "notnull": true }, + "value": { "type": "string", "size": 32, "notnull": true }, + "edupersonaffiliation": { "type": "string", "size": 32 }, + "status": {} + }, + "indexes": { + "types_i2": { "columns": [ "attribute" ] } + } + }, + + "cous": { + "columns": { + "id": {}, + "name": {}, + "description": {}, + "parent_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } } + }, + "indexes": { + "cous_i3": { "columns": [ "parent_id" ] } + }, + "changelog": false + }, + + "people": { + "columns": { + "id": {}, + "status": {}, + "timezone": { "type": "string", "size": 80 }, + "date_of_birth": { "type": "date" } + }, + "indexes": { + }, + "changelog": false + }, + + "person_roles": { + "columns": { + "id": {}, + "person_id": { "notnull": true }, + "status": {}, + "ordr": {}, + "cou_id": {}, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "title": { "type": "string", "size": 128 }, + "organization": { "type": "string", "size": 128 }, + "department": { "type": "string", "size": 128 }, + "manager_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "sponsor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "valid_from": {}, + "valid_through": {} + }, + "indexes": { + "person_roles_i1": { "columns": [ "person_id" ] }, + "person_roles_i2": { "columns": [ "sponsor_person_id" ] }, + "person_roles_i3": { "columns": [ "cou_id" ] }, + "person_roles_i4": { "columns": [ "affiliation_type_id" ] }, + "person_roles_i5": { "columns": [ "manager_person_id" ] } + }, + "changelog": false + }, + + "external_identities": { + "columns": { + "id": {}, + "person_id": { "notnull": true }, + "status": {}, + "date_of_birth": { "type": "date" } + }, + "indexes": { + "external_identities_i1": { "columns": [ "person_id" ] } + }, + "changelog": false + }, + + "external_identity_roles": { + "columns": { + "id": {}, + "external_identity_id": { "notnull": true }, + "status": {}, + "ordr": {}, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "title": { "type": "string", "size": 128 }, + "organization": { "type": "string", "size": 128 }, + "department": { "type": "string", "size": 128 }, + "manager_identifier": { "type": "string", "size": 512 }, + "sponsor_identifier": { "type": "string", "size": 512 }, + "valid_from": {}, + "valid_through": {} + }, + "indexes": { + "external_identity_roles_i1": { "columns": [ "external_identity_id" ] }, + "external_identity_roles_i2": { "columns": [ "affiliation_type_id" ] } + }, + "changelog": false + }, + + "groups": { + "columns": { + "id": {}, + "cou_id": {}, + "name": {}, + "description": { "size": 256 }, + "open": { "type": "boolean" }, + "status": {}, + "group_type": { "type": "string", "size": 2 } + }, + "indexes": { + "groups_i5": { "columns": [ "cou_id" ]} + }, + "changelog": false + }, + + "ad_hoc_attributes": { + "columns": { + "id": {}, + "tag": { "type": "string", "size": 128 }, + "value": { "type": "string", "size": 256 } + }, + "indexes": { + }, + "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "changelog": false + }, + + "addresses": { + "columns": { + "id": {}, + "street": { "type": "text" }, + "room": { "type": "string", "size": 64 }, + "locality": { "type": "string", "size": 128 }, + "state": { "type": "string", "size": 128 }, + "postal_code": { "type": "string", "size": 16 }, + "country": { "type": "string", "size": 128 }, + "description": {}, + "type_id": {}, + "language": {} + }, + "indexes": { + "addresses_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "changelog": false + }, + + "email_addresses": { + "columns": { + "id": {}, + "mail": { "type": "string", "size": 256 }, + "description": {}, + "type_id": {}, + "verified": { "type": "boolean" } + }, + "indexes": { + "email_addresses_i3": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "changelog": false + }, + + "identifiers": { + "columns": { + "id": {}, + "identifier": { "type": "string", "size": 512 }, + "type_id": {}, + "login": { "type": "boolean" }, + "status": {} + }, + "indexes": { + "identifiers_i3": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity", "group" ], + "changelog": false + }, + + "names": { + "columns": { + "id": {}, + "honorific": { "type": "string", "size": 32 }, + "given": { "type": "string", "size": 128 }, + "middle": { "type": "string", "size": 128 }, + "family": { "type": "string", "size": 128 }, + "suffix": { "type": "string", "size": 32 }, + "type_id": {}, + "language": {}, + "primary_name": { "type": "boolean" }, + "display_name": { "type": "string", "size": 256 } + }, + "indexes": { + "names_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "changelog": false + }, + + "pronouns": { + "columns": { + "id": {}, + "pronouns": { "type": "string", "size": 64 }, + "language": {}, + "type_id": {} + }, + "indexes": { + "pronouns_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "changelog": false + }, + + "telephone_numbers": { + "columns": { + "id": {}, + "country_code": { "type": "string", "size": 3 }, + "area_code": { "type": "string", "size": 8 }, + "number": { "type": "string", "size": 64 }, + "extension": { "type": "string", "size": 16 }, + "description": {}, + "type_id": {} + }, + "indexes": { + "telephone_numbers_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "changelog": false + }, + + "urls": { + "columns": { + "id": {}, + "url": { "type": "string", "size": 256 }, + "description": {}, + "type_id": {} + }, + "indexes": { + "urls_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "changelog": false + }, + + "group_members": { + "columns": { + "id": {}, + "group_id": {}, + "person_id": {}, + "valid_from": {}, + "valid_through": {} + }, + "indexes": { + "group_members_i1": { "columns": [ "group_id" ]}, + "group_members_i2": { "columns": [ "person_id" ]} + }, + "changelog": false + }, + + "group_owners": { + "columns": { + "id": {}, + "group_id": {}, + "person_id": {} + }, + "indexes": { + "group_owners_i1": { "columns": [ "group_id" ]}, + "group_owners_i2": { "columns": [ "person_id" ]} + }, + "changelog": false + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields-links.inc b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields-links.inc new file mode 100644 index 000000000..6788eb97a --- /dev/null +++ b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields-links.inc @@ -0,0 +1,48 @@ + 'history', + 'order' => 'Default', + 'label' => __d('sql_connector', 'operation.reapply'), + 'link' => [ + 'action' => 'reapply', + $vv_obj->id + ], + 'class' => '' +]; + +$topLinks[] = [ + 'icon' => 'history', + 'order' => 'Default', + 'label' => __d('sql_connector', 'operation.resync'), + 'link' => [ + 'action' => 'resync', + $vv_obj->id + ], + 'class' => '' +]; diff --git a/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields.inc b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields.inc new file mode 100644 index 000000000..2b23c2634 --- /dev/null +++ b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields.inc @@ -0,0 +1,33 @@ +Field->control('server_id'); + + print $this->Field->control('table_prefix', ['default' => 'sp_']); +} diff --git a/app/availableplugins/SqlConnector/tests/bootstrap.php b/app/availableplugins/SqlConnector/tests/bootstrap.php new file mode 100644 index 000000000..7539568b5 --- /dev/null +++ b/app/availableplugins/SqlConnector/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/availableplugins/SqlConnector/tests/schema.sql b/app/availableplugins/SqlConnector/tests/schema.sql new file mode 100644 index 000000000..b3c02dbcb --- /dev/null +++ b/app/availableplugins/SqlConnector/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for SqlConnector diff --git a/app/availableplugins/SqlConnector/webroot/.gitkeep b/app/availableplugins/SqlConnector/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/composer.json b/app/composer.json index 85310beb1..6c29c7fbb 100644 --- a/app/composer.json +++ b/app/composer.json @@ -29,13 +29,19 @@ }, "autoload": { "psr-4": { - "App\\": "src/" + "App\\": "src/", + "CoreServer\\": "plugins/CoreServer/src/", + "FileConnector\\": "availableplugins/FileConnector/src/", + "SqlConnector\\": "availableplugins/SqlConnector/src/" } }, "autoload-dev": { "psr-4": { "App\\Test\\": "tests/", - "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", + "CoreServer\\Test\\": "plugins/CoreServer/tests/", + "FileConnector\\Test\\": "availableplugins/FileConnector/tests/", + "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/" } }, "scripts": { diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 3a5b7bb3b..652a772d7 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -19,9 +19,13 @@ "id": { "type": "integer", "autoincrement": true, "primarykey": true }, "language": { "type": "string", "size": 16 }, "name": { "type": "string", "size": 128, "notnull": true }, + "ordr": { "type": "integer" }, "person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, "person_role_id": { "type": "integer", "foreignkey": { "table": "person_roles", "column": "id" } }, "plugin": { "type": "string", "size": 80 }, + "provisioning_target_id": { "type": "integer", "foreignkey": { "table": "provisioning_targets", "column": "id" }, "notnull": true }, + "report_id": { "type": "integer", "foreignkey": { "table": "reports", "column": "id" }, "notnull": true }, + "server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" }, "notnull": true }, "status": { "type": "string", "size": 2 }, "type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": true }, "valid_from": { "type": "datetime" }, @@ -202,7 +206,7 @@ "id": {}, "person_id": { "notnull": true }, "status": {}, - "ordr": { "type": "integer" }, + "ordr": {}, "cou_id": {}, "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, "title": { "type": "string", "size": 128 }, @@ -239,7 +243,7 @@ "id": {}, "external_identity_id": { "notnull": true }, "status": {}, - "ordr": { "type": "integer" }, + "ordr": {}, "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, "title": { "type": "string", "size": 128 }, "organization": { "type": "string", "size": 128 }, @@ -391,18 +395,55 @@ "sourced": true }, + "provisioning_targets": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {}, + "provisioning_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, + "retry_interval": { "type": "integer" }, + "ordr": {} + }, + "indexes": { + "provisioning_targets_i1": { "columns": [ "co_id" ]}, + "provisioning_targets_i2": { "needed": false, "columns": [ "provisioning_group_id" ] } + } + }, + + "provisioning_history_records": { + "columns": { + "id": {}, + "provisioning_target_id": {}, + "subject_model": { "type": "string", "size": 80 }, + "subjectid": { "type": "integer", "comment": "This is a foreign key, but not at the database level" }, + "person_id": {}, + "group_id": {}, + "status": {}, + "comment": {} + }, + "indexes": { + "provisioning_history_records_i1": { "columns": [ "provisioning_target_id" ] }, + "provisioning_history_records_i2": { "columns": [ "person_id" ] }, + "provisioning_history_records_i3": { "columns": [ "group_id" ] } + } + }, + "identifiers": { "columns": { "id": {}, "identifier": { "type": "string", "size": 512 }, "type_id": {}, "login": { "type": "boolean" }, - "status": {} + "status": {}, + "provisioning_target_id": { "notnull": false } }, "indexes": { "identifiers_i1": { "columns": [ "identifier", "type_id", "person_id" ] }, "identifiers_i2": { "columns": [ "identifier", "type_id", "external_identity_id" ] }, - "identifiers_i3": { "columns": [ "type_id" ] } + "identifiers_i3": { "columns": [ "type_id" ] }, + "identifiers_i4": { "needed": false, "columns": [ "provisioning_target_id" ] } }, "mvea": [ "person", "external_identity", "group" ], "sourced": true @@ -524,6 +565,19 @@ "job_history_records_i3": { "columns": [ "external_identity_id" ] }, "job_history_records_i4": { "columns": [ "job_id", "record_key" ] } } + }, + + "servers": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {} + }, + "indexes": { + "servers_i1": { "columns": [ "co_id" ] } + } } }, diff --git a/app/plugins/CoreServer/README.md b/app/plugins/CoreServer/README.md new file mode 100644 index 000000000..984a690c6 --- /dev/null +++ b/app/plugins/CoreServer/README.md @@ -0,0 +1,11 @@ +# CoreServer plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/core-server +``` diff --git a/app/plugins/CoreServer/composer.json b/app/plugins/CoreServer/composer.json new file mode 100644 index 000000000..471af66a7 --- /dev/null +++ b/app/plugins/CoreServer/composer.json @@ -0,0 +1,24 @@ +{ + "name": "comanage-registry/core-server", + "description": "CoreServer plugin for COmanage Registry", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "CoreServer\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CoreServer\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/CoreServer/phpunit.xml.dist b/app/plugins/CoreServer/phpunit.xml.dist new file mode 100644 index 000000000..dc201acf9 --- /dev/null +++ b/app/plugins/CoreServer/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/CoreServer/resources/locales/en_US/core_server.po b/app/plugins/CoreServer/resources/locales/en_US/core_server.po new file mode 100644 index 000000000..5a8660f67 --- /dev/null +++ b/app/plugins/CoreServer/resources/locales/en_US/core_server.po @@ -0,0 +1,58 @@ +# COmanage Registry Localizations (core_server domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry-plugins +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.SqlServers" +msgstr "{0,plural,=1{SQL Server} other{SQL Servers}}" + +msgid "enumeration.RdbmsTypeEnum.LT" +msgstr "SQLite" + +msgid "enumeration.RdbmsTypeEnum.MA" +msgstr "MariaDB" + +msgid "enumeration.RdbmsTypeEnum.MS" +msgstr "MS SQL Server" + +msgid "enumeration.RdbmsTypeEnum.MY" +msgstr "MySQL" + +# XXX Not yet supported +#msgid "enumeration.RdbmsTypeEnum.OR" +#msgstr "Oracle" + +msgid "enumeration.RdbmsTypeEnum.PG" +msgstr "Postgres" + +msgid "field.SqlServers.databas" +msgstr "Database Name" + +msgid "field.SqlServers.hostname" +msgstr "Hostname" + +# XXX Temporary? +msgid "field.SqlServers.password" +msgstr "Password" + +msgid "field.SqlServers.type" +msgstr "RDBMS Type" diff --git a/app/plugins/CoreServer/src/Controller/AppController.php b/app/plugins/CoreServer/src/Controller/AppController.php new file mode 100644 index 000000000..554055701 --- /dev/null +++ b/app/plugins/CoreServer/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'SqlServers.hostname' => 'asc' + ] + ]; +} diff --git a/app/plugins/CoreServer/src/CoreServerPlugin.php b/app/plugins/CoreServer/src/CoreServerPlugin.php new file mode 100644 index 000000000..6711a5cf7 --- /dev/null +++ b/app/plugins/CoreServer/src/CoreServerPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'CoreServer', + ['path' => '/core-server'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/plugins/CoreServer/src/Lib/Enum/RdbmsTypeEnum.php b/app/plugins/CoreServer/src/Lib/Enum/RdbmsTypeEnum.php new file mode 100644 index 000000000..a6be23625 --- /dev/null +++ b/app/plugins/CoreServer/src/Lib/Enum/RdbmsTypeEnum.php @@ -0,0 +1,46 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php b/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php new file mode 100644 index 000000000..fc52131a3 --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php @@ -0,0 +1,171 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Servers'); + + $this->setDisplayField('hostname'); + + $this->setPrimaryLink('server_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'types' => [ + 'type' => 'enum', + 'class' => 'CoreServer.RdbmsTypeEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Establish a connection (via Cake's ConnectionManager) to the specified SQL server. + * + * @since COmanage Registry v5.0.0 + * @param int $serverId Server ID (NOT SqlServer ID) + * @param string $name Connection name, used for subsequent access via Models + * @return bool true on success + * @throws Exception + */ + + public function connect(int $serverId, string $name): bool { + // Note if you're looking to add support for tablePrefix here (eg: "cm_") + // Cake basically dropped support for that in v3. As an alternate, + // individual models can be configured to use alternate table names, + // which is basically what the SQL Provisioner does. + + // Pull our configuration via the parent Server object. + $server = $this->Servers->get($serverId, ['contain' => ['SqlServers']]); + + $dbmap = [ + RdbmsTypeEnum::MariaDB => 'Mysql', + RdbmsTypeEnum::MySQL => 'Mysql', + RdbmsTypeEnum::Postgres => 'Postgres', + RdbmsTypeEnum::SQLite => 'Sqlite', + RdbmsTypeEnum::SqlServer => 'Sqlserver' + ]; + + $dbconfig = [ + 'className' => 'Cake\Database\Connection', + 'driver' => "Cake\Database\Driver\\" . $dbmap[$server->sql_server->type], + 'persistent' => false, + 'host' => $server->sql_server->hostname, + 'username' => $server->sql_server->username, + 'password' => $server->sql_server->password, + 'database' => $server->sql_server->databas, + 'quoteIdentifiers' => false, + 'encoding' => 'utf8', + 'timezone' => 'UTC' + ]; + + ConnectionManager::setConfig('targetdb', $dbconfig); + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('server_id'); + + $validator->add('type', [ + 'content' => ['rule' => ['inList', RdbmsTypeEnum::getConstValues()]] + ]); + $validator->notEmptyString('type'); + + $this->registerStringValidation($validator, $schema, 'hostname', true); + + $this->registerStringValidation($validator, $schema, 'databas', true); + + $this->registerStringValidation($validator, $schema, 'username', false); + + $this->registerStringValidation($validator, $schema, 'password', false); + + return $validator; + } +} diff --git a/app/plugins/CoreServer/src/config/plugin.json b/app/plugins/CoreServer/src/config/plugin.json new file mode 100644 index 000000000..876ade57f --- /dev/null +++ b/app/plugins/CoreServer/src/config/plugin.json @@ -0,0 +1,25 @@ +{ + "types": { + "server": [ + "SqlServers" + ] + }, + "schema": { + "tables": { + "sql_servers": { + "columns": { + "id": {}, + "server_id": {}, + "type": { "type": "string", "size": 2 }, + "hostname": { "type": "string", "size": 128 }, + "databas": { "type": "string", "size": 128 }, + "username": { "type": "string", "size": 128 }, + "password": { "type": "string", "size": 80 } + }, + "indexes": { + "sql_servers_i1": { "columns": [ "server_id" ]} + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreServer/templates/SqlServers/fields.inc b/app/plugins/CoreServer/templates/SqlServers/fields.inc new file mode 100644 index 000000000..aaa97647e --- /dev/null +++ b/app/plugins/CoreServer/templates/SqlServers/fields.inc @@ -0,0 +1,39 @@ +Field->control('type'); + + print $this->Field->control('hostname'); + + print $this->Field->control('databas'); + + print $this->Field->control('username'); + + print $this->Field->control('password'); +} diff --git a/app/plugins/CoreServer/tests/bootstrap.php b/app/plugins/CoreServer/tests/bootstrap.php new file mode 100644 index 000000000..d27d0ae8c --- /dev/null +++ b/app/plugins/CoreServer/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/CoreServer/tests/schema.sql b/app/plugins/CoreServer/tests/schema.sql new file mode 100644 index 000000000..f5df4568c --- /dev/null +++ b/app/plugins/CoreServer/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for CoreServer diff --git a/app/plugins/CoreServer/webroot/.gitkeep b/app/plugins/CoreServer/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/resources/locales/en_US/command.po b/app/resources/locales/en_US/command.po index 5a3303979..d67e5fbc4 100644 --- a/app/resources/locales/en_US/command.po +++ b/app/resources/locales/en_US/command.po @@ -132,6 +132,15 @@ msgstr "COmanage Platform Administrator" msgid "se.salt" msgstr "Generating salt file {0}" +msgid "opt.test.database.ok" +msgstr "Database connection established" + +msgid "opt.test.database.source" +msgstr "For database test, the datasource to use" + +msgid "opt.test.test" +msgstr "Test to perform" + msgid "tm.epilog" msgstr "An optional, space separated list of tables to transmogrify may be specified" diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index 6df39e05b..1e3d2425d 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -96,6 +96,15 @@ msgstr "{0,plural,=1{Pronoun Preference} other{Pronouns}}" msgid "Plugins" msgstr "{0,plural,=1{Plugin} other{Plugins}}" +msgid "ProvisioningHistoryRecords" +msgstr "{0,plural,=1{Provisioning History Record} other{Provisioning History Records}}" + +msgid "ProvisioningTargets" +msgstr "{0,plural,=1{Provisioning Target} other{Provisioning Targets}}" + +msgid "Servers" +msgstr "{0,plural,=1{Server} other{Servers}}" + msgid "TelephoneNumbers" msgstr "{0,plural,=1{Telephone Number} other{Telephone Numbers}}" diff --git a/app/resources/locales/en_US/defaultType.po b/app/resources/locales/en_US/defaultType.po index 93cceccdc..3cd270731 100644 --- a/app/resources/locales/en_US/defaultType.po +++ b/app/resources/locales/en_US/defaultType.po @@ -93,8 +93,9 @@ msgstr "OpenID" msgid "Identifiers.type.orcid" msgstr "ORCiD" +# This is coded as "provisioningtarget" for compatibility with v4 msgid "Identifiers.type.provisioningtarget" -msgstr "Provisioning Target" +msgstr "Provisioning Key" msgid "Identifiers.type.reference" msgstr "Match Reference" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index fb01ca1b2..7634d6b41 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -249,6 +249,36 @@ msgstr "Country Code, Area Code, Number" msgid "PermittedTelephoneNumberFieldsEnum.country_code,area_code,number,extension" msgstr "Country Code, Area Code, Number, Extension" +msgid "ProvisionerModeEnum.A" +msgstr "Immediate" + +msgid "ProvisionerModeEnum.E" +msgstr "Enrollment Only" + +msgid "ProvisionerModeEnum.M" +msgstr "Manual" + +msgid "ProvisionerModeEnum.Q" +msgstr "Queue" + +msgid "ProvisionerModeEnum.QE" +msgstr "Queue on Error" + +msgid "ProvisionerModeEnum.X" +msgstr "Disabled" + +msgid "ProvisioningStatusEnum.N" +msgstr "Not Provisioned" + +msgid "ProvisioningStatusEnum.P" +msgstr "Provisioned" + +msgid "ProvisioningStatusEnum.Q" +msgstr "Queued" + +msgid "ProvisioningStatusEnum.X" +msgstr "Unknown" + msgid "RequiredAddressFieldsEnum.country" msgstr "Country" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 28273eb27..c11e25e06 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -216,6 +216,9 @@ msgstr "(Jr, III, etc)" msgid "tag" msgstr "Tag" +msgid "timestamp" +msgstr "Timestamp" + msgid "title" msgstr "Title" @@ -408,6 +411,30 @@ msgstr "Plugin" msgid "Plugins.location" msgstr "Location" +msgid "ProvisioningHistoryRecords.subject_model" +msgstr "Subject Object Type" + +msgid "ProvisioningHistoryRecords.subjectid" +msgstr "Subject Object ID" + +msgid "ProvisioningTargets.ordr.desc" +msgstr "The order in which this provisioner will be run when provisioning occurs (leave blank to run after all current provisioners)" + +msgid "ProvisioningTargets.provisioning_group_id" +msgstr "Provisioning Group" + +msgid "ProvisioningTargets.provisioning_group_id.desc" +msgstr "If set, only members of the specified Group will be provisioned to this target" + +msgid "ProvisioningTargets.retry_interval" +msgstr "Retry Interval" + +msgid "ProvisioningTargets.retry_interval.desc" +msgstr "If the provisioning action fails, it will be automatically retried after this interval (in seconds), default is 900 seconds. Set to 0 to not try again. (To stop retrying, cancel the job in the Job Queue.)" + +msgid "ProvisioningTargets.status" +msgstr "Provisioning Mode" + msgid "TelephoneNumbers.formatted_number" msgstr "Telephone Number" diff --git a/app/resources/locales/en_US/menu.po b/app/resources/locales/en_US/menu.po index a875c0671..d15a079c4 100644 --- a/app/resources/locales/en_US/menu.po +++ b/app/resources/locales/en_US/menu.po @@ -24,6 +24,9 @@ # Menu Messages +msgid "artifacts" +msgstr "Available {0} Artifacts" + msgid "co.Attributes" msgstr "Attributes" @@ -138,9 +141,11 @@ msgstr "All Groups" msgid "co.switch" msgstr "Switch CO" +msgid "registries" +msgstr "Available {0} Registries" + msgid "related.configurations" msgstr "Related Configurations" msgid "related.links" -msgstr "Related Links" - +msgstr "Related Links" \ No newline at end of file diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index fc01b4b65..e01e38929 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -57,6 +57,9 @@ msgstr "Close" msgid "confirm" msgstr "Confirm" +msgid "configure.a" +msgstr "Configure {0}" + msgid "configure.plugin" msgstr "Configure Plugin" @@ -117,6 +120,12 @@ msgstr "Previous" msgid "primary" msgstr "Make Primary" +msgid "provision" +msgstr "Provision Now" + +msgid "provisioning.status" +msgstr "Provisioning Status" + msgid "Types.restore" msgstr "Add/Restore Default Types" diff --git a/app/src/Controller/ApiV2Controller.php b/app/src/Controller/ApiV2Controller.php index 78c5fb7c8..62c5a88a9 100644 --- a/app/src/Controller/ApiV2Controller.php +++ b/app/src/Controller/ApiV2Controller.php @@ -36,6 +36,7 @@ use Cake\ORM\TableRegistry; use Cake\Utility\Inflector; +use \App\Lib\Enum\ProvisioningContextEnum; use \App\Lib\Enum\SuspendableStatusEnum; class ApiV2Controller extends AppController { @@ -94,6 +95,12 @@ public function add() { if($this->$modelsName->saveOrFail($obj)) { $results[] = ['id' => $obj->id]; + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); + $table->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + } } } catch(\Exception $e) { @@ -146,6 +153,16 @@ public function delete($id) { // note similar logic in StandardController $this->$modelsName->deleteOrFail($obj); + if(method_exists($obj, "isReadOnly") && $obj->isReadOnly()) { + throw new BadRequestException(__d('error', 'edit.readonly')); + } + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for deleted entity $modelsName " . $obj->id); + $table->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + } + // Render an empty view $this->render('/Standard/api/v2/json/delete'); } @@ -190,6 +207,12 @@ public function edit($id) { $this->$modelsName->saveOrFail($obj); + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); + $table->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + } + // Let the view render $this->render('/Standard/api/v2/json/add-edit'); } diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 50851736a..58c231870 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -78,6 +78,9 @@ public function initialize(): void { // COmanage specific component that handles authn/z processintg $this->loadComponent('RegistryAuth'); + // Breadcrumb Manager + $this->loadComponent('Breadcrumb'); + $ChangelogEventListener = new ChangelogEventListener($this->RegistryAuth); EventManager::instance()->on($ChangelogEventListener); @@ -145,13 +148,6 @@ public function beforeRender(\Cake\Event\EventInterface $event) { $this->set('vv_menu_permissions', $this->RegistryAuth->getMenuPermissions($this->getCOID())); } - // For breadcrumbs, do we have a target model, and if so is it a configuration - // model (eg: ApiUsers) or an object model (eg: CoPeople)? - if(isset($this->$modelsName) // May not be set under certain error conditions - && method_exists($this->$modelsName, "getIsConfigurationTable")) { - $this->set('vv_is_configuration_model', $this->$modelsName->getIsConfigurationTable()); - } - return parent::beforeRender($event); } @@ -194,7 +190,7 @@ public function getCOID(): ?int { * @throws \RuntimeException */ - protected function getPrimaryLink(bool $lookup=false) { + public function getPrimaryLink(bool $lookup=false) { // Did we already figure this out? (But only if $lookup) if($lookup && isset($this->cur_pl->value)) { return $this->cur_pl; diff --git a/app/src/Controller/CoSettingsController.php b/app/src/Controller/CoSettingsController.php index 626372ab5..0ca438200 100644 --- a/app/src/Controller/CoSettingsController.php +++ b/app/src/Controller/CoSettingsController.php @@ -33,6 +33,19 @@ use Cake\Log\Log; class CoSettingsController extends StandardController { + /** + * Perform Controller initialization. + * + * @since COmanage Registry v5.0.0 + */ + + public function initialize(): void { + parent::initialize(); + + // Configure breadcrumb rendering + $this->Breadcrumb->skipParents(['/^\/co-settings\/edit/']); + } + /** * Manage CO Settings. * diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php new file mode 100644 index 000000000..eeb22f2df --- /dev/null +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -0,0 +1,229 @@ +getSubject(); + $request = $controller->getRequest(); + $modelsName = $controller->getName(); + + if(!$request->is('restful')) { + // Determine the request target, but strip off query params + $requestTarget = $request->getRequestTarget(false); + + $skipAll = false; + $skipConfig = false; + + foreach($this->skipAllPaths as $p) { + if(preg_match($p, $requestTarget)) { + $skipAll = true; + break; + } + } + + foreach($this->skipConfigPaths as $p) { + if(preg_match($p, $requestTarget)) { + $skipConfig = true; + break; + } + } + + // Determine if the current request maps to a path where + // breadcrumb rendering should be skipped in whole or in part + $controller->set('vv_bc_skip', $skipAll); + + $controller->set('vv_bc_skip_config', $skipConfig); + + // Do we have a target model, and if so is it a configuration + // model (eg: ApiUsers) or an object model (eg: CoPeople)? + if(isset($controller->$modelsName) // May not be set under certain error conditions + && method_exists($controller->$modelsName, "isConfigurationTable")) { + $controller->set('vv_bc_configuration_link', $controller->$modelsName->isConfigurationTable()); + } else { + $controller->set('vv_bc_configuration_link', false); + } + + // Build a list of intermediate parent links, starting with any + // injected parents. This overrides $skipParentPaths. + $parents = $this->injectParents; + + $skipParent = false; + + foreach($this->skipParentPaths as $p) { + if(preg_match($p, $requestTarget)) { + $skipParent = true; + break; + } + } + + if(!$skipParent) { + // For non-index views, insert a link back to the index. + $action = $request->getParam('action'); + $primaryLink = $controller->getPrimaryLink(true); + + if($action != 'index') { + $target = [ + 'plugin' => null, + 'controller' => $modelsName, + 'action' => 'index' + ]; + + if(!empty($primaryLink->attr)) { + $target['?'] = [$primaryLink->attr => $primaryLink->value]; + } + + $parents[] = [ + 'label' => __d('controller', $modelsName, [99]), + 'target' => $target + ]; + } + } + + $controller->set('vv_bc_parents', $parents); + } + } + + /** + * Inject the primary link into the breadcrumb path. + * + * @since COmanage Registry v5.0.0 + * @param object link Primary Link (as returned by getPrimaryLink()) + */ + + public function injectPrimaryLink(object $link) { + // eg: "People" + $modelsName = StringUtilities::foreignKeyToClassName($link->attr); + + $contain = []; + + if($modelsName == 'People' || $modelsName == 'ExternalIdentities') { + // We need the Primary Name to render it + $contain[] = 'PrimaryName'; + } + + $linkTable = TableRegistry::getTableLocator()->get($modelsName); + $linkObj = $linkTable->get($link->value, ['contain' => $contain]); + $displayField = $linkTable->getDisplayField(); + + $this->injectParents[] = [ + 'target' => [ + 'plugin' => null, + 'controller' => $modelsName, + 'action' => 'index', + '?' => [ + 'co_id' => $link->co_id + ] + ], + 'label' => __d('controller', $modelsName, [99]) + ]; + + $label = $linkObj->$displayField; + + if(!empty($linkObj->primary_name)) { + $label = $linkObj->primary_name->full_name; + } + + // If we don't have a visible label use the record ID + if(empty($label)) { + $label = $linkObj->id; + } + + $this->injectParents[] = [ + 'target' => [ + 'plugin' => null, + 'controller' => $modelsName, + 'action' => 'edit', + $linkObj->id + ], + 'label' => $label + ]; + } + + /** + * Set the set of paths that should be skipped when rendering breadcrumbs. + * Paths are specified as regular expressions, eg: '/^\/cos\/select/' + * + * @since COmanage Registry v5.0.0 + * @param array $skipPaths Array of regular expressions describing paths to be skipped + */ + + public function skipAll(array $skipPaths) { + $this->skipAllPaths = $skipPaths; + } + + /** + * Set the set of paths which should not get a "configuration" breadcrumb even + * though they might otherwise ordinarily get one (by being configuration objects). + * Paths are specified as regular expressions, eg: '/^\/provisioning-targets\/status/' + * + * @since COmanage Registry v5.0.0 + * @param array $skipPaths Array of regular expressions describing paths + */ + + public function skipConfig(array $skipPaths) { + $this->skipConfigPaths = $skipPaths; + } + + /** + * Set the set of paths that should not automatically get a link back to their parent. + * Paths are specified as regular expressions, eg: '/^\/co-settings\/edit/' + * + * @since COmanage Registry v5.0.0 + * @param array $skipPaths Array of regular expressions describing paths + */ + + public function skipParents(array $skipPaths) { + $this->skipParentPaths = $skipPaths; + } +} \ No newline at end of file diff --git a/app/src/Controller/CosController.php b/app/src/Controller/CosController.php index 604d58339..5156077a1 100644 --- a/app/src/Controller/CosController.php +++ b/app/src/Controller/CosController.php @@ -42,6 +42,19 @@ class CosController extends StandardController { ] ]; + /** + * Perform Controller initialization. + * + * @since COmanage Registry v5.0.0 + */ + + public function initialize(): void { + parent::initialize(); + + // Configure breadcrumb rendering + $this->Breadcrumb->skipAll(['/^\/cos\/select/']); + } + /** * Callback run prior to the view rendering. * diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index 970494723..5e074eb98 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -37,6 +37,57 @@ //use \App\Lib\Enum\PermissionEnum; class DashboardsController extends StandardController { + /** + * Perform Controller initialization. + * + * @since COmanage Registry v5.0.0 + */ + + public function initialize(): void { + parent::initialize(); + + // Configure breadcrumb rendering + $this->Breadcrumb->skipConfig([ + '/^\/dashboards\/artifacts/', + '/^\/dashboards\/dashboard/', + '/^\/dashboards\/registries/' + ]); + // There is currently no inventory of dashboards, so we skip parents + // for configuration, dashboard, and registries actions + $this->Breadcrumb->skipParents(['/^\/dashboards/']); + } + + /** + * Render the CO Registries Dashboard. + * + * @since COmanage Registry v5.0.0 + */ + + public function artifacts() { + $cur_co = $this->getCO(); + + $this->set('vv_title', __d('menu', 'artifacts', [$cur_co->name])); + + // Construct the set of primary registry objects, which + // we want to order by the localized text string. + + // We're assuming that the permission for each of these items is the same as for + // registries() itself, ie: CMP or CO Admin. But plausibly some of this stuff + // could be delegated to (eg) a COU Admin at some point... + + $artifactMenuItems = [ + __d('controller', 'Jobs', [99]) => [ + 'icon' => 'assignment', + 'controller' => 'jobs', + 'action' => 'index' + ] + ]; + + ksort($artifactMenuItems); + + $this->set('vv_artifacts_menu_items', $artifactMenuItems); + } + /** * Render the CO Configuration Dashboard. * @@ -66,6 +117,12 @@ public function configuration() { 'controller' => 'cous', 'action' => 'index' ], + // XXX External Identity Sources should use "cloud_download" for the icon + __d('controller', 'ProvisioningTargets', [99]) => [ + 'icon' => 'cloud_upload', + 'controller' => 'provisioning_targets', + 'action' => 'index' + ], __d('controller', 'Reports', [99]) => [ 'icon' => 'summarize', 'controller' => 'reports', @@ -128,6 +185,47 @@ public function dashboard(?int $id=null) { // XXX placeholder } + /** + * Render the CO Registries Dashboard. + * + * @since COmanage Registry v5.0.0 + */ + + public function registries() { + $cur_co = $this->getCO(); + + $this->set('vv_title', __d('menu', 'registries', [$cur_co->name])); + + // Construct the set of primary registry objects, which + // we want to order by the localized text string. + + // We're assuming that the permission for each of these items is the same as for + // registries() itself, ie: CMP or CO Admin. But plausibly some of this stuff + // could be delegated to (eg) a COU Admin at some point... + + $registryMenuItems = [ + __d('controller', 'Groups', [99]) => [ + 'icon' => 'people', + 'controller' => 'groups', + 'action' => 'index' + ], + __d('controller', 'People', [99]) => [ + 'icon' => 'person', + 'controller' => 'people', + 'action' => 'index' + ], + __d('controller', 'Servers', [99]) => [ + 'icon' => 'computer', + 'controller' => 'servers', + 'action' => 'index' + ] + ]; + + ksort($registryMenuItems); + + $this->set('vv_registries_menu_items', $registryMenuItems); + } + /** * Perform a cross model search. * diff --git a/app/src/Controller/MVEAController.php b/app/src/Controller/MVEAController.php index 7a5fb97f0..4d1204b30 100644 --- a/app/src/Controller/MVEAController.php +++ b/app/src/Controller/MVEAController.php @@ -33,8 +33,45 @@ use Cake\Log\Log; use Cake\ORM\TableRegistry; use Cake\Utility\Inflector; +use \App\Lib\Util\StringUtilities; class MVEAController extends StandardController { + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + // $this->name = Models + $modelsName = $this->name; + + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + // This is all we need where person_id is the primary link, but for MVEAs + // that are more deeply linked (to person_role_id, external_identity_id, + // or external_identity_role_id) we need to look up the further links. + $primaryLink = $this->getPrimaryLink(true); + + if($primaryLink->attr == 'person_id' || $primaryLink->attr == 'group_id') { + $this->Breadcrumb->injectPrimaryLink($primaryLink); + } else { + $parentModel = StringUtilities::foreignKeyToClassName($primaryLink->attr); + + $parentPrimaryLink = $this->$modelsName->$parentModel->findPrimaryLink((int)$primaryLink->value); + + $this->Breadcrumb->injectPrimaryLink($parentPrimaryLink); + $this->Breadcrumb->injectPrimaryLink($primaryLink); + } + } + + return parent::beforeFilter($event); + } + /** * Callback run prior to the request render. * @@ -49,67 +86,6 @@ public function beforeRender(\Cake\Event\EventInterface $event) { $fieldName = Inflector::underscore(Inflector::singularize($modelsName)); if(!$this->request->is('restful')) { - // Use the PrimaryLink to set information for breadcrumbs - - $link = $this->getPrimaryLink(true); - - if(!empty($link->value)) { - $this->set('vv_primary_link_attr', $link->attr); - $this->set('vv_primary_link_id', $link->value); - - $Names = $this->getTableLocator()->get('Names'); - - switch($link->attr) { - case 'external_identity_role_id': - $ExternalIdentityRoles = $this->getTableLocator()->get('ExternalIdentityRoles'); - $roleEntity = $ExternalIdentityRoles->findById((int)$link->value)->firstOrFail(); - - // Note this is a string, but vv_person_name is an entity - $this->set('vv_ei_role', $ExternalIdentityRoles->generateDisplayField($roleEntity)); - $this->set('vv_ei_role_id', $link->value); - // fall through - case 'external_identity_id': - $ExternalIdentity = $this->getTableLocator()->get('ExternalIdentities'); - - // What's the Person ID for the ExternalIdentity? - $eiId = isset($roleEntity) ? $roleEntity->external_identity_id : $link->value; - - $externalIdentity = $ExternalIdentity->findById($eiId)->firstOrFail(); - - // What's the primary name for the Extarnal Identity? - $this->set('vv_ei_name', $Names->primaryName($externalIdentity->id, 'external_identity')); - $this->set('vv_ei_id', $externalIdentity->id); - - // What's the primary name of the Person? - $personName = $Names->primaryName($externalIdentity->person_id); - $this->set('vv_person_name', $personName); - $this->set('vv_supertitle', $personName->full_name); - $this->set('vv_person_id', $externalIdentity->person_id); - break; - case 'person_role_id': - $PersonRoles = $this->getTableLocator()->get('PersonRoles'); - $roleEntity = $PersonRoles->findById((int)$link->value)->firstOrFail(); - // Note this is a string, but vv_person_name is an entity - $this->set('vv_person_role', $PersonRoles->generateDisplayField($roleEntity)); - $this->set('vv_person_role_id', $link->value); - - // Also set a name - $personName = $Names->primaryName($roleEntity->person_id); - $this->set('vv_person_name', $personName); - $this->set('vv_supertitle', $personName->full_name); - $this->set('vv_person_id', $roleEntity->person_id); - break; - case 'person_id': - $personName = $Names->primaryName((int)$link->value); - $this->set('vv_person_name', $personName); - $this->set('vv_supertitle', $personName->full_name); - $this->set('vv_person_id', $link->value); - break; - default; - break; - } - } - // If there is a default type setting for this model, pass it to the view if($this->$modelsName->getSchema()->hasColumn('type_id')) { $defaultTypeField = "default_" . $fieldName . "_type_id"; diff --git a/app/src/Controller/PagesController.php b/app/src/Controller/PagesController.php index 5ad47405e..6dc732847 100644 --- a/app/src/Controller/PagesController.php +++ b/app/src/Controller/PagesController.php @@ -30,7 +30,22 @@ * @link https://book.cakephp.org/4/en/controllers/pages-controller.html */ class PagesController extends AppController -{ +{ + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(): void + { + parent::initialize(); + + // Configure breadcrumb rendering + $this->Breadcrumb->skipAll(['/^\/$/']); + } + /** * Displays a view * diff --git a/app/src/Controller/ProvisioningHistoryRecordsController.php b/app/src/Controller/ProvisioningHistoryRecordsController.php new file mode 100644 index 000000000..888e2f031 --- /dev/null +++ b/app/src/Controller/ProvisioningHistoryRecordsController.php @@ -0,0 +1,61 @@ + [ + 'ProvisioningHistoryRecords.id' => 'desc' + ] + ]; + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + $this->Breadcrumb->injectPrimaryLink($this->getPrimaryLink(true)); + } + + return parent::beforeFilter($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/ProvisioningTargetsController.php b/app/src/Controller/ProvisioningTargetsController.php new file mode 100644 index 000000000..0f9f8fef2 --- /dev/null +++ b/app/src/Controller/ProvisioningTargetsController.php @@ -0,0 +1,101 @@ + [ + 'ProvisioningTargets.description' => 'asc' + ] + ]; + + /** + * Perform Controller initialization. + * + * @since COmanage Registry v5.0.0 + */ + + public function initialize(): void { + parent::initialize(); + + // Configure breadcrumb rendering + $this->Breadcrumb->skipConfig(['/^\/provisioning-targets\/status/']); + $this->Breadcrumb->skipParents(['/^\/provisioning-targets\/status/']); + } + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + if($this->request->getParam('action') == 'status') { + $this->Breadcrumb->injectPrimaryLink($this->getPrimaryLink(true)); + } + } + + return parent::beforeFilter($event); + } + + /** + * Generate a status index. + * + * @since COmanage Registry v5.0.0 + */ + + public function status() { + // PrimaryLinkTrait - Look up our primary link to see which object type we're + // working with, an also get our CO ID + $link = $this->getPrimaryLink(true); + + if($link->attr == 'person_id') { + $statuses = $this->ProvisioningTargets->status(coId: $link->co_id, personId: (int)$link->value); + } elseif($link->attr == 'group_id') { + $statuses = $this->ProvisioningTargets->status(coId: $link->co_id, groupId: (int)$link->value); + } + + $this->set('vv_provisioning_statuses', $statuses); + + if(!$this->request->is('restful')) { + $this->set('vv_title', __d('operation', 'provisioning.status')); + } + } +} \ No newline at end of file diff --git a/app/src/Controller/ServersController.php b/app/src/Controller/ServersController.php new file mode 100644 index 000000000..9cb5931f2 --- /dev/null +++ b/app/src/Controller/ServersController.php @@ -0,0 +1,41 @@ + [ + 'Servers.description' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index e3490a4ad..f88cd1eaa 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -31,6 +31,7 @@ use InvalidArgumentException; use \Cake\Http\Exception\BadRequestException; +use \App\Lib\Enum\ProvisioningContextEnum; use \App\Lib\Enum\SuspendableStatusEnum; use \App\Lib\Util\StringUtilities; @@ -60,6 +61,12 @@ public function add() { if($table->save($obj)) { $this->Flash->success(__d('result', 'saved')); + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); + $table->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + } + // If this is a Pluggable Model, instantiate the plugin and redirect // into the Entry Point Model if(!empty($obj->plugin) && method_exists($this, "instantiatePlugin")) { @@ -209,6 +216,14 @@ public function delete($id) { $this->Flash->success(__d('result', 'deleted')); } + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + // In general, tables should check that they were passed a deleted + // record and martial data/set eligibility appropriately + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for deleted entity $modelsName " . $obj->id); + $table->requestProvisioning(id: (int)$id, context: ProvisioningContextEnum::Automatic); + } + // Return to index since there is no delete view return $this->generateRedirect(null); } @@ -313,6 +328,12 @@ public function edit(string $id) { if($table->save($saveObj)) { $this->Flash->success(__d('result', 'saved')); + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); + $table->requestProvisioning(id: (int)$id, context: ProvisioningContextEnum::Automatic); + } + return $this->generateRedirect((int)$id); } @@ -593,8 +614,13 @@ protected function populateAutoViewVars(object $obj=null) { $this->set($vvar, array_combine($avv['array'], $avv['array'])); break; case 'enum': - // We just want the localized text strings for the defined constants + // We just want the localized text strings for the defined constants. $class = '\\App\\Lib\\Enum\\'.$avv['class']; + // We support plugin notation for plugin defined enumerations. + if(strstr($avv['class'], ".")) { + $bits = explode('.', $avv['class'], 2); + $class = '\\'.$bits[0].'\\Lib\\Enum\\'.$bits[1]; + } $this->set($vvar, $class::getLocalizedConsts()); break; // "auxiliary" and "select" do basically the same thing, but the former @@ -695,11 +721,56 @@ protected function populateAutoViewVars(object $obj=null) { } } + /** + * Handle a provisioning request for a Standard object. + * + * @since COmanage Registry v5.0.0 + * @param string $id Object ID + */ + + public function provision($id) { + // $this->name = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // $tableName = models + $tableName = $table->getTable(); + + // Note that only Primary Models support provisioning, but those that + // don't won't have permission to execute this function. + + try { + $table->requestProvisioning( + id: (int)$id, + context: ProvisioningContextEnum::Manual, + provisioningTargetId: (int)$this->getRequest()->getQuery('provisioning_target_id') + ); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + // We don't render any flash messages since they could get complex + // depending on what was provisioned, so instead we redirect into the + // provisioning status index for the object. + // Redirect to the provisioning status view + + $redirect = [ + 'controller' => 'ProvisioningTargets', + 'action' => 'status', + '?' => [ + StringUtilities::tableToForeignKey($table) => $id + ] + ]; + + return $this->redirect($redirect); + } + /** * Handle a view action for a Standard object. * * @since COmanage Registry v5.0.0 - * @param Integer $id Object ID + * @param string $id Object ID */ public function view($id = null) { diff --git a/app/src/Controller/StandardPluggableController.php b/app/src/Controller/StandardPluggableController.php index 0268a3126..0fb8234b7 100644 --- a/app/src/Controller/StandardPluggableController.php +++ b/app/src/Controller/StandardPluggableController.php @@ -57,7 +57,7 @@ public function configure(string $id) { $pluginTable = $this->getTableLocator()->get($parentObj->plugin); $pluginObj = $pluginTable->find() - ->where(['report_id' => $parentId]) + ->where([StringUtilities::tableToForeignKey($table) => $parentId]) ->firstOrFail(); return $this->redirect([ @@ -85,8 +85,11 @@ protected function instantiatePlugin(object $obj) { // For now, we just populate the foreign key from the instantiated plugin // to its parent object, but we might want to allow the plugin model to // set some default values. + $created = new \Datetime('now'); + $iValues = [ - $parentKey => $obj->id + $parentKey => $obj->id, + 'created' => $created->format('Y-m-d H:i:s') ]; $pTable = $this->getTableLocator()->get($obj->plugin); diff --git a/app/src/Controller/StandardPluginController.php b/app/src/Controller/StandardPluginController.php index 6f705c2d7..4a5553948 100644 --- a/app/src/Controller/StandardPluginController.php +++ b/app/src/Controller/StandardPluginController.php @@ -36,6 +36,74 @@ use \App\Lib\Enum\SuspendableStatusEnum; class StandardPluginController extends StandardController { + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + // $this->name = Models + $modelsName = $this->name; + + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + // This is all we need where person_id is the primary link, but for MVEAs + // that are more deeply linked (to person_role_id, external_identity_id, + // or external_identity_role_id) we need to look up the further links. + $primaryLink = $this->getPrimaryLink(true); + + $this->Breadcrumb->skipParents(['/^\/[a-zA-Z0-9-]+\/[a-zA-Z0-9-]+\/edit\//']); + + if($primaryLink->attr == 'server_id') { + // Servers shouldn't show up as configuration, so automatically hide it + // eg for server plugins + $this->Breadcrumb->skipConfig(['/^\//']); + } + + $this->Breadcrumb->injectPrimaryLink($primaryLink); + } + + return parent::beforeFilter($event); + } + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + // $this->name = Models (ie: from ModelsTable, eg FileProvisionersTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $parentClassName = StringUtilities::foreignKeyToClassName($link->attr); + + $parentObj = $table->$parentClassName->get($link->value); + $parentDisplayField = $table->$parentClassName->getDisplayField(); + + $this->set('vv_bc_parent_obj', $parentObj); + $this->set('vv_bc_parent_displayfield', $parentDisplayField); + + // Override the title set in StandardController. Since that was set in edit() + // which is called before the rendering hooks, this title will take precedence. + + $this->set('vv_title', __d('operation', 'configure.a', $parentObj->$parentDisplayField)); + } + + return parent::beforeRender($event); + } + /** * Determine the filesystem path to a file within a plugin. * diff --git a/app/src/Lib/Enum/ProvisionerModeEnum.php b/app/src/Lib/Enum/ProvisionerModeEnum.php new file mode 100644 index 000000000..ec48a4d37 --- /dev/null +++ b/app/src/Lib/Enum/ProvisionerModeEnum.php @@ -0,0 +1,39 @@ +getConstants(); - $className = substr(strrchr(get_called_class(), '\\'), 1); - - foreach(array_values($consts) as $key) { - $ret[$key] = __d('enumeration', $className.'.'.$key); + // get_called_class() will return something like App\Lib\Enum\StatusEnum + // or CoreServer\Lib\Enum\RdbmsTypeEnum + $classBits = explode('\\', get_called_class(), 4); + + if($classBits[0] == 'App') { + foreach(array_values($consts) as $key) { + $ret[$key] = __d('enumeration', $classBits[3].'.'.$key); + } + } else { + $pluginDomain = Inflector::underscore($classBits[0]); + + foreach(array_values($consts) as $key) { + $ret[$key] = __d($pluginDomain, 'enumeration.'.$classBits[3].'.'.$key); + } } return $ret; diff --git a/app/src/Lib/Traits/PluggableModelTrait.php b/app/src/Lib/Traits/PluggableModelTrait.php index bd31c5ae4..8bb384fc8 100644 --- a/app/src/Lib/Traits/PluggableModelTrait.php +++ b/app/src/Lib/Traits/PluggableModelTrait.php @@ -35,6 +35,9 @@ use App\Lib\Util\StringUtilities; trait PluggableModelTrait { + // The set of plugin entry point models used in configurations for this model + protected $_pluginModels = []; + /** * 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 @@ -73,7 +76,7 @@ protected function instantiatePluginModel(string $pmodel, string $path) { // models that do not represent Cake Tables, we can't use the TableLocator here, // we just use plain PHP "new". - $pluginClassName = "\\" . $pluginName . "\\" . $path . "\\" . $pluginModel; + $pluginClassName = "\\" . $pluginName . $path . "\\" . $pluginModel; $pClass = new $pluginClassName(); return $pClass; @@ -111,12 +114,22 @@ protected function setPluginRelations() { ->all(); foreach($models as $m) { - $this->hasMany($m->plugin) + // In general, a model with a "plugin" field has a 1-1 relation + // with the instantiated plugin configuration. eg: One instance + // of a Server has exactly one SqlServer associated with it. + $this->hasOne($m->plugin) ->setDependent(true) ->setCascadeCallbacks(true); + + // Cache the list of entry points that we found + $this->_pluginModels[] = $m->plugin; } - if($this->isConfigurationTable()) { + // isArtifactTable() might not be the exact right test here... + // for now, we only want to exclude Jobs (since there's nothing + // to configure) but this may change. + + if(!$this->isArtifactTable()) { $this->setAllowLookupPrimaryLink(['configure']); } } diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php index 0ee4f1eba..3e4f4b800 100644 --- a/app/src/Lib/Traits/PrimaryLinkTrait.php +++ b/app/src/Lib/Traits/PrimaryLinkTrait.php @@ -200,7 +200,11 @@ public function findPrimaryLink(int $id) { // should be set. Return the first one we find. foreach(array_keys($this->primaryLinks) as $plKey) { if(!empty($obj->$plKey)) { - return (object)['attr' => $plKey, 'value' => $obj->$plKey]; + return (object)[ + 'attr' => $plKey, + 'value' => $obj->$plKey, + 'co_id' => $this->calculateCoForRecord($obj) + ]; } } @@ -407,7 +411,7 @@ public function setAllowEmptyPrimaryLink(bool $allowEmpty) { * Set whether the primary link can be resolved via the object ID in the URL. * * @since COmanage Registry v5.0.0 - * @param boolean $allowEmpty true if the primary link can be resolved via the URL ID + * @param array $actions Actions where the primary link can be obtained by looking up the record ID */ public function setAllowLookupPrimaryLink(array $actions) { diff --git a/app/src/Lib/Traits/ProvisionableTrait.php b/app/src/Lib/Traits/ProvisionableTrait.php new file mode 100644 index 000000000..349a33146 --- /dev/null +++ b/app/src/Lib/Traits/ProvisionableTrait.php @@ -0,0 +1,83 @@ +marshalProvisioningData($id); + + // Invocation of the plugins is handled by the Pluggable table + $ProvisioningTargets = TableRegistry::getTableLocator()->get('ProvisioningTargets'); + + $ProvisioningTargets->provision( + data: $data['data'], + eligibility: $data['eligibility'], + context: $context, + id: $provisioningTargetId + ); + } else { + // This is a secondary model, eg Names. We need to figure out the primary model + // and then request provisioning on that one instead. + + $primaryLink = $this->findPrimaryLink($id); + + $parentTableName = StringUtilities::foreignKeyToClassName($primaryLink->attr); + + $this->$parentTableName->requestProvisioning( + id: $primaryLink->value, + context: $context, + provisioningTargetId: $provisioningTargetId + ); + } + } +} \ No newline at end of file diff --git a/app/src/Lib/Traits/ProvisionerTrait.php b/app/src/Lib/Traits/ProvisionerTrait.php new file mode 100644 index 000000000..e229e679f --- /dev/null +++ b/app/src/Lib/Traits/ProvisionerTrait.php @@ -0,0 +1,69 @@ +provisonableModels; + } + + /** + * Determine if the requested Model is supported by this Provisioner. + * + * @since COmanage Registry v5.0.0 + * @param string $model Model to check + * @return bool True if $model is supported, false otherwise + */ + + public function isProvisionableModel(string $model): bool { + return in_array($model, $this->provisionableModels); + } + + /** + * Set the supported Provisionable Models. + * + * @since COmanage Registry v5.0.0 + * @param array $models Array of supported Provisionable Models + */ + + public function setProvisionableModels(array $models) { + $this->provisionableModels = $models; + } +} diff --git a/app/src/Lib/Traits/ValidationTrait.php b/app/src/Lib/Traits/ValidationTrait.php index d3a3862cc..fdad31de5 100644 --- a/app/src/Lib/Traits/ValidationTrait.php +++ b/app/src/Lib/Traits/ValidationTrait.php @@ -73,6 +73,7 @@ public function registerPrimaryKeyValidation(Validator $validator, array $primar * @param TableSchemaInterface $schema Cake Schema * @param string $field Field name * @param bool $required Whether this field is required + * @param string $prefix Require the value to start with $prefix * @return Validator Cake Validator */ diff --git a/app/src/Lib/Util/SchemaManager.php b/app/src/Lib/Util/SchemaManager.php index 888ba8f07..36647ed06 100644 --- a/app/src/Lib/Util/SchemaManager.php +++ b/app/src/Lib/Util/SchemaManager.php @@ -65,14 +65,15 @@ class SchemaManager { * Construct a new SchemaManager. * * @since COmanage Registry v5.0.0 - * @param ConsoleIo $io Cake ConsoleIo object + * @param ConsoleIo $io Cake ConsoleIo object + * @param string $connection Database connection name */ - public function __construct(?ConsoleIo $io=null) { + public function __construct(?ConsoleIo $io=null, string $connection='default') { if($io) $this->io = $io; // Use the ConnectionManager to get the database config to pass to DBAL. - $db = ConnectionManager::get('default'); + $db = ConnectionManager::get($connection); // $db is a ConnectionInterface object $cfg = $db->config(); @@ -100,12 +101,18 @@ public function __construct(?ConsoleIo $io=null) { * Apply a schema file. * * @since COmanage Registry v5.0.0 - * @param string $schemaFile Schema file to apply - * @param bool $parseOnly If true, attempt to parse the file only, but perform no other actions - * @param bool $diffOnly If true, generate a diff against the current database state, but do not apply it + * @param string $schemaFile Schema file to apply + * @param bool $parseOnly If true, attempt to parse the file only, but perform no other actions + * @param bool $diffOnly If true, generate a diff against the current database state, but do not apply it + * @param string $tablePrefix String to prefix to table names */ - public function applySchemaFile(string $schemaFile, bool $parseOnly=false, bool $diffOnly=false) { + public function applySchemaFile( + string $schemaFile, + bool $parseOnly=false, + bool $diffOnly=false, + string $tablePrefix="" + ) { if(!is_readable($schemaFile)) { throw new \RuntimeException(__d('error', 'file', [$schemaFile])); } @@ -141,16 +148,17 @@ public function applySchemaFile(string $schemaFile, bool $parseOnly=false, bool * * @since COmanage Registry v5.0.0 * @param object $schemaObject Schema object + * @param string $tablePrefix String to prefix to table names */ - public function applySchemaObject(object $schemaObject) { + public function applySchemaObject(object $schemaObject, string $tablePrefix="") { if(!$this->columnLibrary) { // We need the column library from the core config $this->applySchemaFile(schemaFile: ROOT . DS . 'config' . DS . 'schema' . DS . 'schema.json', parseOnly: true); } - $this->processSchema(schemaConfig: $schemaObject); + $this->processSchema(schemaConfig: $schemaObject, tablePrefix: $tablePrefix); } /** @@ -159,15 +167,20 @@ public function applySchemaObject(object $schemaObject) { * @since COmanage Registry v5.0.0 * @param object $schemaConfig Schema object * @param bool $diffOnly If true, generate a diff against the current database state, but do not apply it + * @param string $tablePrefix String to prefix to table names */ - protected function processSchema(object $schemaConfig, bool $diffOnly=false) { + protected function processSchema( + object $schemaConfig, + bool $diffOnly=false, + string $tablePrefix="" + ) { $schema = new Schema(); // Walk through $schemaConfig and build our schema in DBAL format. foreach($schemaConfig->tables as $tName => $tCfg) { - $table = $schema->createTable($tName); + $table = $schema->createTable($tablePrefix.$tName); foreach($tCfg->columns as $cName => $cCfg) { // We allow "inherited" definitions from the fieldLibrary, so merge together @@ -206,13 +219,13 @@ protected function processSchema(object $schemaConfig, bool $diffOnly=false) { } if(isset($colCfg->foreignkey)) { - $table->addForeignKeyConstraint($colCfg->foreignkey->table, + $table->addForeignKeyConstraint($tablePrefix.$colCfg->foreignkey->table, [$cName], [$colCfg->foreignkey->column], [], // We name our foreign keys the same way they // were previously named by adodb - $tName . "_" . $cName . "_fkey"); + $tablePrefix.$tName . "_" . $cName . "_fkey"); } } @@ -229,8 +242,8 @@ protected function processSchema(object $schemaConfig, bool $diffOnly=false) { // Insert a foreign key to this model and index it $table->addColumn($mColumn, "integer", ['notnull' => false]); - $table->addForeignKeyConstraint($fkTable, [$mColumn], ['id'], [], $tName . "_" . $mColumn . "_fkey"); - $table->addIndex([$mColumn], $tName . "_im" . $i++); + $table->addForeignKeyConstraint($tablePrefix.$fkTable, [$mColumn], ['id'], [], $tablePrefix.$tName . "_" . $mColumn . "_fkey"); + $table->addIndex([$mColumn], $tablePrefix.$tName . "_im" . $i++); } } @@ -255,12 +268,12 @@ protected function processSchema(object $schemaConfig, bool $diffOnly=false) { // an Org Identity Source, so we need a foreign key into ourself. if(isset($tCfg->sourced) && $tCfg->sourced) { - $sColumn = "source_" . \Cake\Utility\Inflector::singularize($tName) . "_id"; + $sColumn = "source_" . $tablePrefix.\Cake\Utility\Inflector::singularize($tName) . "_id"; // Insert a foreign key to this model and index it $table->addColumn($sColumn, "integer", ['notnull' => false]); - $table->addForeignKeyConstraint($table, [$sColumn], ['id'], [], $tName . "_" . $sColumn . "_fkey"); - $table->addIndex([$sColumn], $tName . "_im" . $i++); + $table->addForeignKeyConstraint($tablePrefix.$tName, [$sColumn], ['id'], [], $tablePrefix.$tName . "_" . $sColumn . "_fkey"); + $table->addIndex([$sColumn], $tablePrefix.$tName . "_im" . $i++); } // Default is to insert timestamp and changelog fields, unless disabled @@ -280,7 +293,7 @@ protected function processSchema(object $schemaConfig, bool $diffOnly=false) { $table->addColumn("actor_identifier", "string", ['length' => 256, 'notnull' => false]); $table->addForeignKeyConstraint($table, [$clColumn], ['id'], [], $tName . "_" . $clColumn . "_fkey"); - $table->addIndex([$clColumn], $tName . "_icl", [], []); + $table->addIndex([$clColumn], $tablePrefix.$tName . "_icl", [], []); } } @@ -330,6 +343,7 @@ protected function processSchema(object $schemaConfig, bool $diffOnly=false) { } catch(\Exception $e) { if($this->io) $this->io->out($e->getMessage()); + else throw new \RuntimeException($e->getMessage()); } // We might run bin/cake schema_cache clear or diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index c7056b827..7fd786286 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -119,6 +119,27 @@ public static function foreignKeyToClassName(string $s): string { return Inflector::camelize(Inflector::pluralize(substr($s, 0, strlen($s)-3))); } + /** + * Localize a controller name, accounting for plugins. + * + * @since COmanage Registry v5.0.0 + * @param string $controllerName Name of controller to localize + * @param string $pluginName Plugin name, if appropriate + * @param bool $plural Whether to use plural localization + * @return string Localized text string + */ + + public static function localizeController(string $controllerName, ?string $pluginName, bool $plural=false): string { + if($pluginName) { + // Localize via plugin + return __d(Inflector::underscore($pluginName), 'controller.'.$controllerName, [$plural ? 99 : 1]); + } else { + // Standard localization + + return __d('controller', $modelsName, [$plural ? 99 : 1]); + } + } + /** * Determine the model component of a Plugin path. * @@ -161,6 +182,21 @@ public static function tableToEntityName($table): string { return substr($classPath, strrpos($classPath, '\\')+1); } + /** + * Determine the foreign key name to point to a Cake Entity (eg: foo_id for FooTable). + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity + * @return string Foreign key name + */ + + public static function tableToForeignKey($table): string { + // $classPath will be something like App\Model\Entity\Name, but we want to return "name_id" + $classPath = $table->getEntityClass(); + + return Inflector::underscore(Inflector::singularize(substr($classPath, strrpos($classPath, '\\')+1))) . "_id"; + } + // The following two utilities provide base64 encoding and decoding for // strings that might contain special characters that could interfere with // URLs. base64 can generate reserved characters, so we handle those specially diff --git a/app/src/Model/Behavior/OrderableBehavior.php b/app/src/Model/Behavior/OrderableBehavior.php new file mode 100644 index 000000000..917bec5bb --- /dev/null +++ b/app/src/Model/Behavior/OrderableBehavior.php @@ -0,0 +1,66 @@ +getSubject(); + + $query = $Table->find(); + $query->select(['maxorder' => $query->func()->max('ordr', ['ordr'])]); + + $row = $query->first(); + + if(!empty($row->maxorder)) { + $data['ordr'] = $row->maxorder+1; + } else { + $data['ordr'] = 1; + } + } + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Group.php b/app/src/Model/Entity/Group.php index b7e170923..e47027272 100644 --- a/app/src/Model/Entity/Group.php +++ b/app/src/Model/Entity/Group.php @@ -39,11 +39,22 @@ class Group extends Entity { 'slug' => false, ]; + /** + * Determine if this is the All Members group. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is the All Members group, false otherwise. + */ + + public function isAllMembers(): bool { + return $this->group_type == GroupTypeEnum::AllMembers; + } + /** * Determine if this is an automatic group. * * @since COmanage Registry v5.0.0 - * @return bool true if this is not an automatic group, false otherwise. + * @return bool true if this is an automatic group, false otherwise. */ public function isAutomatic(): bool { diff --git a/app/src/Model/Entity/GroupMember.php b/app/src/Model/Entity/GroupMember.php index 3b0dd4b24..21471edf6 100644 --- a/app/src/Model/Entity/GroupMember.php +++ b/app/src/Model/Entity/GroupMember.php @@ -37,4 +37,14 @@ class GroupMember extends Entity { 'id' => false, 'slug' => false, ]; + + /** + * Determine if this Group Membership is valid, meaning it has validity dates + * that are current. + */ + + public function isValid(): bool { + return (!$this->valid_from || $this->valid_from->isPast()) + && (!$this->valid_through || $this->valid_through->isFuture()); + } } \ No newline at end of file diff --git a/app/src/Model/Entity/Person.php b/app/src/Model/Entity/Person.php index 5e64536ef..b81d904a0 100644 --- a/app/src/Model/Entity/Person.php +++ b/app/src/Model/Entity/Person.php @@ -51,4 +51,15 @@ class Person extends Entity { public function isActive(): bool { return in_array($this->status, [StatusEnum::Active, StatusEnum::GracePeriod]); } + + /** + * Determine if this Person is Locked). + * + * @since COmanage Registry v5.0.0 + * @return bool true if Person is Active or GracePeriod, false otherwise + */ + + public function isLocked(): bool { + return $this->status == StatusEnum::Locked; + } } \ No newline at end of file diff --git a/app/src/Model/Entity/PersonRole.php b/app/src/Model/Entity/PersonRole.php index 4254c8267..6662d037e 100644 --- a/app/src/Model/Entity/PersonRole.php +++ b/app/src/Model/Entity/PersonRole.php @@ -51,4 +51,22 @@ class PersonRole extends Entity { public function isActive(): bool { return in_array($this->status, [StatusEnum::Active, StatusEnum::GracePeriod]); } + + /** + * Determine if this Person Role is valid. A valid record isActive() AND also + * has validity dates that are current. + * + * @since COmange Registry v5.0.0 + * @return bool true if the Person Role is valid, false otherwise + */ + + public function isValid(): bool { + // AR-PersonRole-3 A Person Role is considered valid (and provisionable) if + // (1) the Person Role is in Active or Grace Period status, + // (2) the valid from date is unspecified or in the past, and + // (3) the valid through date is unspecified or in the future. + return $this->isActive() + && (!$this->valid_from || $this->valid_from->isPast()) + && (!$this->valid_through || $this->valid_through->isFuture()); + } } \ No newline at end of file diff --git a/app/src/Model/Entity/ProvisioningHistoryRecord.php b/app/src/Model/Entity/ProvisioningHistoryRecord.php new file mode 100644 index 000000000..946439233 --- /dev/null +++ b/app/src/Model/Entity/ProvisioningHistoryRecord.php @@ -0,0 +1,54 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // History records can't be altered once created + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/ProvisioningTarget.php b/app/src/Model/Entity/ProvisioningTarget.php new file mode 100644 index 000000000..09dae98f7 --- /dev/null +++ b/app/src/Model/Entity/ProvisioningTarget.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Server.php b/app/src/Model/Entity/Server.php new file mode 100644 index 000000000..30a1b3fa4 --- /dev/null +++ b/app/src/Model/Entity/Server.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Table/AdHocAttributesTable.php b/app/src/Model/Table/AdHocAttributesTable.php index b7857f8d2..c0b95f022 100644 --- a/app/src/Model/Table/AdHocAttributesTable.php +++ b/app/src/Model/Table/AdHocAttributesTable.php @@ -38,6 +38,7 @@ class AdHocAttributesTable extends Table { use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; use \App\Lib\Traits\SearchFilterTrait; @@ -73,7 +74,6 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], - 'primary' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index a149dd445..e81d5e2f1 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -41,6 +41,7 @@ class AddressesTable extends Table { use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; @@ -101,7 +102,6 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], - 'primary' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index f3ef2f1df..bbb1d4316 100644 --- a/app/src/Model/Table/CousTable.php +++ b/app/src/Model/Table/CousTable.php @@ -35,12 +35,15 @@ use Cake\ORM\TableRegistry; use Cake\Validation\Validator; +use \App\Lib\Enum\ProvisioningEligibilityEnum; + class CousTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; 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\ValidationTrait; @@ -63,13 +66,18 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); + // AR-COU-2 A COU may not be deleted if it has any children. $this->belongsTo('Cous') ->setForeignKey('parent_id') // Property is set so ruleValidateCO can find it. We don't use the // _id suffix to match Cake's default pattern. ->setProperty('parent'); - $this->hasMany('Groups'); + // AR-COU-6 If a COU is deleted, the special groups associated with the COU will also be deleted. + $this->hasMany('Groups') + ->setDependent(true) + ->setCascadeCallbacks(true); + // AR-COU-1 A COU may not be deleted if it has any members. $this->hasMany('PersonRoles'); $this->setDisplayField('name'); @@ -142,7 +150,6 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour } } - if($entity->isNew() && !empty($entity->id)) { // Run setup for new COU @@ -152,6 +159,32 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } + /** + * Marshal object data for provisioning. + * + * @since COmanage Registry v5.0.0 + * @param int $id Entity ID + * @return array An array of provisionable data and eligibility + */ + + public function marshalProvisioningData(int $id): array { + $ret = []; + // We need the archived record on delete to properly deprovision + $ret['data'] = $this->get($id, ['archived' => true]); + + // Provisioning Eligibility is + // - Deleted if the changelog deleted flag is true + // - Eligible otherwise (COUs don't currently have a suspended status) + + $ret['eligibility'] = ProvisioningEligibilityEnum::Eligible; + + if($ret['data']->deleted) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Deleted; + } + + return $ret; + } + /** * Assemble the set of potential parent COUs. * diff --git a/app/src/Model/Table/DashboardsTable.php b/app/src/Model/Table/DashboardsTable.php index c6f164438..8a10f22f2 100644 --- a/app/src/Model/Table/DashboardsTable.php +++ b/app/src/Model/Table/DashboardsTable.php @@ -59,7 +59,13 @@ public function initialize(array $config): void { $this->setPrimaryLink('co_id'); $this->setRequiresCO(true); - $this->setAllowUnkeyedPrimaryCO(['configuration', 'dashboard', 'search']); + $this->setAllowUnkeyedPrimaryCO([ + 'artifacts', + 'configuration', + 'dashboard', + 'registries', + 'search'] + ); $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) @@ -71,9 +77,11 @@ public function initialize(array $config): void { ], // Actions that operate over a table (ie: do not require an $id) 'table' => [ + 'artifacts' => ['platformAdmin', 'coAdmin'], 'configuration' => ['platformAdmin', 'coAdmin'], // XXX CFM-230 This needs to be updated for actual Dashboard permissions 'dashboard' => ['platformAdmin', 'coAdmin', 'coMember'], + 'registries' => ['platformAdmin', 'coAdmin'], 'search' => ['platformAdmin', 'coAdmin'] /* 'add' => ['platformAdmin', 'coAdmin'], 'index' => ['platformAdmin', 'coAdmin']*/ diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index 7d48c5218..c8a21fa54 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -39,6 +39,7 @@ class EmailAddressesTable extends Table { use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; @@ -96,7 +97,6 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], - 'primary' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) diff --git a/app/src/Model/Table/GroupMembersTable.php b/app/src/Model/Table/GroupMembersTable.php index 9fd0a85c4..c5a697a72 100644 --- a/app/src/Model/Table/GroupMembersTable.php +++ b/app/src/Model/Table/GroupMembersTable.php @@ -45,6 +45,7 @@ class GroupMembersTable extends Table { use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index f12c98e2a..4abd3c4ca 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -37,6 +37,7 @@ use \App\Lib\Util\PaginatedSqlIterator; use \App\Lib\Enum\ActionEnum; use \App\Lib\Enum\GroupTypeEnum; +use \App\Lib\Enum\ProvisioningEligibilityEnum; use \App\Lib\Enum\StatusEnum; use \App\Lib\Enum\SuspendableStatusEnum; @@ -48,6 +49,7 @@ class GroupsTable extends Table { use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; use \App\Lib\Traits\SearchFilterTrait; @@ -86,11 +88,14 @@ public function initialize(array $config): void { $this->hasMany('Identifiers') ->setDependent(true) ->setCascadeCallbacks(true); - + $this->hasMany('ProvisioningHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->setDisplayField('name'); $this->setPrimaryLink('co_id'); - $this->setAllowLookupPrimaryLink(['reconcile']); + $this->setAllowLookupPrimaryLink(['provision', 'reconcile']); $this->setRequiresCO(true); $this->setAutoViewVars([ @@ -110,6 +115,7 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'provision' => ['platformAdmin', 'coAdmin'], 'reconcile' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], @@ -130,7 +136,8 @@ public function initialize(array $config): void { 'GroupNestings', 'GroupOwners', 'HistoryRecords', - 'Identifiers' + 'Identifiers', + 'ProvisioningTargets' ] ]); } @@ -368,6 +375,88 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } + /** + * Marshal object data for provisioning. + * + * @since COmanage Registry v5.0.0 + * @param int $id Entity ID + * @return array An array of provisionable data and eligibility + */ + + public function marshalProvisioningData(int $id): array { + $ret = []; + + $ret['data'] = $this->get($id, [ + // We need archives for handling deleted records + 'archived' => 'true', + 'contain' => [ + 'GroupMembers', + 'Identifiers' + ] + ]); + + // Provisioning Eligibility is + // - Deleted if the changelog deleted flag is true + // - Eligible if the status is Active + // - Ineligible otherwise + + $ret['eligibility'] = ProvisioningEligibilityEnum::Ineligible; + + // We filter various attributes depending on the status of the record. + + if($ret['data']->deleted) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Deleted; + + // For deleted or archived records, we remove all Group Members, + // but we leave the Identifiers in place. + + $ret['data']->group_members = []; + } elseif($ret['data']->status == SuspendableStatusEnum::Active) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Eligible; + + // For Eligible, we still need to remove Group Memberships that are + // invalid, and Identifiers that are suspended. + + $groupMembers = []; + + foreach($ret['data']->group_members as $gm) { + if($gm->isValid()) { + $groupMembers[] = $gm; + } + } + + $ret['data']->group_members = $groupMembers; + + $identifiers = []; + + foreach($ret['data']->identifiers as $id) { + if($id->status == SuspendableStatusEnum::Active) { + $identifiers[] = $id; + } + } + + $ret['data']->identifiers = $identifiers; + } else { + $ret['eligibility'] = ProvisioningEligibilityEnum::Ineligible; + // For Ineligible records, we remove the group memberships, and + // any suspended Identifiers. + + $ret['data']->group_members = []; + + $identifiers = []; + + foreach($ret['data']->identifiers as $id) { + if($id->status == SuspendableStatusEnum::Active) { + $identifiers[] = $id; + } + } + + $ret['data']->identifiers = $identifiers; + } + + return $ret; + } + /** * Reconcile the members of an automatic or nested Group. * diff --git a/app/src/Model/Table/HistoryRecordsTable.php b/app/src/Model/Table/HistoryRecordsTable.php index c10585b81..fa9325f7a 100644 --- a/app/src/Model/Table/HistoryRecordsTable.php +++ b/app/src/Model/Table/HistoryRecordsTable.php @@ -77,7 +77,6 @@ public function initialize(array $config): void { // XXX note primary link is external_identity_id when set... // or the other fields as we add them $this->setPrimaryLink(['external_identity_id', 'group_id', 'person_id']); - $this->setAllowLookupPrimaryLink(['primary']); $this->setRequiresCO(true); // XXX does some of this stuff really belong in the controller? diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 241eef6cb..899ebefc6 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -40,6 +40,7 @@ class IdentifiersTable extends Table { use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; @@ -89,6 +90,7 @@ public function initialize(array $config): void { $this->belongsTo('ExternalIdentities'); $this->belongsTo('Groups'); $this->belongsTo('People'); + $this->belongsTo('ProvisioningTargets'); $this->belongsTo('Types'); $this->setDisplayField('identifier'); @@ -113,7 +115,6 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], - 'primary' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) diff --git a/app/src/Model/Table/JobHistoryRecordsTable.php b/app/src/Model/Table/JobHistoryRecordsTable.php index 11743f374..ced38a86f 100644 --- a/app/src/Model/Table/JobHistoryRecordsTable.php +++ b/app/src/Model/Table/JobHistoryRecordsTable.php @@ -67,7 +67,6 @@ public function initialize(array $config): void { $this->setDisplayField('comment'); $this->setPrimaryLink(['job_id']); - $this->setAllowLookupPrimaryLink(['primary']); $this->setRequiresCO(true); $this->setAutoViewVars([ @@ -91,7 +90,7 @@ public function initialize(array $config): void { ], // Actions that operate over a table (ie: do not require an $id) 'table' => [ - 'add' => false, //['platformAdmin', 'coAdmin'], + 'add' => false, 'index' => ['platformAdmin', 'coAdmin'] ] ]); diff --git a/app/src/Model/Table/JobsTable.php b/app/src/Model/Table/JobsTable.php index 601fec6c8..cdf55aec5 100644 --- a/app/src/Model/Table/JobsTable.php +++ b/app/src/Model/Table/JobsTable.php @@ -77,7 +77,7 @@ public function initialize(array $config): void { $this->hasMany('JobHistoryRecords') ->setDependent(true) - ->setCascadeCallbacks(true);; + ->setCascadeCallbacks(true); $this->setPluginRelations(); diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index a09777c64..e748c622c 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -45,6 +45,7 @@ class NamesTable extends Table { use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index fd131ee95..b925f728b 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -35,6 +35,8 @@ use Cake\Validation\Validator; use \App\Lib\Enum\GroupTypeEnum; use \App\Lib\Enum\StatusEnum; +use \App\Lib\Enum\SuspendableStatusEnum; +use \App\Lib\Enum\ProvisioningEligibilityEnum; use \App\Lib\Util\PaginatedSqlIterator; class PeopleTable extends Table { @@ -45,6 +47,7 @@ class PeopleTable extends Table { use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -107,6 +110,9 @@ public function initialize(array $config): void { $this->hasMany('Pronouns') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('ProvisioningHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('TelephoneNumbers') ->setDependent(true) ->setCascadeCallbacks(true); @@ -120,6 +126,7 @@ public function initialize(array $config): void { $this->setPrimaryLink('co_id'); $this->setRequiresCO(true); $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['provision']); // XXX does some of this stuff really belong in the controller? $this->setEditContains([ @@ -150,10 +157,10 @@ public function initialize(array $config): void { // Actions that operate over an entity (ie: require an $id) // See also CFM-126 'entity' => [ - 'delete' => ['platformAdmin', 'coAdmin'], - 'edit' => ['platformAdmin', 'coAdmin'], - 'canvas' => ['platformAdmin', 'coAdmin'], - 'view' => ['platformAdmin', 'coAdmin'] + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'provision' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) 'table' => [ @@ -170,6 +177,7 @@ public function initialize(array $config): void { 'HistoryRecords', 'Identifiers', 'PersonRoles', + 'ProvisioningTargets', 'TelephoneNumbers', 'Urls' ] @@ -207,27 +215,6 @@ public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $op return true; } - /** - * Callback after model save. - * - * @since COmanage Registry v5.0.0 - * @param EventInterface $event Event - * @param EntityInterface $entity Entity (ie: Co) - * @param ArrayObject $options Save options - * @return bool True on success - */ - - public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { - $this->recordHistory($entity); - - // XXX implement this eventually? - //$provision = (isset($options['provision']) ? $options['provision'] : true); - - $this->reconcileCoMembersGroupMemberships($entity); - - return true; - } - /** * Table specific logic to generate a display field. * @@ -261,6 +248,177 @@ public function getMembers(int $coId): PaginatedSqlIterator { return new PaginatedSqlIterator($this, $conditions); } + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + // XXX implement this eventually? + //$provision = (isset($options['provision']) ? $options['provision'] : true); + + $this->reconcileCoMembersGroupMemberships($entity); + + return true; + } + + /** + * Marshal object data for provisioning. + * + * @since COmanage Registry v5.0.0 + * @param int $id Entity ID + * @return array An array of provisionable data and eligibility + */ + + public function marshalProvisioningData(int $id): array { + $ret = []; + + $ret['data'] = $this->get($id, [ + // We need archives for handling deleted records + 'archived' => 'true', + 'contain' => [ + 'PrimaryName' => [ 'Types' ], + 'Addresses' => [ 'Types' ], + 'AdHocAttributes', + 'EmailAddresses' => [ 'Types' ], + 'ExternalIdentities' => [ + 'PrimaryName' => [ 'Types' ], + 'Addresses' => [ 'Types' ], + 'AdHocAttributes', + 'EmailAddresses' => [ 'Types' ], + 'ExternalIdentityRoles' => [ + 'Addresses' => [ 'Types' ], + 'AdHocAttributes', + 'TelephoneNumbers' => [ 'Types' ], + 'Types' + ], + 'Identifiers' => [ 'Types' ], + 'Names' => [ 'Types' ], + 'Pronouns', + 'TelephoneNumbers' => [ 'Types' ], + 'Urls' => [ 'Types' ] + ], + 'GroupMembers' => [ 'Groups' ], + 'GroupOwners' => [ 'Groups' ], + 'Identifiers' => [ 'Types' ], + 'Names' => [ 'Types' ], + 'PersonRoles' => [ + 'Addresses' => [ 'Types' ], + 'AdHocAttributes', + 'Cous', + 'ManagerPeople' => [ 'PrimaryName' ], + 'SponsorPeople' => [ 'PrimaryName' ], + 'TelephoneNumbers' => [ 'Types' ], + 'Types' + ], + 'Pronouns', + 'TelephoneNumbers' => [ 'Types' ], + 'Urls' => [ 'Types' ] + ] + ]); + + // Provisioning Eligibility is + // - Deleted if the changelog deleted flag is true OR status is Archived + // - Eligible if entity->isActive() + // - Ineligible otherwise + + // Most statuses don't provision anything + $ret['eligibility'] = ProvisioningEligibilityEnum::Deleted; + + // We filter various attributes depending on the status of the record. + + if($ret['data']->deleted || $ret['data']->status == StatusEnum::Archived) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Deleted; + + // For deleted or archived records, we remove everything except names + // and identifiers, which might be useful for error reporting and record keeping. + // Unlike Ineligible, we *don't* keep the All Members groups. + + $ret['data']->ad_hoc_attributes = []; + $ret['data']->addresses = []; + $ret['data']->email_addresses = []; + $ret['data']->external_identities = []; + $ret['data']->group_members = []; + $ret['data']->group_owners = []; + $ret['data']->person_roles = []; + $ret['data']->pronouns = []; + $ret['data']->telephone_numbers = []; + $ret['data']->urls = []; + } elseif($ret['data']->isActive()) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Eligible; + + // For Eligible, we still need to remove Person Roles and Group Memberships + // that are invalid, and Identifiers that are suspended. + + $personRoles = []; + + foreach($ret['data']->person_roles as $pr) { + if($pr->isValid()) { + $personRoles[] = $pr; + } + } + + $ret['data']->person_roles = $personRoles; + + $groupMembers = []; + + foreach($ret['data']->group_members as $gm) { + if($gm->isValid()) { + $groupMembers[] = $gm; + } + } + + $ret['data']->group_members = $groupMembers; + + $identifiers = []; + + foreach($ret['data']->identifiers as $id) { + if($id->status == SuspendableStatusEnum::Active) { + $identifiers[] = $id; + } + } + + $ret['data']->identifiers = $identifiers; + } else { + $ret['eligibility'] = ProvisioningEligibilityEnum::Ineligible; + // For Ineligible records, we remove the items that may be used for eligibilities, + // specifically group memberships/ownerships and PersonRoles. We leave the + // All Members group in place. We also remove any suspended Identifiers. + + $groupMembers = []; + + foreach($ret['data']->group_members as $gm) { + if($gm->group->isAllMembers()) { + $groupMembers[] = $gm; + } + } + + $ret['data']->group_members = $groupMembers; + + $identifiers = []; + + foreach($ret['data']->identifiers as $id) { + if($id->status == SuspendableStatusEnum::Active) { + $identifiers[] = $id; + } + } + + $ret['data']->identifiers = $identifiers; + + $ret['data']->group_owners = []; + $ret['data']->person_roles = []; + } + + return $ret; + } + /** * Reconcile memberships in CO members groups based on the Person entity. * diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php index 0278a59b7..900f727ff 100644 --- a/app/src/Model/Table/PersonRolesTable.php +++ b/app/src/Model/Table/PersonRolesTable.php @@ -45,6 +45,7 @@ class PersonRolesTable extends Table { use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; @@ -112,7 +113,7 @@ public function initialize(array $config): void { ->setDependent(true) ->setCascadeCallbacks(true); - $this->setDisplayField('id'); + $this->setDisplayField('title'); $this->setPrimaryLink('person_id'); $this->setRequiresCO(true); diff --git a/app/src/Model/Table/PluginsTable.php b/app/src/Model/Table/PluginsTable.php index edc3296ff..de90a5a6e 100644 --- a/app/src/Model/Table/PluginsTable.php +++ b/app/src/Model/Table/PluginsTable.php @@ -241,6 +241,28 @@ public function getActivePluginModels(string $type): array { return array_combine($active, $active); } + /** + * Read the value for a configuration key for a plugin, which must be Active. + * + * @since COmanage Registry v5.0.0 + * @param string $plugin Plugin name + * @param string $key Configuration key + * @param array Array of configuration information + */ + + public function getPluginConfig(string $plugin, string $key) { + // While most calls to this table accept a plugin object, this one takes + // a string to simplify code that needs a value out of plugin.json. + $pObj = $this->find() + ->where([ + 'plugin' => $plugin, + 'status' => SuspendableStatusEnum::Active + ]) + ->firstOrFail(); + + return $this->readPluginConfig($pObj, $key); + } + /** * Obtain the Entry Point Models implemented by a plugin for a specific plugin type. * @@ -302,9 +324,9 @@ public function pluginPath(\App\Model\Entity\Plugin $plugin, string $file): stri return $fileName; } - $this->llog('error', "Could not find $plugin"); + $this->llog('error', "Could not find $fileName"); - throw new \InvalidArgumentException("Could not find $plugin"); + throw new \InvalidArgumentException("Could not find $fileName"); } /** @@ -439,7 +461,7 @@ public function syncPluginRegistry() { // Create an array of the already registered plugins foreach($registered as $rp) { - $registeredIndex[$rp->plugin] = $rp->location; + $registeredIndex[$rp->plugin] = $rp; } // Insert rows for any plugin not currently in the Registry. @@ -450,6 +472,7 @@ public function syncPluginRegistry() { foreach(array_keys($plugins) as $pluginType) { foreach($plugins[$pluginType] as $p) { if(!isset($registeredIndex[$p])) { + // This is a new plugin $obj = $this->newEntity([ 'plugin' => $p, 'location' => $pluginType, @@ -461,6 +484,24 @@ public function syncPluginRegistry() { ]); $this->saveOrFail($obj); + } elseif($registeredIndex[$p]->location != $pluginIndex[$p]) { + // The plugin location moved. This won't typically happen, but might + // if a developer moves a plugin around. + + $rp = $registeredIndex[$p]; + + if($rp->location == PluginLocationEnum::Core) { + // If the old location was core, update the comment but leave the plugin as active + $rp->comment = __d('information', 'plugin.active'); + } elseif($pluginIndex[$p] == PluginLocationEnum::Core) { + // If the new location is core, make sure the plugin is active + $rp->status = SuspendableStatusEnum::Active; + $rp->comment = __d('information', 'plugin.active.only'); + } + + $rp->location = $pluginIndex[$p]; + + $this->saveOrFail($rp); } } } diff --git a/app/src/Model/Table/PronounsTable.php b/app/src/Model/Table/PronounsTable.php index b7b1bc702..6f6fb511c 100644 --- a/app/src/Model/Table/PronounsTable.php +++ b/app/src/Model/Table/PronounsTable.php @@ -40,6 +40,7 @@ class PronounsTable extends Table { use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; @@ -94,7 +95,6 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], - 'primary' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) diff --git a/app/src/Model/Table/ProvisioningHistoryRecordsTable.php b/app/src/Model/Table/ProvisioningHistoryRecordsTable.php new file mode 100644 index 000000000..e9e0cdc81 --- /dev/null +++ b/app/src/Model/Table/ProvisioningHistoryRecordsTable.php @@ -0,0 +1,218 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('ProvisioningTargets'); + $this->belongsTo('People'); + $this->belongsTo('Groups'); + + $this->setDisplayField('comment'); + + // We list provisioning_target_id last so breadcrumbs don't try to use it + $this->setPrimaryLink(['person_id', 'group_id', 'provisioning_target_id']); + //$this->setAllowLookupPrimaryLink(['primary']); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'ProvisioningStatusEnum' + ] + ]); + + $this->setIndexContains(['ProvisioningTargets']); + + $this->setViewContains([ + 'People' => ['PrimaryName'], + 'Groups' + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param JobHistoryRecord $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\ProvisioningHistoryRecord $entity): string { + // Comments may be too long to render, so we just use the model name + // (which will get appended with the record ID) + + return __d('controller', 'ProvisioningHistoryRecords', [1]); + } + + /** + * Record a Provisioning History Record. + * + * @since COmanage Registry v5.0.0 + * @param int $provisioningTargetId Provisioning Target ID + * @param string $comment Comment + * @param string $status ProvisioningStatusEnum + * @param string $subjectModel The provisioned model + * @param int $subjectId The provisioned entity id (of type $subjectModel) + * @return int Provisioning History Record ID + */ + + public function record(int $provisioningTargetId, + string $comment, + string $status, + string $subjectModel, + int $subjectId): int { + // We record all models and foreign keys, but only select (primary) models + // have database level foreign key relations (for viewing history) so we + // populate the correct foreign key if supported + + $personId = null; + $groupId = null; + + switch($subjectModel) { + case 'Groups': + $groupId = $subjectId; + break; + case 'People': + $personId = $subjectId; + break; + default: + break; + } + + $obj = $this->newEntity([ + 'provisioning_target_id' => $provisioningTargetId, + 'comment' => $comment, + 'status' => $status, + 'subject_model' => $subjectModel, + 'subjectid' => $subjectId, + 'person_id' => $personId, + 'group_id' => $groupId + ]); + + $this->saveOrFail($obj); + +// XXX trace this too? (below is copy/paste from Job History) + // For now, always trace log Job History. We might do something more complicated later. + // eg: Make it configurable whether we create Job History, log, or both? + // This is documented at https://spaces.at.internet2.edu/display/COmanage/Registry+PE+Jobs#RegistryPEJobs-RegistryJobHistory +// $this->llog('trace', $comment, "{$jobId}:{$recordKey}"); + + return $obj->id; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('provisioning_target_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('provisioning_target_id'); + + $this->registerStringValidation($validator, $schema, 'comment', true); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', ProvisioningStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'subject_model', true); + + $validator->add('subjectid', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('subjectid'); + + $validator->add('person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('person_id'); + + $validator->add('group', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('group'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ProvisioningTargetsTable.php b/app/src/Model/Table/ProvisioningTargetsTable.php new file mode 100644 index 000000000..e0a3d76e7 --- /dev/null +++ b/app/src/Model/Table/ProvisioningTargetsTable.php @@ -0,0 +1,341 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Orderable'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('ProvisioningGroups') + ->setClassName('Groups') + ->setForeignKey('provisioning_group_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('provisioning_group'); + + $this->hasMany('ProvisioningHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink(['co_id', 'group_id', 'person_id']); + $this->setRequiresCO(true); + $this->setAllowUnkeyedPrimaryLink(['status']); + + $this->setAutoViewVars([ + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'provisioner' + ], + 'provisioningGroups' => [ + 'type' => 'select', + 'model' => 'ProvisioningGroups' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'ProvisionerModeEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + 'status' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Invoke provisioning. This function is intended to be called via ProvisionableTrait. + * + * @since COmanage Registry v5.0.0 + * @param mixed $data Provisioning object data, eg as returned by Table::get() + * @param ProvisioningEligibilityEnum $eligibility Provisioning eligibility + * @param ProvisioningContextEnum $context Provisioning context + * @param int $id Provisioning Target ID, or null to provision all targets + */ + + public function provision( + mixed $data, + string $eligibility, + string $context, + ?int $id=null + ) { + // Convert the primary data object to the primary provisioned object name + // (eg: People or Cous) + $provisionedModel = StringUtilities::entityToClassName($data); + + $query = $this->find() + ->where([ + 'ProvisioningTargets.co_id' => $data->co_id, +// XXX how do we know which mode's worth of provisioners we want? + 'ProvisioningTargets.status <>' => ProvisionerModeEnum::Disabled + ]); + + if($id) { + $query = $query->where(['ProvisioningTargets.id' => $id]); + } + + $targets = $query->order(['ProvisioningTargets.ordr' => 'ASC']) + ->contain($this->getContainableModels()) + ->all(); + + foreach($targets as $t) { + // Compare our $context against the target's $status. There are three possible + // contexts, with their corresponding provisionable statuses: + // Automatic: Immediate, Queue, QuueOnError + // Enrollment: Enrollment, Immediate, Queue, QueueOnError + // Manual: Enrollment, Immediate, Manual, Queue, QueueOnError +// XXX do we need ARs or PARs for this? add appropriate logging along with ARs + + switch($context) { + case ProvisioningContextEnum::Automatic: + if(!in_array($t->status, [ + ProvisionerModeEnum::Immediate, + ProvisionerModeEnum::Queue, + ProvisionerModeEnum::QueueOnError + ])) { + $this->llog('trace', "Skipping Provisioning Target with mode " . $t->status . " (automatic context)", $t->id); + continue 2; + } + break; + case ProvisioningContextEnum::Enrollment: + if($t->status == ProvisionerModeEnum::Manual) { + $this->llog('trace', "Skipping Provisioning Target with mode " . $t->status . " (enrollment context)", $t->id); + continue 2; + } + break; + case ProvisioningContextEnum::Manual: + // Manual provisioning is permitted regardless of target status + break; + } + + $pluginModel = StringUtilities::pluginModel($t->plugin); + // The model in underscore format, eg file_provisioner + $uPluginModel = Inflector::underscore(Inflector::singularize($pluginModel)); + + // Does this plugin support this model? + if(!$this->$pluginModel->isProvisionableModel($provisionedModel)) { + $this->llog('trace', "Skipping $provisionedModel for $pluginModel (not supported)", $t->id); + continue; + } + + try { + $this->llog('trace', "Provisioning $provisionedModel for $pluginModel (context: $context)", $t->id); + + $result = $this->$pluginModel->provision($t->$uPluginModel, $provisionedModel, $data, $eligibility); + + $this->ProvisioningHistoryRecords->record( + provisioningTargetId: $t->id, + comment: $result['comment'], + status: $result['status'], + subjectModel: $provisionedModel, + subjectId: $data->id + ); + } + catch(\Exception $e) { + $this->ProvisioningHistoryRecords->record( + provisioningTargetId: $t->id, + comment: $e->getMessage(), + status: ProvisioningStatusEnum::NotProvisioned, + subjectModel: $provisionedModel, + subjectId: $data->id + ); + } + } + } + + /** + * Obtain provisioning status. (Either $groupId or $personId must be requested.) + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @param int $groupId Group ID + * @param int $personId Person ID + */ + + public function status(int $coId, int $groupId=null, int $personId=null): array { + $ret = []; + + // Start by pulling the set of active provisioning targets + + $targets = $this->find() + ->where([ + 'ProvisioningTargets.co_id' => $coId, + 'ProvisioningTargets.status <>' => ProvisionerModeEnum::Disabled + ]) + ->all(); + + if(!empty($targets)) { + foreach($targets as $t) { + // For each target, get the status of the target for the requested subject. + // If the plugin implements a status() function we'll call it, otherwise + // we'll get the status from ProvisioningHistory. + + $pluginModel = StringUtilities::pluginModel($t->plugin); + + if(method_exists($this->$pluginModel, 'status')) { + // XXX define interface and call (implement with SqlProvisioner) + throw new \RuntimeException('NOT IMPLEMENTED'); + } else { + $subjectFK = null; + $subjectID = null; + + if(!empty($personId)) { + $subjectFK = 'person_id'; + $subjectID = $personId; + } elseif(!empty($groupId)) { + $subjectFK = 'group_id'; + $subjectID = $groupId; + } else { + throw new \InvalidArgumentException("NOT IMPKEMENTED"); + } + + $rec = $this->ProvisioningHistoryRecords->find() + ->where([ + 'provisioning_target_id' => $t->id, + $subjectFK => $subjectID + ]) + ->order(['id' => 'DESC']) + ->first(); + + if(!empty($rec)) { + $ret[] = [ + 'target' => $t, + 'status' => $rec->status, + 'comment' => $rec->comment, + // XXX where does identifier come from? + //'identifier' => '?', + 'timestamp' => $rec->created + ]; + } else { + $ret[] = [ + 'target' => $t, + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('enumeration', 'ProvisioningStatusEnum.'.ProvisioningStatusEnum::NotProvisioned) + ]; + } + } + } + } + + return $ret; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', ProvisionerModeEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + $validator->add('provisioning_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('provisioning_group_id'); + + $validator->add('retry_interval', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('retry_interval'); + + $validator->add('ordr', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('ordr'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ServersTable.php b/app/src/Model/Table/ServersTable.php new file mode 100644 index 000000000..6c70d8156 --- /dev/null +++ b/app/src/Model/Table/ServersTable.php @@ -0,0 +1,163 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Primary); + + // Define associations + $this->belongsTo('Cos'); + +// XXX Note this will bind to (eg) CoreServer but not (eg) SqlProvisioner + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'server' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-Server-1 A Server cannot be deleted if it is referenced from + // a Configuration object (including plugins). + + $rules->addDelete([$this, 'ruleInUse'], + 'serverInUse', + ['errorField' => 'status']); + + return $rules; + } + + /** + * Application Rule to determine if the server is in use. + * + * @since COmanage Registyr v5.0.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 ruleInUse($entity, $options) { + // XXX CFM-281 we need to do something here + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', false); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index 3044435ed..ca8022826 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -40,6 +40,7 @@ class TelephoneNumbersTable extends Table { use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; @@ -97,7 +98,6 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], - 'primary' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php index b62b4c5fa..2f763c65f 100644 --- a/app/src/Model/Table/TypesTable.php +++ b/app/src/Model/Table/TypesTable.php @@ -37,11 +37,14 @@ use \App\Lib\Enum\EduPersonAffiliationEnum; use \App\Lib\Enum\SuspendableStatusEnum; +use \App\Lib\Enum\ProvisioningEligibilityEnum; + class TypesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; 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\ValidationTrait; @@ -263,6 +266,35 @@ public function getTypeLabel(int $id): string { return $type->value; } + /** + * Marshal object data for provisioning. + * + * @since COmanage Registry v5.0.0 + * @param int $id Entity ID + * @return array An array of provisionable data and eligibility + */ + + public function marshalProvisioningData(int $id): array { + $ret = []; + // We need the archived record on delete to properly deprovision + $ret['data'] = $this->get($id, ['archived' => true]); + + // Provisioning Eligibility is + // - Deleted if the changelog deleted flag is true + // - Eligible if status is Active + // - Ineligible otherwise + + $ret['eligibility'] = ProvisioningEligibilityEnum::Ineligible; + + if($ret['data']->deleted) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Deleted; + } elseif($ret['data']->status == SuspendableStatusEnum::Active) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Eligible; + } + + return $ret; + } + /** * Determine if this type is in use. * diff --git a/app/src/Model/Table/UrlsTable.php b/app/src/Model/Table/UrlsTable.php index 914d051ee..1c446eca5 100644 --- a/app/src/Model/Table/UrlsTable.php +++ b/app/src/Model/Table/UrlsTable.php @@ -39,6 +39,7 @@ class UrlsTable extends Table { use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; @@ -90,7 +91,6 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], - 'primary' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 02f014cdb..da303680d 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -45,6 +45,9 @@ class FieldHelper extends Helper { // Our current model name protected $modelName = null; + // The plugin we are rendering within, if set + protected $pluginName = null; + // The list of required fields protected $reqFields = []; @@ -130,7 +133,7 @@ public function control(string $fieldName, 'label' => __d('operation', 'configure.plugin'), 'url' => [ 'plugin' => null, - 'controller' => 'reports', + 'controller' => StringUtilities::entityToClassname($vv_obj), 'action' => 'configure', $vv_obj->id ] @@ -332,25 +335,54 @@ protected function formNameDiv(string $fieldName, string $labelText=null): strin // First try to autogenerate the field label (if we weren't given one). + $pluginDomain = (!empty($this->pluginName) + ? Inflector::underscore($this->pluginName) + : null); + if(!$label) { // We autogenerate field labels and descriptions from the field name. - // Fields of the form foo_id map to the singular form of registry.ct.foos. - // All others map first to registry.fd.Model.foo, then to registry.fd.foo - // if no Model specific key is found. - - $label = __d('field', $mn.".".$fn); - if($label == $mn.".".$fn) { - // Model specific label not found, try again - - $f = null; - - if(preg_match('/^(.*?)_id$/', $fn, $f)) { - // Map foreign keys (foo_id) to the controller label - $label = __d('controller', Inflector::camelize(Inflector::pluralize($f[1])), [1]); + // We loop over the field generation logic twice, first for a plugin + // context (if set) and then generally (if no plugin localization was found). + + // We use $core as the variable for this loop, so the rest of the code + // is easier to read (!$core = plugin) + for($core = 0;$core < 2;$core++) { + if(!$core && empty($this->pluginName)) { + // No plugin set, just go to the core field checks + continue; + } + + // Is there a model specific key? For plugins, this will be in field.Model.Field + + $key = (!$core ? "field." : "") . "$mn.$fn"; + $label = __d(($core ? 'field' : $pluginDomain), $key); + + if($label == $key) { + // Model specific label not found, try again for a general label + + $f = null; + + if(preg_match('/^(.*?)_id$/', $fn, $f)) { + // Map foreign keys (foo_id) to the controller label + $key = (!$core ? "controller." : "") . Inflector::camelize(Inflector::pluralize($f[1])); + $label = __d(($core ? 'controller' : $pluginDomain), $key, [1]); + + if($key != $label) { + break; + } + } else { + // Just look up the key + $key = (!$core ? "field." : "") . $fn; + $label = __d(($core ? 'field' : $pluginDomain), $key); + + if($key != $label) { + break; + } + } } else { - // Just look up the key - $label = __d('field', $fn); + // If we found a key, break the loop + break; } } } @@ -358,15 +390,21 @@ protected function formNameDiv(string $fieldName, string $labelText=null): strin // We try to automagically determine if a description for the field exists by // looking for the corresponding .desc language translation. - $desc = __d('field', $mn.".".$fn.".desc"); - - if($desc == $mn.".".$fn.".desc") { - $desc = __d('field', $fn.".desc"); - } - - // If the description is the literal key we just generated, there is no description - if($desc == $fn.".desc") { - $desc = null; + for($core = 0;$core < 2;$core++) { + if(!$core && empty($this->pluginName)) { + // No plugin set, just go to the core field checks + continue; + } + + $key = (!$core ? "field." : "") . "$mn.$fn.desc"; + $desc = __d(($core ? 'field' : $pluginDomain), $key); + + // If the description is the literal key we just generated, there is no description + if($desc == $key) { + $desc = null; + } else { + break; + } } return '
@@ -444,14 +482,16 @@ public function statusControl(string $fieldName, public function startControlSet(string $modelName, string $action, bool $editable, - array $reqFields, - $entity=null): string { + array $reqFields, + $entity=null, + ?string $pluginName=null): string { $this->editable = $editable; $this->modelName = $modelName; + $this->pluginName = $pluginName; $this->reqFields = $reqFields; $this->entity = $entity; $this->action = $action; - + return ' - + $this->Menu->getMenuOrder('Default'), 'icon' => 'history', 'url' => $actionUrl, - 'label' => __d('operation', 'HistoryRecords') + 'label' => __d('controller', 'HistoryRecords', [99]) + ); + // provisioning actions + $actionUrl = $this->Url->build( + [ + 'controller' => 'provisioning_targets', + 'action' => 'status', + '?' => [ + 'person_id' => $curId + ] + ] + ); + $action_args['vv_actions'][] = array( + 'order' => $this->Menu->getMenuOrder('Default'), + 'icon' => 'cloud_sync', + 'url' => $actionUrl, + 'label' => __d('operation', 'provisioning.status') ); // delete $actionPostBtnArray = ['action' => 'delete', $curId]; diff --git a/app/templates/layout/default.php b/app/templates/layout/default.php index ee2b7377a..2f220b2f7 100644 --- a/app/templates/layout/default.php +++ b/app/templates/layout/default.php @@ -168,13 +168,9 @@
- - - - + diff --git a/app/vendor/autoload.php b/app/vendor/autoload.php index 3e8e561ee..3b4acb469 100644 --- a/app/vendor/autoload.php +++ b/app/vendor/autoload.php @@ -2,6 +2,24 @@ // autoload.php @generated by Composer +if (PHP_VERSION_ID < 50600) { + if (!headers_sent()) { + header('HTTP/1.1 500 Internal Server Error'); + } + $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL; + if (!ini_get('display_errors')) { + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + fwrite(STDERR, $err); + } elseif (!headers_sent()) { + echo $err; + } + } + trigger_error( + $err, + E_USER_ERROR + ); +} + require_once __DIR__ . '/composer/autoload_real.php'; return ComposerAutoloaderInitb25f76eec921984aa94dcf4015a4846e::getLoader(); diff --git a/app/vendor/cakephp-plugins.php b/app/vendor/cakephp-plugins.php index 8f0549d56..5b33c03b7 100644 --- a/app/vendor/cakephp-plugins.php +++ b/app/vendor/cakephp-plugins.php @@ -5,6 +5,7 @@ 'plugins' => [ 'Bake' => $baseDir . '/vendor/cakephp/bake/', 'Cake/TwigView' => $baseDir . '/vendor/cakephp/twig-view/', + 'CoreServer' => $baseDir . '/plugins/CoreServer/', 'DebugKit' => $baseDir . '/vendor/cakephp/debug_kit/', 'Migrations' => $baseDir . '/vendor/cakephp/migrations/', 'TestWidget' => $baseDir . '/plugins/TestWidget/', diff --git a/app/vendor/composer/ClassLoader.php b/app/vendor/composer/ClassLoader.php index 0cd6055d1..a72151c77 100644 --- a/app/vendor/composer/ClassLoader.php +++ b/app/vendor/composer/ClassLoader.php @@ -42,6 +42,9 @@ */ class ClassLoader { + /** @var \Closure(string):void */ + private static $includeFile; + /** @var ?string */ private $vendorDir; @@ -106,6 +109,7 @@ class ClassLoader public function __construct($vendorDir = null) { $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); } /** @@ -149,7 +153,7 @@ public function getFallbackDirsPsr4() /** * @return string[] Array of classname => path - * @psalm-var array + * @psalm-return array */ public function getClassMap() { @@ -425,7 +429,8 @@ public function unregister() public function loadClass($class) { if ($file = $this->findFile($class)) { - includeFile($file); + $includeFile = self::$includeFile; + $includeFile($file); return true; } @@ -555,18 +560,26 @@ private function findFileWithExtension($class, $ext) return false; } -} -/** - * Scope isolated include. - * - * Prevents access to $this/self from included files. - * - * @param string $file - * @return void - * @private - */ -function includeFile($file) -{ - include $file; + /** + * @return void + */ + private static function initializeIncludeClosure() + { + if (self::$includeFile !== null) { + return; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + * + * @param string $file + * @return void + */ + self::$includeFile = \Closure::bind(static function($file) { + include $file; + }, null, null); + } } diff --git a/app/vendor/composer/autoload_classmap.php b/app/vendor/composer/autoload_classmap.php index 03201ebda..17572c4b0 100644 --- a/app/vendor/composer/autoload_classmap.php +++ b/app/vendor/composer/autoload_classmap.php @@ -2,7 +2,7 @@ // autoload_classmap.php @generated by Composer -$vendorDir = dirname(dirname(__FILE__)); +$vendorDir = dirname(__DIR__); $baseDir = dirname($vendorDir); return array( diff --git a/app/vendor/composer/autoload_files.php b/app/vendor/composer/autoload_files.php index c6ab79d61..8cb5843bd 100644 --- a/app/vendor/composer/autoload_files.php +++ b/app/vendor/composer/autoload_files.php @@ -2,23 +2,17 @@ // autoload_files.php @generated by Composer -$vendorDir = dirname(dirname(__FILE__)); +$vendorDir = dirname(__DIR__); $baseDir = dirname($vendorDir); return array( - '6124b4c8570aa390c21fafd04a26c69f' => $vendorDir . '/myclabs/deep-copy/src/DeepCopy/deep_copy.php', - 'ec07570ca5a812141189b1fa81503674' => $vendorDir . '/phpunit/phpunit/src/Framework/Assert/Functions.php', '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php', '320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php', '6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php', - 'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php', '8825ede83f2f289127722d4e842cf7e8' => $vendorDir . '/symfony/polyfill-intl-grapheme/bootstrap.php', - '667aeda72477189d0494fecd327c3641' => $vendorDir . '/symfony/var-dumper/Resources/functions/dump.php', + 'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php', + '34122c0574b76bf21c9a8db62b5b9cf3' => $vendorDir . '/cakephp/chronos/src/carbon_compat.php', 'b6b991a57620e2fb6b2f66f03fe9ddc2' => $vendorDir . '/symfony/string/Resources/functions.php', - 'ad155f8f1cf0d418fe49e248db8c661b' => $vendorDir . '/react/promise/src/functions_include.php', - 'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php', - '23c18046f52bef3eea034657bafda50f' => $vendorDir . '/symfony/polyfill-php81/bootstrap.php', - '0d59ee240a4cd96ddbb4ff164fccea4d' => $vendorDir . '/symfony/polyfill-php73/bootstrap.php', '07d7f1a47144818725fd8d91a907ac57' => $vendorDir . '/laminas/laminas-diactoros/src/functions/create_uploaded_file.php', 'da94ac5d3ca7d2dbab84ce561ce72bfd' => $vendorDir . '/laminas/laminas-diactoros/src/functions/marshal_headers_from_sapi.php', '3d97c8dcdfba8cb85d3b34f116bb248b' => $vendorDir . '/laminas/laminas-diactoros/src/functions/marshal_method_from_sapi.php', @@ -35,11 +29,17 @@ 'cc8e14526dc240491e17a838cb78508c' => $vendorDir . '/laminas/laminas-diactoros/src/functions/normalize_server.legacy.php', '786bf90caabc9e09b6ad4cc5ca8f0e30' => $vendorDir . '/laminas/laminas-diactoros/src/functions/normalize_uploaded_files.legacy.php', '751a5a3f463e4be759be31748b61737c' => $vendorDir . '/laminas/laminas-diactoros/src/functions/parse_cookie_header.legacy.php', - '34122c0574b76bf21c9a8db62b5b9cf3' => $vendorDir . '/cakephp/chronos/src/carbon_compat.php', 'c720f792236cd163ece8049879166850' => $vendorDir . '/cakephp/cakephp/src/Core/functions.php', 'ede59e3a405fb689cd1cebb7bb1db3fb' => $vendorDir . '/cakephp/cakephp/src/Collection/functions.php', '90236b492da7ca2983a2ad6e33e4152e' => $vendorDir . '/cakephp/cakephp/src/I18n/functions.php', '2cb76c05856dfb60ada40ef54138d49a' => $vendorDir . '/cakephp/cakephp/src/Routing/functions.php', 'b1fc73705e1bec51cd2b20a32cf1c60a' => $vendorDir . '/cakephp/cakephp/src/Utility/bootstrap.php', + 'ad155f8f1cf0d418fe49e248db8c661b' => $vendorDir . '/react/promise/src/functions_include.php', + '0d59ee240a4cd96ddbb4ff164fccea4d' => $vendorDir . '/symfony/polyfill-php73/bootstrap.php', + 'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php', + '23c18046f52bef3eea034657bafda50f' => $vendorDir . '/symfony/polyfill-php81/bootstrap.php', + '6124b4c8570aa390c21fafd04a26c69f' => $vendorDir . '/myclabs/deep-copy/src/DeepCopy/deep_copy.php', + '667aeda72477189d0494fecd327c3641' => $vendorDir . '/symfony/var-dumper/Resources/functions/dump.php', + 'ec07570ca5a812141189b1fa81503674' => $vendorDir . '/phpunit/phpunit/src/Framework/Assert/Functions.php', '801c31d8ed748cfa537fa45402288c95' => $vendorDir . '/psy/psysh/src/functions.php', ); diff --git a/app/vendor/composer/autoload_namespaces.php b/app/vendor/composer/autoload_namespaces.php index 7be99901a..136acb8a1 100644 --- a/app/vendor/composer/autoload_namespaces.php +++ b/app/vendor/composer/autoload_namespaces.php @@ -2,7 +2,7 @@ // autoload_namespaces.php @generated by Composer -$vendorDir = dirname(dirname(__FILE__)); +$vendorDir = dirname(__DIR__); $baseDir = dirname($vendorDir); return array( diff --git a/app/vendor/composer/autoload_psr4.php b/app/vendor/composer/autoload_psr4.php index c6aec9d22..1fd70561a 100644 --- a/app/vendor/composer/autoload_psr4.php +++ b/app/vendor/composer/autoload_psr4.php @@ -2,7 +2,7 @@ // autoload_psr4.php @generated by Composer -$vendorDir = dirname(dirname(__FILE__)); +$vendorDir = dirname(__DIR__); $baseDir = dirname($vendorDir); return array( @@ -25,6 +25,8 @@ 'Symfony\\Component\\Filesystem\\' => array($vendorDir . '/symfony/filesystem'), 'Symfony\\Component\\Console\\' => array($vendorDir . '/symfony/console'), 'Symfony\\Component\\Config\\' => array($vendorDir . '/symfony/config'), + 'SqlConnector\\Test\\' => array($baseDir . '/availableplugins/SqlConnector/tests'), + 'SqlConnector\\' => array($baseDir . '/availableplugins/SqlConnector/src'), 'SlevomatCodingStandard\\' => array($vendorDir . '/slevomat/coding-standard/SlevomatCodingStandard'), 'Seld\\Signal\\' => array($vendorDir . '/seld/signal-handler/src'), 'Seld\\PharUtils\\' => array($vendorDir . '/seld/phar-utils/src'), @@ -49,6 +51,8 @@ 'Laminas\\Diactoros\\' => array($vendorDir . '/laminas/laminas-diactoros/src'), 'JsonSchema\\' => array($vendorDir . '/justinrainbow/json-schema/src/JsonSchema'), 'Jasny\\Twig\\' => array($vendorDir . '/jasny/twig-extensions/src'), + 'FileConnector\\Test\\' => array($baseDir . '/availableplugins/FileConnector/tests'), + 'FileConnector\\' => array($baseDir . '/availableplugins/FileConnector/src'), 'Doctrine\\Instantiator\\' => array($vendorDir . '/doctrine/instantiator/src/Doctrine/Instantiator'), 'Doctrine\\Deprecations\\' => array($vendorDir . '/doctrine/deprecations/lib/Doctrine/Deprecations'), 'Doctrine\\DBAL\\' => array($vendorDir . '/doctrine/dbal/src'), @@ -57,6 +61,8 @@ 'DeepCopy\\' => array($vendorDir . '/myclabs/deep-copy/src/DeepCopy'), 'DebugKit\\Test\\Fixture\\' => array($vendorDir . '/cakephp/debug_kit/tests/Fixture'), 'DebugKit\\' => array($vendorDir . '/cakephp/debug_kit/src'), + 'CoreServer\\Test\\' => array($baseDir . '/plugins/CoreServer/tests'), + 'CoreServer\\' => array($baseDir . '/plugins/CoreServer/src'), 'CoreReport\\Test\\' => array($baseDir . '/availableplugins/CoreReport/tests'), 'CoreReport\\' => array($baseDir . '/availableplugins/CoreReport/src'), 'Composer\\XdebugHandler\\' => array($vendorDir . '/composer/xdebug-handler/src'), diff --git a/app/vendor/composer/autoload_real.php b/app/vendor/composer/autoload_real.php index 0209e6f28..8072a483a 100644 --- a/app/vendor/composer/autoload_real.php +++ b/app/vendor/composer/autoload_real.php @@ -25,51 +25,26 @@ public static function getLoader() require __DIR__ . '/platform_check.php'; spl_autoload_register(array('ComposerAutoloaderInitb25f76eec921984aa94dcf4015a4846e', 'loadClassLoader'), true, true); - self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(\dirname(__FILE__))); + self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__)); spl_autoload_unregister(array('ComposerAutoloaderInitb25f76eec921984aa94dcf4015a4846e', 'loadClassLoader')); - $useStaticLoader = PHP_VERSION_ID >= 50600 && !defined('HHVM_VERSION') && (!function_exists('zend_loader_file_encoded') || !zend_loader_file_encoded()); - if ($useStaticLoader) { - require __DIR__ . '/autoload_static.php'; + require __DIR__ . '/autoload_static.php'; + call_user_func(\Composer\Autoload\ComposerStaticInitb25f76eec921984aa94dcf4015a4846e::getInitializer($loader)); - call_user_func(\Composer\Autoload\ComposerStaticInitb25f76eec921984aa94dcf4015a4846e::getInitializer($loader)); - } else { - $map = require __DIR__ . '/autoload_namespaces.php'; - foreach ($map as $namespace => $path) { - $loader->set($namespace, $path); - } + $loader->register(true); - $map = require __DIR__ . '/autoload_psr4.php'; - foreach ($map as $namespace => $path) { - $loader->setPsr4($namespace, $path); - } + $filesToLoad = \Composer\Autoload\ComposerStaticInitb25f76eec921984aa94dcf4015a4846e::$files; + $requireFile = \Closure::bind(static function ($fileIdentifier, $file) { + if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) { + $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true; - $classMap = require __DIR__ . '/autoload_classmap.php'; - if ($classMap) { - $loader->addClassMap($classMap); + require $file; } - } - - $loader->register(true); - - if ($useStaticLoader) { - $includeFiles = Composer\Autoload\ComposerStaticInitb25f76eec921984aa94dcf4015a4846e::$files; - } else { - $includeFiles = require __DIR__ . '/autoload_files.php'; - } - foreach ($includeFiles as $fileIdentifier => $file) { - composerRequireb25f76eec921984aa94dcf4015a4846e($fileIdentifier, $file); + }, null, null); + foreach ($filesToLoad as $fileIdentifier => $file) { + $requireFile($fileIdentifier, $file); } return $loader; } } - -function composerRequireb25f76eec921984aa94dcf4015a4846e($fileIdentifier, $file) -{ - if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) { - require $file; - - $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true; - } -} diff --git a/app/vendor/composer/autoload_static.php b/app/vendor/composer/autoload_static.php index 2d4c1836d..b446bd07a 100644 --- a/app/vendor/composer/autoload_static.php +++ b/app/vendor/composer/autoload_static.php @@ -7,19 +7,13 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e { public static $files = array ( - '6124b4c8570aa390c21fafd04a26c69f' => __DIR__ . '/..' . '/myclabs/deep-copy/src/DeepCopy/deep_copy.php', - 'ec07570ca5a812141189b1fa81503674' => __DIR__ . '/..' . '/phpunit/phpunit/src/Framework/Assert/Functions.php', '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php', '320cde22f66dd4f5d3fd621d3e88b98f' => __DIR__ . '/..' . '/symfony/polyfill-ctype/bootstrap.php', '6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php', - 'e69f7f6ee287b969198c3c9d6777bd38' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/bootstrap.php', '8825ede83f2f289127722d4e842cf7e8' => __DIR__ . '/..' . '/symfony/polyfill-intl-grapheme/bootstrap.php', - '667aeda72477189d0494fecd327c3641' => __DIR__ . '/..' . '/symfony/var-dumper/Resources/functions/dump.php', + 'e69f7f6ee287b969198c3c9d6777bd38' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/bootstrap.php', + '34122c0574b76bf21c9a8db62b5b9cf3' => __DIR__ . '/..' . '/cakephp/chronos/src/carbon_compat.php', 'b6b991a57620e2fb6b2f66f03fe9ddc2' => __DIR__ . '/..' . '/symfony/string/Resources/functions.php', - 'ad155f8f1cf0d418fe49e248db8c661b' => __DIR__ . '/..' . '/react/promise/src/functions_include.php', - 'a4a119a56e50fbb293281d9a48007e0e' => __DIR__ . '/..' . '/symfony/polyfill-php80/bootstrap.php', - '23c18046f52bef3eea034657bafda50f' => __DIR__ . '/..' . '/symfony/polyfill-php81/bootstrap.php', - '0d59ee240a4cd96ddbb4ff164fccea4d' => __DIR__ . '/..' . '/symfony/polyfill-php73/bootstrap.php', '07d7f1a47144818725fd8d91a907ac57' => __DIR__ . '/..' . '/laminas/laminas-diactoros/src/functions/create_uploaded_file.php', 'da94ac5d3ca7d2dbab84ce561ce72bfd' => __DIR__ . '/..' . '/laminas/laminas-diactoros/src/functions/marshal_headers_from_sapi.php', '3d97c8dcdfba8cb85d3b34f116bb248b' => __DIR__ . '/..' . '/laminas/laminas-diactoros/src/functions/marshal_method_from_sapi.php', @@ -36,12 +30,18 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'cc8e14526dc240491e17a838cb78508c' => __DIR__ . '/..' . '/laminas/laminas-diactoros/src/functions/normalize_server.legacy.php', '786bf90caabc9e09b6ad4cc5ca8f0e30' => __DIR__ . '/..' . '/laminas/laminas-diactoros/src/functions/normalize_uploaded_files.legacy.php', '751a5a3f463e4be759be31748b61737c' => __DIR__ . '/..' . '/laminas/laminas-diactoros/src/functions/parse_cookie_header.legacy.php', - '34122c0574b76bf21c9a8db62b5b9cf3' => __DIR__ . '/..' . '/cakephp/chronos/src/carbon_compat.php', 'c720f792236cd163ece8049879166850' => __DIR__ . '/..' . '/cakephp/cakephp/src/Core/functions.php', 'ede59e3a405fb689cd1cebb7bb1db3fb' => __DIR__ . '/..' . '/cakephp/cakephp/src/Collection/functions.php', '90236b492da7ca2983a2ad6e33e4152e' => __DIR__ . '/..' . '/cakephp/cakephp/src/I18n/functions.php', '2cb76c05856dfb60ada40ef54138d49a' => __DIR__ . '/..' . '/cakephp/cakephp/src/Routing/functions.php', 'b1fc73705e1bec51cd2b20a32cf1c60a' => __DIR__ . '/..' . '/cakephp/cakephp/src/Utility/bootstrap.php', + 'ad155f8f1cf0d418fe49e248db8c661b' => __DIR__ . '/..' . '/react/promise/src/functions_include.php', + '0d59ee240a4cd96ddbb4ff164fccea4d' => __DIR__ . '/..' . '/symfony/polyfill-php73/bootstrap.php', + 'a4a119a56e50fbb293281d9a48007e0e' => __DIR__ . '/..' . '/symfony/polyfill-php80/bootstrap.php', + '23c18046f52bef3eea034657bafda50f' => __DIR__ . '/..' . '/symfony/polyfill-php81/bootstrap.php', + '6124b4c8570aa390c21fafd04a26c69f' => __DIR__ . '/..' . '/myclabs/deep-copy/src/DeepCopy/deep_copy.php', + '667aeda72477189d0494fecd327c3641' => __DIR__ . '/..' . '/symfony/var-dumper/Resources/functions/dump.php', + 'ec07570ca5a812141189b1fa81503674' => __DIR__ . '/..' . '/phpunit/phpunit/src/Framework/Assert/Functions.php', '801c31d8ed748cfa537fa45402288c95' => __DIR__ . '/..' . '/psy/psysh/src/functions.php', ); @@ -70,6 +70,8 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'Symfony\\Component\\Filesystem\\' => 29, 'Symfony\\Component\\Console\\' => 26, 'Symfony\\Component\\Config\\' => 25, + 'SqlConnector\\Test\\' => 18, + 'SqlConnector\\' => 13, 'SlevomatCodingStandard\\' => 23, 'Seld\\Signal\\' => 12, 'Seld\\PharUtils\\' => 15, @@ -110,6 +112,11 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'JsonSchema\\' => 11, 'Jasny\\Twig\\' => 11, ), + 'F' => + array ( + 'FileConnector\\Test\\' => 19, + 'FileConnector\\' => 14, + ), 'D' => array ( 'Doctrine\\Instantiator\\' => 22, @@ -123,6 +130,8 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e ), 'C' => array ( + 'CoreServer\\Test\\' => 16, + 'CoreServer\\' => 11, 'CoreReport\\Test\\' => 16, 'CoreReport\\' => 11, 'Composer\\XdebugHandler\\' => 23, @@ -229,6 +238,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e array ( 0 => __DIR__ . '/..' . '/symfony/config', ), + 'SqlConnector\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/SqlConnector/tests', + ), + 'SqlConnector\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/SqlConnector/src', + ), 'SlevomatCodingStandard\\' => array ( 0 => __DIR__ . '/..' . '/slevomat/coding-standard/SlevomatCodingStandard', @@ -327,6 +344,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e array ( 0 => __DIR__ . '/..' . '/jasny/twig-extensions/src', ), + 'FileConnector\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/FileConnector/tests', + ), + 'FileConnector\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/FileConnector/src', + ), 'Doctrine\\Instantiator\\' => array ( 0 => __DIR__ . '/..' . '/doctrine/instantiator/src/Doctrine/Instantiator', @@ -359,6 +384,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e array ( 0 => __DIR__ . '/..' . '/cakephp/debug_kit/src', ), + 'CoreServer\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreServer/tests', + ), + 'CoreServer\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreServer/src', + ), 'CoreReport\\Test\\' => array ( 0 => __DIR__ . '/../..' . '/availableplugins/CoreReport/tests',