diff --git a/README.md b/README.md index 7a1b748e0..7e77fdfd7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # COmanage Registry (Pupal Eclosion) -This is the development repository for COmanage Registry v5.0.0. +This is the repository for COmanage Registry v5+. -For production deployments, see [this repository](https://github.com/Internet2/comanage-registry) instead. +For v4, see [this repository](https://github.com/Internet2/comanage-registry) instead. diff --git a/app/availableplugins/ApiConnector/config/plugin.json b/app/availableplugins/ApiConnector/config/plugin.json new file mode 100644 index 000000000..50a98e0c8 --- /dev/null +++ b/app/availableplugins/ApiConnector/config/plugin.json @@ -0,0 +1,48 @@ +{ + "types": { + "api": [ + "ApiSourceEndpoints" + ], + "external_identity_source": [ + "ApiSources" + ] + }, + "schema": { + "tables": { + "api_sources": { + "columns": { + "id": {}, + "external_identity_source_id": {} + }, + "indexes": { + "api_sources_i1": { "columns": [ "external_identity_source_id" ] } + } + }, + "api_source_endpoints": { + "columns": { + "id": {}, + "api_id": {}, + "external_identity_source_id": {} + }, + "indexes": { + "api_source_endpoints_i1": { "columns": [ "api_id" ] }, + "api_source_endpoints_i2": { "columns": [ "external_identity_source_id" ] } + } + }, + "api_source_records": { + "columns": { + "id": {}, + "api_source_id": { "type": "integer", "foreignkey": { "table": "api_sources", "column": "id" } }, + "source_key": { "type": "string", "size": 1024 }, + "source_record": { "type": "text" } + }, + "indexes": { + "api_source_records_i1": { "columns": [ "api_source_id" ] }, + "api_source_records_i2": { "columns": [ "api_source_id", "source_key" ] } + }, + "changelog": false, + "clone_relation": true + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/config/routes.php b/app/availableplugins/ApiConnector/config/routes.php index a7af3b212..6b0020f74 100644 --- a/app/availableplugins/ApiConnector/config/routes.php +++ b/app/availableplugins/ApiConnector/config/routes.php @@ -55,21 +55,21 @@ $builder->delete( '/{id}/v2/sorPeople/{sorlabel}/{sorid}', - ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'delete'] + ['plugin' => 'ApiConnector', 'controller' => 'SorApiV2', 'action' => 'delete'] ) ->setPass(['id', 'sorlabel', 'sorid']) ->setPatterns(['id' => '[0-9]+']); $builder->get( '/{id}/v2/sorPeople/{sorlabel}/{sorid}', - ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'get'] + ['plugin' => 'ApiConnector', 'controller' => 'SorApiV2', 'action' => 'get'] ) ->setPass(['id', 'sorlabel', 'sorid']) ->setPatterns(['id' => '[0-9]+']); $builder->put( '/{id}/v2/sorPeople/{sorlabel}/{sorid}', - ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'upsert'] + ['plugin' => 'ApiConnector', 'controller' => 'SorApiV2', 'action' => 'upsert'] ) ->setPass(['id', 'sorlabel', 'sorid']) ->setPatterns(['id' => '[0-9]+']); diff --git a/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po b/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po index eea0637bd..7ce68b752 100644 --- a/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po +++ b/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po @@ -25,6 +25,13 @@ msgid "controller.ApiSources" #msgstr "{0,plural,=1{API Source} other{API Sources}}" +# XXX this should autodetect and use the controller key? +msgid "field.api_source_id" +msgstr "API Source" + +msgid "field.ApiSourceEndpoints.external_identity_source_id" +msgstr "API Source" + msgid "field.ApiSources.push_mode" msgstr "Push Mode" diff --git a/app/availableplugins/ApiConnector/src/Controller/ApiSourceEndpointsController.php b/app/availableplugins/ApiConnector/src/Controller/ApiSourceEndpointsController.php new file mode 100644 index 000000000..119202628 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Controller/ApiSourceEndpointsController.php @@ -0,0 +1,79 @@ + [ + 'ApiSourceEndpoints.id' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.2.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->ApiSourceEndpoints->Apis->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->ApiSourceEndpoints->Apis->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->ApiSourceEndpoints->Apis->getPrimaryKey()); + } + + $vv_obj = $this->viewBuilder()->getVar('vv_obj'); + + if(!empty($vv_obj->external_identity_source->api_source->id)) { + // For consistency with other plugins, the data model points to the External Identity Source + // but the API points to Api Source. + + $this->set( + 'vv_push_endpoint', + Router::url( + url: '/api/apisource/' + . $vv_obj->external_identity_source->api_source->id + . '/v2/sorPeople/' + . $vv_obj->external_identity_source->sor_label, + full: true + ) + ); + } + + return parent::beforeRender($event); + } +} diff --git a/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php b/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php index f003372ed..b779a9a65 100644 --- a/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php +++ b/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php @@ -29,11 +29,12 @@ namespace ApiConnector\Controller; +use Cake\Event\EventInterface; use Cake\Routing\Router; use App\Controller\StandardPluginController; class ApiSourcesController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'ApiSources.id' => 'asc' ] @@ -42,24 +43,20 @@ class ApiSourcesController extends StandardPluginController { /** * Callback run prior to the request render. * + * @param EventInterface $event Cake Event + * + * @return Response|void * @since COmanage Registry v5.0.0 - * @param EventInterface $event Cake Event - * @return \Cake\Http\Response HTTP Response */ - public function beforeRender(\Cake\Event\EventInterface $event) { - $apiSource = $this->ApiSources->get( - $this->request->getParam('pass.0'), - ['contain' => 'ExternalIdentitySources'] - ); - - $this->set( - 'vv_push_endpoint', - Router::url( - url: '/api/apisource/' . $apiSource->id . '/v2/sorPeople/' . $apiSource->external_identity_source->sor_label, - full: true - ) - ); + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->ApiSources->ExternalIdentitySources->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->ApiSources->ExternalIdentitySources->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->ApiSources->ExternalIdentitySources->getPrimaryKey()); + } return parent::beforeRender($event); } diff --git a/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php b/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php deleted file mode 100644 index d865f96c5..000000000 --- a/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php +++ /dev/null @@ -1,209 +0,0 @@ -request->getParam('id'); - - $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); - - $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ExternalIdentitySources']); - - return $cfg->external_identity_source->co_id ?? null; - } - - /** - * Calculate authorization for the current request. - * - * @since COmanage Registry v5.0.0 - * @return bool True if the current request is permitted, false otherwise - */ - - public function calculatePermission(): bool { - $request = $this->getRequest(); - $action = $request->getParam('action'); - $authUser = $this->RegistryAuth->getAuthenticatedUser(); - - $authorized = false; - - // Our authorization is pretty straightforward, the configured API User - // is permitted to perform all actions. - - // This should be set or the route won't match - $apiSourceId = $this->request->getParam('id'); - - $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); - - $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ApiUsers']); - - if(!empty($cfg->api_user->username) - && !empty($authUser) - && $authUser == $cfg->api_user->username) { - $authorized = true; - } - - return $authorized; - } - - /** - * Handle an SOR Person Role Deleted request. - * - * @since COmanage Registry v5.0.0 - * @param string $id ApiSource ID - * @param string $sorlabel System of Record Label from request URL - * @param string $sorid System of Record ID from request URL - */ - - public function delete(string $id, string $sorlabel, string $sorid) { - $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); - - $resultCode = 500; - $results = []; - - try { - $ApiSource->remove((int)$id, $sorlabel, $sorid); - - $resultCode = 200; - } - catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { - $resultCode = 404; - $results['error'] = $e->getMessage(); - } - catch(\Exception $e) { - $this->llog('debug', $e->getMessage()); - $results['error'] = $e->getMessage(); - - $resultCode = 500; - } - - $this->response = $this->response->withStatus($resultCode); - $this->set('vv_results', $results); - } - - /** - * Handle a Get SOR Person Role request. - * - * @since COmanage Registry v5.0.0 - * @param string $id ApiSource ID - * @param string $sorlabel System of Record Label from request URL - * @param string $sorid System of Record ID from request URL - */ - - public function get(string $id, string $sorlabel, string $sorid) { - // We basically just pull the currently cached source record and return it. - - $ApiSourceRecord = TableRegistry::getTableLocator()->get('ApiConnector.ApiSourceRecords'); - - $results = []; - $resultCode = 500; - - try { - $record = $ApiSourceRecord->find() - ->where(['api_source_id' => $id, 'source_key' => $sorid]) - ->firstOrFail(); - - $resultCode = 200; - $results = json_decode($record->source_record); - } - catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { - $resultCode = 404; - $results['error'] = $e->getMessage(); - } - catch(\Exception $e) { - $results['error'] = $e->getMessage(); - } - - $this->response = $this->response->withStatus($resultCode); - $this->set('vv_results', $results); - } - - /** - * Handle an SOR Person Role Added or Updated request. - * - * @since COmanage Registry v5.0.0 - * @param string $id ApiSource ID - * @param string $sorlabel System of Record Label from request URL - * @param string $sorid System of Record ID from request URL - */ - - public function upsert(string $id, string $sorlabel, string $sorid) { - // Pass the requested data to the Backend and return a response. -// XXX todo: add support for returnUrl back in - - $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); - - $resultCode = 400; - $results = []; - - try { - $result = $ApiSource->upsert((int)$id, $sorlabel, $sorid, $this->request->getData()); - - if(isset($result['new']) && $result['new']) { - $resultCode = 201; - } else { - $resultCode = 200; - } - } - catch(\Exception $e) { - $this->llog('debug', $e->getMessage()); - $results['error'] = $e->getMessage(); - - $resultCode = 400; - } - - $this->response = $this->response->withStatus($resultCode); - $this->set('vv_results', $results); - } - - /** - * Indicate whether this Controller will handle some or all authnz. - * - * @since COmanage Registry v5.0.0 - * @param EventInterface $event Cake event, ie: from beforeFilter - * @return string "no", "open", "authz", or "yes" - */ - - public function willHandleAuth(\Cake\Event\EventInterface $event): string { - // We always take over authz - return 'authz'; - } -} diff --git a/app/availableplugins/ApiConnector/src/Controller/AppController.php b/app/availableplugins/ApiConnector/src/Controller/AppController.php index c11c4469c..9fbc89105 100644 --- a/app/availableplugins/ApiConnector/src/Controller/AppController.php +++ b/app/availableplugins/ApiConnector/src/Controller/AppController.php @@ -1,7 +1,7 @@ 'ApiSourceEndpoints', + 'get' => 'ApiSourceEndpoints', + 'upsert' => 'ApiSourceEndpoints' + ]; + + /** + * Calculate the CO ID associated with the request. + * + * @since COmanage Registry v5.0.0 + * @return int CO ID, or null if no CO contextwas found + */ + + public function calculateRequestedCOID(): ?int { + $apiSourceId = $this->request->getParam('id'); + + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + return $ApiSource->findCoForRecord((int)$apiSourceId); + + // $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ExternalIdentitySources']); + + // return $cfg->external_identity_source->co_id ?? null; + } + + /** + * Handle an SOR Person Role Deleted request. + * + * @since COmanage Registry v5.0.0 + * @param string $id ApiSource ID + * @param string $sorlabel System of Record Label from request URL + * @param string $sorid System of Record ID from request URL + */ + + public function delete(string $id, string $sorlabel, string $sorid) { + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + $resultCode = 500; + $results = []; + + try { + $ApiSource->remove((int)$id, $sorlabel, $sorid); + + $resultCode = 200; + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + $resultCode = 404; + $results['error'] = $e->getMessage(); + } + catch(\Exception $e) { + $this->llog('debug', $e->getMessage()); + $results['error'] = $e->getMessage(); + + $resultCode = 500; + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } + + /** + * Handle a Get SOR Person Role request. + * + * @since COmanage Registry v5.0.0 + * @param string $id ApiSource ID + * @param string $sorlabel System of Record Label from request URL + * @param string $sorid System of Record ID from request URL + */ + + public function get(string $id, string $sorlabel, string $sorid) { + // We basically just pull the currently cached source record and return it. + + $ApiSourceRecord = TableRegistry::getTableLocator()->get('ApiConnector.ApiSourceRecords'); + + $results = []; + $resultCode = 500; + + try { + $record = $ApiSourceRecord->find() + ->where(['api_source_id' => $id, 'source_key' => $sorid]) + ->firstOrFail(); + + $resultCode = 200; + $results = json_decode($record->source_record); + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + $resultCode = 404; + $results['error'] = $e->getMessage(); + } + catch(\Exception $e) { + $results['error'] = $e->getMessage(); + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } + + /** + * Handle an SOR Person Role Added or Updated request. + * + * @since COmanage Registry v5.0.0 + * @param string $id ApiSource ID + * @param string $sorlabel System of Record Label from request URL + * @param string $sorid System of Record ID from request URL + */ + + public function upsert(string $id, string $sorlabel, string $sorid) { + // Pass the requested data to the Backend and return a response. +// XXX todo: add support for returnUrl back in + + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + $resultCode = 400; + $results = []; + + try { + $result = $ApiSource->upsert((int)$id, $sorlabel, $sorid, $this->request->getData()); + + if(isset($result['new']) && $result['new']) { + $resultCode = 201; + } else { + $resultCode = 200; + } + } + catch(\Exception $e) { + $this->llog('debug', $e->getMessage()); + $results['error'] = $e->getMessage(); + + $resultCode = 400; + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } +} diff --git a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSource.php b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSource.php index 7403f3759..890e06c4c 100644 --- a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSource.php +++ b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSource.php @@ -1,6 +1,6 @@ */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceEndpoint.php b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceEndpoint.php new file mode 100644 index 000000000..8df2cab59 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceEndpoint.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php index 763b797cc..8df244ead 100644 --- a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php +++ b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php @@ -1,6 +1,6 @@ */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, ]; + + public array $_supportedMetadata = ['clonable-related']; } diff --git a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourceEndpointsTable.php b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourceEndpointsTable.php new file mode 100644 index 000000000..bba81edc0 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourceEndpointsTable.php @@ -0,0 +1,123 @@ +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('ExternalIdentitySources'); + $this->belongsTo('Apis'); + + $this->setDisplayField('api_id'); + + $this->setPrimaryLink(['api_id']); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + + $this->setEditContains([ + 'ExternalIdentitySources' => ['ApiSources'] + ]); + + + $this->setAutoViewVars([ + 'externalIdentitySources' => [ + 'type' => 'plugin', + 'model' => 'ApiConnector.ApiSources' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * 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('api_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('api_id'); + + $validator->add('external_identity_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('external_identity_source_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php index 1712c158f..07bfdfdfa 100644 --- a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php +++ b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php @@ -70,13 +70,6 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_source_id']); $this->setRequiresCO(true); - - $this->setAutoViewVars([ - 'apiUsers' => [ - 'type' => 'select', - 'model' => 'ApiUsers', - ] - ]); $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) @@ -190,11 +183,8 @@ protected function mapApiToRegistry(string $model, array $attributes): array { */ public function remove(int $id, string $sorLabel, string $sorId): bool { - // We call this remove() so as not to interfere with the default table::delete(). - $apiSource = $this->get($id, ['contain' => ['ExternalIdentitySources']]); - // Pull our configuration - $apiSource = $this->get($id, ['contain' => ['ExternalIdentitySources']]); + $apiSource = $this->get($id, contain: ['ExternalIdentitySources']); // Like upsert(), we don't really need $sorLabel, but we check it for // consistency with upsert() (which also doesn't really need it). @@ -417,7 +407,7 @@ public function upsert( $ret = []; // Pull our configuration - $apiSource = $this->get($id, ['contain' => ['ExternalIdentitySources']]); + $apiSource = $this->get($id, contain: ['ExternalIdentitySources']); // Strictly speaking we don't need $sorLabel since we know which configuration // to use from the ApiSource ID, and $sorLabel might not be unique across COs @@ -491,11 +481,6 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('external_source_identity_id'); - $validator->add('api_user_id', [ - 'content' => ['rule' => 'isInteger'] - ]); - $validator->notEmptyString('api_user_id'); - return $validator; } } \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/src/config/plugin.json b/app/availableplugins/ApiConnector/src/config/plugin.json deleted file mode 100644 index be96e7f42..000000000 --- a/app/availableplugins/ApiConnector/src/config/plugin.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "types": { - "source": [ - "ApiSources" - ] - }, - "schema": { - "tables": { - "api_sources": { - "columns": { - "id": {}, - "external_identity_source_id": {}, - "api_user_id": {} - }, - "indexes": { - "api_sources_i1": { "columns": [ "external_identity_source_id" ] } - } - }, - "api_source_records": { - "columns": { - "id": {}, - "api_source_id": { "type": "integer", "foreignkey": { "table": "api_sources", "column": "id" } }, - "source_key": { "type": "string", "size": 1024 }, - "source_record": { "type": "text" } - }, - "indexes": { - "api_source_records_i1": { "columns": [ "api_source_id" ] }, - "api_source_records_i2": { "columns": [ "api_source_id", "source_key" ] } - }, - "changelog": false - } - } - } -} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/templates/ApiSourceEndpoints/fields.inc b/app/availableplugins/ApiConnector/templates/ApiSourceEndpoints/fields.inc new file mode 100644 index 000000000..e3b79cfa3 --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiSourceEndpoints/fields.inc @@ -0,0 +1,48 @@ + 'information', + 'message' => __d('api_connector', 'information.endpoint.push', [$vv_push_endpoint]) + ] + ]; +} + +$fields = [ + 'external_identity_source_id' +]; + +$subnav = [ + 'tabs' => ['Apis', 'ApiConnector.ApiSourceEndpoints'], + 'action' => [ + 'Apis' => ['edit'], + 'ApiConnector.ApiSourceEndpoints' => ['edit'] + ] +]; diff --git a/app/availableplugins/ApiConnector/templates/ApiSources/fields-nav.inc b/app/availableplugins/ApiConnector/templates/ApiSources/fields-nav.inc deleted file mode 100644 index b4d5a2809..000000000 --- a/app/availableplugins/ApiConnector/templates/ApiSources/fields-nav.inc +++ /dev/null @@ -1,31 +0,0 @@ - 'plugin', - 'active' => 'plugin' - ]; \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc b/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc index c469f6a67..e3178098d 100644 --- a/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc +++ b/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc @@ -25,20 +25,25 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only -if($vv_action == 'add' || $vv_action == 'edit') { - - print '
  • ' . __d('api_connector', 'field.ApiSources.push_mode') . '

  • '; +$alerts = [ + [ + 'type' => 'information', + 'message' => __d('information', 'plugin.config.none') + ] +]; - print $this->element('notify/banner', [ - 'info' => __d('api_connector', 'information.endpoint.push', [$vv_push_endpoint]) - ]); +// XXX Revisit this - what's the "Push Mode" text attempting to convey here (with no other information)? +$fields = [ + 'SUBTITLE' => [ + 'subtitle' => __d('api_connector', 'field.ApiSources.push_mode') + ] +]; - - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'api_user_id', - ] - ]); - -} +$subnav = [ + 'tabs' => ['ExternalIdentitySources', 'ApiConnector.ApiSources', 'ExternalIdentitySources@action.search'], + 'action' => [ + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'ApiConnector.ApiSources' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], +]; diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php b/app/availableplugins/ApiConnector/templates/SorApiV2/json/delete.php similarity index 100% rename from app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php rename to app/availableplugins/ApiConnector/templates/SorApiV2/json/delete.php diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/get.php b/app/availableplugins/ApiConnector/templates/SorApiV2/json/get.php similarity index 100% rename from app/availableplugins/ApiConnector/templates/ApiV2/json/get.php rename to app/availableplugins/ApiConnector/templates/SorApiV2/json/get.php diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php b/app/availableplugins/ApiConnector/templates/SorApiV2/json/upsert.php similarity index 100% rename from app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php rename to app/availableplugins/ApiConnector/templates/SorApiV2/json/upsert.php diff --git a/app/availableplugins/FileConnector/config/plugin.json b/app/availableplugins/FileConnector/config/plugin.json new file mode 100644 index 000000000..13973daa5 --- /dev/null +++ b/app/availableplugins/FileConnector/config/plugin.json @@ -0,0 +1,38 @@ +{ + "types": { + "provisioning_target": [ + "FileProvisioners" + ], + "external_identity_source": [ + "FileSources" + ] + }, + "schema": { + "tables": { + "file_provisioners": { + "columns": { + "id": {}, + "provisioning_target_id": {}, + "filename": { "type": "string", "size": 256 } + }, + "indexes": { + "file_provisioners_i1": { "columns": [ "provisioning_target_id" ]} + } + }, + "file_sources": { + "columns": { + "id": {}, + "external_identity_source_id": {}, + "filename": { "type": "string", "size": 256 }, + "format": { "type": "string", "size": 2 }, + "archivedir": { "type": "string", "size": 256 }, + "threshold_check": { "type": "integer" }, + "threshold_override": { "type": "boolean" } + }, + "indexes": { + "file_sources_i1": { "columns": [ "external_identity_source_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po b/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po index 5569bfd6f..6ca5cf325 100644 --- a/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po +++ b/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po @@ -28,15 +28,30 @@ msgstr "{0,plural,=1{File Provisioner} other{File Provisioners}}" msgid "enumeration.FileSourceFormatEnum.C3" msgstr "CSV v3" +msgid "error.filename.absolute" +msgstr "File path \"{0}\" does not begin with \"/\"" + msgid "error.filename.readable" msgstr "The file \"{0}\" is not readable" -msgid "error.filename.writeable" +msgid "error.filename.writable" msgstr "The file \"{0}\" is not writable" +msgid "error.FileSource.copy" +msgstr "Failed to copy {0} to {1}" + +msgid "error.FileSource.threshold" +msgstr "{0} of {1} records changed ({2}%, including new records), exceeding threshold of {3}% - processing canceled" + +msgid "error.FileSource.threshold.config" +msgstr "Threshold Check requires Archive Directory" + msgid "error.header" msgstr "Did not find CSV file header" +msgid "error.header.invalid" +msgstr "\"{0}\" is not a valid field" + msgid "error.header.sorid" msgstr "Did not find SORID as first defined column, check file header definition" @@ -61,14 +76,17 @@ msgstr "Full path to file to read from, which must exist and be readable" msgid "field.FileSources.format" msgstr "File Format" -msgid "field.FileSources.threshold_warn" +msgid "field.FileSources.threshold_check" msgstr "Warning Threshold" -msgid "field.FileSources.threshold_warn.desc" +msgid "field.FileSources.threshold_check.desc" msgstr "If the number of changed records exceeds the specified percentage, a warning will be generated and processing will stop (requires Archive Directory)" msgid "field.FileSources.threshold_override" msgstr "Warning Threshold Override" msgid "field.FileSources.threshold_override.desc" -msgstr "If set, the next Full sync will ignore the Warning Threshold" \ No newline at end of file +msgstr "If set, the next Full sync will ignore the Warning Threshold" + +msgid "result.FileProvisioner.done" +msgstr "Wrote 1 record to file" \ No newline at end of file diff --git a/app/availableplugins/FileConnector/src/Controller/AppController.php b/app/availableplugins/FileConnector/src/Controller/AppController.php index d1cf9843f..d9616cd93 100644 --- a/app/availableplugins/FileConnector/src/Controller/AppController.php +++ b/app/availableplugins/FileConnector/src/Controller/AppController.php @@ -1,7 +1,7 @@ [ 'FileProvisioners.id' => 'asc' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->FileProvisioners->ProvisioningTargets->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->FileProvisioners->ProvisioningTargets->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->FileProvisioners->ProvisioningTargets->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } diff --git a/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php b/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php index 0e5371d43..7af831586 100644 --- a/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php +++ b/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php @@ -34,7 +34,7 @@ use Cake\Http\Response; class FileSourcesController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'FileSources.id' => 'asc' ] diff --git a/app/availableplugins/FileConnector/src/Model/Entity/FileProvisioner.php b/app/availableplugins/FileConnector/src/Model/Entity/FileProvisioner.php index 36fc9b3e3..123cced99 100644 --- a/app/availableplugins/FileConnector/src/Model/Entity/FileProvisioner.php +++ b/app/availableplugins/FileConnector/src/Model/Entity/FileProvisioner.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class FileProvisioner extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class FileProvisioner extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/availableplugins/FileConnector/src/Model/Entity/FileSource.php b/app/availableplugins/FileConnector/src/Model/Entity/FileSource.php index ca49ad543..912109c1b 100644 --- a/app/availableplugins/FileConnector/src/Model/Entity/FileSource.php +++ b/app/availableplugins/FileConnector/src/Model/Entity/FileSource.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class FileSource extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class FileSource extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php index 3a0aff846..baf3e93c9 100644 --- a/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php +++ b/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php @@ -34,7 +34,7 @@ use Cake\Validation\Validator; use App\Lib\Enum\ProvisioningEligibilityEnum; use App\Lib\Enum\ProvisioningStatusEnum; -use \FileConnector\Model\Entity\FileProvisioner; +use App\Model\Entity\ProvisioningTarget; class FileProvisionersTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; @@ -98,10 +98,10 @@ public function initialize(array $config): void { */ public function buildRules(RulesChecker $rules): RulesChecker { - // The requested file must exist and be writeable. + // The requested file must exist and be writable. - $rules->add([$this, 'ruleIsFileWriteable'], - 'isFileWriteable', + $rules->add([$this, 'ruleIsFileWritable'], + 'isFileWritable', ['errorField' => 'filename']); return $rules; @@ -110,17 +110,17 @@ public function buildRules(RulesChecker $rules): RulesChecker { /** * Provision object data to the provisioning target. * - * @param FileProvisioner $provisioningTarget FileProvisioner configuration - * @param string $entityName - * @param object $data Provisioning data in Entity format (eg: \App\Model\Entity\Person) - * @param string $eligibility Provisioning Eligibility Enum + * @param ProvisioningTarget $provisioningTarget FileProvisioner configuration + * @param string $entityName + * @param object $data Provisioning data in Entity format (eg: \App\Model\Entity\Person) + * @param string $eligibility Provisioning Eligibility Enum * * @return array Array of status, comment, and optional identifier * @since COmanage Registry v5.0.0 */ public function provision( - FileProvisioner $provisioningTarget, + ProvisioningTarget $provisioningTarget, string $entityName, object $data, string $eligibility @@ -133,22 +133,22 @@ public function provision( } if(file_put_contents( - filename: $provisioningTarget->filename, + filename: $provisioningTarget->file_provisioner->filename, data: json_encode($output, JSON_INVALID_UTF8_SUBSTITUTE) . "\n", flags: FILE_APPEND ) === false) { - throw new \RuntimeException("Write to " . $provisioningTarget->filename . " failed"); + throw new \RuntimeException("Write to " . $provisioningTarget->file_provisioner->filename . " failed"); } return [ 'status' => ProvisioningStatusEnum::Provisioned, - 'comment' => "Wrote 1 record to file", + 'comment' => __d('file_connector', 'result.FileProvisioner.done'), 'identifier' => null ]; } /** - * Application Rule to determine if the current entity is a writeable file. + * Application Rule to determine if the current entity is a writable file. * * @param Entity $entity Entity to be validated * @param array $options Application rule options @@ -157,9 +157,9 @@ public function provision( * @since COmanage Registry v5.0.0 */ - public function ruleIsFileWriteable($entity, array $options): string|bool { + public function ruleIsFileWritable($entity, array $options): string|bool { if(!is_writable($entity->filename)) { - return __d('file_connector', 'error.filename.writeable', [$entity->filename]); + return __d('file_connector', 'error.filename.writable', [$entity->filename]); } return true; diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php index 8029d6961..ee57ddd7a 100644 --- a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php +++ b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php @@ -29,9 +29,11 @@ namespace FileConnector\Model\Table; +use App\Model\Entity\ExternalIdentitySource; use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\ORM\TableRegistry; +use Cake\Utility\Inflector; use Cake\Validation\Validator; use \App\Model\Entity\ExternalIdentity; use \FileConnector\Lib\Enum\FileSourceFormatEnum; @@ -40,15 +42,22 @@ class FileSourcesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\TabTrait; // Cache of the field configuration protected $fieldCfg = null; + // Cache of archive file paths + protected $archive1 = null; + protected $archive2 = null; + + // Whether postRunTasks should rotate the archive + protected $rotate = false; + /** * Perform Cake Model initialization. * @@ -79,23 +88,6 @@ public function initialize(array $config): void { ] ]); - // All the tabs share the same configuration in the ModelTable file - $this->setTabsConfig( - [ - // Ordered list of Tabs - 'tabs' => ['ExternalIdentitySources', 'FileConnector.FileSources', 'ExternalIdentitySources@action.search'], - // What actions will include the subnavigation header - 'action' => [ - // If a model renders in a subnavigation mode in edit/view mode, it cannot - // render in index mode for the same use case/context - // XXX edit should go first. - 'ExternalIdentitySources' => ['edit', 'view', 'search'], - 'FileConnector.FileSources' => ['edit'], - 'ExternalIdentitySources@action.search' => [], - ], - ] - ); - $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ @@ -126,11 +118,34 @@ public function buildRules(RulesChecker $rules): RulesChecker { 'isFileReadable', ['errorField' => 'filename']); -// XXX CFM-117 should we also check that the archive dir, if specified, is writeable? + $rules->add([$this, 'ruleIsArchiveWriteable'], + 'isArchiveWriteable', + ['errorField' => 'archivedir']); return $rules; } + /** + * Obtain the set of changed records from the source file. + * + * @since COmanage Registry v5.2.0 + * @param ExternalIdentitySource $source External Identity Source + * @param int $lastStart Timestamp of last run + * @param int $curStart Timestamp of current run + * @return array|bool An array of changed source keys, or false + * @throws RuntimeException + */ + + public function getChangeList( + \App\Model\Entity\ExternalIdentitySource $source, + int $lastStart, // timestamp of last run + int $curStart // timestamp of current run + ): array|bool { + $changeList = $this->processChangeList($source, $lastStart, $curStart); + + return ($changeList == false) ? false : $changeList['changeList']; + } + /** * Obtain the full set of records from the source database. * @@ -154,9 +169,11 @@ public function inventory( fgetcsv($handle); while(($data = fgetcsv($handle)) !== false) { - // The source key is always the first field in each line + // The source key is always the first field in each line, make sure it is not empty - $ret[] = $data[0]; + if(!empty($data[0]) && !ctype_space($data[0])) { + $ret[] = $data[0]; + } } fclose($handle); @@ -167,6 +184,240 @@ public function inventory( return $ret; } + /** + * Perform checks before a Sync Job proceeds. + * + * @since COmanage Registry v5.2.0 + * @param ExternalIdentitySource $source External Identity Source + * @param int $lastStart Timestamp of last run + * @param int $curStart Timestamp of current run + * @throws RuntimeException + */ + + public function preRunChecks( + \App\Model\Entity\ExternalIdentitySource $source, + int $lastStart, + int $curStart + ) { + // If a threshold is set, check to make sure less than that many records changed + // (by percent). + + // By default, we'll rotate the archive files in postRunTasks. (If we throw an + // exception, that hook won't be called.) + + $this->rotate = true; + + if(!empty($source->file_source->threshold_check) + && $source->file_source->threshold_check > 0) { + // threshold_check requires archive directories since we can't otherwise + // efficiently calculate diffs. + + if(empty($source->file_source->archivedir)) { + $this->llog('debug', 'Threshold Check for ' . $source->description . ' is configured but no Archive Directory is available, ignoring'); + throw new \RuntimeException(__d('file_connector', 'error.FileSource.threshold.config')); + } + + // Check the number of changed records vs warning threshold. Note this + // check (correctly) does not run the first time a file is processed + // since there will be no archive file to compare against. + + if($source->file_source->threshold_override) { + // Ignore thresholds, but unset this configuration for our next run + + $source->file_source->threshold_override = false; + $this->saveOrFail($source->file_source, ['associated' => false]); + + $this->llog('trace', 'Threshold Check for ' . $source->description . ' is overridden, ignoring this time only'); + } else { + $info = $this->processChangeList($source, $lastStart, $curStart); + + if($info['knownCount'] > 0) { + $changed = count($info['changeList']) + $info['newCount']; + $pct = floor(($changed * 100) / $info['knownCount']); + + if($pct > $source->file_source->threshold_check) { + $this->llog('trace', 'Threshold Check for ' . $source->description . ' exceeded, stopping processing (changed=' . $changed . ', known=' . $info['knownCount'] . ', percent=' . $pct . ')'); + + throw new \RuntimeException(__d('file_connector', 'error.FileSource.threshold', [ + $changed, $info['knownCount'], $pct, $source->file_source->threshold_check + ])); + } + } + // else no previous records, so treat as all new + + if(empty($info['changeList'] + && $info['newCount'] == 0) + && $info['knownCount'] > 0) { + // We don't want to rotate the Archive files if there were no changed records + // (since nothing happened). + + $this->rotate = false; + } + } + } + } + + /** + * Obtain the set of changed records from the source file. + * + * @since COmanage Registry v5.2.0 + * @param ExternalIdentitySource $source External Identity Source + * @param int $lastStart Timestamp of last run + * @param int $curStart Timestamp of current run + * @return array|bool An array of + * changelist: Changed record Source Keys + * newCount: Count of new records + * knownCount: Count of known records + * or false + * @throws RuntimeException + */ + + protected function processChangeList( + \App\Model\Entity\ExternalIdentitySource $source, + int $lastStart, // timestamp of last run + int $curStart // timestamp of current run + ): array|bool { + if(empty($source->file_source->archivedir)) { + // If there is no archivedir we don't support changelist calculation + return false; + } + + $ret = []; + $knownCount = 0; + $newCount = 0; + + $infile = $source->file_source->filename; + $basename = basename($infile); + $this->archive1 = $source->file_source->archivedir . DS . $basename . ".1"; + $this->archive2 = $source->file_source->archivedir . DS . $basename . ".2"; + + // We could either read the files simultaneously in order (lower memory requirement), + // or read one and hash it (can read records out of sequence). For now we'll take + // the second approach. + + if(is_readable($this->archive1)) { + // Start by creating a set of previously known records. + $knownRecords = []; + + $handle = fopen($this->archive1, "r"); + + if(!$handle) { + throw new \RuntimeException(__d('file_connector', 'error.filename.readable', [$this->archive1])); + } + + // Ignore the header line + fgetcsv($handle); + + while(($data = fgetcsv($handle)) !== false) { + // Implode the record back together for string comparison purposes. + // This may not be the same as the original line due to quotes, etc. + // $data[0] is the SORID + $knownRecords[ $data[0] ] = implode(',', $data); + } + + $knownCount = count($knownRecords); + + fclose($handle); + + // Now read the new file and look for changes. + $handle = fopen($infile, "r"); + + if(!$handle) { + throw new \RuntimeException(__d('file_connector', 'error.filename.readable', [$infile])); + } + + // Ignore the header line + fgetcsv($handle); + + while(($data = fgetcsv($handle)) !== false) { + // $data[0] is the SORID + if(array_key_exists($data[0], $knownRecords)) { + $newData = implode(',', $data); + + if($newData != $knownRecords[ $data[0] ]) { + // This record changed, push the SORID onto the change list + $ret[] = $data[0]; + } + + // Unset the key so we can see which records were deleted. + unset($knownRecords[ $data[0] ]); + } else { + // This is a new record (ie: in $infile, not in $archive1), + // so we ignore it, except to count it. + $newCount++; + } + } + + fclose($handle); + + // Finally, any remaining keys in $knownRecords are delete operations. + if(!empty($knownRecords)) { + $ret = array_merge($ret, array_keys($knownRecords)); + } + } else { + // If there is no archive file, we've either never run at all, or the admin + // updated the configuration and we have no idea what changed. In either case + // we'll report all records as new, which will cause them all to be + // (re)processed. In the latter case, admins can avoid this by manually creating + // the .1 file before updating the configuration. + + $ret = $this->inventory($source); + $newCount = count($ret); + } + + return [ + 'changeList' => $ret, + 'newCount' => $newCount, + 'knownCount' => $knownCount + ]; + } + + /** + * Perform tasks following a Sync Job. + * + * @param ExternalIdentitySource $source External Identity Source + * @since COmanage Registry v5.2.0 + */ + + public function postRunTasks( + \App\Model\Entity\ExternalIdentitySource $source + ): void { + // Update the archive file. updateCache() is called after processing is complete, + // and only if at least one record changed. It's possible an irregular exit will + // prevent the cache from being updated, in that case we'll just end up reprocessing + // some records, which should effectively be a no-op. Historically, we kept two backup + // copies in case something went wrong, we still do so here, though it's less critical now. + + if(!$this->rotate) { + $this->llog('trace', 'Not rotating archive files due to no changes'); + return; + } + + if($this->archive1 !== null && is_readable($this->archive1)) { + $this->llog('trace', 'Copying ' . $this->archive1 . ' to ' . $this->archive2); + + if(!copy($this->archive1, $this->archive2)) { + throw new \RuntimeException(__d('file_connector', 'error.FileSource.copy', [ + $this->archive1, $this->archive2 + ])); + } + } + + if( + $source->file_source->filename !== null + && $this->archive1 !== null + && is_readable($source->file_source->filename) + ) { + $this->llog('trace', 'Copying ' . $source->file_source->filename . ' to ' . $this->archive1); + + if(!copy($source->file_source->filename, $this->archive1)) { + throw new \RuntimeException(__d('file_connector', 'error.FileSource.copy', [ + $source->file_source->filename, $this->archive1 + ])); + } + } + } + /** * Obtain the file field configuration. * @@ -190,7 +441,7 @@ protected function readFieldConfig( $handle = fopen($filesource->filename, "r"); if(!$handle) { - throw new \RuntimeException(__d('file_connector', 'error.filename.readable', $file_source->filename)); + throw new \RuntimeException(__d('file_connector', 'error.filename.readable', $filesource->filename)); } // The first line is our configuration @@ -202,9 +453,12 @@ protected function readFieldConfig( throw new \RuntimeException(__d('error.header')); } + // Calculate the CO for $filesource, which we'll need for field type validation + $coId = $this->calculateCoForRecord($filesource); + foreach($cfg as $i => $label) { // Labels are of the forms described in the switch statement. - // Parse them out into the fieldcfg array. + // Parse them out into the fieldcfg array. We also validate them as we parse them. $bits = explode('.', $label, 5); @@ -221,20 +475,37 @@ protected function readFieldConfig( // external_identity.field // ad_hoc_attributes.tag (attached to EI) // related_model.field (not currently used) + if(!in_array($bits[0], ['ad_hoc_attributes', 'external_identity'])) { + throw new \RuntimeException(__d('file_connector', 'error.header.invalid', [$label])); + } + + if($bits[0] != 'ad_hoc_attributes' && !$this->validField($bits[0], $bits[1], $coId)) { + throw new \RuntimeException(__d('file_connector', 'error.header.invalid', [$label])); + } + $this->fieldCfg[ $bits[0] ][ $bits[1] ] = $i; break; case 3: - // related_models.type.field + // related_models.field.type // external_identity_roles.#.field (special case) - // Note we _no longer_ flip the order model/type/field - // (this is inverted from CSV v2) + // Note the old v2 order model/type/field is inverted here // and identifier+login is no longer supported if($bits[0] == 'external_identity_roles') { // Store based on role + + if(!$this->validField($bits[0], $bits[2], $coId)) { + throw new \RuntimeException(__d('file_connector', 'error.header.invalid', [$label])); + } + $this->fieldCfg[ $bits[0] ]['roles'][ $bits[1] ]['fields'][ $bits[2] ] = $i; } else { // Store based on type - $this->fieldCfg[ $bits[0] ]['types'][ $bits[1] ][ $bits[2] ] = $i; + + if(!$this->validField($bits[0], $bits[1], $coId, $bits[2])) { + throw new \RuntimeException(__d('file_connector', 'error.header.invalid', [$label])); + } + + $this->fieldCfg[ $bits[0] ]['types'][ $bits[2] ][ $bits[1] ] = $i; } break; case 4: @@ -242,9 +513,9 @@ protected function readFieldConfig( $this->fieldCfg[ $bits[0] ]['roles'][ $bits[1] ]['related'][ $bits[2] ][ $bits[3] ] = $i; break; case 5: - // external_identity_roles.#.related_models.type.field + // external_identity_roles.#.related_models.field.type // Note these are keyed on an SOR Role ID - $this->fieldCfg[ $bits[0] ]['roles'][ $bits[1] ]['related'][ $bits[2] ]['types'][ $bits[3] ][ $bits[4] ] = $i; + $this->fieldCfg[ $bits[0] ]['roles'][ $bits[1] ]['related'][ $bits[2] ]['types'][ $bits[4] ][ $bits[3] ] = $i; break; } } @@ -308,7 +579,7 @@ protected function resultToEntityData(array $result): array { } } } - +/* External Identities no longer have Primary Names // Make sure we have a Primary Name $primaryNameSet = false; @@ -321,7 +592,7 @@ protected function resultToEntityData(array $result): array { if(!$primaryNameSet) { $eidata['names'][0]['primary_name'] = true; - } + }*/ // Process Ad Hoc Attributes (case 2) if(!empty($this->fieldCfg['ad_hoc_attributes'])) { @@ -457,16 +728,52 @@ public function retrieve( } /** - * Application Rule to determine if the current entity is a readable file. + * Application Rule to determine if the current entity has a writeable archive directory. * - * @param Entity $entity Entity to be validated - * @param array $options Application rule options + * @since COmanage Registry v5.2.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return string|bool true if the Rule check passes, false otherwise + */ + + public function ruleIsArchiveWriteable($entity, array $options): string|bool { + // Archive Directory is optional, so we only complain if it's set but not writeable + + if(!empty($entity->archivedir)) { + // We also check if the archive directory is absolute or relative. This is partly to give a + // more helpful message, and partly because if a deployer configures the directory into + // $webroot it'll be readable by the web server but not the command line. + + if(mb_substr($entity->archivedir, 0, 1) != '/') { + return __d('file_connector', 'error.filename.absolute', [$entity->archivedir]); + } + + if(!is_writable($entity->archivedir)) { + return __d('file_connector', 'error.filename.writable', [$entity->archivedir]); + } + } + + return true; + } + + /** + * Application Rule to determine if the current entity has a readable file. * - * @return string|bool true if the Rule check passes, false otherwise * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return string|bool true if the Rule check passes, false otherwise */ public function ruleIsFileReadable($entity, array $options): string|bool { + // We also check if the file path is absolute or relative. This is partly to give a + // more helpful message, and partly because if a deployer drops the file into $webroot + // it'll be readable by the web server but not the command line. + + if(mb_substr($entity->filename, 0, 1) != '/') { + return __d('file_connector', 'error.filename.absolute', [$entity->filename]); + } + if(!is_readable($entity->filename)) { return __d('file_connector', 'error.filename.readable', [$entity->filename]); } @@ -542,6 +849,63 @@ public function searchableAttributes(): array { 'q' => __d('field', 'search.placeholder') ]; } + + /** + * Determine if $field is a valid field for $model. + * + * @since COmanage Registry v5.2.0 + * @param string $model Model, in under_score format + * @param string $field Field name + * @param int $coId Current CO ID (only used for Type validation) + * @param string $type Field type, for MVEAs + * @return bool true if $field is a field of $model, false otherwise + */ + + protected function validField( + string $model, + string $field, + int $coId, + ?string $type=null + ): bool { + // As a first pass, we just check the schema for the field, which means metadata + // such as revision or created will be accepted as valid. A better approach would + // be to do something like TabelMetaTrait::filterMetadataFields, since we probably + // don't want to accept metadata fields as valid. + + $tableName = Inflector::pluralize(Inflector::classify($model)); + + $Table = TableRegistry::getTableLocator()->get($tableName); + + $schema = $Table->getSchema(); + + // We need to handle the ExternalIdentityRoles.affiliation csv header specially. + if($field == 'affiliation' && $tableName == 'ExternalIdentityRoles') { + // We do not know the type. Because the type is part of each record. As a result we will return true + // and the validation will fail later. + $field = 'affiliation_type_id'; + } + + if(!$schema->hasColumn($field)) { + return false; + } + + if($type) { + // We need to see if $type is a valid Type value. This will mostly be + // $tableName.type (eg: Names.type), except for ExternalIdentityRoles + + try { + $Types = TableRegistry::getTableLocator()->get("Types"); + + // This will throw an exception if not valid + $Types->getTypeId($coId, $tableName . ".type", $type); + } + catch(\Exception $e) { + return false; + } + } + + return true; + } /** * Set validation rules. @@ -570,13 +934,13 @@ public function validationDefault(Validator $validator): Validator { $this->registerStringValidation($validator, $schema, 'archivedir', false); - $validator->add('threshold_warn', [ + $validator->add('threshold_check', [ 'content' => ['rule' => 'isInteger'] ]); - $validator->add('threshold_warn', [ + $validator->add('threshold_check', [ 'range' => ['rule' => 'range', 0, 100] ]); - $validator->allowEmptyString('threshold_warn'); + $validator->allowEmptyString('threshold_check'); $validator->add('threshold_override', [ 'content' => ['rule' => ['boolean']] diff --git a/app/availableplugins/FileConnector/src/config/plugin.json b/app/availableplugins/FileConnector/src/config/plugin.json deleted file mode 100644 index 977e900b2..000000000 --- a/app/availableplugins/FileConnector/src/config/plugin.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "types": { - "provisioner": [ - "FileProvisioners" - ], - "source": [ - "FileSources" - ] - }, - "schema": { - "tables": { - "file_provisioners": { - "columns": { - "id": {}, - "provisioning_target_id": {}, - "filename": { "type": "string", "size": 256 } - }, - "indexes": { - "file_provisioners_i1": { "columns": [ "provisioning_target_id" ]} - } - }, - "file_sources": { - "columns": { - "id": {}, - "external_identity_source_id": {}, - "filename": { "type": "string", "size": 256 }, - "format": { "type": "string", "size": 2 }, - "archivedir": { "type": "string", "size": 256 }, - "threshold_warn": { "type": "integer" }, - "threshold_override": { "type": "boolean" } - }, - "indexes": { - "file_sources_i1": { "columns": [ "external_identity_source_id" ] } - } - } - } - } -} \ No newline at end of file diff --git a/app/availableplugins/FileConnector/templates/FileProvisioners/fields.inc b/app/availableplugins/FileConnector/templates/FileProvisioners/fields.inc index d5e5c8353..c5eb0f373 100644 --- a/app/availableplugins/FileConnector/templates/FileProvisioners/fields.inc +++ b/app/availableplugins/FileConnector/templates/FileProvisioners/fields.inc @@ -25,11 +25,14 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only -if($vv_action == 'add' || $vv_action == 'edit') { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'filename', - ] - ]); -} +$fields = [ + 'filename' +]; + +$subnav = [ + 'tabs' => ['ProvisioningTargets', 'FileConnector.FileProvisioners'], + 'action' => [ + 'ProvisioningTargets' => ['edit'], + 'FileConnector.FileProvisioners' => ['edit'] + ] +]; diff --git a/app/availableplugins/FileConnector/templates/FileSources/fields.inc b/app/availableplugins/FileConnector/templates/FileSources/fields.inc index e9e7ae621..a9f4af3de 100644 --- a/app/availableplugins/FileConnector/templates/FileSources/fields.inc +++ b/app/availableplugins/FileConnector/templates/FileSources/fields.inc @@ -25,19 +25,19 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only -if($vv_action == 'add' || $vv_action == 'edit') { - foreach([ - 'filename', - 'format', - 'archivedir', - 'threshold_warn', - 'threshold_override', - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field, - ] - ]); - } -} +$fields = [ + 'filename', + 'format', + 'archivedir', + 'threshold_check', + 'threshold_override', +]; + +$subnav = [ + 'tabs' => ['ExternalIdentitySources', 'FileConnector.FileSources', 'ExternalIdentitySources@action.search'], + 'action' => [ + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'FileConnector.FileSources' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], +]; diff --git a/app/availableplugins/HistoricPetitionViewer/HistoricPetitionViewerPlugin.php b/app/availableplugins/HistoricPetitionViewer/HistoricPetitionViewerPlugin.php new file mode 100644 index 000000000..1355e0fd8 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/HistoricPetitionViewerPlugin.php @@ -0,0 +1,95 @@ +plugin( + 'HistoricPetitionViewer', + ['path' => '/historic-petition-viewer'], + function (RouteBuilder $builder) { + // Your snippet goes here, using $builder instead of $routes + $builder->setRouteClass(DashedRoute::class); + + // Add your plugin routes here if needed, then: + $builder->fallbacks(DashedRoute::class); + } + ); + 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/HistoricPetitionViewer/config/plugin.json b/app/availableplugins/HistoricPetitionViewer/config/plugin.json new file mode 100644 index 000000000..f7a8a1ca7 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/config/plugin.json @@ -0,0 +1,78 @@ +{ + "types": {}, + "schema": { + "tables": { + "petition_hist_attrs": { + "comment": "Transmogrified from cm_co_petition_attributes", + "columns": { + "id": {}, + "petition_id": {}, + "historic_petition_viewer_id": { + "type": "integer", + "foreignkey": { "table": "historic_petition_viewers", "column": "id" }, + "notnull": false + }, + "attribute": { "type": "string", "size": 128 }, + "value": { "type": "text" } + }, + "indexes": { + "petition_hist_attrs_i1": { "columns": [ "petition_id" ] }, + "petition_hist_attrs_i2": { "columns": [ "attribute" ] } + } + }, + "petition_meta_hist_recs": { + "comment": "Transmogrified from cm_co_petitions fields that no longer map to petitions", + "columns": { + "id": {}, + "petition_id": {}, + "enrollment_flow_id": { + "type": "integer", + "foreignkey": { "table": "enrollment_flows", "column": "id" } + }, + "historic_petition_viewer_id": { + "type": "integer", + "foreignkey": { "table": "historic_petition_viewers", "column": "id" } + }, + + "enrollee_external_identity_id": { + "type": "integer", + "foreignkey": { "table": "external_identities", "column": "id" } + }, + "archived_external_identity_id": { + "type": "integer", + "foreignkey": { "table": "external_identities", "column": "id" } + }, + "enrollee_person_role_id": { + "type": "integer", + "foreignkey": { "table": "person_roles", "column": "id" } + }, + "sponsor_person_id": { + "type": "integer", + "foreignkey": { "table": "people", "column": "id" } + }, + "approver_person_id": { + "type": "integer", + "foreignkey": { "table": "people", "column": "id" } + }, + "approver_comment": { "type": "string", "size": 256 } + }, + "indexes": { + "petition_meta_hist_recs_i1": { "columns": [ "petition_id" ] }, + "petition_meta_hist_recs_i2": { "columns": [ "enrollee_person_role_id" ] }, + "petition_meta_hist_recs_i3": { "columns": [ "sponsor_person_id" ] }, + "petition_meta_hist_recs_i4": { "columns": [ "approver_person_id" ] } + } + }, + "historic_petition_viewers": { + "comment": "Enrollment Flow Step for historic petition data", + "columns": { + "id": {}, + "enrollment_flow_step_id": {} + }, + "indexes": { + "historic_petition_step_links_i1": { "columns": [ "enrollment_flow_step_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/HistoricPetitionViewer/config/routes.php b/app/availableplugins/HistoricPetitionViewer/config/routes.php new file mode 100644 index 000000000..eea3ee230 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/config/routes.php @@ -0,0 +1,39 @@ +plugin( + 'HistoricPetitionViewer', + ['path' => '/historic-petition-viewer/'], + function ($routes) { + $routes->setRouteClass(DashedRoute::class); + } +); diff --git a/app/availableplugins/HistoricPetitionViewer/resources/locales/en_US/historic_petition_viewer.po b/app/availableplugins/HistoricPetitionViewer/resources/locales/en_US/historic_petition_viewer.po new file mode 100644 index 000000000..fb56d820b --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/resources/locales/en_US/historic_petition_viewer.po @@ -0,0 +1,39 @@ +# COmanage Registry Localizations (historic_petition_viewer 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.2.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + + +msgid "attributes" +msgstr "Attributes" + +msgid "attributes.none" +msgstr "No historic attributes recorded for this petition." + +msgid "historic.petition" +msgstr "Historic Petition #{0}" + +msgid "metadata" +msgstr "Metadata" + +msgid "metadata.none" +msgstr "No historic metadata recorded for this petition." diff --git a/app/availableplugins/HistoricPetitionViewer/src/Controller/AppController.php b/app/availableplugins/HistoricPetitionViewer/src/Controller/AppController.php new file mode 100644 index 000000000..bcc7e6ad2 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'HistoricPetitionViewers.id' => 'asc' + ] + ]; +} diff --git a/app/availableplugins/HistoricPetitionViewer/src/HistoricPetitionViewerPlugin.php b/app/availableplugins/HistoricPetitionViewer/src/HistoricPetitionViewerPlugin.php new file mode 100644 index 000000000..648cc8095 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/src/HistoricPetitionViewerPlugin.php @@ -0,0 +1,98 @@ +plugin( + 'HistoricPetitionViewer', + ['path' => '/historic-petition-viewer'], + 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 + // remove this method hook if you don't need it + + 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 + // remove this method hook if you don't need it + + $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/5/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + // remove this method hook if you don't need it + } +} diff --git a/app/availableplugins/HistoricPetitionViewer/src/Model/Entity/HistoricPetitionAttribute.php b/app/availableplugins/HistoricPetitionViewer/src/Model/Entity/HistoricPetitionAttribute.php new file mode 100644 index 000000000..5d0aaf7e3 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/src/Model/Entity/HistoricPetitionAttribute.php @@ -0,0 +1,43 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/availableplugins/HistoricPetitionViewer/src/Model/Entity/HistoricPetitionMetadataRecord.php b/app/availableplugins/HistoricPetitionViewer/src/Model/Entity/HistoricPetitionMetadataRecord.php new file mode 100644 index 000000000..24d3607b8 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/src/Model/Entity/HistoricPetitionMetadataRecord.php @@ -0,0 +1,43 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/availableplugins/HistoricPetitionViewer/src/Model/Entity/HistoricPetitionViewer.php b/app/availableplugins/HistoricPetitionViewer/src/Model/Entity/HistoricPetitionViewer.php new file mode 100644 index 000000000..8ada2824e --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/src/Model/Entity/HistoricPetitionViewer.php @@ -0,0 +1,43 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/availableplugins/HistoricPetitionViewer/src/Model/Table/HistoricPetitionAttributesTable.php b/app/availableplugins/HistoricPetitionViewer/src/Model/Table/HistoricPetitionAttributesTable.php new file mode 100644 index 000000000..999d33c8d --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/src/Model/Table/HistoricPetitionAttributesTable.php @@ -0,0 +1,98 @@ +setTable('petition_hist_attrs'); + $this->setPrimaryKey('id'); + $this->setDisplayField('id'); + + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + + $this->setDisplayField('attribute'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(false); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + // petition_id (required, integer) + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + // attribute (required, string up to 128) + $validator->add('attribute', [ + 'size' => ['rule' => ['maxLength', 128]] + ]); + $validator->notEmptyString('attribute'); + + // value (text, optional) + $validator->allowEmptyString('value'); + + // created/modified handled by Timestamp behavior; no explicit validation needed + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/HistoricPetitionViewer/src/Model/Table/HistoricPetitionMetadataRecordsTable.php b/app/availableplugins/HistoricPetitionViewer/src/Model/Table/HistoricPetitionMetadataRecordsTable.php new file mode 100644 index 000000000..9d0c7a6c6 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/src/Model/Table/HistoricPetitionMetadataRecordsTable.php @@ -0,0 +1,95 @@ +setTable('petition_meta_hist_recs'); + $this->setPrimaryKey('id'); + $this->setDisplayField('id'); + + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + $this->belongsTo('EnrollmentFlows') + ->setForeignKey('enrollment_flow_id'); + + $this->belongsTo('HistoricPetitionViewers') + ->setForeignKey('historic_petition_viewer_id'); + + $this->belongsTo('EnrolleePersonRoles') + ->setClassName('PersonRoles') + ->setForeignKey('enrollee_person_role_id') + ->setProperty('enrollee_person_role'); + + $this->belongsTo('SponsorPeople') + ->setClassName('People') + ->setForeignKey('sponsor_person_id') + ->setProperty('sponsor_person'); + + $this->belongsTo('ApproverPeople') + ->setClassName('People') + ->setForeignKey('approver_person_id') + ->setProperty('approver_person'); + + $this->belongsTo('EnrolleeExternalIdentities') + ->setClassName('ExternalIdentities') + ->setForeignKey('enrollee_external_identity_id') + ->setProperty('enrollee_external_identity'); + + $this->belongsTo('ArchivedExternalIdentities') + ->setClassName('ExternalIdentities') + ->setForeignKey('archived_external_identity_id') + ->setProperty('archived_external_identity'); + + $this->setDisplayField('petition_id'); + $this->setRequiresCO(true); + } +} \ No newline at end of file diff --git a/app/availableplugins/HistoricPetitionViewer/src/Model/Table/HistoricPetitionViewersTable.php b/app/availableplugins/HistoricPetitionViewer/src/Model/Table/HistoricPetitionViewersTable.php new file mode 100644 index 000000000..3aedb0182 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/src/Model/Table/HistoricPetitionViewersTable.php @@ -0,0 +1,106 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmnetFlowSteps'); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['display']); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'dispatch' => false, + 'display' => true, + 'edit' => true, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + // petition_id (required, integer) + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + // enrollment_flow_step_id (required, integer) + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + // created/modified handled by Timestamp behavior; no explicit validation needed + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/HistoricPetitionViewer/src/View/Cell/HistoricPetitionViewersCell.php b/app/availableplugins/HistoricPetitionViewer/src/View/Cell/HistoricPetitionViewersCell.php new file mode 100644 index 000000000..a5f29cec7 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/src/View/Cell/HistoricPetitionViewersCell.php @@ -0,0 +1,119 @@ + + */ + protected array $_validCellOptions = [ + 'vv_obj', + 'vv_step', + 'viewVars', + ]; + + /** + * Initialization logic run at the end of object construction. + * + * @return void + */ + public function initialize(): void + { + } + + /** + * Default display method. + * + * @param int $petitionId + * @return void + * @since COmanage Registry v5.2.0 + */ + public function display(int $petitionId): void + { + // Latest (current) metadata record for this petition + $metaTable = $this->fetchTable('HistoricPetitionViewer.HistoricPetitionMetadataRecords'); + $latestMeta = $metaTable->find() + ->applyOptions(['archived' => false]) + ->where(['petition_id' => $petitionId]) + ->orderBy(['id' => 'DESC']) + ->limit(1) + ->all(); + + // Current attributes for this petition, deduplicated to latest row per attribute + $attrTable = $this->fetchTable('HistoricPetitionViewer.HistoricPetitionAttributes'); + + // Subquery: max(id) per attribute for the petition + $sub = $attrTable->find() + ->select([ + 'max_id' => $attrTable->find()->func()->max('id'), + ]) + ->applyOptions(['archived' => false]) + ->where(['petition_id' => $petitionId]) + ->group('attribute'); + + $latestAttrs = $attrTable->find() + ->applyOptions(['archived' => false]) + ->where(function ($exp) use ($sub) { + // id IN (SELECT max(id) ...) for the petition, one per attribute + return $exp->in('HistoricPetitionAttributes.id', $sub); + }) + ->orderBy(['attribute' => 'ASC', 'id' => 'ASC']) + ->all(); + + $this->set('vv_historic_petition_metadata_records', $latestMeta); // array with at most 1 record + $this->set('vv_historic_petition_attributes', $latestAttrs); // deduped by attribute + $this->set('vv_obj', $this->vv_obj); + } +} diff --git a/app/availableplugins/HistoricPetitionViewer/templates/HistoricPetitionViewers/fields.inc b/app/availableplugins/HistoricPetitionViewer/templates/HistoricPetitionViewers/fields.inc new file mode 100644 index 000000000..1a0396301 --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/templates/HistoricPetitionViewers/fields.inc @@ -0,0 +1,44 @@ +Field->disableFormEditMode(); + +// There are currently no configurable options +print $this->element('notify/banner', ['info' => __d('information', 'plugin.config.none')]); + +$subnav = [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlowSteps', 'HistoricPetitionViewer.HistoricPetitionViewers'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlowSteps' => ['view'], + 'HistoricPetitionViewer.HistoricPetitionViewers' => ['view'] + ] +]; \ No newline at end of file diff --git a/app/availableplugins/HistoricPetitionViewer/templates/cell/HistoricPetitionViewers/display.php b/app/availableplugins/HistoricPetitionViewer/templates/cell/HistoricPetitionViewers/display.php new file mode 100644 index 000000000..215d40a2f --- /dev/null +++ b/app/availableplugins/HistoricPetitionViewer/templates/cell/HistoricPetitionViewers/display.php @@ -0,0 +1,104 @@ +id)) ? (string)$vv_obj->id : null; + +if ($entityId === null) { + echo __d('error', 'notfound', 'Historic Petition'); + return; +} + +// Helpers +$toArr = static function ($row): array { + if (is_array($row)) return $row; + if (is_object($row)) return method_exists($row, 'toArray') ? (array)$row->toArray() : (array)$row; + return []; +}; + +$prettify = static function (string $label): string { + // petition_meta_hist_rec_id -> Petition Meta Hist Rec Id + $label = str_replace(['_', '-'], ' ', strtolower($label)); + $label = preg_replace('/\s+/', ' ', trim($label)); + return ucwords($label); +}; + +$renderLi = static function (string $label, $value) { + if ($value === null || $value === '') return; + $out = is_scalar($value) ? (string)$value : json_encode($value); + ?> +
  • +

    + +

    +
    + +
    +
  • + + +
    + + +

    + + + + + + +

    + + + +

    + + + +

    + + diff --git a/app/vendor/cakephp/bake/docs/config/__init__.py b/app/availableplugins/HistoricPetitionViewer/webroot/.gitkeep similarity index 100% rename from app/vendor/cakephp/bake/docs/config/__init__.py rename to app/availableplugins/HistoricPetitionViewer/webroot/.gitkeep diff --git a/app/availableplugins/KerberosConnector/config/plugin.json b/app/availableplugins/KerberosConnector/config/plugin.json new file mode 100644 index 000000000..240b44742 --- /dev/null +++ b/app/availableplugins/KerberosConnector/config/plugin.json @@ -0,0 +1,43 @@ +{ + "types": { + "provisioning_target": [ + "KerberosProvisioners" + ], + "server": [ + "KerberosServers" + ] + }, + "schema": { + "tables": { + "kerberos_servers": { + "columns": { + "id": {}, + "server_id": {}, + "hostname": { "type": "string", "size": 256 }, + "port": { "type": "integer" }, + "realm": { "type": "string", "size": 256 }, + "admin_principal": { "type": "string", "size": 256 }, + "keytab_path": { "type": "string", "size": 256 } + }, + "indexes": { + "kerberos_servers_i1": { "columns": [ "server_id" ]} + } + }, + "kerberos_provisioners": { + "columns": { + "id": {}, + "provisioning_target_id": {}, + "server_id": { "notnull": false }, + "type_id": { "notnull": false }, + "authenticator_id": { "notnull": false } + }, + "indexes": { + "kerberos_provisioners_i1": { "columns": [ "provisioning_target_id" ]}, + "kerberos_provisioners_i2": { "columns": [ "server_id" ]}, + "kerberos_provisioners_i3": { "columns": [ "type_id" ]}, + "kerberos_provisioners_i4": { "columns": [ "authenticator_id" ]} + } + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/KerberosConnector/resources/locales/en_US/kerberos_connector.po b/app/availableplugins/KerberosConnector/resources/locales/en_US/kerberos_connector.po new file mode 100644 index 000000000..6ea378430 --- /dev/null +++ b/app/availableplugins/KerberosConnector/resources/locales/en_US/kerberos_connector.po @@ -0,0 +1,99 @@ +# COmanage Registry Localizations (kerberos_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-plugins +# @since COmanage Registry v5.2.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.KerberosServers" +msgstr "{0,plural,=1{Kerberos Server} other{Kerberos Servers}}" + +msgid "error.KerberosServers.admin.cfg" +msgstr "Kerberos Server configuration does not have admin principal or keytab" + +msgid "error.principal.identifier" +msgstr "No Identifier of configured type found, unable to construct principal" + +msgid "field.admin_principal" +msgstr "Admin Principal" + +msgid "field.admin_principal.desc" +msgstr "The admin principal to bind to the KDC as, required for admin operations only" + +msgid "field.authenticator_id" +msgstr "Password Authenticator" + +msgid "field.authenticator_id.desc" +msgstr "The Password Authenticator whose value to use to provision the Kerberos Server." + +msgid "field.keytab_path" +msgstr "Keytab Path" + +msgid "field.keytab_path.desc" +msgstr "The filesystem path to the keytab file holding credentials for the admin principal, required for admin operations only" + +msgid "field.realm" +msgstr "Kerberos Realm" + +msgid "field.realm.desc" +msgstr "The Realm is case sensitive" + +msgid "field.server_id" +msgstr "Kerberos Server" + +msgid "field.server_id.desc" +msgstr "The Kerberos Server must be configured with an Admin Principal and Keytab Path." + +msgid "field.type_id" +msgstr "Principal Identifier Type" + +msgid "field.type_id.desc" +msgstr "The Identifier Type used to construct the subject Principal. The Identifier value should not include the Kerberos Realm." + +msgid "result.created" +msgstr "Created new principal {0}" + +msgid "result.never" +msgstr "Never" + +msgid "result.notprov" +msgstr "Principal {0} does not exist" + +msgid "result.active" +msgstr "Principal active (expires: {0}), password expires: {1}" + +msgid "result.expired" +msgstr "Principal expired {0}, password expires: {1}" + +msgid "result.locked" +msgstr "Principal locked (expires: {0}, password expires {1})" + +msgid "result.locked-p" +msgstr "Principal {0} is locked" + +msgid "result.pwexpired" +# Note we swap the rendering order to make it more obvious, but the _parameter_ order is unchanged +msgstr "Password expired {1}, Principal active (expires: {0})" + +msgid "result.unlocked-p" +msgstr "Principal {0} is unlocked" + +msgid "result.synced" +msgstr "Synced existing principal {0}" diff --git a/app/availableplugins/KerberosConnector/src/Controller/AppController.php b/app/availableplugins/KerberosConnector/src/Controller/AppController.php new file mode 100644 index 000000000..edef26010 --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'KerberosProvisioners.id' => 'asc' + ] + ]; +} diff --git a/app/availableplugins/KerberosConnector/src/Controller/KerberosServersController.php b/app/availableplugins/KerberosConnector/src/Controller/KerberosServersController.php new file mode 100644 index 000000000..e818c8cb8 --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Controller/KerberosServersController.php @@ -0,0 +1,62 @@ + [ + 'KerberosServers.hostname' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->KerberosServers->Servers->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->KerberosServers->Servers->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->KerberosServers->Servers->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/availableplugins/KerberosConnector/src/KerberosConnectorPlugin.php b/app/availableplugins/KerberosConnector/src/KerberosConnectorPlugin.php new file mode 100644 index 000000000..01c29527f --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/KerberosConnectorPlugin.php @@ -0,0 +1,98 @@ +plugin( + 'KerberosConnector', + ['path' => '/kerberos-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 + // remove this method hook if you don't need it + + 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 + // remove this method hook if you don't need it + + $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/5/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + // remove this method hook if you don't need it + } +} diff --git a/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosProvisioner.php b/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosProvisioner.php new file mode 100644 index 000000000..a2b87d65a --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosProvisioner.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosServer.php b/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosServer.php new file mode 100644 index 000000000..31314a4d5 --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosServer.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/KerberosConnector/src/Model/Table/KerberosProvisionersTable.php b/app/availableplugins/KerberosConnector/src/Model/Table/KerberosProvisionersTable.php new file mode 100644 index 000000000..0adebfbc1 --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Model/Table/KerberosProvisionersTable.php @@ -0,0 +1,467 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Authenticators'); + $this->belongsTo('ProvisioningTargets'); + $this->belongsTo('Servers'); + $this->belongsTo('Types'); + + $this->setDisplayField('server_id'); + + $this->setPrimaryLink(['provisioning_target_id']); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'authenticators' => [ + 'type' => 'plugin', + 'model' => 'PasswordAuthenticator.PasswordAuthenticators' + ], + 'servers' => [ + 'type' => 'plugin', + 'model' => 'KerberosConnector.KerberosServers' + ], + 'types' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + '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([ + 'People' + ]); + } + + /** + * Provision object data to the provisioning target. + * + * @since COmanage Registry v5.2.0 + * @param ProvisioningTarget $provisioningTarget KerberosProvisioner 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( + \App\Model\Entity\ProvisioningTarget $provisioningTarget, + string $className, + object $data, // $data is currently only \App\Model\Entity\Person, but that might change + string $eligibility + ): array { + // We need to have an Identifier of the configured type and a Password associated + // with the configured Authenticator. + + // We need the Kerberos Server configuration for the realm + $server = $this->Servers->get( + $provisioningTarget->kerberos_provisioner->server_id, + contain: ['KerberosServers'] + ); + + // Establish a connection to kadmin + + $cxn = $this->Servers->KerberosServers->connect( + serverId: $provisioningTarget->kerberos_provisioner->server_id, + admin: true + ); + + // First look for an Identifier of our configured Type. If we can't find one, we can't + // do anything at all (including deprovisioning). + + $identifier = null; + + if(!empty($data->identifiers)) { + $identifier = Hash::extract($data->identifiers, '{n}[type_id='.$provisioningTarget->kerberos_provisioner->type_id.']'); + } + + if(empty($identifier)) { + throw new \RuntimeException(__d('kerberos_connector', 'error.principal.identifier')); + } + + // We construct a principal with the realm for completeness and predictability, + // but in general the KDC would just append the default realm if we only sent + // the lefthand side + + $principal = $identifier[0]->identifier . "@" . $server->kerberos_server->realm; + + // Map the Authenticator ID (configured on the Provisioning Target in line with the + // pattern of configurations pointing to the Pluggable Model) to the Password + // Authenticator ID (the foreign key on the Password entity). + + $authenticator = $this->Authenticators->get( + $provisioningTarget->kerberos_provisioner->authenticator_id, + contain: ['PasswordAuthenticators'] + ); + + // We always take an action here in order to ensure the KDC is in sync, even though + // in many cases (ie: when called because some other data changed) we'll just be + // confirming the current state. + + $action = 'unknown'; + $password = null; + + if($eligibility == ProvisioningEligibilityEnum::Eligible) { + // Next try to find a Password entity that matches our configured Authenticotor. + // We also need a Password of type PasswordEncodingEnum::Plain, since that's what + // the Kerberos protocol requires. + + if(!empty($data->passwords)) { + $password = Hash::extract($data->passwords, '{n}[type='.PasswordEncodingEnum::Plain.'][password_authenticator_id='.$authenticator->password_authenticator->id.']'); + } + + if(!empty($password)) { + $action = 'update'; + } else { + // If Pass Through Provisioning is enabled, we may have various scenarios where + // we will get a blank password for an Eligible Person, including reprovisioning + // or Authenticator lock/unlock. We'll need to look at the Authenticator Status + // for more information, but we'll default to locking. + + $action = 'lock'; + + // There should be at least one entry with an authenticator status + + if(!empty($data->passwords[0]->authenticator_status) + && !$data->passwords[0]->authenticator_status->locked) { + $action = 'unlock'; + } + } + } elseif($eligibility == ProvisioningEligibilityEnum::Ineligible) { + // Check to see if the principal exists in the KDC, and if so lock it + + $action = 'lock'; + } elseif($eligibility == ProvisioningEligibilityEnum::Deleted) { + // Check to see if the principal exists in the KDC, and if so lock it. + // It's plausible we should remove it instead, but for now we'll start with + // the "safer" operation. + + $action = 'lock'; + // $action = 'remove'; + } + + // Before we perform any action, retrieve the current state of the principal + // (if any) from the KDC. + + $curprinc = null; + + try { + $curprinc = $cxn->getPrincipal($principal); + } + catch(\Exception $e) { + // This is most likely that the principal does not exist on the KDC. + } + + if($curprinc) { + // We have an existing principal, ensure it is in sync + + if($action == 'lock') { + // We lock the principal by adding 64 to the attribute mask. This isn't + // documented anywhere, but DISALLOW_ALL_TIX = 64, and is the setting that + // will prevent authentication. + + $attributes = $curprinc->getAttributes(); + + if(!($attributes & 64)) { + // Add the locked bit + $curprinc->setAttributes($curprinc->getAttributes() | 64); + $curprinc->save(); + } + // else the principal is already locked + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('kerberos_connector', 'result.locked-p', [$principal]), + 'identifier' => $principal + ]; + } elseif($action == 'unlock') { + // We only end up here if Pass Through Provisioning is enabled, in which case + // we have an authenticator with no Password (but presumably a disabled password + // in the KDC). + + $attributes = $curprinc->getAttributes(); + + if($attributes & 64) { + // Remove the locked bit + $curprinc->setAttributes($curprinc->getAttributes() ^ 64); + $curprinc->save(); + } + // else the principal is already unlocked + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('kerberos_connector', 'result.unlocked-p', [$principal]), + 'identifier' => $principal + ]; + } elseif($action == 'update') { + // Make sure we aren't currently DISALLOWING_ALL_TIX -- if we are clear the flag. + + $attributes = $curprinc->getAttributes(); + + if($attributes & 64) { + // Remove the locked bit + $curprinc->setAttributes($curprinc->getAttributes() ^ 64); + $curprinc->save(); + } + // else the principal is already unlocked + + // We submit a change password request even though the password might not have changed. + // This will show up as a password change on the KDC, which may or may not be OK + // depending on what policies the deploying site might have. Use Pass Through + // Provisioning to avoid this, or maybe set a default policy of -history 1 (though + // that will cause this call will fail, creating "Cannot reuse password" noise in + // Provisioning History Records). + $curprinc->changePassword($password[0]->password); + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('kerberos_connector', 'result.synced', [$principal]), + 'identifier' => $principal + ]; + } + } else { + // No existing principal, the only operation we'll perform is 'update' + + if($action == 'update') { + // From here, we'll just let errors bubble up + + $curprinc = new \KADM5Principal($principal); + + $cxn->createPrincipal(principal: $curprinc, password: $password[0]->password); + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('kerberos_connector', 'result.created', [$principal]), + 'identifier' => $principal + ]; + } + + return [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('kerberos_connector', 'result.notprov', [$principal]) + ]; + } + } + + /** + * Obtain status information for the requested provisioned subject. + * + * @since COmanage Registry v5.2.0 + * @param ProvisioningTarget $cfg Provisioning Target configuration + * @param int $groupId Group ID to retrieve status for + * @param int $personId Person ID to retrieve status for + * @return array Array of status information: status, comment, timestamp + */ + + public function status( + \App\Model\Entity\ProvisioningTarget $cfg, + ?int $groupId, + ?int $personId + ): array { + $ret = [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('enumeration', 'ProvisioningStatusEnum.N'), + 'timestamp' => null + ]; + + // We only support provisioning People. We won't treat a $groupId as an error, + // we can simply return (accurately) that the record is Not Provisioned. + + if($personId) { + // Rather than reconstruct the Principal, we'll just pull it from the Identifiers table + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + $typeId = $Identifiers->Types->getTypeId( + coId: $cfg->co_id, + attribute: 'Identifiers.type', + // Although we now call these "Provisioning Keys", we reuse the database value from v4 + value: 'provisioningtarget' + ); + + $principal = $Identifiers->find() + ->where([ + 'person_id' => $personId, + 'type_id' => $typeId, + 'provisioning_target_id' => $cfg->id + ]) + ->firstOrFail(); + + // Establish a connection to kadmin + + $cxn = $this->Servers->KerberosServers->connect( + serverId: $cfg->kerberos_provisioner->server_id, + admin: true + ); + + // Look for the principal + + try { + $princ = $cxn->getPrincipal($principal->identifier); + + // Construct status based on both the principal and password expiration times (if set) + + $expiry = __d('kerberos_connector', 'result.never'); + $pwexpiry = __d('kerberos_connector', 'result.never'); + $status = 'active'; + + if($princ->getPasswordExpiryTime() > 0) { + $pwExpiryTime = FrozenTime::createFromTimestamp($princ->getPasswordExpiryTime()); + $pwexpiry = $pwExpiryTime->nice(); + + if($pwExpiryTime->isPast()) { + $status = 'pwexpired'; + } + } + + if($princ->getExpiryTime() > 0) { + $expiryTime = FrozenTime::createFromTimestamp($princ->getExpiryTime()); + $expiry = $expiryTime->nice(); + + if($expiryTime->isPast()) { + $status = 'expired'; + } + } + + // Test for locked status last to populate the correct comment + $attributes = $princ->getAttributes(); + + if($attributes & 64) { + $status = 'locked'; + } + + $ret['status'] = ProvisioningStatusEnum::Provisioned; + $ret['comment'] = __d('kerberos_connector', 'result.'.$status, [$expiry, $pwexpiry]); + $ret['timestamp'] = FrozenTime::createFromTimestamp($princ->getLastModificationDate()); + } + catch(\Exception $e) { + // We'll get an Exception on principal not found. We should only get here + // on edge case error conditions, eg a Provisioning Key exists in the Identifiers + // table but we didn't successfully provision (or an admin deleted the entry + // from the KDC). + + $ret['comment'] = __d('kerberos_connector', 'result.notprov', [$principal->identifier]); + } + } + + return $ret; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.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'); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + $validator->add('authenticator_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('authenticator_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/KerberosConnector/src/Model/Table/KerberosServersTable.php b/app/availableplugins/KerberosConnector/src/Model/Table/KerberosServersTable.php new file mode 100644 index 000000000..01bb4ee9e --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Model/Table/KerberosServersTable.php @@ -0,0 +1,164 @@ +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->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 to the specified Kerberos server. + * + * @since COmanage Registry v5.2.0 + * @param int $serverId Server ID (NOT KerberosServer ID) + * @param bool $admin If true, establish a kadmin connetion using the Admin Principal and Keytab + * @return mixed KADM5 object if $admin is true + * @throws Exception + */ + + public function connect(int $serverId, bool $admin): \KADM5 { + // Pull our configuration via the parent Server object. + $server = $this->Servers->get($serverId, contain: ['KerberosServers']); + + if($server->status != SuspendableStatusEnum::Active) { + throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Servers', [1]), $serverId])); + } + + if(empty($server->kerberos_server->admin_principal) + || empty($server->kerberos_server->keytab_path)) { + throw new \InvalidArgumentException(__d('kerberos_connector', 'error.KerberosServers.admin.cfg')); + } + + // If we omit this configuration, the local krb5.conf values will be used, + // but that would be confusing so we require the settings and check for them above. + $config = [ + 'realm' => $server->kerberos_server->realm, + 'admin_server' => $server->kerberos_server->hostname + ]; + + if(!empty($server->kerberos_server->port) && (int)$server->kerberos_server->port > 0) { + $config['admin_port'] = $server->kerberos_server->port; + } + + if(!is_readable($server->kerberos_server->keytab_path)) { + throw new \InvalidArgumentException(__d('error', 'file', [$server->kerberos_server->keytab_path])); + } + + return new \KADM5( + principal: $server->kerberos_server->admin_principal, + credentials: $server->kerberos_server->keytab_path, + use_keytab: true, + config: $config + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.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'); + + $this->registerStringValidation($validator, $schema, 'hostname', true); + + $validator->add('port', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('port'); + + $this->registerStringValidation($validator, $schema, 'realm', true); + + $this->registerStringValidation($validator, $schema, 'admin_principal', false); + + $this->registerStringValidation($validator, $schema, 'keytab_path', false); + + return $validator; + } +} diff --git a/app/availableplugins/KerberosConnector/templates/KerberosProvisioners/fields.inc b/app/availableplugins/KerberosConnector/templates/KerberosProvisioners/fields.inc new file mode 100644 index 000000000..b51a14878 --- /dev/null +++ b/app/availableplugins/KerberosConnector/templates/KerberosProvisioners/fields.inc @@ -0,0 +1,32 @@ + ['Servers', 'KerberosConnector.KerberosServers'], + 'action' => [ + 'Servers' => ['edit'], + 'KerberosConnector.KerberosServers' => ['edit'] + ] +]; diff --git a/app/vendor/cakephp/chronos/docs/config/__init__.py b/app/availableplugins/KerberosConnector/webroot/.gitkeep similarity index 100% rename from app/vendor/cakephp/chronos/docs/config/__init__.py rename to app/availableplugins/KerberosConnector/webroot/.gitkeep diff --git a/app/availableplugins/PasswordAuthenticator/README.md b/app/availableplugins/PasswordAuthenticator/README.md new file mode 100644 index 000000000..07be512ba --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/README.md @@ -0,0 +1,11 @@ +# PasswordAuthenticator 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/password-authenticator +``` diff --git a/app/availableplugins/PasswordAuthenticator/composer.json b/app/availableplugins/PasswordAuthenticator/composer.json new file mode 100644 index 000000000..5ab64ddd8 --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/password-authenticator", + "description": "PasswordAuthenticator plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.6.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "PasswordAuthenticator\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PasswordAuthenticator\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/availableplugins/PasswordAuthenticator/config/plugin.json b/app/availableplugins/PasswordAuthenticator/config/plugin.json new file mode 100644 index 000000000..679af58e3 --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/config/plugin.json @@ -0,0 +1,41 @@ +{ + "types": { + "authenticator": [ + "PasswordAuthenticators" + ] + }, + "schema": { + "tables": { + "password_authenticators": { + "columns": { + "id": {}, + "authenticator_id": {}, + "source_mode": { "type": "string", "size": 2 }, + "min_length": { "type": "integer" }, + "max_length": { "type": "integer" }, + "format_crypt_php": { "type": "boolean" }, + "format_plaintext": { "type": "boolean" }, + "format_sha1_ldap": { "type": "boolean" }, + "prevent_reuse": { "type": "boolean" }, + "use_hard_delete": { "type": "boolean" } + }, + "indexes": { + "password_authenticators_i1": { "columns": [ "authenticator_id" ]} + } + }, + "passwords": { + "columns": { + "id": {}, + "password_authenticator_id": { "type": "integer", "foreignkey": { "table": "password_authenticators", "column": "id" }, "notnull": true }, + "person_id": {}, + "password": { "type": "string", "size": 256 }, + "type": { "type": "string", "size": 2 } + }, + "indexes": { + "passwords_i1": { "columns": [ "password_authenticator_id" ]}, + "passwords_i2": { "columns": [ "person_id" ]} + } + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/PasswordAuthenticator/phpunit.xml.dist b/app/availableplugins/PasswordAuthenticator/phpunit.xml.dist new file mode 100644 index 000000000..4219d9568 --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po b/app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po new file mode 100644 index 000000000..843b681b8 --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po @@ -0,0 +1,122 @@ +# COmanage Registry Localizations (password_authenticator 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.2.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.PasswordAuthenticators" +msgstr "{0,plural,=1{Password Authenticator} other{Password Authenticators}}" + +msgid "controller.Passwords" +msgstr "{0,plural,=1{Password} other{Passwords}}" + +msgid "enumeration.PasswordEncodingEnum.CR" +msgstr "Crypt" + +msgid "enumeration.PasswordEncodingEnum.EX" +msgstr "External" + +msgid "enumeration.PasswordEncodingEnum.MT" +msgstr "Empty" + +msgid "enumeration.PasswordEncodingEnum.NO" +msgstr "Plain" + +msgid "enumeration.PasswordEncodingEnum.SH" +msgstr "SSHA" + +msgid "enumeration.PasswordSourceEnum.AG" +msgstr "Autogenerate" + +msgid "enumeration.PasswordSourceEnum.EX" +msgstr "External" + +msgid "enumeration.PasswordSourceEnum.SL" +msgstr "Self Select" + +msgid "error.Passwords.current" +msgstr "Incorrect current password" + +msgid "error.Passwords.len.max" +msgstr "Password cannot be more than {0} characters" + +msgid "error.Passwords.len.min" +msgstr "Password must be at least {0} characters" + +msgid "error.Passwords.match" +msgstr "New passwords do not match" + +msgid "error.Passwords.reuse" +msgstr "Password was previously used, please pick a new one" + +msgid "field.PasswordAuthenticators.format_crypt_php" +msgstr "Store as Crypt" + +msgid "field.PasswordAuthenticators.format_crypt_php.desc" +msgstr "The password will be stored in Crypt format (required for Self Select)" + +msgid "field.PasswordAuthenticators.format_plaintext" +msgstr "Store as Plain Text" + +msgid "field.PasswordAuthenticators.format_plaintext.desc" +msgstr "If enabled, the password will be stored unhashed in the database" + +msgid "field.PasswordAuthenticators.format_sha1_ldap" +msgstr "Store as Salted SHA 1" + +msgid "field.PasswordAuthenticators.format_sha1_ldap.desc" +msgstr "If enabled, the password will be stored in Salted SHA 1 format" + +msgid "field.PasswordAuthenticators.min_length" +msgstr "Minimum Password Length" + +msgid "field.PasswordAuthenticators.min_length.desc" +msgstr "Must be between 8 and 64 characters (inclusive), default is 8" + +msgid "field.PasswordAuthenticators.max_length" +msgstr "Maximum Password Length" + +msgid "field.PasswordAuthenticators.max_length.desc" +msgstr "Must be between 8 and 64 characters (inclusive), default is 64 for Self Select and 16 for Autogenerate" + +msgid "field.PasswordAuthenticators.prevent_reuse" +msgstr "Prevent Password Reuse" + +msgid "field.PasswordAuthenticators.prevent_reuse.desc" +msgstr "Require a new Password to be different than any prior Password (requires Store as Crypt, cannot be used with Hard Delete)" + +msgid "field.PasswordAuthenticators.source_mode" +msgstr "Password Source" + +msgid "field.PasswordAuthenticators.use_hard_delete" +msgstr "Use Hard Delete" + +msgid "field.Passwords.password2" +msgstr "Password (Again)" + +msgid "operation.set" +msgstr "Set Password for {0}" + +msgid "result.Passwords.modified" +msgstr "Last changed {0} UTC" + +msgid "result.Passwords.set" +msgstr "Password {0} Set" diff --git a/app/availableplugins/PasswordAuthenticator/src/Controller/AppController.php b/app/availableplugins/PasswordAuthenticator/src/Controller/AppController.php new file mode 100644 index 000000000..125ed5d89 --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'PasswordAuthenticators.id' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->PasswordAuthenticators->Authenticators->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->PasswordAuthenticators->Authenticators->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->PasswordAuthenticators->Authenticators->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/availableplugins/PasswordAuthenticator/src/Controller/PasswordsController.php b/app/availableplugins/PasswordAuthenticator/src/Controller/PasswordsController.php new file mode 100644 index 000000000..04d68109d --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/src/Controller/PasswordsController.php @@ -0,0 +1,42 @@ + [ + 'Passwords.id' => 'asc' + ] + ]; +} diff --git a/app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php b/app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php new file mode 100644 index 000000000..dc731c2bf --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php @@ -0,0 +1,40 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/PasswordAuthenticator/src/Model/Entity/PasswordAuthenticator.php b/app/availableplugins/PasswordAuthenticator/src/Model/Entity/PasswordAuthenticator.php new file mode 100644 index 000000000..abc2cdd74 --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/src/Model/Entity/PasswordAuthenticator.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php new file mode 100644 index 000000000..158f0cf03 --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php @@ -0,0 +1,236 @@ +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('Authenticators'); + + $this->hasMany('PasswordAuthenticator.Passwords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('authenticator_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'sourceModes' => [ + 'type' => 'enum', + 'class' => 'PasswordAuthenticator.PasswordSourceEnum' + ] + ]); + + $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'] + ] + ]); + } + + /** + * Callback before data is marshaled into an entity. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) { + // PAR-PasswordAuthenticator-1 When the Password Source is Self Select, the password + // must be stored in PHP Crypt format, unless the Authenticator is configured in Pass + // Through Provisioning mode. + + $authenticator = $this->Authenticators->get($data['authenticator_id']); + + if($authenticator->enable_ptp) { + $data['format_crypt_php'] = false; + $data['format_sha1_ldap'] = false; + $data['format_plaintext'] = false; + } elseif(!empty($data['source_mode']) && $data['source_mode'] == PasswordSourceEnum::SelfSelect) { + $data['format_crypt_php'] = true; + } + } + + /** + * Assemble Authenticator data for provisioning. + * + * @since COmanage Registry v5.2.0 + * @param Authenticator $cfg Authenticator Configuration + * @param int $personId Person ID + * @return array Array of Password entities + */ + + public function marshalProvisioningData( + \App\Model\Entity\Authenticator $cfg, + int $personId + ): array { + // If the Authenticator is in Pass Through Provisioning mode, do not pull any existing + // records from the database. + + if($cfg->enable_ptp) { + return []; + } + + // Retrieve any Passwords associated with this Person and the requested configuration. + // We'll include all available Password types (encodings) since we don't know which types + // any specific Provisioner will be interested in. + + $passwords = $this->Passwords->find() + ->where([ + 'Passwords.person_id' => $personId, + 'Passwords.password_authenticator_id' => $cfg->password_authenticator->id + ]) + ->all(); + + return $passwords->toArray(); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('authenticator_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('authenticator_id'); + + $validator->add('source_mode', [ + 'content' => ['rule' => ['inList', PasswordSourceEnum::getConstValues()]] + ]); + $validator->notEmptyString('source_mode'); + +// XXX min_length and max_length required depend on source_mode + $validator->add('min_length', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->add('min_length', [ + 'contentgt' => ['rule' => ['comparison', '>', 7]] + ]); + $validator->add('min_length', [ + 'contentlt' => ['rule' => ['comparison', '<', 65]] + ]); + $validator->allowEmptyString('min_length'); + + $validator->add('max_length', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->add('max_length', [ + 'contentgt' => ['rule' => ['comparison', '>', 7]] + ]); + $validator->add('max_length', [ + 'contentlt' => ['rule' => ['comparison', '<', 65]] + ]); + $validator->allowEmptyString('max_length'); + + $validator->add('format_crypt_php', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('format_crypt_php'); + + $validator->add('format_plaintext', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('format_plaintext'); + + $validator->add('format_sha1_ldap', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('format_sha1_ldap'); + + $validator->add('prevent_reuse', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('prevent_reuse'); + + $validator->add('use_hard_delete', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('use_hard_delete'); + + return $validator; + } +} diff --git a/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php new file mode 100644 index 000000000..a56dbf7ec --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php @@ -0,0 +1,457 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('PasswordAuthenticator.PasswordAuthenticators'); + $this->belongsTo('People'); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('PasswordAuthenticator.password_authenticator_id'); + $this->setRequiresCO(true); + $this->setAllowUnkeyedPrimaryLink(['manage']); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => false, // use manage instead ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'manage' => ['platformAdmin', 'coAdmin'], + 'index' => false // ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Handle an Authenticator update from a manage() request. + * + * @since COmanage Registry v5.2.0 + * @param Authenticator $cfg Authenticator configuration + * @param int $personId Person ID + * @param array $data Array of data from fields.inc + * @throws InvalidArgumentException + */ + + public function manage( + Authenticator $cfg, + int $personId, + array $data + ): void { + // Run password validation checks and let any Exceptions bubble up + $this->validateRequest($cfg, $data); + +// XXX Note we're not checking the current password yet because we don't support self +// service yet. It might make sense to implement that check in the code that receives +// the self service request (presumably a dashboard widget). + + $cxn = $this->getConnection(); + $cxn->begin(); + + try { + // Delete any existing password for the user. We do it this way in case the + // plugin configuration is changed. + + $passwords = $this->find('all', archived: $cfg->password_authenticator->use_hard_delete) + ->where([ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId + ]) + ->all(); + + foreach($passwords as $password) { + // Note this is a changelog "soft" delete, meaning the old Password is + // kept as an archive. This would allow us to eventually support Password + // policies (eg to prevent reuse). + + $this->delete($password, ['useHardDelete' => $cfg->password_authenticator->use_hard_delete]); + } + + // We'll store one entry per hashing type. We always store CRYPT + // so we can use the native php routines (which require PHP 5.5+). + // Enabling SSHA requires PHP 7 for random_bytes. + + // We could use something like https://multiformats.io/multihash, but the + // type column basically accomplishes the same thing. + + $pdata = null; + + if($cfg->password_authenticator->format_crypt_php) { + // We use password_hash, which due to various portability issues with crypt + // is really only useful with password_verify. + + $pdata = $this->newEntity([ + 'password_authenticator_id' => $data['password_authenticator_id'], + 'person_id' => $personId, + 'password' => password_hash($data['password'], PASSWORD_DEFAULT), + 'type' => PasswordEncodingEnum::Crypt + ]); + + $this->saveOrFail($pdata); + } + + if($cfg->password_authenticator->format_sha1_ldap) { + // Salted SHA1 isn't really a great algorithm (and our salt generation + // could probably be better), but OpenLDAP doesn't support a better option + // out of the box. + + $salt = substr(bin2hex(random_bytes(8)),0,4); + $shapwd = base64_encode(sha1($data['password'].$salt, true) . $salt); + + $pdata = $this->newEntity([ + 'password_authenticator_id' => $data['password_authenticator_id'], + 'person_id' => $personId, + 'password' => $shapwd, + 'type' => PasswordEncodingEnum::SSHA + ]); + + $this->saveOrFail($pdata); + } + + if($cfg->password_authenticator->format_plaintext) { + // Other than being easily readable by admins, plaintext is arguably not + // that much less secure than the other supported options... + + $pdata = $this->newEntity([ + 'password_authenticator_id' => $data['password_authenticator_id'], + 'person_id' => $personId, + 'password' => $data['password'], + 'type' => PasswordEncodingEnum::Plain + ]); + + $this->saveOrFail($pdata); + } + + // At this point we've deleted any existing Passwords and correctly stored all + // configured variations, so we can commit the transaction. + $cxn->commit(); + + // Record history + $comment = __d('password_authenticator', 'result.Passwords.set', [$cfg->description]); + + $this->People->recordHistory( + // It doesn't matter which version of $pdata we use + $pdata, + ActionEnum::AuthenticatorEdited, + $comment + ); + } + catch(\Exception $e) { + $cxn->rollback(); + + throw $e; + } + } + + /** + * Handle an Authenticator update from a manage() request, but process as a + * Pass Through request. + * + * @since COmanage Registry v5.2.0 + * @param Authenticator $cfg Authenticator configuration + * @param int $personId Person ID + * @param array $data Array of data from fields.inc + * @return array Array of data in the same format as marshalProvisioningData() + * @throws InvalidArgumentException + */ + + public function process( + Authenticator $cfg, + int $personId, + array $data + ): array { + // For Pass Through Mode, we need to do a little extra record keeping. + + // First lun password validation checks and let any Exceptions bubble up. + $this->validateRequest($cfg, $data); + + // Like for manage(), delete any previously existing Passwords. This will catch + // the scenario where the configuration was switched from normal provisioning to PTP. + // Unlike manage(), we will perform a hard delete. (Note we don't delete the password + // of type "Empty", which is our placeholder to track time of last update.) + + // Because we're using a hard delete we need to pull archived records as well. + $passwords = $this->find('all', archived: true)->where([ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId, + 'type <>' => PasswordEncodingEnum::Empty + ]) + ->all(); + + foreach($passwords as $password) { + $this->delete($password, ['useHardDelete' => true]); + } + + // Upsert an Empty password so we have a timestamp of last Password changes. + // Upsert will also allow us to recreate a history of Password changes via changelog. + // Because we haven't actually returned data to pass through to the Provisioners yet, + // if this fails we'll fail the whole request. + + $this->upsertOrFail( + data: [ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId, + 'password' => "*", + 'type' => PasswordEncodingEnum::Empty + ], + whereClause: [ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId, + 'type' => PasswordEncodingEnum::Empty + ] + ); + + // Finally convert the submitted password into a new entity, in plaintext format + // (since presumably the configured Provisioner needs to know what the password + // is in order to do something with it). + + $entity = $this->newEntity([ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId, + 'password' => $data['password'], + 'type' => PasswordEncodingEnum::Plain + ]); + + return [$entity]; + } + + /** + * Reset a Password for a Person, + * + * @since COmanage Registry v5.2.0 + * @param Authenticator $cfg Authenticator Configuration + * @param int $personId Person ID + */ + + public function reset( + Authenticator $cfg, + int $personId + ): void { + // We simply delete all Passwords for $personId, if any + + $cxn = $this->getConnection(); + $cxn->begin(); + + try { + $passwords = $this->find()->where([ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId + ]) + ->all(); + + foreach($passwords as $password) { + $this->delete($password, ['reset' => true]); + } + + $cxn->commit(); + + // We don't need to record history or provision because the infrastructure will handle that + } + catch(\Exception $e) { + $cxn->rollback(); + + throw $e; + } + } + + /** + * Obtain the current Authenticator status for a Person. + * + * @since COmanage Registry v5.2.0 + * @param Authenticator $cfg Authenticator Configuration + * @param int $personId Person ID + * @return array Array with values + * status: AuthenticatorStatusEnum + * comment: Human readable string, visible to the CO Person + */ + + public function status(Authenticator $cfg, int $personId): array { + $pwd = $this->find() + ->where([ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId + ]) + ->first(); + + // We don't know which password type we have (unless PTP is enabled, in which + // case the type is "Empty"), but they should all have the same mod time + + if(!empty($pwd->modified)) { + return [ + 'status' => AuthenticatorStatusEnum::Active, + // Note we don't currently have access to local timezone setting +// XXX is this still true? + 'comment' => __d('password_authenticator', 'result.Passwords.modified', [$pwd->modified]) + ]; + } + + return [ + 'status' => AuthenticatorStatusEnum::NotSet, + 'comment' => __d('result', 'set.not') + ]; + } + + /** + * Validate a password change request according to the configuration. + * + * @since COmanage Registry v5.2.0 + * @param Authenticator $cfg Authenticator configuration + * @param array $data Array of data from fields.inc + * @throws InvalidArgumentException + */ + + protected function validateRequest( + Authenticator $cfg, + array $data + ) { + // Perform sanity checks on Self Selected passwords only + if($cfg->password_authenticator->source_mode == PasswordSourceEnum::SelfSelect) { + $minlen = $cfg->password_authenticator->min_length ?: 8; + $maxlen = $cfg->password_authenticator->max_length ?: 64; + + // Check minimum length + if(strlen($data['password']) < $minlen) { + throw new \InvalidArgumentException(__d('password_authenticator', 'error.Passwords.len.min', [$minlen])); + } + + // Check maximum length + if(strlen($data['password']) > $maxlen) { + throw new \InvalidArgumentException(__d('password_authenticator', 'error.Passwords.len.max', [$minlen])); + } + + // Check that passwords match + if($data['password'] != $data['password2']) { + throw new \InvalidArgumentException(__d('password_authenticator', 'error.Passwords.match')); + } + } + + // If Password Reuse Prevention is enabled (and we're not using hard delete or PTP) + // check the Password against the previous ones + if($cfg->password_authenticator->prevent_reuse + && $cfg->password_authenticator->format_crypt_php + && !$cfg->password_authenticator->use_hard_delete + && !$cfg->enable_ptp) { + $passwords = $this->find('all', archived: true) + ->where([ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $data['person_id'], + 'type' => PasswordEncodingEnum::Crypt + ]) + ->all(); + + foreach($passwords as $p) { + if(password_verify($data['password'], $p->password)) { + // The passwords match, throw a validation error + throw new \InvalidArgumentException(__d('password_authenticator', 'error.Passwords.reuse')); + } + } + } + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('password_authenticator_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('password_authenticator_id'); + + $validator->add('person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('person_id'); + + $this->registerStringValidation($validator, $schema, 'password', true); + + $this->registerStringValidation($validator, $schema, 'password2', true); + + $validator->add('type', [ + 'content' => ['rule' => ['inList', PasswordEncodingEnum::getConstValues()]] + ]); + $validator->notEmptyString('type'); + + return $validator; + } +} diff --git a/app/availableplugins/PasswordAuthenticator/src/PasswordAuthenticatorPlugin.php b/app/availableplugins/PasswordAuthenticator/src/PasswordAuthenticatorPlugin.php new file mode 100644 index 000000000..e2888ede4 --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/src/PasswordAuthenticatorPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'PasswordAuthenticator', + ['path' => '/password-authenticator'], + 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/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc b/app/availableplugins/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc new file mode 100644 index 000000000..b7351c29a --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc @@ -0,0 +1,106 @@ + ['Authenticators', 'PasswordAuthenticator.PasswordAuthenticators'], + 'action' => [ + 'Authenticators' => ['edit'], + 'PasswordAuthenticator.PasswordAuthenticators' => ['edit'] + ] +]; + +// Determine if Pass Through Provisioning is enabled +$ptp = $vv_bc_parent_obj?->enable_ptp ? "PTP" : "NO"; +?> + + \ No newline at end of file diff --git a/app/availableplugins/PasswordAuthenticator/templates/Passwords/fields.inc b/app/availableplugins/PasswordAuthenticator/templates/Passwords/fields.inc new file mode 100644 index 000000000..d72d7a19b --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/templates/Passwords/fields.inc @@ -0,0 +1,63 @@ + 'information', + 'message' => $vv_status->comment + ] +]; + +$fields = []; + +if(!$vv_status->locked) { + $fields = [ + 'password', + 'password2' => [ + 'type' => 'password' + ] + ]; + // Inject the parent keys + $hidden = [ + 'password_authenticator_id' => $vv_authenticator->password_authenticator->id, + 'person_id' => $vv_status->person_id + ]; +} else { + $suppress_submit = true; +} + +$topLinks[] = [ + 'icon' => 'history', + 'order' => 'Default', + 'label' => __d('operation', 'reset'), + 'link' => [ + 'controller' => 'authenticators', + 'action' => 'reset', + ] +]; \ No newline at end of file diff --git a/app/availableplugins/PasswordAuthenticator/tests/bootstrap.php b/app/availableplugins/PasswordAuthenticator/tests/bootstrap.php new file mode 100644 index 000000000..e78a9863a --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/availableplugins/PasswordAuthenticator/tests/schema.sql b/app/availableplugins/PasswordAuthenticator/tests/schema.sql new file mode 100644 index 000000000..c1ded9668 --- /dev/null +++ b/app/availableplugins/PasswordAuthenticator/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for PasswordAuthenticator diff --git a/app/vendor/cakephp/debug_kit/docs/config/__init__.py b/app/availableplugins/PasswordAuthenticator/webroot/.gitkeep similarity index 100% rename from app/vendor/cakephp/debug_kit/docs/config/__init__.py rename to app/availableplugins/PasswordAuthenticator/webroot/.gitkeep diff --git a/app/availableplugins/PipelineToolkit/config/plugin.json b/app/availableplugins/PipelineToolkit/config/plugin.json new file mode 100644 index 000000000..96425352e --- /dev/null +++ b/app/availableplugins/PipelineToolkit/config/plugin.json @@ -0,0 +1,65 @@ +{ + "types": { + "flange": [ + "IdentifierMappers", + "PersonRoleMappers" + ] + }, + "schema": { + "tables": { + "identifier_mappers": { + "columns": { + "id": {}, + "flange_id": { "type": "integer", "foreignkey": { "table": "flanges", "column": "id" } } + }, + "indexes": { + "identifier_mappers_i1": { "columns": [ "flange_id" ] } + } + }, + + "login_identifier_types": { + "columns": { + "id": {}, + "identifier_mapper_id": { "type": "integer", "foreignkey": { "table": "identifier_mappers", "column": "id" } }, + "type_id": {}, + "login": { "type": "boolean" } + }, + "indexes": { + "login_identifier_types_i1": { "columns": [ "identifier_mapper_id"] }, + "login_identifier_types_i2": { "needed": false, "columns": [ "type_id"] } + } + }, + + "person_role_mappers": { + "columns": { + "id": {}, + "flange_id": { "type": "integer", "foreignkey": { "table": "flanges", "column": "id" } } + }, + "indexes": { + "person_role_mappers_i1": { "columns": [ "flange_id" ] } + } + }, + "person_role_mappings": { + "columns": { + "id": {}, + "person_role_mapper_id": { "type": "integer", "foreignkey": { "table": "person_role_mappers", "column": "id" } }, + "attribute": { "type": "string", "size": 80 }, + "ad_hoc_tag": { "type": "string", "size": 128 }, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "comparison": { "type": "string", "size": 4 }, + "pattern": { "type": "string", "size": 80 }, + "target_cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, + "target_affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "ordr": {} + }, + "indexes": { + "person_role_mappings_i1": { "columns": [ "person_role_mapper_id" ] }, + "person_role_mappings_i2": { "needed": false, "columns": [ "affiliation_type_id" ] }, + "person_role_mappings_i3": { "needed": false, "columns": [ "target_cou_id" ] }, + "person_role_mappings_i4": { "needed": false, "columns": [ "target_affiliation_type_id" ] } + }, + "clone_relation": true + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/PipelineToolkit/src/Controller/IdentifierMappersController.php b/app/availableplugins/PipelineToolkit/src/Controller/IdentifierMappersController.php index 619e72252..738a0078a 100644 --- a/app/availableplugins/PipelineToolkit/src/Controller/IdentifierMappersController.php +++ b/app/availableplugins/PipelineToolkit/src/Controller/IdentifierMappersController.php @@ -32,7 +32,7 @@ use App\Controller\StandardPluginController; class IdentifierMappersController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'IdentifierMappers.id' => 'asc' ] diff --git a/app/availableplugins/PipelineToolkit/src/Controller/LoginIdentifierTypesController.php b/app/availableplugins/PipelineToolkit/src/Controller/LoginIdentifierTypesController.php index 43f7d0950..7a8d0bd5a 100644 --- a/app/availableplugins/PipelineToolkit/src/Controller/LoginIdentifierTypesController.php +++ b/app/availableplugins/PipelineToolkit/src/Controller/LoginIdentifierTypesController.php @@ -32,7 +32,7 @@ use App\Controller\StandardPluginController; class LoginIdentifierTypesController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'LoginIdentifierTypes.id' => 'asc' ] diff --git a/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappersController.php b/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappersController.php index a434aa4a1..b1a0dbcb6 100644 --- a/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappersController.php +++ b/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappersController.php @@ -32,7 +32,7 @@ use App\Controller\StandardPluginController; class PersonRoleMappersController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'PersonRoleMappers.id' => 'asc' ] diff --git a/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappingsController.php b/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappingsController.php index 00b3b16b3..ffe0b65c2 100644 --- a/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappingsController.php +++ b/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappingsController.php @@ -32,7 +32,7 @@ use App\Controller\StandardPluginController; class PersonRoleMappingsController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'PersonRoleMappings.id' => 'asc' ] diff --git a/app/availableplugins/PipelineToolkit/src/Model/Entity/IdentifierMapper.php b/app/availableplugins/PipelineToolkit/src/Model/Entity/IdentifierMapper.php index da62bb347..0d76ebfba 100644 --- a/app/availableplugins/PipelineToolkit/src/Model/Entity/IdentifierMapper.php +++ b/app/availableplugins/PipelineToolkit/src/Model/Entity/IdentifierMapper.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class IdentifierMapper extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class IdentifierMapper extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/availableplugins/PipelineToolkit/src/Model/Entity/LoginIdentifierType.php b/app/availableplugins/PipelineToolkit/src/Model/Entity/LoginIdentifierType.php index 5e24a0756..c356c1926 100644 --- a/app/availableplugins/PipelineToolkit/src/Model/Entity/LoginIdentifierType.php +++ b/app/availableplugins/PipelineToolkit/src/Model/Entity/LoginIdentifierType.php @@ -34,6 +34,8 @@ // use \App\Model\Entity\ExternalIdentityRole; class LoginIdentifierType extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -43,7 +45,7 @@ class LoginIdentifierType extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapper.php b/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapper.php index 3629f6a4f..f47bc355b 100644 --- a/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapper.php +++ b/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapper.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class PersonRoleMapper extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class PersonRoleMapper extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapping.php b/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapping.php index c817649c3..1a68d5468 100644 --- a/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapping.php +++ b/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapping.php @@ -34,6 +34,8 @@ use \App\Model\Entity\ExternalIdentityRole; class PersonRoleMapping extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -43,7 +45,7 @@ class PersonRoleMapping extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/availableplugins/PipelineToolkit/src/Model/Table/PersonRoleMappersTable.php b/app/availableplugins/PipelineToolkit/src/Model/Table/PersonRoleMappersTable.php index 9bdd1bf90..9dc38c1b4 100644 --- a/app/availableplugins/PipelineToolkit/src/Model/Table/PersonRoleMappersTable.php +++ b/app/availableplugins/PipelineToolkit/src/Model/Table/PersonRoleMappersTable.php @@ -109,7 +109,7 @@ public function buildPersonRole( // We need our Mapping configuration, which won't be in $flange $mappings = $this->PersonRoleMappings->find() ->where(['person_role_mapper_id' => $flange->person_role_mapper->id]) - ->order(['ordr' => 'ASC']) + ->orderBy(['ordr' => 'ASC']) ->all(); foreach($mappings as $mapping) { diff --git a/app/availableplugins/PipelineToolkit/src/config/plugin.json b/app/availableplugins/PipelineToolkit/src/config/plugin.json deleted file mode 100644 index 3bd7608d9..000000000 --- a/app/availableplugins/PipelineToolkit/src/config/plugin.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "types": { - "pipeline": [ - "IdentifierMappers", - "PersonRoleMappers" - ] - }, - "schema": { - "tables": { - "identifier_mappers": { - "columns": { - "id": {}, - "flange_id": { "type": "integer", "foreignkey": { "table": "flanges", "column": "id" } } - }, - "indexes": { - "identifier_mappers_i1": { "columns": [ "flange_id" ] } - } - }, - - "login_identifier_types": { - "columns": { - "id": {}, - "identifier_mapper_id": { "type": "integer", "foreignkey": { "table": "identifier_mappers", "column": "id" } }, - "type_id": {}, - "login": { "type": "boolean" } - }, - "indexes": { - "login_identifier_types_i1": { "columns": [ "identifier_mapper_id"] }, - "login_identifier_types_i2": { "needed": false, "columns": [ "type_id"] } - } - }, - - "person_role_mappers": { - "columns": { - "id": {}, - "flange_id": { "type": "integer", "foreignkey": { "table": "flanges", "column": "id" } } - }, - "indexes": { - "person_role_mappers_i1": { "columns": [ "flange_id" ] } - } - }, - "person_role_mappings": { - "columns": { - "id": {}, - "person_role_mapper_id": { "type": "integer", "foreignkey": { "table": "person_role_mappers", "column": "id" } }, - "attribute": { "type": "string", "size": 80 }, - "ad_hoc_tag": { "type": "string", "size": 128 }, - "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "comparison": { "type": "string", "size": 4 }, - "pattern": { "type": "string", "size": 80 }, - "target_cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, - "target_affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "ordr": {} - }, - "indexes": { - "person_role_mappings_i1": { "columns": [ "person_role_mapper_id" ] }, - "person_role_mappings_i2": { "needed": false, "columns": [ "affiliation_type_id" ] }, - "person_role_mappings_i3": { "needed": false, "columns": [ "target_cou_id" ] }, - "person_role_mappings_i4": { "needed": false, "columns": [ "target_affiliation_type_id" ] } - } - } - } - } -} \ No newline at end of file diff --git a/app/availableplugins/PipelineToolkit/templates/IdentifierMappers/fields-nav.inc b/app/availableplugins/PipelineToolkit/templates/IdentifierMappers/fields-nav.inc deleted file mode 100644 index e5db5ae5c..000000000 --- a/app/availableplugins/PipelineToolkit/templates/IdentifierMappers/fields-nav.inc +++ /dev/null @@ -1,44 +0,0 @@ - 'plugin', - 'active' => 'plugin' -]; - -$topLinks[] = [ - 'icon' => 'transform', - 'order' => 'Default', - 'label' => __d('pipeline_toolkit', 'controller.LoginIdentifierTypes', [99]), - 'link' => [ - 'plugin' => 'PipelineToolkit', - 'controller' => 'LoginIdentifierTypes', - 'action' => 'index', - 'identifier_mapper_id' => $vv_obj->id - ], - 'class' => '' -]; diff --git a/app/availableplugins/PipelineToolkit/templates/IdentifierMappers/fields.inc b/app/availableplugins/PipelineToolkit/templates/IdentifierMappers/fields.inc index ad18b384e..403f3f929 100644 --- a/app/availableplugins/PipelineToolkit/templates/IdentifierMappers/fields.inc +++ b/app/availableplugins/PipelineToolkit/templates/IdentifierMappers/fields.inc @@ -27,4 +27,18 @@ // There are currently no directly configurable fields for this model.) $suppress_submit = true; -?> \ No newline at end of file + +// Top Links +$topLinks[] = [ + 'icon' => 'transform', + 'order' => 'Default', + 'label' => __d('pipeline_toolkit', 'controller.LoginIdentifierTypes', [99]), + 'link' => [ + 'plugin' => 'PipelineToolkit', + 'controller' => 'LoginIdentifierTypes', + 'action' => 'index', + 'identifier_mapper_id' => $vv_obj->id + ], + 'class' => '' +]; +?> diff --git a/app/availableplugins/PipelineToolkit/templates/LoginIdentifierTypes/fields.inc b/app/availableplugins/PipelineToolkit/templates/LoginIdentifierTypes/fields.inc index d1866d0a1..69c161ed5 100644 --- a/app/availableplugins/PipelineToolkit/templates/LoginIdentifierTypes/fields.inc +++ b/app/availableplugins/PipelineToolkit/templates/LoginIdentifierTypes/fields.inc @@ -25,14 +25,7 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does not support read-only -if($vv_action == 'add' || $vv_action == 'edit') { - foreach (['type_id', - 'login' - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field - ]]); - } -} +$fields = [ + 'type_id', + 'login' +]; diff --git a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields-nav.inc b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields-nav.inc deleted file mode 100644 index 2f2ed3258..000000000 --- a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields-nav.inc +++ /dev/null @@ -1,44 +0,0 @@ - 'plugin', - 'active' => 'plugin' -]; - -$topLinks[] = [ - 'icon' => 'transform', - 'order' => 'Default', - 'label' => __d('pipeline_toolkit', 'controller.PersonRoleMappings', [99]), - 'link' => [ - 'plugin' => 'PipelineToolkit', - 'controller' => 'PersonRoleMappings', - 'action' => 'index', - 'person_role_mapper_id' => $vv_obj->id - ], - 'class' => '' -]; diff --git a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields.inc b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields.inc index c50caa436..5579fb55a 100644 --- a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields.inc +++ b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields.inc @@ -27,4 +27,19 @@ // There are currently no directly configurable fields for this model.) $suppress_submit = true; -?> \ No newline at end of file + +// Top Links +$topLinks[] = [ + 'icon' => 'transform', + 'order' => 'Default', + 'label' => __d('pipeline_toolkit', 'controller.PersonRoleMappings', [99]), + 'link' => [ + 'plugin' => 'PipelineToolkit', + 'controller' => 'PersonRoleMappings', + 'action' => 'index', + 'person_role_mapper_id' => $vv_obj->id + ], + 'class' => '' +]; + +?> diff --git a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappings/fields.inc b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappings/fields.inc index ddd7dd9b0..7162e3d23 100644 --- a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappings/fields.inc +++ b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappings/fields.inc @@ -24,9 +24,24 @@ * @since COmanage Registry v5.0.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ + +$fields = [ + 'attribute' => [ + 'type' => 'select' + ], + 'ad_hoc_tag', + 'affiliation_type_id', + 'comparison', + 'pattern', + 'target_cou_id', + 'target_affiliation_type_id', + 'ordr' +]; + ?> - -element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'attribute', - 'fieldOptions' => [ - 'onChange' => 'updateGadgets()' - ], - 'fieldType' => 'select' - ] - ]); - - foreach (['ad_hoc_tag', - 'affiliation_type_id', - 'comparison', - 'pattern', - 'target_cou_id', - 'target_affiliation_type_id', - 'ordr' - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field - ]]); - } -} + + // register onchange events + // note that underscores in field names above must be represented with hyphens here. + $('#attribute').change(function() { + updateGadgets(); + }); + }); + \ No newline at end of file diff --git a/app/availableplugins/SqlConnector/config/plugin.json b/app/availableplugins/SqlConnector/config/plugin.json new file mode 100644 index 000000000..2a0953ca3 --- /dev/null +++ b/app/availableplugins/SqlConnector/config/plugin.json @@ -0,0 +1,301 @@ +{ + "types": { + "provisioning_target": [ + "SqlProvisioners" + ], + "external_identity_source": [ + "SqlSources" + ] + }, + "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" ]} + } + }, + "sql_sources": { + "columns": { + "id": {}, + "external_identity_source_id": {}, + "server_id": { "notnull": false }, + "table_mode": { "type": "string", "size": 2 }, + "source_table": { "type": "string", "size": 80 }, + "address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "pronouns_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "url_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "threshold_check": { "type": "integer" }, + "threshold_override": { "type": "boolean" } + }, + "indexes": { + "sql_sources_i1": { "columns": [ "external_identity_source_id" ]}, + "sql_sources_i2": { "columns": [ "server_id" ]}, + "sql_sources_i3": { "needed": false, "columns": [ "address_type_id" ]}, + "sql_sources_i4": { "needed": false, "columns": [ "email_address_type_id" ]}, + "sql_sources_i5": { "needed": false, "columns": [ "identifier_type_id" ]}, + "sql_sources_i6": { "needed": false, "columns": [ "name_type_id" ]}, + "sql_sources_i7": { "needed": false, "columns": [ "pronouns_type_id" ]}, + "sql_sources_i8": { "needed": false, "columns": [ "telephone_number_type_id" ]}, + "sql_sources_i9": { "needed": false, "columns": [ "url_type_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 + }, + + "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" ], + "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" ], + "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" ], + "changelog": false + }, + + "identifiers": { + "columns": { + "id": {}, + "identifier": { "type": "string", "size": 512 }, + "type_id": {}, + "login": { "type": "boolean" }, + "status": {} + }, + "indexes": { + "identifiers_i3": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "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" ], + "changelog": false + }, + + "pronouns": { + "columns": { + "id": {}, + "pronouns": { "type": "string", "size": 64 }, + "language": {}, + "type_id": {} + }, + "indexes": { + "pronouns_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person" ], + "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" ], + "changelog": false + }, + + "urls": { + "columns": { + "id": {}, + "url": { "type": "string", "size": 256 }, + "description": {}, + "type_id": {} + }, + "indexes": { + "urls_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person" ], + "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/resources/locales/en_US/sql_connector.po b/app/availableplugins/SqlConnector/resources/locales/en_US/sql_connector.po index fdf0f1eb3..f1f9f8dc0 100644 --- a/app/availableplugins/SqlConnector/resources/locales/en_US/sql_connector.po +++ b/app/availableplugins/SqlConnector/resources/locales/en_US/sql_connector.po @@ -25,6 +25,9 @@ msgid "controller.SqlProvisioners" msgstr "{0,plural,=1{SQL Provisioner} other{SQL Provisioners}}" +msgid "display.SqlSource" +msgstr "{0} Source" + msgid "enumeration.SqlSourceTableModeEnum.FL" msgstr "Flat" diff --git a/app/availableplugins/SqlConnector/src/Controller/SqlProvisionersController.php b/app/availableplugins/SqlConnector/src/Controller/SqlProvisionersController.php index 40b1ae658..f700a31c5 100644 --- a/app/availableplugins/SqlConnector/src/Controller/SqlProvisionersController.php +++ b/app/availableplugins/SqlConnector/src/Controller/SqlProvisionersController.php @@ -30,14 +30,36 @@ namespace SqlConnector\Controller; use App\Controller\StandardPluginController; +use Cake\Event\EventInterface; class SqlProvisionersController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'SqlProvisioners.id' => 'asc' ] ]; + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->SqlProvisioners->ProvisioningTargets->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->SqlProvisioners->ProvisioningTargets->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->SqlProvisioners->ProvisioningTargets->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + /** * Reapply the target database schema. * diff --git a/app/availableplugins/SqlConnector/src/Controller/SqlSourcesController.php b/app/availableplugins/SqlConnector/src/Controller/SqlSourcesController.php index 091938103..903f05490 100644 --- a/app/availableplugins/SqlConnector/src/Controller/SqlSourcesController.php +++ b/app/availableplugins/SqlConnector/src/Controller/SqlSourcesController.php @@ -30,11 +30,34 @@ namespace SqlConnector\Controller; use App\Controller\StandardPluginController; +use Cake\Event\EventInterface; +use Cake\Http\Response; class SqlSourcesController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'SqlSources.id' => 'asc' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.2.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->SqlSources->ExternalIdentitySources->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->SqlSources->ExternalIdentitySources->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->SqlSources->ExternalIdentitySources->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } diff --git a/app/availableplugins/SqlConnector/src/Model/Entity/SqlProvisioner.php b/app/availableplugins/SqlConnector/src/Model/Entity/SqlProvisioner.php index 5d5ff2247..92667dd1a 100644 --- a/app/availableplugins/SqlConnector/src/Model/Entity/SqlProvisioner.php +++ b/app/availableplugins/SqlConnector/src/Model/Entity/SqlProvisioner.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class SqlProvisioner extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class SqlProvisioner extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/availableplugins/SqlConnector/src/Model/Entity/SqlSource.php b/app/availableplugins/SqlConnector/src/Model/Entity/SqlSource.php index ce1bd4e61..c2ad280e9 100644 --- a/app/availableplugins/SqlConnector/src/Model/Entity/SqlSource.php +++ b/app/availableplugins/SqlConnector/src/Model/Entity/SqlSource.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class SqlSource extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class SqlSource extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_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 index ba0835387..12fae5525 100644 --- a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php +++ b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php @@ -213,6 +213,15 @@ class SqlProvisionersTable extends Table { 'related' => [] ] /* XXX not yet implemented + +Note that all currently supported reference data (Cous, Types, and Groups, +which are treated like reference data) are also provisionable, meaning we +don't need to jump through special hoops to detect when they have changed. +If we add additional reference models that are not provisionable, then we +will need to implement something like v4's Reference Data Event Listener, +which would call syncReferenceData() on model.afterSave for the appropriate +reference data mode. + [ 'table' => 'co_terms_and_conditions', // Ordinarily we'd call this SpCoTermsAndConditions, but it's not worth @@ -256,9 +265,8 @@ public function initialize(array $config): void { $this->setAutoViewVars([ 'servers' => [ - 'type' => 'select', - 'model' => 'Servers', - 'where' => ['plugin' => 'CoreServer.SqlServers'] + 'type' => 'plugin', + 'model' => 'CoreServer.SqlServers' ] ]); @@ -338,7 +346,9 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour // Also, When the SQL Provisioner is deleted, neither the // database schema nor reference data is touched (PAR-SqlProvisioner-4). - if(!empty($entity->server_id) && !$entity->deleted) { + // Similarly, we skip this when cloning. + if(!empty($entity->server_id) && !$entity->deleted + && (!isset($options['clone']) || !$options['clone'])) { // Apply the database schema (PAR-SqlProvisioner-1) $this->llog('rule', "PAR-SqlProvisioner-1 Applying database schema for SqlProvisioner " . $entity->id); $this->applySchema($entity->id); @@ -355,7 +365,7 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour * Provision object data to the provisioning target. * * @since COmanage Registry v5.0.0 - * @param SqlProvisioner $provisioningTarget SqlProvisioner configuration + * @param ProvisioningTarget $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 @@ -363,22 +373,90 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour */ public function provision( - \SqlConnector\Model\Entity\SqlProvisioner $provisioningTarget, + \App\Model\Entity\ProvisioningTarget $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'); + // Connect to the target database. We append the SQL Provisioner ID to generate a + // unique data source name in case there are multiple SQL Provisioners in the same CO. + // Table Alias construction in the sync* commands will need to do the same thing. + + $cxnLabel = "targetdb" . $provisioningTarget->sql_provisioner->id; + + $this->Servers->SqlServers->connect($provisioningTarget->sql_provisioner->server_id, $cxnLabel); return $this->syncEntity( - $provisioningTarget, + $provisioningTarget->sql_provisioner, $className, $data, - $eligibility + $eligibility, + $cxnLabel ); } + /** + * Obtain status information for the requested provisioned subject. + * + * @since COmanage Registry v5.2.0 + * @param ProvisioningTarget $cfg Provisioning Target configuration + * @param int $groupId Group ID to retrieve status for + * @param int $personId Person ID to retrieve status for + * @return array Array of status information: status, comment, timestamp + */ + + public function status( + \App\Model\Entity\ProvisioningTarget $cfg, + ?int $groupId, + ?int $personId + ): array { + $ret = [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('enumeration', 'ProvisioningStatusEnum.N'), + 'timestamp' => null + ]; + + // We just look for the primary in the appropriate table. + + if($personId) { + $mconfig = $this->primaryModels['People']; + $id = $personId; + } else { + $mconfig = $this->primaryModels['Groups']; + $id = $groupId; + } + + // We use the same cxnLabel logic as provision(). + $cxnLabel = "targetdb" . $cfg->sql_provisioner->id; + + $this->Servers->SqlServers->connect($cfg->sql_provisioner->server_id, $cxnLabel); + + $options = [ + 'table' => $cfg->sql_provisioner->table_prefix . $mconfig['table'], + 'alias' => $mconfig['name'] . $cfg->sql_provisioner->id, + 'connection' => ConnectionManager::get($cxnLabel) + ]; + + $SpTable = TableUtilities::getTableFromRegistry(alias: $options['alias'], options: $options); + + try { + $curEntity = $SpTable->get($id); + + $ret['status'] = ProvisioningStatusEnum::Provisioned; + $ret['comment'] = __d('enumeration', 'ProvisioningStatusEnum.P'); + $ret['timestamp'] = $curEntity->modified; + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + // Record not found, the default $ret will suffice + } + catch(\Exception $e) { + $ret['status'] = ProvisioningStatusEnum::Unknown; + $ret['comment'] = $e->getMessage(); + } + + return $ret; + } + /** * Sync an entity to the target database schema. * @@ -396,7 +474,8 @@ protected function syncEntity( string $entityName, $data, string $eligibility, - string $dataSource='targetdb'): array { + 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] @@ -407,14 +486,14 @@ protected function syncEntity( } // Pull the current target record -// XXX similar code in syncReferenceData, refactor? +// XXX similar code in syncReferenceData and status, refactor? $options = [ 'table' => $SqlProvisioner->table_prefix . $mconfig['table'], - 'alias' => $mconfig['name'], + 'alias' => $mconfig['name'] . $SqlProvisioner->id, 'connection' => ConnectionManager::get($dataSource) ]; - $SpTable = TableUtilities::getTableFromRegistry(alias: $mconfig['name'], options: $options); + $SpTable = TableUtilities::getTableFromRegistry(alias: $options['alias'], options: $options); try { $curEntity = $SpTable->get($data->id); @@ -430,6 +509,11 @@ protected function syncEntity( // We have a currently provisioned record and the subject is not Deleted, // patch it with $data and try saving. $patchedEntity = $SpTable->patchEntity($curEntity, $data->toArray(), ['validate' => false]); + + // We always force an update to the modified timestamp in order to allow status() + // to report the last time provisioning ran. Cake has a touch() function as part + // of TimestampBehavior to do this, but our use of auto-tables means we can't use that. + $patchedEntity->modified = new \Cake\I18n\DateTime(); $SpTable->saveOrFail( $patchedEntity, @@ -550,7 +634,7 @@ protected function syncEntity( */ public function syncReferenceData(int $id, string $dataSource='targetdb') { - $spcfg = $this->get($id, ['contain' => ['ProvisioningTargets']]); + $spcfg = $this->get($id, contain: ['ProvisioningTargets']); $this->Servers->SqlServers->connect($spcfg->server_id, $dataSource); @@ -566,11 +650,11 @@ public function syncReferenceData(int $id, string $dataSource='targetdb') { $options = [ 'table' => $spcfg->table_prefix . $m['table'], - 'alias' => $m['name'], + 'alias' => $m['name'] . $id, 'connection' => ConnectionManager::get($dataSource) ]; - $SpTable = TableUtilities::getTableFromRegistry(alias: $m['name'], options: $options); + $SpTable = TableUtilities::getTableFromRegistry(alias: $options['alias'], options: $options); // Next get the source table model @@ -641,7 +725,8 @@ protected function syncRelatedEntities( string $relatedEntityName, $parentData, string $eligibility, - string $dataSource='targetdb') { + string $dataSource='targetdb' + ) { // eg: person_id $parentFk = StringUtilities::entityToForeignKey($parentData); // eg: names @@ -658,11 +743,11 @@ protected function syncRelatedEntities( $options = [ 'table' => $SqlProvisioner->table_prefix . $mconfig['table'], - 'alias' => $mconfig['name'], + 'alias' => $mconfig['name'] . $SqlProvisioner->id, 'connection' => ConnectionManager::get($dataSource) ]; - $SpTable = TableUtilities::getTableFromRegistry(alias: $mconfig['name'], options: $options); + $SpTable = TableUtilities::getTableFromRegistry(alias: $options['alias'], options: $options); // We have the source values, but we need to convert them to arrays // for patchEntities @@ -742,11 +827,11 @@ protected function syncRelatedEntities( $options = [ 'table' => $SqlProvisioner->table_prefix . $subconfig['table'], - 'alias' => $subconfig['name'], + 'alias' => $subconfig['name'] . $SqlProvisioner->id, 'connection' => ConnectionManager::get($dataSource) ]; - $SubTable = TableUtilities::getTableFromRegistry(alias: $subconfig['name'], options: $options); + $SubTable = TableUtilities::getTableFromRegistry(alias: $options['alias'], options: $options); foreach($toDelete as $d) { // We shouldn't get here if either $parentKey or $d->id is null... diff --git a/app/availableplugins/SqlConnector/src/Model/Table/SqlSourcesTable.php b/app/availableplugins/SqlConnector/src/Model/Table/SqlSourcesTable.php index a9170e643..1bbf353f5 100644 --- a/app/availableplugins/SqlConnector/src/Model/Table/SqlSourcesTable.php +++ b/app/availableplugins/SqlConnector/src/Model/Table/SqlSourcesTable.php @@ -45,6 +45,7 @@ class SqlSourcesTable extends Table { use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -105,6 +106,16 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_source_id']); $this->setRequiresCO(true); + + $this->setEditContains([ + 'Servers' => ['SqlServers'], + 'ExternalIdentitySources', + ]); + + $this->setViewContains([ + 'Servers' => ['SqlServers'], + 'ExternalIdentitySources', + ]); $this->setAutoViewVars([ 'addressTypes' => [ @@ -128,9 +139,8 @@ public function initialize(array $config): void { 'attribute' => 'Pronouns.type' ], 'servers' => [ - 'type' => 'select', - 'model' => 'Servers', - 'where' => ['plugin' => 'CoreServer.SqlServers'] + 'type' => 'plugin', + 'model' => 'CoreServer.SqlServers' ], 'telephoneNumberTypes' => [ 'type' => 'type', @@ -186,10 +196,10 @@ protected function getChanges( if($SourceTable->getSchema()->getColumnType('modified')) { $this->llog('trace', "Calculating changes via modified timestamp for " . $source->description); - $query = $SourceTable->find('list', [ - 'keyField' => 'source_key', - 'valueField' => 'modified' - ]) + $query = $SourceTable->find('list', + keyField: 'source_key', + valueField: 'modified', + ) ->where([ 'modified >' => date('Y-m-d H:i:s', $lastStart), 'modified <=' => date('Y-m-d H:i:s', $curStart) @@ -213,6 +223,18 @@ protected function getChanges( return false; } + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.2.0 + * @param \SqlConnector\Model\Entity\SqlSource $entity Entity to generate display field for + * @return string Display field + */ + public function generateDisplayField(\SqlConnector\Model\Entity\SqlSource $entity): string { + return __d('sql_connector', 'display.SqlSource', [$entity->external_identity_source->description]); + } + + /** * Obtain the set of changed records from the source database. * @@ -246,10 +268,10 @@ protected function getInventory( ): int|array { $SourceTable = $this->getRecordTable($source->sql_source); - $query = $SourceTable->find('list', [ - 'keyField' => 'source_key', - 'valueField' => 'modified' - ]); + $query = $SourceTable->find('list', + keyField: 'source_key', + valueField: 'modified', + ); if($count) { return $query->count(); diff --git a/app/availableplugins/SqlConnector/src/config/plugin.json b/app/availableplugins/SqlConnector/src/config/plugin.json deleted file mode 100644 index 5d32cb333..000000000 --- a/app/availableplugins/SqlConnector/src/config/plugin.json +++ /dev/null @@ -1,301 +0,0 @@ -{ - "types": { - "provisioner": [ - "SqlProvisioners" - ], - "source": [ - "SqlSources" - ] - }, - "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" ]} - } - }, - "sql_sources": { - "columns": { - "id": {}, - "external_identity_source_id": {}, - "server_id": { "notnull": false }, - "table_mode": { "type": "string", "size": 2 }, - "source_table": { "type": "string", "size": 80 }, - "address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "pronouns_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "url_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "threshold_check": { "type": "integer" }, - "threshold_override": { "type": "boolean" } - }, - "indexes": { - "sql_sources_i1": { "columns": [ "external_identity_source_id" ]}, - "sql_sources_i2": { "columns": [ "server_id" ]}, - "sql_sources_i3": { "needed": false, "columns": [ "address_type_id" ]}, - "sql_sources_i4": { "needed": false, "columns": [ "email_address_type_id" ]}, - "sql_sources_i5": { "needed": false, "columns": [ "identifier_type_id" ]}, - "sql_sources_i6": { "needed": false, "columns": [ "name_type_id" ]}, - "sql_sources_i7": { "needed": false, "columns": [ "pronouns_type_id" ]}, - "sql_sources_i8": { "needed": false, "columns": [ "telephone_number_type_id" ]}, - "sql_sources_i9": { "needed": false, "columns": [ "url_type_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 - }, - - "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" ], - "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" ], - "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" ], - "changelog": false - }, - - "identifiers": { - "columns": { - "id": {}, - "identifier": { "type": "string", "size": 512 }, - "type_id": {}, - "login": { "type": "boolean" }, - "status": {} - }, - "indexes": { - "identifiers_i3": { "columns": [ "type_id" ] } - }, - "mvea": [ "person", "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" ], - "changelog": false - }, - - "pronouns": { - "columns": { - "id": {}, - "pronouns": { "type": "string", "size": 64 }, - "language": {}, - "type_id": {} - }, - "indexes": { - "pronouns_i1": { "columns": [ "type_id" ] } - }, - "mvea": [ "person" ], - "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" ], - "changelog": false - }, - - "urls": { - "columns": { - "id": {}, - "url": { "type": "string", "size": 256 }, - "description": {}, - "type_id": {} - }, - "indexes": { - "urls_i1": { "columns": [ "type_id" ] } - }, - "mvea": [ "person" ], - "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.inc b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields.inc index 9ea45315a..f82265102 100644 --- a/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields.inc +++ b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields.inc @@ -25,20 +25,41 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only -if($vv_action == 'add' || $vv_action == 'edit') { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'server_id', - ] - ]); +$fields = [ + 'server_id', + 'table_prefix' => [ + 'default' => 'sp_' + ] +]; - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'table_prefix', - 'fieldOptions' => [ - 'default' => 'sp_' - ] - ] - ]); -} +$subnav = [ + 'tabs' => ['ProvisioningTargets', 'SqlConnector.SqlProvisioners'], + 'action' => [ + 'ProvisioningTargets' => ['edit'], + 'SqlConnector.SqlProvisioners' => ['edit'] + ] +]; + +// Top Links +$topLinks = [ + [ + 'icon' => 'history', + 'order' => 'Default', + 'label' => __d('sql_connector', 'operation.reapply'), + 'link' => [ + 'action' => 'reapply', + $vv_obj->id + ], + 'class' => '' + ], + [ + 'icon' => 'history', + 'order' => 'Default', + 'label' => __d('sql_connector', 'operation.resync'), + 'link' => [ + 'action' => 'resync', + $vv_obj->id + ], + 'class' => '' + ] +]; \ No newline at end of file diff --git a/app/availableplugins/SqlConnector/templates/SqlSources/fields-nav.inc b/app/availableplugins/SqlConnector/templates/SqlSources/fields-nav.inc deleted file mode 100644 index 1ac382831..000000000 --- a/app/availableplugins/SqlConnector/templates/SqlSources/fields-nav.inc +++ /dev/null @@ -1,31 +0,0 @@ - 'plugin', - 'active' => 'plugin' - ]; \ No newline at end of file diff --git a/app/availableplugins/SqlConnector/templates/SqlSources/fields.inc b/app/availableplugins/SqlConnector/templates/SqlSources/fields.inc index f0b65af0b..2081bf11b 100644 --- a/app/availableplugins/SqlConnector/templates/SqlSources/fields.inc +++ b/app/availableplugins/SqlConnector/templates/SqlSources/fields.inc @@ -24,8 +24,46 @@ * @since COmanage Registry v5.0.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ + +$fields = [ + 'server_id', + 'table_mode' => [ + 'type' => 'select' + ] +]; + +foreach([ + 'source_table', + 'address_type_id', + 'email_address_type_id', + 'identifier_type_id', + 'name_type_id', + 'pronouns_type_id', + 'telephone_number_type_id', + 'url_type_id' + ] as $field) { + $fields[$field] = [ + 'required' => false + ]; +} + +$fields = array_merge($fields, [ + 'threshold_check', + 'threshold_override' +]); + +$subnav = [ + 'tabs' => ['ExternalIdentitySources', 'SqlConnector.SqlSources', 'ExternalIdentitySources@action.search'], + 'action' => [ + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'SqlConnector.SqlSources' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], +]; + ?> - -element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'server_id' - ] - ]); - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'table_mode', - 'fieldOptions' => [ - 'onChange' => 'updateGadgets()' - ], - 'fieldType' => 'select' - ] - ]); - - foreach([ - 'source_table', - 'address_type_id', - 'email_address_type_id', - 'identifier_type_id', - 'name_type_id', - 'pronouns_type_id', - 'telephone_number_type_id', - 'url_type_id' - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field, - 'fieldOptions' => [ - 'required' => false - ] - ] - ]); - } - - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'threshold_check' - ] - ]); + $(function() { + // run on first load + updateGadgets(true); - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'threshold_override' - ] - ]); -} + // register onchange events + // note that underscores in field names above must be represented with hyphens here. + $('#table-mode').change(function() { + updateGadgets(); + }); + }); + \ No newline at end of file diff --git a/app/availableplugins/Transmogrify/README.md b/app/availableplugins/Transmogrify/README.md new file mode 100644 index 000000000..3ce3d58ef --- /dev/null +++ b/app/availableplugins/Transmogrify/README.md @@ -0,0 +1,9 @@ +# Transmogrify (COmanage Registry Plugin) + +Transmogrify is a command‑line migration tool bundled as a CakePHP plugin for COmanage Registry PE. It copies and transforms data from a legacy/source Registry schema (cm_… tables) into the PE target schema. + +## Documentation + +For background, concepts, and Registry-side guidance on transmogrification, see the COmanage documentation: + +- [COmanage Registry Transmogrification](https://spaces.at.internet2.edu/spaces/COmanage/pages/178161313/Registry+Transmogrification) \ No newline at end of file diff --git a/app/availableplugins/Transmogrify/config/plugin.json b/app/availableplugins/Transmogrify/config/plugin.json new file mode 100644 index 000000000..2c63c0851 --- /dev/null +++ b/app/availableplugins/Transmogrify/config/plugin.json @@ -0,0 +1,2 @@ +{ +} diff --git a/app/availableplugins/Transmogrify/config/schema/tables.json b/app/availableplugins/Transmogrify/config/schema/tables.json new file mode 100644 index 000000000..a94e20d3f --- /dev/null +++ b/app/availableplugins/Transmogrify/config/schema/tables.json @@ -0,0 +1,948 @@ +{ + "__COMMENT__": "Template for adding new table configurations to the Transmogrify mapping. Keys starting with double underscores (__) are ignored by processors and exist only for documentation.", + "__EXAMPLE_TABLE_TEMPLATE__": { + "__INSTRUCTIONS_1__": "Copy this object, rename the key to your logical table name (eg, 'my_items'), and adjust values. Do NOT leave this example enabled in production.", + "__INSTRUCTIONS_2__": "When mapping legacy type fields, call the function-based mapper (eg, &map...Type) before configuring null for the old column name. The mapper still needs the original source column value, and performNoMapping() will unset that column once it sees a null mapping. In other words, always place the null mapping for the old column after the new field’s function mapping so the mapper can read the legacy value before it is removed.", + "__INSTRUCTIONS_3__": "Columns new to v5 (not in v4) should be omitted entirely from the configuration.", + "__INSTRUCTIONS_4__": "Columns present in v4 but dropped in v5 must be mapped to null to be unset, and these mappings must go at the end of the fieldMap list.", + "source": "cm_my_items", + "displayField": "name", + "addChangelog": true, + "plugin": "MyItems", + "booleans": ["is_active", "is_primary"], + "cache": [ + "co_id", + ["co_id", "status"], + ["co_id", "name", "value"] + ], + "sqlSelect": "myItemsSqlSelect", + "preTable": "beforeInsertMyItems", + "postTable": "afterInsertMyItems", + "preRow": "beforeInsertMyItemRow", + "postRow": "afterInsertMyItemRow", + "fieldMap": { + "id": "id", + "co_id": "co_id", + "name": "name", + "description": "details", + "status": "status", + "value": "amount", + "created": "&mapNow", + "modified": "&mapNow", + "type_id": "&mapExtendedType", + "owner_identifier": "&mapIdentifierToPersonId", + "valid_from": "valid_from", + "valid_through": "valid_through" + }, + "__NOTES__": "Common keys: source (DB table), displayField (for printing), addChangelog (add created/modified history), booleans (type coercion), cache (key(s) to cache rows by), sqlSelect (custom SELECT provider function), pre/postTable and pre/postRow (hook function names), fieldMap (left=new table column, right=source column name or &function). Use &mapNow to inject current timestamp, &mapExtendedType to resolve cm_co_extended_types, and mapping helpers found in Transmogrify traits. Note: There is no generic config-driven enforcement for required/defaults/unique; enforce uniqueness via database constraints." + }, + "__NOTES__": "CONFIGURATION MIGRATIONS", + "cos": { + "source": "cm_cos", + "displayField": "name", + "addChangelog": true, + "cache": ["status"] + }, + "types": { + "source": "cm_co_extended_types", + "displayField": "display_name", + "postTable": "insertPronounTypes", + "fieldMap": { + "attribute": "&mapExtendedType", + "name": "value", + "created": "&mapNow", + "modified": "&mapNow" + }, + "cache": [["co_id", "attribute", "value"]], + "dependencies": ["cos"] + }, + "co_settings": { + "source": "cm_co_settings", + "displayField": "co_id", + "addChangelog": true, + "booleans": [], + "postTable": "insertDefaultSettings", + "cache": ["co_id"], + "fieldMap": { + "global_search_limit": "search_global_limit", + "required_fields_addr": "required_fields_address", + "permitted_fields_telephone_number": "&populateCoSettingsPhone", + "enable_nsf_demo": null, + "disable_expiration": null, + "disable_ois_sync": null, + "group_validity_sync_window": null, + "garbage_collection_interval": null, + "enable_normalization": null, + "enable_empty_cou": null, + "invitation_validity": null, + "t_and_c_login_mode": null, + "sponsor_eligibility": null, + "sponsor_co_group_id": null, + "theme_stacking": null, + "default_co_pipeline_id": null, + "elect_strategy_primary_name": null, + "co_dashboard_id": null, + "co_theme_id": null, + "person_picker_email_type": null, + "person_picker_identifier_type": null, + "person_picker_display_types": null, + "group_create_admin_only": null, + "t_and_c_return_url_allowlist": null, + "population_hide": null + }, + "dependencies": ["cos"] + }, + "api_users": { + "source": "cm_api_users", + "displayField": "username", + "booleans": ["privileged"], + "cache": ["co_id"], + "fieldMap": { + "password": "api_key" + }, + "dependencies": ["cos"] + }, + "cous": { + "source": "cm_cous", + "displayField": "name", + "sqlSelect": "couSqlSelect", + "dependencies": ["cos"] + }, + "servers": { + "source": "cm_servers", + "plugin": "CoreServer", + "displayField": "description", + "addChangelog": true, + "cache": ["co_id", "status"], + "fieldMap": { + "plugin": "&mapServerTypeToPlugin", + "server_type": null + }, + "dependencies": ["cos"] + }, + "http_servers": { + "source": "cm_http_servers", + "plugin": "CoreServer", + "displayField": "serverurl", + "addChangelog": true, + "booleans": [ + "ssl_verify_host", + "ssl_verify_peer" + ], + "cache": ["server_id"], + "fieldMap": { + "serverurl": "url", + "ssl_verify_peer": "skip_ssl_verification", + "ssl_verify_host": null + }, + "dependencies": ["servers"] + }, + "oauth2_servers": { + "source": "cm_oauth2_servers", + "plugin": "CoreServer", + "displayField": "serverurl", + "addChangelog": true, + "cache": ["server_id"], + "booleans": ["access_token_exp"], + "fieldMap": { + "serverurl": "url", + "proxy": null + }, + "dependencies": ["servers"] + }, + "sql_servers": { + "source": "cm_sql_servers", + "plugin": "CoreServer", + "displayField": "hostname", + "cache": ["server_id"], + "addChangelog": true, + "fieldMap": { + "dbport": "port" + }, + "dependencies": ["servers"] + }, + "match_servers": { + "source": "cm_match_servers", + "plugin": "CoreServer", + "displayField": "username", + "addChangelog": true, + "booleans": [ + "ssl_verify_peer", + "ssl_verify_host" + ], + "cache": ["server_id"], + "fieldMap": { + "serverurl": "url", + "auth_type": "?BA", + "ssl_verify_peer": "skip_ssl_verification", + "ssl_verify_host": null + }, + "dependencies": ["servers"] + }, + "match_server_attributes": { + "source": "cm_match_server_attributes", + "displayField": "attribute", + "plugin": "CoreServer", + "addChangelog": true, + "booleans": [ + "required" + ], + "cache": ["match_server_id"], + "fieldMap": { + "type_id": "&mapMatchAttributeTypeId", + "type": null + }, + "dependencies": ["match_servers", "types"] + }, + "message_templates": { + "source": "cm_co_message_templates", + "displayField": "description", + "fieldMap": { + "context": "&mapMessageTemplateContext", + "message_subject": "subject", + "message_body": "body_text", + "message_body_html": "body_html", + "co_message_template_id": "message_template_id" + }, + "dependencies": ["cos"] + }, + "pipelines": { + "source": "cm_co_pipelines", + "displayField": "description", + "cache": ["co_id"], + "fieldMap": { + "name": "description", + "sync_affiliation_type_id": "&mapAffiliationType", + "sync_identifier_type_id": "&mapIdentifierType", + "match_identifier_type_id": "&mapIdentifierType", + "match_email_address_type_id": "&mapEmailType", + "co_pipeline_id": "pipeline_id", + "sync_on_add": null, + "sync_on_update": null, + "sync_on_delete": null, + "sync_coperson_status": null, + "sync_coperson_attributes": null, + "create_role": null, + "match_type": null, + "sync_affiliation": null, + "sync_identifier_type": null, + "establish_clusters": null, + "co_enrollment_flow_id": null + }, + "dependencies": ["cos", "types"] + }, + "external_identity_sources": { + "source": "cm_org_identity_sources", + "displayField": "description", + "booleans": ["hash_source_record"], + "cache": ["co_id"], + "fieldMap": { + "plugin": "&mapExternalIdentitySourcePlugin", + "co_pipeline_id": "pipeline_id", + "org_identity_source_id": "external_identity_source_id", + "status": "&mapStatusAndSyncToStatus", + "sync_mode": null, + "sync_query_mismatch_mode": null, + "sync_query_skip_known": null, + "sync_on_user_login": null, + "eppn_identifier_type": null, + "eppn_suffix": null + }, + "dependencies": ["cos", "pipelines"] + }, + "orcid_sources": { + "source": "cm_orcid_sources", + "displayField": "id", + "plugin": "OrcidSource", + "booleans": [ + "scope_inherit" + ], + "cache": ["external_identity_source_id", "server_id"], + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "default_affiliation_type_id": "&mapToDefaultAffiliationTypeId", + "address_type_id": "&mapToDefaultAddressTypeId", + "email_address_type_id": "&mapToDefaultEmailAddressTypeId", + "name_type_id": "&mapToDefaultNameTypeId", + "telephone_number_type_id": "&mapToDefaultTelephoneNumberTypeId" + }, + "addChangelog": true, + "dependencies": ["external_identity_sources", "types"] + }, + "orcid_tokens": { + "source": "cm_orcid_tokens", + "displayField": "id", + "plugin": "OrcidSource", + "cache": ["orcid_identifier", "orcid_source_id"], + "addChangelog": true, + "dependencies": ["orcid_sources"] + }, + "env_sources": { + "source": "cm_env_sources", + "plugin": "EnvSource", + "displayField": "id", + "addChangelog": true, + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "redirect_on_duplicate": "redirect_on_duplicate", + "sp_type": "sp_mode", + "default_affiliation_type_id": "&mapAffiliationType", + "address_type_id": "&mapToDefaultAddressTypeId", + "email_address_type_id": "&mapToDefaultEmailAddressTypeId", + "name_type_id": "&mapToDefaultNameTypeId", + "telephone_number_type_id": "&mapToDefaultTelephoneNumberTypeId", + "env_o": "env_organization", + "env_ou": "env_department", + "env_identifier_sorid": "env_identifier_sourcekey", + "env_identifier_eppn_login": null, + "env_identifier_eptid_login": null, + "env_identifier_epuid_login": null, + "env_identifier_oidcsub_login": null, + "env_identifier_orcid": null, + "env_identifier_orcid_login": null, + "env_identifier_samlpairwiseid_login": null, + "env_identifier_samlsubjectid_login": null, + "env_identifier_sorid_login": null, + "env_identifier_network_login": null, + "duplicate_mode": null, + "default_affiliation": null + }, + "dependencies": ["external_identity_sources", "types"] + }, + "api_sources": { + "source": "cm_api_sources", + "plugin": "ApiConnector", + "displayField": "id", + "postRow": "createApiForApiSource", + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "api_user_id": null, + "poll_mode": null, + "kafka_server_id": null + }, + "addChangelog": true, + "dependencies": ["external_identity_sources"] + }, + "api_source_endpoints": { + "source": "cm_api_sources", + "plugin": "ApiConnector", + "displayField": "id", + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "api_id": "&mapApiIdFromCache", + "api_user_id": null, + "poll_mode": null, + "kafka_server_id": null + }, + "addChangelog": true, + "dependencies": ["external_identity_sources"] + }, + "file_sources": { + "source": "cm_file_sources", + "plugin": "FileConnector", + "displayField": "id", + "booleans": [ + "threshold_override" + ], + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "filepath": "filename", + "format": "=C3", + "threshold_warn": "threshold_check" + }, + "addChangelog": true, + "dependencies": ["external_identity_sources"] + }, + "sql_sources": { + "source": "cm_sql_sources", + "plugin": "SqlConnector", + "displayField": "id", + "booleans": [ + "threshold_override" + ], + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "address_type_id": "&mapAddressType", + "email_address_type_id": "&mapEmailType", + "identifier_type_id": "&mapIdentifierType", + "name_type_id": "&mapNameType", + "pronouns_type_id": "&mapPronounsTypeDefault", + "telephone_number_type_id": "&mapTelephoneType", + "url_type_id": "&mapUrlType", + "__NOTE__": "Old columns removal should go after the function mappers", + "address_type": null, + "email_address_type": null, + "identifier_type": null, + "name_type": null, + "telephone_number_type": null, + "url_type": null + }, + "addChangelog": true, + "dependencies": ["external_identity_sources", "types"] + }, + "authenticators": { + "source": "cm_authenticators", + "displayField": "description", + "cache": ["co_id"], + "fieldMap": { + "plugin": "&mapAuthenticatorPlugin", + "co_message_template_id": "message_template_id" + }, + "addChangelog": true, + "dependencies": ["cos", "message_templates"] + }, + "ssh_key_authenticators": { + "source": "cm_ssh_key_authenticators", + "displayField": "id", + "cache": ["authenticator_id"], + "addChangelog": true, + "dependencies": ["authenticators"] + }, + "password_authenticators": { + "source": "cm_password_authenticators", + "displayField": "id", + "plugin": "PasswordAuthenticator", + "cache": ["authenticator_id"], + "booleans": [ + "format_crypt_php", + "format_plaintext", + "format_sha1_ldap" + ], + "fieldMap": { + "password_source": "source_mode" + }, + "addChangelog": true, + "dependencies": ["authenticators"] + }, + "identifier_assignments": { + "source": "cm_co_identifier_assignments", + "displayField": "description", + "plugin": null, + "booleans": [ + "login", + "allow_empty" + ], + "cache": ["co_id"], + "postRow": "createIdentifierAssignmentPluginRecord", + "fieldMap": { + "co_group_id": "group_id", + "email_address_type_id": "&mapEmailType", + "identifier_type_id": "&mapIdentifierType", + "plugin": "&mapAlgorithmToPlugin", + "algorithm": null, + "format": null, + "minimum_length": null, + "minimum": null, + "maximum": null, + "transliterate": null, + "permitted": null, + "collision_resolution": null, + "exclusions": null, + "identifier_type": null, + "email_type": null + }, + "addChangelog": true, + "dependencies": ["cos", "groups", "types"] + }, + "format_assigner_sequences": { + "source": "cm_co_sequential_identifier_assignments", + "displayField": "id", + "fieldMap": { + "co_identifier_assignment_id": "format_assigner_id" + }, + "addChangelog": false, + "dependencies": ["identifier_assignments"] + }, + "__NOTES__": "DATA MIGRATIONS", + "authentication_events": { + "source": "cm_authentication_events", + "displayField": "authenticated_identifier", + "canSkip": "true", + "dependencies": [] + }, + "people": { + "source": "cm_co_people", + "displayField": "id", + "cache": ["co_id"], + "fieldMap": { + "co_person_id": "person_id", + "status": "&mapPersonStatus" + }, + "dependencies": ["cos"] + }, + "authenticator_statuses": { + "source": "cm_authenticator_statuses", + "displayField": "id", + "booleans": [ + "locked" + ], + "cache": ["person_id", "authenticator_id"], + "fieldMap": { + "co_person_id": "person_id" + }, + "addChangelog": true, + "dependencies": ["people", "authenticators"] + }, + "ssh_keys": { + "source": "cm_ssh_keys", + "displayField": "comment", + "cache": ["person_id", "ssh_key_authenticator_id"], + "fieldMap": { + "co_person_id": "person_id" + }, + "dependencies": ["people", "ssh_key_authenticators"] + }, + "passwords": { + "source": "cm_passwords", + "displayField": "id", + "plugin": "PasswordAuthenticator", + "cache": ["person_id", "password_authenticator_id"], + "fieldMap": { + "co_person_id": "person_id", + "password_type": "type" + }, + "dependencies": ["people", "password_authenticators"] + }, + "person_roles": { + "source": "cm_co_person_roles", + "sqlSelect": "roleSqlSelect", + "displayField": "id", + "cache": ["status", "person_id", "manager_person_id", "sponsor_person_id"], + "fieldMap": { + "co_person_id": "person_id", + "co_person_role_id": "person_role_id", + "affiliation_type_id": "&mapAffiliationType", + "manager_co_person_id": "manager_person_id", + "sponsor_co_person_id": "sponsor_person_id", + "o": "organization", + "ou": "department", + "source_org_identity_id": null, + "affiliation": null + }, + "dependencies": ["people", "types"] + }, + "external_identities": { + "source": "cm_org_identities", + "displayField": "id", + "cache": ["person_id"], + "sqlSelect": "orgidentitiesSqlSelect", + "postRow": "mapExternalIdentityToExternalIdentityRole", + "fieldMap": { + "person_id": "&mapOrgIdentityCoPersonId", + "org_identity_id": "external_identity_id", + "title": null, + "o": null, + "ou": null, + "affiliation": null, + "manager_identifier": null, + "sponsor_identifier": null, + "valid_from": null, + "valid_through": null, + "co_id": null + }, + "dependencies": ["people"] + }, + "groups": { + "source": "cm_co_groups", + "displayField": "name", + "cache": ["co_id", "owners_group_id"], + "booleans": ["nesting_mode_all", "open"], + "preRow": "applyCheckGroupNameARRule", + "fieldMap": { + "co_group_id": "group_id", + "group_type": "?S", + "introduction": null, + "auto": null + }, + "postTable": "createOwnersGroups", + "dependencies": ["cos", "cous"] + }, + "group_nestings": { + "source": "cm_co_group_nestings", + "displayField": "id", + "booleans": ["negate"], + "fieldMap": { + "co_group_id": "group_id", + "target_co_group_id": "target_group_id", + "co_group_nesting_id": "group_nesting_id" + }, + "dependencies": ["groups"] + }, + "provisioning_targets": { + "source": "cm_co_provisioning_targets", + "displayField": "description", + "cache": ["id", "co_id"], + "fieldMap": { + "plugin": "&mapProvisionerPlugin", + "provision_co_group_id": "provisioning_group_id", + "skip_org_identity_source_id": null + }, + "dependencies": ["groups"] + }, + "sql_provisioners": { + "source": "cm_co_sql_provisioner_targets", + "displayField": "provisioning_target_id", + "plugin": "ApiConnector", + "fieldMap": { + "co_provisioning_target_id": "provisioning_target_id" + }, + "addChangelog": true + }, + "group_members": { + "source": "cm_co_group_members", + "displayField": "id", + "booleans": ["member", "owner"], + "preRow": "reconcileGroupMembershipOwnership", + "fieldMap": { + "co_group_id": "group_id", + "co_person_id": "person_id", + "co_group_nesting_id": "group_nesting_id", + "co_group_member_id": "group_member_id", + "source_org_identity_id": null, + "member": null, + "owner": null + }, + "dependencies": ["groups", "people", "group_nestings"] + }, + "names": { + "source": "cm_names", + "displayField": "id", + "booleans": ["primary_name"], + "sqlSelect": "mveaSqlSelect", + "fieldMap": { + "type_id": "&mapNameType", + "co_person_id": "person_id", + "org_identity_id": "external_identity_id", + "type": null + }, + "dependencies": ["people", "external_identities", "types"] + }, + "email_addresses": { + "source": "cm_email_addresses", + "displayField": "id", + "booleans": ["verified"], + "sqlSelect": "mveaSqlSelect", + "fieldMap": { + "type_id": "&mapEmailType", + "co_person_id": "person_id", + "org_identity_id": "external_identity_id", + "co_department_id": null, + "organization_id": null, + "type": null + }, + "dependencies": ["people", "external_identities", "types"] + }, + "identifiers": { + "source": "cm_identifiers", + "displayField": "id", + "booleans": ["login"], + "sqlSelect": "mveaSqlSelect", + "preRow": "mapLoginIdentifiers", + "fieldMap": { + "type_id": "&mapIdentifierType", + "co_group_id": "group_id", + "co_person_id": "person_id", + "org_identity_id": "external_identity_id", + "co_department_id": null, + "co_provisioning_target_id": null, + "organization_id": null, + "type": null, + "language": null + }, + "dependencies": ["groups", "people", "external_identities", "types"] + }, + "urls": { + "source": "cm_urls", + "displayField": "id", + "sqlSelect": "mveaSqlSelect", + "fieldMap": { + "type_id": "&mapUrlType", + "co_person_id": "person_id", + "org_identity_id": "external_identity_id", + "co_department_id": null, + "organization_id": null, + "type": null, + "language": null + }, + "dependencies": ["people", "external_identities", "types"] + }, + "addresses": { + "source": "cm_addresses", + "displayField": "id", + "sqlSelect": "mveaSqlSelect", + "fieldMap": { + "type_id": "&mapAddressType", + "co_person_role_id": "person_role_id", + "org_identity_id": "external_identity_id", + "co_department_id": null, + "organization_id": null, + "type": null + }, + "dependencies": ["person_roles", "external_identities", "types"] + }, + "telephone_numbers": { + "source": "cm_telephone_numbers", + "displayField": "id", + "sqlSelect": "mveaSqlSelect", + "fieldMap": { + "type_id": "&mapTelephoneType", + "co_person_role_id": "person_role_id", + "org_identity_id": "external_identity_id", + "co_department_id": null, + "organization_id": null, + "type": null + }, + "dependencies": ["person_roles", "external_identities", "types"] + }, + "ad_hoc_attributes": { + "source": "cm_ad_hoc_attributes", + "displayField": "id", + "sqlSelect": "mveaSqlSelect", + "fieldMap": { + "co_person_role_id": "person_role_id", + "org_identity_id": "external_identity_id", + "co_department_id": null, + "organization_id": null + }, + "postTable": "migrateExtendedAttributesToAdHocAttributes", + "dependencies": ["person_roles", "external_identities"] + }, + "notifications": { + "source": "cm_co_notifications", + "displayField": "id", + "canSkip": "true", + "addChangelog": true, + "fieldMap": { + "subject_co_person_id": "subject_person_id", + "subject_co_group_id": "subject_group_id", + "actor_co_person_id": "actor_person_id", + "recipient_co_person_id": "recipient_person_id", + "recipient_co_group_id": "recipient_group_id", + "resolver_co_person_id": "resolver_person_id", + "action": "&mapNotificationAction", + "source_url": "source", + "email_body": "email_body_text", + "source_controller": null, + "source_action": null, + "source_id": null, + "source_arg0": null, + "source_val0": null + }, + "dependencies": ["people", "groups"] + }, + "history_records": { + "source": "cm_history_records", + "displayField": "id", + "sqlSelect": "historyRecordsSqlSelect", + "canSkip": "true", + "fieldMap": { + "actor_co_person_id": "actor_person_id", + "co_person_id": "person_id", + "co_person_role_id": "person_role_id", + "co_group_id": "group_id", + "org_identity_id": "external_identity_id", + "action": "&mapHistoryAction", + "co_email_list_id": null, + "co_service_id": null + }, + "dependencies": ["people", "person_roles", "groups", "external_identities"] + }, + "jobs": { + "source": "cm_co_jobs", + "displayField": "id", + "fieldMap": { + "job_type": "plugin", + "queue_time": "register_time", + "complete_time": "finish_time", + "job_params": "parameters", + "requeued_from_co_job_id": "requeued_from_job_id", + "max_retry": null, + "max_retry_count": null, + "job_type_fk": null, + "job_mode": null + }, + "preRow": "validateJobIsTransmogrifiable", + "dependencies": ["cos"] + }, + "job_history_records": { + "source": "cm_co_job_history_records", + "displayField": "id", + "sqlSelect": "jobHistoryRecordsSqlSelect", + "canSkip": "true", + "fieldMap": { + "co_job_id": "job_id", + "co_person_id": "person_id", + "org_identity_id": "external_identity_id" + }, + "dependencies": ["jobs", "people", "external_identities"] + }, + "enrollment_flows": { + "source": "cm_co_enrollment_flows", + "displayField": "name", + "booleans": [ + "collect_enrollee_email" + ], + "cache": ["co_id", "auth_cou_id", "authz_co_group_id"], + "postRow": "createEnrollmentFlowStep", + "fieldMap": { + "authz_level": "authz_type", + "authz_co_group_id": "authz_group_id", + "notification_co_group_id": "notification_group_id", + "redirect_on_finalize": "redirect_on_finalize", + "status": "=S", + "approval_template_id": "notification_message_template_id", + "finalization_template_id": "finalization_message_template_id", + "co_enrollment_flow_id": "enrollment_flow_id", + "my_identity_shortcut": null, + "match_policy": null, + "match_server_id": null, + "enable_person_find": null, + "approval_required": null, + "approver_co_group_id": null, + "approval_confirmation_mode": null, + "approval_require_comment": null, + "verify_email": null, + "email_verification_mode": null, + "invitation_validity": null, + "regenerate_expired_verification": null, + "require_authn": null, + "notify_from": null, + "verification_subject": null, + "verification_body": null, + "verification_template_id": null, + "request_vetting": null, + "notify_on_approval": null, + "approval_subject": null, + "approval_body": null, + "approver_template_id": null, + "denial_template_id": null, + "notify_on_finalize": null, + "introduction_text": null, + "conclusion_text": null, + "introduction_text_pa": null, + "t_and_c_mode": null, + "redirect_on_submit": null, + "redirect_on_confirm": null, + "return_url_allowlist": null, + "ignore_authoritative": null, + "duplicate_mode": null, + "co_theme_id": null, + "theme_stacking": null, + "establish_authenticators": null, + "establish_cluster_accounts": null + }, + "dependencies": ["cos", "cous", "groups", "message_templates"] + }, + "petitions": { + "source": "cm_co_petitions", + "displayField": "authenticated_identifier", + "canSkip": "true", + "fieldMap": { + "co_enrollment_flow_id": "enrollment_flow_id", + "cou_id": "cou_id", + "status": "&mapPetitionStatus", + "enrollee_co_person_id": "enrollee_person_id", + "petitioner_co_person_id": "petitioner_person_id", + "authenticated_identifier": "petitioner_identifier", + "reference_identifier": "enrollee_identifier", + "co_petition_id": "petition_id", + "petitioner_token": null, + "enrollee_token": null, + "return_url": null, + "approver_comment": null, + "sponsor_co_person_id": null, + "approver_co_person_id": null, + "co_invite_id": null, + "vetting_request_id": null, + "enrollee_org_identity_id": null, + "archived_org_identity_id": null, + "enrollee_co_person_role_id": null, + "token": null, + "co_id": null + }, + "dependencies": ["enrollment_flows", "cous", "people"] + }, + "petition_meta_hist_recs": { + "source": "cm_co_petitions", + "displayField": "id", + "fieldMap": { + "co_enrollment_flow_id": "enrollment_flow_id", + "historic_petition_viewer_id" :"&mapHistoricPetitionViewerId", + "enrollee_org_identity_id": "enrollee_external_identity_id", + "archived_org_identity_id": "archived_external_identity_id", + "enrollee_co_person_role_id": "enrollee_person_role_id", + "sponsor_co_person_id": "sponsor_person_id", + "approver_co_person_id": "approver_person_id", + "co_petition_id": "petition_id", + "co_id": null, + "cou_id": null, + "status": null, + "authenticated_identifier": null, + "reference_identifier": null, + "petitioner_co_person_id": null, + "enrollee_co_person_id": null, + "co_invite_id": null, + "vetting_request_id": null, + "enrollee_token": null, + "petitioner_token": null, + "return_url": null + }, + "dependencies": ["enrollment_flows", "external_identities", "person_roles", "people", "petitions"] + }, + "petition_hist_attrs": { + "source": "cm_co_petition_attributes", + "displayField": "id", + "fieldMap": { + "co_petition_id": "petition_id", + "historic_petition_viewer_id" :"&mapHistoricPetitionViewerId", + "co_enrollment_attribute_id": null, + "attribute_foreign_key": null, + "co_petition_attribute_id": null + }, + "addChangelog": true, + "dependencies": ["petitions"] + }, + "ext_identity_source_records": { + "source": "cm_org_identity_source_records", + "displayField": "id", + "cache": ["org_identity_id", "sorid"], + "preRow": "findAdoptedPersonByLinkedOrgIdentityId", + "fieldMap": { + "org_identity_source_id": "external_identity_source_id", + "sorid": "source_key", + "org_identity_id": "external_identity_id", + "co_person_id": "adopted_person_id", + "reference_identifier": "reference_identifier", + "org_identity_source_record_id": "ext_identity_source_record_id", + "co_petition_id": null + }, + "dependencies": ["external_identity_sources", "external_identities", "people"] + }, + "api_source_records": { + "source": "cm_api_source_records", + "displayField": "id", + "plugin": "ApiConnector", + "fieldMap": { + "sorid": "source_key" + }, + "dependencies": ["api_sources"] + }, + "provisioning_history_records": { + "source": "cm_co_provisioning_exports", + "displayField": "id", + "fieldMap": { + "status": "=P", + "subject_model": "&mapToSubjectModel", + "subjectid": "&mapToSubjectId", + "co_provisioning_target_id": "provisioning_target_id", + "comment": "=Record Exported", + "co_person_id": "person_id", + "co_group_id": "group_id", + "co_email_list_id": null, + "co_service_id": null, + "exporttime": null + }, + "addChangelog": true, + "dependencies": ["groups", "people", "provisioning_targets"] + } +} diff --git a/app/availableplugins/Transmogrify/src/Command/TransmogrifyCommand.php b/app/availableplugins/Transmogrify/src/Command/TransmogrifyCommand.php new file mode 100644 index 000000000..dfa4f99fd --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Command/TransmogrifyCommand.php @@ -0,0 +1,976 @@ +pluginRoot = dirname(__DIR__, 2); + parent::__construct(); + } + + /** + * Override run command + * + * @param array $argv + * @param ConsoleIo $io + * @return int + * @since COmanage Registry v5.2.0 + */ + public function run(array $argv, ConsoleIo $io): int + { + $this->inconn = DBALConnection::factory($io, 'transmogrify'); + $this->outconn = DBALConnection::factory($io); + return parent::run($argv, $io); + } + + /** + * Build an Option Parser. + * + * @since COmanage Registry v5.2.0 + * @param ConsoleOptionParser $parser ConsoleOptionParser + * @return ConsoleOptionParser ConsoleOptionParser + */ + + protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + // Allow overriding the tables config path + $parser->addOption('tables-config', [ + 'help' => 'Path to transmogrify tables JSON config', + 'default' => TransmogrifyEnum::TABLES_JSON_PATH + ]); + $parser->addOption('dump-tables-config', [ + 'help' => 'Output the effective tables configuration (after schema extension) and exit', + 'boolean' => true + ]); + // Specify a table (or repeat option) to migrate only a subset + $parser->addOption('table', [ + 'help' => 'Migrate only the specified table. Repeat the option to migrate multiple tables', + 'multiple' => true + ]); + // List available target tables and exit + $parser->addOption('list-tables', [ + 'help' => 'List available target tables from the transmogrify config and exit', + 'boolean' => true + ]); + // Info options integrated into TransmogrifyCommand + $parser->addOption('info', [ + 'help' => 'Print source and target database configuration and exit', + 'boolean' => true + ]); + $parser->addOption('info-json', [ + 'help' => 'Output info in JSON (use with --info)', + 'boolean' => true + ]); + $parser->addOption('info-ping', [ + 'help' => 'Ping connections and include connectivity + server version (use with --info or --info-schema)', + 'boolean' => true + ]); + $parser->addOption('info-schema', [ + 'help' => 'Print schema information and whether the database is empty (defaults to target). Use --info-schema-role to select source/target', + 'boolean' => true + ]); + $parser->addOption('info-schema-role', [ + 'help' => 'When using --info-schema, which database to inspect: source or target (default: target)' + ]); + $parser->addOption('login-identifier-copy', [ + 'help' => __d('command', 'tm.login-identifier-copy'), + 'boolean' => true + ]); + + $parser->addOption('login-identifier-type', [ + 'help' => __d('command', 'tm.login-identifier-type') + ]); + + // Health report option (Org Identities readiness) + $parser->addOption('orgidentities-health', [ + 'help' => 'Run Org Identities health check (eligibility/exclusion breakdown) and exit', + 'boolean' => true + ]); + // Health report option (Groups naming rule readiness) + $parser->addOption('groups-health', [ + 'help' => 'Run Groups health check (AR-Group-9: invalid Standard names) and exit', + 'boolean' => true + ]); + // Optional: replace colons in Standard group names during migration (opt-in, off by default) + $parser->addOption('groups-colon-replacement', [ + 'help' => 'If set, replace ":" with this value in Standard group names during migration. WARNING: name "CO" remains invalid and is not auto-renamed.' + ]); + // Convenience flag for using a literal dash as replacement + $parser->addOption('groups-colon-replacement-dash', [ + 'help' => 'Use "-" as the replacement for ":" in Standard group names (shorthand when passing a lone "-" is problematic)', + 'boolean' => true + ]); + $parser->addOption('plugin-bootstrap', [ + 'help' => 'Initialize plugin registry and activate non-required plugins (eg, passwords, ssh keys) before transmogrification', + 'boolean' => true + ]); + + $parser->setEpilog(__d('command', 'tm.epilog')); + + return $parser; + } + + /** + * Execute the Transmogrify Command. + * + * @param Arguments $args Command Arguments + * @param ConsoleIo $io Console IO + * @throws Exception + * @since COmanage Registry v5.2.0 + */ + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->args = $args; + $this->io = $io; + + // Now that BaseCommand set verbosity, construct the printer so it can detect it correctly + $this->cmdPrinter = new CommandLinePrinter($io, 'green', 50, true); + + // Start tracking execution time + $this->startTime = microtime(true); + + // Initialize the index manager with the live target connection and printer + $this->indexManager->initialize($this->outconn, $this->cmdPrinter); + + // Validate "info" option combinations and handle errors + $code = $this->validateInfoOptions($io); + if ($code !== null) { + return $code; + } + + // Handle info modes early (no tables config needed unless ping) + $code = $this->maybeHandleInfo(); + if ($code !== null) { + return $code; + } + + // Health report: run and exit + if ($this->args->getOption('orgidentities-health')) { + OrgIdentitiesHealth::run($this->inconn, $this->io); + return BaseCommand::CODE_SUCCESS; + } + if ($this->args->getOption('groups-health')) { + GroupsHealth::run($this->inconn, $this->io); + return BaseCommand::CODE_SUCCESS; + } + + // Load tables configuration (from JSON) and extend it with schema data + $this->loadTablesConfig(); + + // Dump tables config if requested + $code = $this->maybeDumpTablesConfig($io); + if ($code !== null) { + return $code; + } + + // List tables and exit + $code = $this->maybeListTables($io); + if ($code !== null) { + return $code; + } + + // Build list of tables to migrate from --table option and positional args + $selected = $this->buildSelectedTables($args); + + // Validate and warn for subset selection + $code = $this->maybeValidateSelectedTables($selected, $io); + if ($code !== null) { + return $code; + } + + // Determine the actual list of tables to process. + // If specific tables are selected, we recursively resolve dependencies and sort them + // according to the configuration order. + // Otherwise, we process all tables in configuration order. + $tablesToProcess = !empty($selected) + ? $this->resolveDependencies($selected) + : array_keys($this->tables); + + if (!empty($selected)) { + $this->cmdPrinter->info('Tables to process (including dependencies):'); + foreach ($tablesToProcess as $table) { + $this->cmdPrinter->info(' - ' . $table); + } + } + + // Register the current version for future upgrade purposes + $this->metaTable = TableRegistry::getTableLocator()->get('Meta'); + $this->metaTable->setUpgradeVersion(); + + // Track remaining selected tables (if any) so we can exit early when done. + // Note: $selected contains only the explicitly requested tables, not dependencies. + $pendingSelected = []; + if (!empty($selected)) { + $pendingSelected = array_fill_keys($selected, true); + } + + // Plugin bootstrap is now optional and controlled by the CLI flag + if ($this->args->getOption('plugin-bootstrap')) { + try { + $this->pluginBootstrap(); + return BaseCommand::CODE_SUCCESS; + } catch (\Throwable $e) { + $this->cmdPrinter?->error('Plugin bootstrap failed: ' . $e->getMessage()); + return BaseCommand::CODE_ERROR; + } + } + + foreach ($tablesToProcess as $t) { + // Check per-table skip configuration and optionally prompt user + $canSkipCfg = $this->tables[$t]['canSkip'] ?? null; + if (filter_var($canSkipCfg, FILTER_VALIDATE_BOOLEAN)) { + $question = sprintf( + 'Table "%s" (%s) may be skipped.' . PHP_EOL . 'Skip transmogrification? yes/no [default: no]', + Inflector::classify($t), + $t + ); + $reply = $this->cmdPrinter->ask($question . ' '); + $replyBool = filter_var($reply, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($replyBool === true) { + $this->cmdPrinter->info(sprintf('Skipping transmogrification for table %s as requested.', $t)); + + // We need to skip the petiton metadata migration if the petitions table is not present + if ($t === 'petitions') { + unset($this->tables['historic_petition_metadata_records']); + unset($this->tables['historic_petition_attributes']); + } + continue; + } + // Proceed for false, null, or empty responses + $this->cmdPrinter->info(sprintf('Proceeding with transmogrification for table %s.', $t)); + } + + // Initializations per table migration + $outboundTableEmpty = true; + $inboundQualifiedTableName = $this->inconn->qualifyTableName($this->tables[$t]['source']); + $outboundQualifiedTableName = $this->outconn->qualifyTableName($t); + $Model = TableRegistry::getTableLocator()->get($t); + + $this->cmdPrinter->out(message: sprintf("Transmogrifying table: %s(%s)", Inflector::classify($t), $t)); + + + /* + * Run checks before processing the table + **/ + + // Check if source table exists and warn if not present + if (!empty($this->tables[$t]['source'])) { + $src = $this->tables[$t]['source']; + if (!$this->tableExists($src)) { + $this->cmdPrinter->warning("Source table '$src' does not exist in source database, skipping table '$t'"); + continue; + } + } + + // Skip a table if already contains data + if ($Model->find()->count() > 0) { + $outboundTableEmpty = false; + $this->cmdPrinter->warning("Table (" . $t . ") is not empty. We will not overwrite existing data."); + } + + // Mark the table as skipped if it is not empty since we have to process all the tables in the $tablesToProcess array + $this->cache['skipInsert'][$outboundQualifiedTableName] = !$outboundTableEmpty; + $this->cache['current'] = $outboundQualifiedTableName; + /* + * End of checks + */ + + + // Configure sequence ID for the target table + if (!RawSqlQueries::setSequenceId( + $this->inconn, + $this->outconn, + $this->tables[$t]['source'], + $t, + $this->cmdPrinter + )) { + $this->cmdPrinter->warning("Skipping Transmogrification. Can not properly configure the Sequence for the primary key for the Table (\"$t\""); + return BaseCommand::CODE_ERROR; + } + + + // Execute any pre-processing hooks for the current table + $this->runPreTableHook($t); + + // Step 8: Build and execute query to fetch all source records + $insql = match (true) { + !empty($this->tables[$t]['sqlSelect']) => $this->runSqlSelectHook($t, $inboundQualifiedTableName), + default => RawSqlQueries::buildSelectAllOrderedById($inboundQualifiedTableName) + }; + + // Verbose message to show the SQL query being executed + $this->cmdPrinter->verbose(sprintf('[Inbound SQL] Table=%s | %s', $t, $insql)); + // Fetch the inbound data. + $stmt = $this->inconn->executeQuery($insql); + + /* + * PROGRESS STARTING + **/ + // If a custom SELECT is used, count the exact result set; otherwise count the whole table + if (!empty($this->tables[$t]['sqlSelect'])) { + $countSql = RawSqlQueries::buildCountFromSelect($insql); + $count = (int)$this->inconn->fetchOne($countSql); + } else { + $count = (int)$this->inconn->fetchOne(RawSqlQueries::buildCountAll($inboundQualifiedTableName)); + } + $this->cmdPrinter->start($count); + $tally = 0; + $this->cache['error'] = 0; + $this->cache['warns'] = 0; + + // Drop non-PK indexes before the bulk load to avoid per-row index maintenance overhead. + // They will be recreated in a single pass after all rows are inserted. + $indexesDisabled = false; + if ($this->cache['skipInsert'][$outboundQualifiedTableName] === false) { + try { + $this->indexManager->disableIndexes($outboundQualifiedTableName); + $indexesDisabled = $this->indexManager->hasSavedIndexes($outboundQualifiedTableName); + } catch (\Throwable $e) { + $this->cmdPrinter->warning( + 'Could not drop indexes on ' . $outboundQualifiedTableName . ': ' . $e->getMessage() + ); + $this->cmdPrinter->warning('Proceeding with indexes in place (slower).'); + } + } + + while ($row = $stmt->fetchAssociative()) { + if (!empty($row[$this->tables[$t]['displayField']])) { + $displayMessage = "$t " . $row[$this->tables[$t]['displayField']]; + $this->cmdPrinter->verbose($displayMessage); + } + + try { + // Create a copy of the original row data to preserve it for post-processing + $origRow = $row; + + // Execute any pre-processing hooks to transform or validate the row data + $this->runPreRowHook($t, $origRow, $row); + + // Set changelog defaults (created/modified timestamps, user IDs) + // Must be done before boolean normalization as it adds new fields + $this->populateChangelogDefaults( + $t, + $row, + isset($this->tables[$t]['addChangelog']) && $this->tables[$t]['addChangelog'] + ); + + // Convert boolean values to database-compatible format + $this->normalizeBooleanFieldsForDb($t, $row); + + // Map old field names to new schema field names + $this->mapLegacyFieldNames($t, $row); + + // Insert the transformed row into the target database + if ($this->cache['skipInsert'][$outboundQualifiedTableName] === false) { + // Check if a parent record for this row was previously rejected; if so, skip this insert + if ($this->skipIfRejectedParent(currentTable: $t, row: $row)) { + continue; + } + + $this->outconn->insert($outboundQualifiedTableName, $row); + // Execute any post-processing hooks after successful insertion + $this->runPostRowHook($t, $origRow, $row); + } + + // Store row data in cache for potential later use. + // This happens even if insert is skipped (which is the goal of handling dependencies). + $this->cacheResults($t, $row, $origRow); + } catch (ForeignKeyConstraintViolationException $e) { + // A foreign key associated with this record did not load, so we can't + // load this record. This can happen, eg, because the source_field_id + // did not load, perhaps because it was associated with an Org Identity + // not linked to a CO Person that was not migrated. + $this->cache['warns'] += 1; + $rowIdLabel = $row['id'] ?? ($this->tables[$t]['displayField'] ?? 'n/a'); + if (isset($row['id'])) { + $this->cache['rejected'][$outboundQualifiedTableName][$row['id']] = $row; + } + $this->cmdPrinter->warning("Skipping $t record " . (string)$rowIdLabel . " due to invalid foreign key: " . $e->getMessage()); +// $this->cmdPrinter->pause(); + } catch (\InvalidArgumentException $e) { + // If we can't find a value for mapping we skip the record + // (ie: mapLegacyFieldNames basically requires a successful mapping) + $this->cache['warns'] += 1; + $rowIdLabel = $row['id'] ?? ($this->tables[$t]['displayField'] ?? 'n/a'); + if (isset($row['id'])) { + $this->cache['rejected'][$outboundQualifiedTableName][$row['id']] = $row; + } + $this->cmdPrinter->warning("Skipping $t record " . (string)$rowIdLabel . ": " . $e->getMessage()); +// $this->cmdPrinter->pause(); + } catch (\Exception $e) { + $this->cache['error'] += 1; + if (isset($row['id'])) { + $this->cache['rejected'][$outboundQualifiedTableName][$row['id']] = $row; + } + $rowIdLabel = $row['id'] ?? ($this->tables[$t]['displayField'] ?? 'n/a'); + $this->cmdPrinter->error("$t record " . (string)$rowIdLabel . ": " . $e->getMessage()); + $this->cmdPrinter->pause(); + } + + $tally++; + // Always delegate progress updates to the printer; it will decide what to draw + $this->cmdPrinter->update($tally); + } + + $this->cmdPrinter->finish(); + /** + * FINISH PROGRESS + */ + + // Recreate indexes that were dropped before the bulk load + if ($indexesDisabled) { + try { + $this->indexManager->enableIndexes($outboundQualifiedTableName); + } catch (\Throwable $e) { + $this->cmdPrinter->error( + 'Failed to recreate indexes on ' . $outboundQualifiedTableName . ': ' . $e->getMessage() + ); + $this->cmdPrinter->error( + 'You may need to manually recreate indexes. Run: bin/cake database' + ); + } + } + + // Output final warning and error counts for the table + $this->cmdPrinter->warning(sprintf('Warnings: %d', $this->cache['warns'])); + $this->cmdPrinter->error(sprintf('Errors: %d', $this->cache['error'])); + + // Execute any post-processing hooks for the table + if ($this->cache['skipInsert'][$outboundQualifiedTableName] === false) { + $this->cmdPrinter->out('Running post-table hook for ' . $t); + $this->runPostTableHook($t); + } + + // If user selected a subset, exit as soon as all explicitly selected tables are processed + if (!empty($pendingSelected) && isset($pendingSelected[$t])) { + unset($pendingSelected[$t]); + if (empty($pendingSelected)) { + $this->cmdPrinter->out('All selected tables have been processed. Exiting.'); + return BaseCommand::CODE_SUCCESS; + } + } + + // Prompt for confirmation before processing table + // Note: we use $tablesToProcess for the index lookup to find the next table correctly + $currentIndex = array_search($t, $tablesToProcess); + if (isset($tablesToProcess[$currentIndex + 1])) { + $this->cmdPrinter->info("Next table to process: " . $tablesToProcess[$currentIndex + 1]); + } else { + $this->cmdPrinter->out(PHP_EOL . "Table import complete. Exiting."); + } + + $this->cmdPrinter->pause(); + } + + // Assign UUIDs for all clonable models + $this->cmdPrinter->out('Running assignUuids task via UpgradeCommand...'); + $this->executeCommand(UpgradeCommand::class, ['-D', '-X', '-t', 'assignUuids'], $this->io); + + // Display total execution time + $executionTime = microtime(true) - $this->startTime; + + $hours = floor($executionTime / 3600); + $minutes = floor(($executionTime % 3600) / 60); + $seconds = $executionTime % 60; + + $formatted = sprintf( + '%02d:%02d:%02d', + (int)$hours, + (int)$minutes, + (int)$seconds + ); + + $this->cmdPrinter->out(sprintf('Total execution time: %s (HH:MM:SS)', $formatted)); + + return BaseCommand::CODE_SUCCESS; + } + + + /** + * Validate incompatible/invalid "info" related options. + * Returns an exit code when invalid, or null to continue. + * + * @param ConsoleIo $io Console IO object for output + * @return int|null Command exit code or null to continue + * @since COmanage Registry v5.2.0 + */ + private function validateInfoOptions(ConsoleIo $io): ?int + { + if ( + $this->args->getOption('info-ping') + && !$this->args->getOption('info') + && !$this->args->getOption('info-schema') + ) { + $io->err('Option --info-ping must be used together with --info or --info-schema.'); + $io->err('Examples:'); + $io->err(' bin/cake transmogrify --info --info-ping'); + $io->err(' bin/cake transmogrify --info-schema --info-ping [--info-schema-role source|target]'); + return BaseCommand::CODE_ERROR; + } + + if ( + $this->args->getOption('info-schema-role') !== null + && !$this->args->getOption('info-schema') + ) { + $io->err('Option --info-schema-role must be used together with --info-schema.'); + $io->err('Examples:'); + $io->err(' bin/cake transmogrify --info-schema --info-schema-role target'); + $io->err(' bin/cake transmogrify --info-schema --info-ping --info-schema-role source'); + $io->err(' bin/cake transmogrify --info-schema --info-json --info-schema-role source'); + return BaseCommand::CODE_ERROR; + } + + return null; + } + + /** + * Handle --info / --info-schema early-exit modes. + * Returns exit code if handled, or null to continue normal execution. + * + * @return int|null Command exit code if handled, null to continue execution + * @since COmanage Registry v5.2.0 + */ + private function maybeHandleInfo(): ?int + { + if ($this->args->getOption('info')) { + (DbInfoPrinter::initialize($this->io, $this->dbInfoService))->print( + (bool)$this->args->getOption('info-json'), + (bool)$this->args->getOption('info-ping') + ); + return BaseCommand::CODE_SUCCESS; + } + + if ($this->args->getOption('info-schema')) { + $role = $this->args->getOption('info-schema-role') ?: 'target'; + if (!in_array($role, ['source', 'target'], true)) { $role = 'target'; } + (DbInfoPrinter::initialize($this->io, $this->dbInfoService))->print( + (bool)$this->args->getOption('info-json'), + (bool)$this->args->getOption('info-ping'), + true, + $role + ); + return BaseCommand::CODE_SUCCESS; + } + + return null; + } + + /** + * Load tables configuration from JSON and attach to $this->tables. + * + * @return void + * @since COmanage Registry v5.2.0 + */ + private function loadTablesConfig(): void + { + $path = $this->args->getOption('tables-config') ?? Transmogrify::TABLES_JSON_PATH; + if (!str_starts_with($path, $this->pluginRoot . DS)) { + $path = $this->pluginRoot . DS . $path; + } + $this->tables = $this->configLoader->load($path); + } + + /** + * If requested, dump effective tables configuration and exit. + * + * @param ConsoleIo $io Console IO object for output + * @return int|null Command exit code if dumped, null to continue + * @since COmanage Registry v5.2.0 + */ + private function maybeDumpTablesConfig(ConsoleIo $io): ?int + { + if ($this->args->getOption('dump-tables-config')) { + $io->out(json_encode($this->tables, JSON_PRETTY_PRINT)); + return BaseCommand::CODE_SUCCESS; + } + return null; + } + + /** + * If requested, list available tables and exit. + * + * @param ConsoleIo $io Console IO object for output + * @return int|null Command exit code if listed, null to continue + * @since COmanage Registry v5.2.0 + */ + private function maybeListTables(ConsoleIo $io): ?int + { + if ($this->args->getOption('list-tables')) { + $io->out(implode("\n", array_keys($this->tables))); + return BaseCommand::CODE_SUCCESS; + } + return null; + } + + /** + * Build list of tables from --table options and positional args. + * + * @param Arguments $args Command arguments + * @return array List of selected table names + * @since COmanage Registry v5.2.0 + */ + private function buildSelectedTables(Arguments $args): array + { + $selected = $args->getArrayOption('table') ?? []; + $positional = $args->getArguments(); + if (!empty($positional)) { + $selected = array_merge($selected, $positional); + } + return array_values(array_unique($selected)); + } + + /** + * Recursively resolve dependencies for the selected tables. + * + * @param array $selected Selected tables + * @return array Selected tables with dependencies included + */ + private function resolveDependencies(array $selected): array + { + $queue = $selected; + // Track visited tables to prevent infinite loops and re-processing. + // We initialize this with the selected tables so we don't add them as dependencies of themselves. + $visited = array_flip($selected); + $dependencies = []; + + while (!empty($queue)) { + $table = array_shift($queue); + + if (isset($this->tables[$table]['dependencies'])) { + foreach ($this->tables[$table]['dependencies'] as $dependency) { + if (!isset($visited[$dependency])) { + $visited[$dependency] = true; + // Track this as a discovered dependency + $dependencies[$dependency] = true; + $queue[] = $dependency; + $this->cmdPrinter?->verbose("Adding dependency table '$dependency' for '$table'"); + } + } + } + } + + $allTables = array_keys($this->tables); + + // 1. Sort the discovered dependencies according to the configuration file order + $orderedDependencies = array_values(array_intersect($allTables, array_keys($dependencies))); + + // 2. Sort the explicitly selected tables according to the configuration file order + $orderedSelected = array_values(array_intersect($allTables, $selected)); + + // 3. Merge: Run dependencies first, then the selected tables + return array_merge($orderedDependencies, $orderedSelected); + } + + /** + * Validate selected tables against config and warn about partial migration. + * Returns exit code on error, or null if OK. + * + * @param array $selected List of selected table names + * @param ConsoleIo $io Console IO object for output + * @return int|null Command exit code on error, null if valid + * @since COmanage Registry v5.2.0 + */ + private function maybeValidateSelectedTables(array $selected, ConsoleIo $io): ?int + { + if (!empty($selected)) { + $unknown = array_diff($selected, array_keys($this->tables)); + if (!empty($unknown)) { + $io->err('Unknown table(s): ' . implode(', ', $unknown)); + $io->err('Use --list-tables to see available options.'); + return BaseCommand::CODE_ERROR; + } + $io->warning('Migrating a subset of tables may lead to foreign key or type mapping warnings if dependencies are not loaded (eg, types, people, groups).'); + $io->out('Selected tables: ' . implode(', ', $selected)); + } + return null; + } + + /** + * Bootstrap plugin state for transmogrification when requested via --plugin-bootstrap: + * - Ensure the Plugins table is in sync with plugins on disk. + * - Activate any plugins that are referenced in tables.json (via the "plugin" key), + * if they are present but currently suspended. + * + * For a table entry like: + * "servers": { "plugin": "CoreServer", ... } + * the corresponding model will be "CoreServer.Servers". + * + * @return void + * @since COmanage Registry v5.2.0 + */ + protected function pluginBootstrap(): void + { + $Plugins = TableRegistry::getTableLocator()->get('Plugins'); + + $this->cmdPrinter?->info(PHP_EOL . 'Initializing plugin registry for transmogrification...'); + + // 1. Make sure the registry reflects what is actually available on disk. + $Plugins->syncPluginRegistry(); + + // 2. Collect plugins referenced by tables.json via the "plugin" key. + // We also compute the full model path "Plugin.TableClass" for logging. + $referencedPlugins = []; // [pluginName => true] + $pluginModels = []; // [pluginName => [fullModelName1, fullModelName2, ...]] + + foreach ($this->tables as $tableName => $cfg) { + if (empty($cfg['plugin']) || !is_string($cfg['plugin'])) { + continue; + } + + $pluginName = $cfg['plugin']; + $referencedPlugins[$pluginName] = true; + + $tableClass = Inflector::classify($tableName); // eg "servers" -> "Servers" + $fullModel = $pluginName . '.' . $tableClass; + + if (!isset($pluginModels[$pluginName])) { + $pluginModels[$pluginName] = []; + } + $pluginModels[$pluginName][] = $fullModel; + } + + if (empty($referencedPlugins)) { + $this->cmdPrinter?->info('No plugins referenced in tables.json; plugin bootstrap skipped.' . PHP_EOL); + return; + } + + $this->cmdPrinter?->verbose('Plugins referenced in tables.json:'); + foreach ($pluginModels as $pluginName => $models) { + $this->cmdPrinter?->verbose(sprintf( + ' - %s (%s)', + $pluginName, + implode(', ', array_unique($models)) + )); + } + + // 3. Ensure each referenced plugin exists and is active. + foreach (array_keys($referencedPlugins) as $pluginName) { + $plugin = $Plugins->find() + ->where(['plugin' => $pluginName]) + ->first(); + + if ($plugin === null) { + $this->cmdPrinter?->warning(sprintf( + 'Plugin "%s" is referenced in tables.json but not registered in Plugins table.', + $pluginName + )); + continue; + } + + if ($plugin->status === SuspendableStatusEnum::Active) { + continue; + } + + $this->cmdPrinter?->info(sprintf( + 'Activating plugin "%s" referenced in tables.json.', + $pluginName + )); + + // PluginsTable::activate() will also apply the plugin schema if defined. + $Plugins->activate((int)$plugin->id); + } + + $this->cmdPrinter?->info('Plugin initialization complete.' . PHP_EOL); + } + + + /** + * Check if a table exists in the source database + * + * @param string $tableName Name of table to check + * @return bool True if table exists + * @throws \Exception + * @since COmanage Registry v5.2.0 + */ + protected function tableExists(string $tableName): bool + { + $dbSchemaManager = $this->inconn->createSchemaManager(); + $tableList = $dbSchemaManager->listTableNames(); + return in_array($tableName, $tableList); + } + + /** + * Check whether this row references a rejected record and, if so, mark it rejected and warn. + * This preserves the original self-reference check and adds a generic parent-table check. + * + * Self-reference (original semantics): + * if (cache['rejected'][qualifiedCurrentTable][row[singular(currentTable)_id]]) then skip + * + * Cross-table parent: + * find a *_id in the row that corresponds to a known target table (eg, job_id -> jobs), + * then if (cache['rejected'][qualifiedParentTable][row[parent_fk]]) skip. + * + * @param string $currentTable Logical target table name (eg, 'job_history_records') + * @param array $row Row to insert + * @return bool True if the row should be skipped, false otherwise + * @since COmanage Registry v5.2.0 + */ + private function skipIfRejectedParent(string $currentTable, array $row): bool + { + if (!isset($this->cache['rejected'])) { + return false; + } + + // Compute qualified table names once + $qualifiedCurrent = $this->outconn->qualifyTableName($currentTable); + + // 1) Self-reference check (preserves the original semantics) + // With the original code, fkOutboundQualifiedTableName was derived from the table, + // effectively matching "_id". + $selfFk = StringUtilities::classNameToForeignKey($currentTable); + if ( + isset($row[$selfFk]) && + !empty($this->cache['rejected'][$qualifiedCurrent][$row[$selfFk]]) + ) { + $childId = $row['id'] ?? null; + if ($childId !== null) { + $this->cache['rejected'][$qualifiedCurrent][$childId] = $row; + } + $this->cmdPrinter->warning(sprintf( + 'Skipping record %d in table %s - parent %s(%d) was rejected (self-reference)', + (int)($childId ?? 0), + $currentTable, + $currentTable, + (int)$row[$selfFk] + )); + return true; + } + + // 2) Cross-table parents: check ALL candidate *_id columns + foreach ($row as $col => $val) { + if ($val === null) { continue; } + if (!is_string($col) || !str_ends_with($col, '_id')) { continue; } + if ($col === 'id' || str_ends_with($col, '_type_id')) { continue; } + + $base = substr($col, 0, -3); + $candidate = Inflector::pluralize(Inflector::underscore($base)); + + // Skip self-table here (already handled) + if ($candidate === $currentTable) { continue; } + + // Only check known target tables + if (!isset($this->tables[$candidate])) { continue; } + + $qualifiedParent = $this->outconn->qualifyTableName($candidate); + $parentId = $val; + + if (!empty($this->cache['rejected'][$qualifiedParent][$parentId])) { + $childId = $row['id'] ?? null; + if ($childId !== null) { + $this->cache['rejected'][$qualifiedCurrent][$childId] = $row; + } + $this->cmdPrinter->warning(sprintf( + 'Skipping record %d in table %s - parent %s(%d) was rejected', + (int)($childId ?? 0), + $currentTable, + $candidate, + (int)$parentId + )); + return true; + } + } + + return false; + } +} diff --git a/app/availableplugins/Transmogrify/src/Command/TransmogrifySourceToTargetCommand.php b/app/availableplugins/Transmogrify/src/Command/TransmogrifySourceToTargetCommand.php new file mode 100644 index 000000000..52265a631 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Command/TransmogrifySourceToTargetCommand.php @@ -0,0 +1,770 @@ +setDescription('Print a JSON template shaped like __EXAMPLE_TABLE_TEMPLATE__ for mapping a source table to a target table. Target config is used only to read the example template; no values are copied. Paths must be absolute.'); + + // Switch from positional arguments to named options (long and short) + $parser->addOption('source-path', [ + 'short' => 's', + 'help' => 'Absolute path to the SOURCE configuration file (JSON or XML).', + ]); + $parser->addOption('target-path', [ + 'short' => 't', + 'help' => 'Absolute path to the TARGET configuration file (JSON or XML).', + ]); + $parser->addOption('source-table', [ + 'short' => 'S', + 'help' => 'Table key/name in the source configuration to map from.', + ]); + $parser->addOption('target-table', [ + 'short' => 'T', + 'help' => 'Table key/name in the target configuration to map to.', + ]); + $parser->addOption('source-prefix', [ + 'short' => 'p', + 'help' => 'Prefix to prepend to the source table in the output (eg, "cm_"). Default: empty string.', + ]); + + // Association tree printer option + $parser->addOption('assoc-tree', [ + 'short' => 'A', + 'help' => 'Print an ASCII tree of associations starting from the given --source-table based on the source XML. Use with -s and -S. Ignores other options and exits.', + 'boolean' => true, + ]); + + $epilog = []; + $epilog[] = 'Examples:'; + $epilog[] = ' bin/cake transmogrify_source_to_target \\\n --source-path /path/to/registry/app/Config/Schema/schema.xml \\\n --target-path /path/to/registry/app/config/transmogrifytables.json \\\n --source-table cm_co_people \\\n --target-table people'; + $epilog[] = ''; + $epilog[] = ' bin/cake transmogrify_source_to_target -s /abs/source.xml -t /abs/target.json -S cousins -T cous -p cm_'; + $epilog[] = ''; + $epilog[] = 'Notes:'; + $epilog[] = ' - Paths must be absolute.'; + $epilog[] = ' - Files may be JSON (.json) or XML (.xml).'; + $epilog[] = ' - The output is a single JSON object similar to __EXAMPLE_TABLE_TEMPLATE__ in tables.json.'; + $epilog[] = ' - Use --source-prefix/-p to control any prefix (like cm_) applied to the source table name in the output.'; + $parser->setEpilog(implode(PHP_EOL, $epilog)); + + return parent::buildOptionParser($parser); + } + + /** + * Execute the command + * + * @param Arguments $args Command arguments + * @param ConsoleIo $io Console IO instance + * @return int Exit code + */ + public function execute(Arguments $args, ConsoleIo $io): int + { + // capture prefix for helper methods + $this->currentPrefix = (string)($args->getOption('source-prefix') ?? ''); + $sourcePath = (string)($args->getOption('source-path') ?? ''); + $targetPath = (string)($args->getOption('target-path') ?? ''); + $sourceTable = (string)($args->getOption('source-table') ?? ''); + $targetTable = (string)($args->getOption('target-table') ?? ''); + $sourcePrefix = (string)($args->getOption('source-prefix') ?? ''); + $assocTree = (bool)($args->getOption('assoc-tree') ?? false); + + // If only association tree requested, we only need source path and source table + if ($assocTree) { + $missing = []; + if ($sourcePath === '') { $missing[] = '--source-path (-s)'; } + if ($sourceTable === '') { $missing[] = '--source-table (-S)'; } + if (!empty($missing)) { + $io->err('Missing required option(s) for --assoc-tree: ' . implode(', ', $missing)); + $io->err('Run with --help to see usage.'); + return BaseCommand::CODE_ERROR; + } + if ($sourcePath === '' || !str_starts_with($sourcePath, DIRECTORY_SEPARATOR)) { + $io->err('SOURCE path must be absolute: ' . $sourcePath); + return BaseCommand::CODE_ERROR; + } + if (!is_readable($sourcePath)) { + $io->err('SOURCE file not readable: ' . $sourcePath); + return BaseCommand::CODE_ERROR; + } + + try { + $srcCfgRaw = $this->configLoaderService->loadGeneric($sourcePath); + } catch (\Throwable $e) { + $io->err('Failed to load source configuration: ' . $e->getMessage()); + return BaseCommand::CODE_ERROR; + } + $this->printAssociationTreeFromSchema($srcCfgRaw, $sourceTable, $io); + return BaseCommand::CODE_SUCCESS; + } + + // Validate required named options are present + $missing = []; + if ($sourcePath === '') { $missing[] = '--source-path (-s)'; } + if ($targetPath === '') { $missing[] = '--target-path (-t)'; } + if ($sourceTable === '') { $missing[] = '--source-table (-S)'; } + if ($targetTable === '') { $missing[] = '--target-table (-T)'; } + if (!empty($missing)) { + $io->err('Missing required option(s): ' . implode(', ', $missing)); + $io->err('Run with --help to see usage.'); + return BaseCommand::CODE_ERROR; + } + + // Validate absolute paths + foreach (['source' => $sourcePath, 'target' => $targetPath] as $label => $path) { + if ($path === '' || !str_starts_with($path, DIRECTORY_SEPARATOR)) { + $io->err(strtoupper($label) . ' path must be absolute: ' . $path); + return BaseCommand::CODE_ERROR; + } + if (!is_readable($path)) { + $io->err(strtoupper($label) . ' file not readable: ' . $path); + return BaseCommand::CODE_ERROR; + } + } + + try { + // Load target config to read __EXAMPLE_TABLE_TEMPLATE__ shape + $tgtCfg = $this->configLoaderService->loadGeneric($targetPath); + // Load source config to extract old schema (eg, from XML) + $srcCfgRaw = $this->configLoaderService->loadGeneric($sourcePath); + } catch (\Throwable $e) { + $io->err('Failed to load configuration: ' . $e->getMessage()); + return BaseCommand::CODE_ERROR; + } + + // Load the migration config (tables.json) to derive generic co_* FK renames + $tablesJsonPath = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'schema' . DIRECTORY_SEPARATOR . 'tables.json'; + $tablesCfg = []; + if (is_readable($tablesJsonPath)) { + try { + $tablesCfg = $this->configLoaderService->load($tablesJsonPath); + } catch (\Throwable $e) { + $io->warning('Could not load migration config tables.json: ' . $e->getMessage()); + } + } + + // Normalize possible schema.xml structures into transmogrify-like configs for source + $srcCfg = $this->normalizeConfig(is_array($srcCfgRaw) ? $srcCfgRaw : []); + + // Extract example template (shape) from target config without filtering out __ keys first + $example = is_array($tgtCfg) && array_key_exists('__EXAMPLE_TABLE_TEMPLATE__', $tgtCfg) + ? (array)$tgtCfg['__EXAMPLE_TABLE_TEMPLATE__'] + : []; + + // Determine the output keys based on the example (ignoring any __* documentation keys) + $exampleKeys = array_filter(array_keys($example), static function($k) { + return !is_string($k) || !str_starts_with($k, '__'); + }); + + // Determine source fields from old schema configuration (if available) + $sourceFields = []; + [$srcKey, $srcTableCfg] = $this->findTableConfig($srcCfg, $sourceTable); + if (is_array($srcTableCfg)) { + if (!empty($srcTableCfg['fieldMap']) && is_array($srcTableCfg['fieldMap'])) { + $sourceFields = array_keys($srcTableCfg['fieldMap']); + } elseif (!empty($srcTableCfg['fields']) && is_array($srcTableCfg['fields'])) { + $sourceFields = array_values($srcTableCfg['fields']); + } + } + $sourceFieldSet = array_flip($sourceFields); + + // Build legacy FK pattern map (eg, *_co_person_id -> *_person_id, *_co_message_template_id -> *_message_template_id) + $legacyFkPatterns = $this->buildLegacyFkPatterns($tablesCfg, (string)$sourcePrefix); + + // Inspect default database to find target table columns + try { + $conn = DBALConnection::factory($io); + $columns = []; + $candidate = $targetTable; + $alts = [ $candidate ]; + // toggle provided prefix to try alternative table naming + $prefix = (string)($sourcePrefix ?? ''); + if ($prefix !== '') { + $alts[] = str_starts_with($candidate, $prefix) ? substr($candidate, strlen($prefix)) : ($prefix . $candidate); + } + $foundTable = null; + foreach (array_unique($alts) as $tbl) { + if ($conn->isMySQL()) { + $db = $conn->fetchOne('SELECT DATABASE()'); + $cols = $conn->fetchAllAssociative('SELECT column_name, data_type, column_type FROM information_schema.columns WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position', [$db, $tbl]); + if (!empty($cols)) { $columns = $cols; $foundTable = $tbl; break; } + } else { + // PostgreSQL: search in current schema(s) + $cols = $conn->fetchAllAssociative("SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ? ORDER BY ordinal_position", [$tbl]); + if (!empty($cols)) { $columns = $cols; $foundTable = $tbl; break; } + } + } + if ($foundTable === null) { + $io->err('Target table not found in default database: ' . $targetTable); + return BaseCommand::CODE_ERROR; + } + + // Build target column name lists + $targetCols = array_map(static fn($c) => (string)$c['column_name'], $columns); + $targetColSetAll = array_flip($targetCols); + + $singular = \Cake\Utility\Inflector::singularize($targetTable); + $metaCols = [ + $singular . '_id', + 'revision', + 'deleted', + 'actor_identifier', + 'created', + 'modified', + ]; + + $targetColSet = $targetColSetAll; + foreach ($metaCols as $mc) { + unset($targetColSet[$mc]); + } + + // Determine if target natively has changelog fields (controls addChangelog) + $targetHasChangelog = ( + array_key_exists($singular . '_id', $targetColSetAll) + && array_key_exists('revision', $targetColSetAll) + && array_key_exists('deleted', $targetColSetAll) + && array_key_exists('actor_identifier', $targetColSetAll) + ); + + // Determine if source has changelog (v4 pattern: revision, deleted, actor_identifier, and legacy self-FK) + $legacySelfFkExact = 'co_' . $singular . '_id'; + $legacySelfFkSuffix = '_co_' . $singular . '_id'; + $fieldsLower = array_map('strtolower', $sourceFields); + $sourceFieldSet = array_flip($fieldsLower); + $sourceHasChangelog = ( + isset($sourceFieldSet['revision']) + && isset($sourceFieldSet['deleted']) + && isset($sourceFieldSet['actor_identifier']) + && ( + in_array($legacySelfFkExact, $fieldsLower, true) + || (bool)array_filter($fieldsLower, fn($s) => str_ends_with($s, $legacySelfFkSuffix)) + ) + ); + + // Detect booleans by database type (for non-metadata only) + $booleanCols = []; + foreach ($columns as $col) { + $colName = (string)$col['column_name']; + if (in_array($colName, $metaCols, true)) { continue; } + if ($conn->isMySQL()) { + $dt = strtolower((string)($col['data_type'] ?? '')); + $ct = strtolower((string)($col['column_type'] ?? '')); + $isBool = ($dt === 'tinyint' && str_starts_with($ct, 'tinyint(1)')) || ($dt === 'bit' && str_starts_with($ct, 'bit(1)')) || ($dt === 'boolean'); + if ($isBool) { $booleanCols[] = $colName; } + } else { + $dt = strtolower((string)($col['data_type'] ?? '')); + if ($dt === 'boolean' || $dt === 'bool') { $booleanCols[] = $colName; } + } + } + + // Build fieldMap as source-left => target-right (include null when unknown) + $fieldMap = []; + $mappedTargets = []; + $sourceMeta = ['revision','deleted','actor_identifier','created','modified']; + foreach ($sourceFields as $scol) { + if (in_array($scol, $sourceMeta, true)) { continue; } + + // 1) exact same-name target (non-metadata set) + $tcol = array_key_exists($scol, $targetColSet) ? $scol : null; + + // 2) normalization-based mapping (generic co_* fk rename via patterns + specific one-offs) + if ($tcol === null) { + $candidate = $this->normalizeTargetNameForSourceColumn($scol, $legacyFkPatterns); + if ($candidate !== null) { + $isMetaSelfFk = ($candidate === $singular . '_id'); + // accept candidate if it's a normal column, or it's the allowed self-FK + if (array_key_exists($candidate, $targetColSet) + || ($isMetaSelfFk && array_key_exists($candidate, $targetColSetAll))) { + $tcol = $candidate; + } + } + } + + $fieldMap[$scol] = $tcol; + if ($tcol !== null) { $mappedTargets[$tcol] = true; } + } + + // Collect target-only columns (user can decide later). + $targetUnmapped = []; + foreach (array_keys($targetColSetAll) as $tcol) { + // skip global metadata from the hint list + if (in_array($tcol, $metaCols, true)) { continue; } + if (!isset($mappedTargets[$tcol])) { + $targetUnmapped[] = $tcol; + } + } + } catch (\Throwable $e) { + $io->err('Failed to inspect database schema: ' . $e->getMessage()); + return BaseCommand::CODE_ERROR; + } + + + // Build a template strictly using the example shape. The target config is only used for the example; no values are copied. + $defaults = [ + 'source' => $sourcePrefix . $sourceTable, + 'displayField' => $this->determineDisplayField($sourceFields), + // Note: we will attach/omit addChangelog below based on source/target + //'addChangelog' => '??', + 'booleans' => $booleanCols, + 'cache' => [], + 'sqlSelect' => null, + 'preTable' => null, + 'postTable' => null, + 'preRow' => null, + 'postRow' => null, + 'fieldMap' => $fieldMap ?: new \stdClass(), + ]; + + // If example keys were found, limit output to those keys (plus ensure 'source' exists). + if (!empty($exampleKeys)) { + $template = []; + foreach ($exampleKeys as $k) { + if ($k === 'source') { + $template[$k] = $sourcePrefix . $sourceTable; + continue; + } + $template[$k] = $defaults[$k] ?? null; + } + foreach (['source','fieldMap'] as $must) { + if (!array_key_exists($must, $template)) { + $template[$must] = $defaults[$must]; + } + } + } else { + $template = $defaults; + } + + // Decide addChangelog per rules: + // - both have changelog: omit configuration + // - target has changelog (source doesn't): true + // - target doesn't have changelog (source does): false + $addChangelogOpt = null; + if ($targetHasChangelog && $sourceHasChangelog) { + $addChangelogOpt = null; // omit + } elseif ($targetHasChangelog && !$sourceHasChangelog) { + $addChangelogOpt = true; + } elseif (!$targetHasChangelog && $sourceHasChangelog) { + $addChangelogOpt = false; + } else { + $addChangelogOpt = null; // neither has: omit + } + + // Apply addChangelog decision + if (array_key_exists('addChangelog', $template)) { + unset($template['addChangelog']); + } + if ($addChangelogOpt !== null) { + $template['addChangelog'] = $addChangelogOpt; + } + + + // Attach target-only hints for the user + if (!empty($targetUnmapped)) { + $template['targetUnmapped'] = $targetUnmapped; + } + + // Wrap result under the target-table key for direct insertion into tables.json + $wrapped = [ $targetTable => $template ]; + + // Ensure JSON encoding preserves empty objects properly for fieldMap + $json = json_encode($wrapped, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + $io->err('Failed to encode output JSON'); + return BaseCommand::CODE_ERROR; + } + + $io->out($json); + return BaseCommand::CODE_SUCCESS; + } + + /** + * Filter out documentation keys (starting with __) from configuration array + * + * @param array $cfg Configuration array to filter + * @return array Filtered configuration without documentation keys + */ + private function filterDocKeys(array $cfg): array + { + return array_filter($cfg, static function ($value, $key) { + return !(is_string($key) && str_starts_with($key, '__')); + }, ARRAY_FILTER_USE_BOTH); + } + + /** + * Decide if addChangelog should be true based on source configuration fields. + * Requirements: revision, deleted, actor_identifier, and self-referencing FK (eg, cous -> cou_id). + * + * @param string $tableName Name/key of the source table (may be prefixed like cm_) + * @param array $sourceFields List of source column names + */ + private function shouldAddChangelog(string $tableName, array $sourceFields): bool + { + if (empty($sourceFields)) { + return false; + } + $fields = array_map('strtolower', $sourceFields); + $fieldSet = array_flip($fields); + + // Required fixed columns + foreach (['revision','deleted','actor_identifier'] as $req) { + if (!array_key_exists($req, $fieldSet)) { + return false; + } + } + // Determine expected self FK + $name = strtolower($tableName); + $prefix = (string)($this->currentPrefix ?? ''); + if ($prefix !== '' && str_starts_with($name, $prefix)) { + $name = substr($name, strlen($prefix)); + } + // If table name is schema-qualified (eg, public.table), take part after dot + $dot = strrpos($name, '.'); + if ($dot !== false) { + $name = substr($name, $dot + 1); + } + $base = $name; + if (str_ends_with($base, 's')) { + $base = substr($base, 0, -1); + } + $selfFk = $base . '_id'; + + return array_key_exists($selfFk, $fieldSet); + } + + /** + * Normalize a loaded configuration array. + * - If it's a schema-like array (has 'table' nodes), convert to a transmogrify-like map + * keyed by table name with at least 'source' and 'fieldMap'. + */ + private function normalizeConfig(array $cfg): array + { + // Detect schema root + $tablesNode = null; + if (array_key_exists('table', $cfg)) { + $tablesNode = $cfg['table']; + } elseif (isset($cfg['schema']) && is_array($cfg['schema']) && array_key_exists('table', $cfg['schema'])) { + $tablesNode = $cfg['schema']['table']; + } + if ($tablesNode === null) { + // Already config-like + return $cfg; + } + // Ensure list + if (!is_array($tablesNode) || (is_array($tablesNode) && array_keys($tablesNode) !== range(0, count($tablesNode)-1))) { + // Single table object -> wrap + $tables = [$tablesNode]; + } else { + $tables = $tablesNode; + } + $out = []; + foreach ($tables as $t) { + if (!is_array($t)) { continue; } + // Name is typically under '@attributes' => ['name' => ...] + $name = null; + if (isset($t['@attributes']['name'])) { + $name = (string)$t['@attributes']['name']; + } elseif (isset($t['name'])) { + // Fallback if converter placed it differently + $name = is_array($t['name']) && isset($t['name']['@attributes']) ? (string)($t['name']['@attributes']['value'] ?? '') : (string)$t['name']; + } + if ($name === null || $name === '') { continue; } + + // Build simple 1:1 field map using elements, if present + $fieldMap = new \stdClass(); + if (isset($t['field'])) { + $fieldsNode = $t['field']; + // Normalize to list + if (!is_array($fieldsNode) || (is_array($fieldsNode) && array_keys($fieldsNode) !== range(0, count($fieldsNode)-1))) { + $fields = [$fieldsNode]; + } else { + $fields = $fieldsNode; + } + $map = []; + foreach ($fields as $f) { + if (!is_array($f)) { continue; } + $fname = $f['@attributes']['name'] ?? null; + if ($fname) { + $map[$fname] = $fname; + } + } + if (!empty($map)) { + $fieldMap = $map; + } + } + + $out[$name] = [ + 'source' => $name, + 'displayField' => $this->determineDisplayField(is_array($fieldMap) ? array_keys($fieldMap) : []), + 'fieldMap' => $fieldMap + ]; + } + return $out; + } + + /** + * Locate a table configuration by key or by matching the 'source' attribute. + * Also tries matching names with/without a leading provided prefix. + * + * @param array $cfg + * @param string $nameOrSource Either the config key or the source table name + * @return array{0:string|null,1:array|null} [key, config] + */ + private function findTableConfig(array $cfg, string $nameOrSource): array + { + if (array_key_exists($nameOrSource, $cfg) && is_array($cfg[$nameOrSource])) { + return [$nameOrSource, $cfg[$nameOrSource]]; + } + $prefix = (string)($this->currentPrefix ?? ''); + $alt = $nameOrSource; + if ($prefix !== '') { + $alt = str_starts_with($nameOrSource, $prefix) ? substr($nameOrSource, strlen($prefix)) : ($prefix . $nameOrSource); + } + if (array_key_exists($alt, $cfg) && is_array($cfg[$alt])) { + return [$alt, $cfg[$alt]]; + } + foreach ($cfg as $key => $val) { + if (!is_array($val)) { continue; } + $src = $val['source'] ?? null; + if ($src === $nameOrSource || $src === $alt) { + return [$key, $val]; + } + // Also allow key matching to bare table name + if ($key === $nameOrSource || $key === $alt) { + return [$key, $val]; + } + } + return [null, null]; + } + + /** + * Determine displayField from source table fields using provided order: + * name, display_name, username, authenticated_identifier, description, id. + * Returns null if none are present. + */ + private function determineDisplayField(array $sourceFields): ?string + { + if (empty($sourceFields)) { + return null; + } + $fields = array_map('strtolower', $sourceFields); + $set = array_flip($fields); + foreach (['name','display_name','username','authenticated_identifier','description','id'] as $candidate) { + if (array_key_exists($candidate, $set)) { + return $candidate; + } + } + return null; + } + + /** + * Print an ASCII association tree for a given starting table using the parsed XML schema array. + * Supports common schema.xml structures with elements containing @attributes["foreignTable"] + * and nested elements. + */ + private function printAssociationTreeFromSchema(array $schemaArr, string $startTable, ConsoleIo $io): void + { + // Build associative array tree from schema.xml-like array + // We expect tables under ['schema']['table'] or directly under ['table'] + $tablesNode = $schemaArr['schema']['table'] ?? ($schemaArr['table'] ?? []); + // Normalize to list of tables + if (!is_array($tablesNode) || (is_array($tablesNode) && array_keys($tablesNode) !== range(0, count($tablesNode)-1))) { + $tables = [$tablesNode]; + } else { + $tables = $tablesNode; + } + // Build map: tableName => [ children tables ... ] based on column @attributes['constraint'] value 'REFERENCE table(column)' + $children = []; + foreach ($tables as $t) { + if (!is_array($t)) { continue; } + $tbl = $t['@attributes']['name'] ?? ($t['name'] ?? null); + if ($tbl === null) { continue; } + $tbl = (string)$tbl; + if (!isset($children[$tbl])) { $children[$tbl] = []; } + // inspect columns/field definitions; schema.xml may use or + $colsNode = $t['column'] ?? ($t['field'] ?? []); + $cols = []; + if ($colsNode !== [] && $colsNode !== null) { + if (!is_array($colsNode) || (is_array($colsNode) && array_keys($colsNode) !== range(0, count($colsNode)-1))) { + $cols = [$colsNode]; + } else { + $cols = $colsNode; + } + } + foreach ($cols as $idx => $col) { + if (!is_array($col)) { continue; } + $constraint = $col['constraint'] ?? null; + if (!is_string($constraint)) { continue; } + // Expect format: 'REFERENCE table(column)' + if (preg_match('/^REFERENCES\s+([A-Za-z0-9_\.]+)\s*\(([^)]+)\)/', $constraint, $m)) { + $refTable = $m[1]; + // Remove prefix if it was provided and matches + if ($this->currentPrefix !== '' && str_starts_with($refTable, $this->currentPrefix)) { + $refTable = substr($refTable, strlen($this->currentPrefix)); + } + + // current table depends on refTable -> edge from current to parent + // For tree of ancestors starting from $startTable, we build parent map + // but for associative array tree representation, we'll build nested children where parent has child + // i.e., parent -> [ child1, child2 ] based on references found in child + if (!isset($children[$tbl])) { $children[$tbl] = []; } + if (!in_array($refTable, $children[$tbl], true)) { + $children[$tbl][] = $refTable; + } + } + } + } + // Build recursive associative array tree starting at $startTable + $visited = []; + $buildTree = function(string $node) use (&$buildTree, &$children, &$visited) { + if (isset($visited[$node])) { + // cycle: denote specially + return ['__cycle__' => $node]; + } + $visited[$node] = true; + $kids = $children[$node] ?? []; + $tree = []; + foreach ($kids as $k) { + $tree[$k] = $buildTree($k); + } + return $tree; + }; + $tree = [ $startTable => $buildTree($startTable) ]; + // Output the array via Console IO as JSON for readability + $io->out(json_encode($tree, JSON_PRETTY_PRINT)); + } + + /** + * Build legacy co_* foreign key rename patterns based on migration config. + * Returns an array of [suffixFrom => suffixTo], eg: + * '_co_person_id' => '_person_id' + * '_co_group_id' => '_group_id' + * '_co_message_template_id' => '_message_template_id' + * + * The patterns are applied as “ends-with” replacements to source column names. + */ + private function buildLegacyFkPatterns(array $tablesCfg, string $sourcePrefix): array + { + if (!is_array($tablesCfg) || empty($tablesCfg)) { + return [ + '_co_person_id' => '_person_id', + '_co_group_id' => '_group_id', + // Also support exact-form defaults for standalone columns + 'co_person_id' => 'person_id', + 'co_group_id' => 'group_id', + ]; + } + + // Filter out documentation keys and reduce to table entries that have a 'source' + $entries = []; + foreach ($tablesCfg as $k => $v) { + if (!is_array($v)) { continue; } + if (is_string($k) && str_starts_with($k, '__')) { continue; } + if (!isset($v['source']) || !is_string($v['source'])) { continue; } + $entries[$k] = $v['source']; + } + + $patterns = [ + // suffix form (covers prefix + co_* cases) + '_co_person_id' => '_person_id', + '_co_group_id' => '_group_id', + // exact form (covers exact co_* columns with no left prefix) + 'co_person_id' => 'person_id', + 'co_group_id' => 'group_id', + ]; + + foreach ($entries as $targetKey => $src) { + $bareSource = $src; + if ($sourcePrefix !== '' && str_starts_with($bareSource, $sourcePrefix)) { + $bareSource = substr($bareSource, strlen($sourcePrefix)); + } + + // If bare source starts with "co_", derive both forms for this target + // targetKey 'message_templates' -> singular 'message_template' + if (str_starts_with($bareSource, 'co_')) { + $singular = \Cake\Utility\Inflector::singularize($targetKey); + + // suffix form: ..._co__id -> ...__id + $fromSuffix = '_co_' . $singular . '_id'; + $toSuffix = '_' . $singular . '_id'; + $patterns[$fromSuffix] = $toSuffix; + + // exact form: co__id -> _id + $fromExact = 'co_' . $singular . '_id'; + $toExact = $singular . '_id'; + $patterns[$fromExact] = $toExact; + } + } + + return $patterns; + } + + + /** + * Normalize a v4 source column name to a likely v5 target column name based on: + * - generic co_* foreign key patterns derived from migration config + * - specific one-offs + */ + private function normalizeTargetNameForSourceColumn(string $sourceCol, array $legacyFkPatterns): ?string + { + // Try exact-form first (eg, 'co_message_template_id' -> 'message_template_id') + if (isset($legacyFkPatterns[$sourceCol])) { + return $legacyFkPatterns[$sourceCol]; + } + + // Then apply suffix-based rewrites (eg, 'approver_co_group_id' -> 'approver_group_id') + foreach ($legacyFkPatterns as $from => $to) { + if ($from === $sourceCol) { continue; } // already handled exact + // only treat entries starting with '_' as suffix rules + if (str_starts_with($from, '_') && str_ends_with($sourceCol, $from)) { + $prefix = substr($sourceCol, 0, -strlen($from)); + return $prefix . $to; + } + } + + // Specific one-offs + if ($sourceCol === 'source_url') { + return 'source'; + } + if ($sourceCol === 'email_body') { + return 'email_body_text'; + } + + return null; + } +} diff --git a/app/availableplugins/Transmogrify/src/Controller/AppController.php b/app/availableplugins/Transmogrify/src/Controller/AppController.php new file mode 100644 index 000000000..dabc4c59b --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Controller/AppController.php @@ -0,0 +1,10 @@ + + */ + protected const ACTION_CODE_DIRECT_MAP = [ + // Authenticators + 'EAUT' => 'EAUT', // AuthenticatorEdited + + // Comments + 'CMNT' => 'CMNT', // CommentAdded + + // Email + 'EMLV' => 'EMLV', // EmailAddressVerified -> EmailVerified + 'EMLS' => 'EMLS', // EmailAddressVerifyReqSent -> EmailVerifyCodeSent + + // External Identity / Login env + 'EOIE' => 'EOIE', // OrgIdEditedLoginEnv -> ExternalIdentityLoginUpdate + + // Groups + 'ACGR' => 'ACGR', // CoGroupAdded -> GroupAdded + 'DCGR' => 'DCGR', // CoGroupDeleted -> GroupDeleted + 'ECGR' => 'ECGR', // CoGroupEdited -> GroupEdited + + // Group Members + 'ACGM' => 'ACGM', // CoGroupMemberAdded -> GroupMemberAdded + 'DCGM' => 'DCGM', // CoGroupMemberDeleted -> GroupMemberDeleted + 'ECGM' => 'ECGM', // CoGroupMemberEdited -> GroupMemberEdited + + // Identifiers / Matching + 'AIDA' => 'AIDA', // IdentifierAutoAssigned + 'UMAT' => 'UMAT', // MatchAttributesUpdated + + // Names + 'PNAM' => 'PNAM', // NamePrimary + + // Notifications + 'NOTA' => 'NOTA', // NotificationAcknowledged + 'NOTX' => 'NOTX', // NotificationCanceled + 'NOTD' => 'NOTD', // NotificationDelivered + 'NOTR' => 'NOTR', // NotificationResolved + + // Person pipeline + 'ACPP' => 'ACPP', // CoPersonAddedPetition -> PersonAddedPetition + 'ACPL' => 'ACPL', // CoPersonAddedPipeline -> PersonAddedPipeline + 'MCPL' => 'MCPL', // CoPersonMatchedPipeline -> PersonMatchedPipeline + + // Person status + 'RCPS' => 'RCPS', // CoPersonStatusRecalculated -> PersonStatusRecalculated + + // Petitions + 'CPPC' => 'CPPC', // CoPetitionCreated -> PetitionCreated + 'CPUP' => 'CPUP', // CoPetitionUpdated -> PetitionUpdated + + // Reference ID + 'OIDR' => 'OIDR', // ReferenceIdentifierObtained + ]; + + /** + * v4 codes that map to v5 ActionEnum with a changed code. + * + * @var array + */ + protected const ACTION_CODE_RENAMED_MAP = [ + // CoPersonRoleRelinked (LCRM) -> PersonRoleRelinked (LCPR) + 'LCRM' => 'LCPR', + ]; + + /** + * v4 ActionEnum codes that moved to PetitionActionEnum in v5. + * + * Keys are v4 codes, values are v5 PetitionActionEnum codes. + * + * @var array + */ + protected const ACTION_CODE_PETITION_MAP = [ + // Invitations moved into PetitionActionEnum + 'INVC' => 'IC', // InvitationConfirmed -> Accepted + 'INVD' => 'PX', // InvitationDeclined -> Declined + 'INVV' => 'IV', // InvitationViewed -> InvitationViewed + // 'INVE' => null, // InvitationExpired (no explicit equivalent) + // 'INVS' => null, // InvitationSent (no explicit equivalent) + ]; + + /** + * Optional/opinionated mappings to collapse specific attribute events (names) + * into v5’s generic MVEA* events. Disabled by default for correctness. + * + * @var array + */ + protected const ACTION_CODE_OPTIONAL_OPINIONATED_MAP = [ + // Names -> generic Multi-Valued Extended Attribute events + 'ANAM' => 'AMVE', // NameAdded -> MVEAAdded + 'ENAM' => 'EMVE', // NameEdited -> MVEAEdited + 'DNAM' => 'DMVE', // NameDeleted -> MVEADeleted + ]; + + /** + * Legacy SSH key history actions that should be normalized to SSHU. + * + * Keys are incoming v4 history action codes, values are the v5 code. + * + * @var array + */ + protected const HISTORY_ACTION_SSH_MAP = [ + // Legacy SSH key events that no longer exist as separate actions + 'SSHA' => SshKeyActionEnum::SshKeyUploaded, // Added -> Uploaded + 'SSHE' => SshKeyActionEnum::SshKeyUploaded, // Edited -> Uploaded + ]; + + + /** + * Map a v4 ActionEnum right-hand code to v5. + * + * Returns: + * - enum: 'ActionEnum' | 'PetitionActionEnum' | 'SshKeyActionEnum' | null + * - code: string|null + * + * When enum is null, there is no v5 equivalent; callers can log/skip. + * + * @param string $v4Code + * @param bool $enableOpinionated Enable optional generalized mappings (default=false) + * @return array{enum: string|null, code: string|null} + */ + protected function mapActionCode(string $v4Code, bool $enableOpinionated = false): array + { + $key = strtoupper(trim($v4Code)); + + if ($key === '') { + return ['enum' => null, 'code' => null]; + } + + // 1) Direct ActionEnum mappings (same code) + if (isset(self::ACTION_CODE_DIRECT_MAP[$key])) { + return ['enum' => 'ActionEnum', 'code' => self::ACTION_CODE_DIRECT_MAP[$key]]; + } + + // 2) Renamed ActionEnum mappings + if (isset(self::ACTION_CODE_RENAMED_MAP[$key])) { + return ['enum' => 'ActionEnum', 'code' => self::ACTION_CODE_RENAMED_MAP[$key]]; + } + + // 3) PetitionActionEnum mappings + if (isset(self::ACTION_CODE_PETITION_MAP[$key])) { + return ['enum' => 'PetitionActionEnum', 'code' => self::ACTION_CODE_PETITION_MAP[$key]]; + } + + // 4) Optional/opinionated ActionEnum mappings + if ($enableOpinionated && isset(self::ACTION_CODE_OPTIONAL_OPINIONATED_MAP[$key])) { + return ['enum' => 'ActionEnum', 'code' => self::ACTION_CODE_OPTIONAL_OPINIONATED_MAP[$key]]; + } + + // 5) Legacy SSH key actions (SSHA/SSHE) normalized to SSHU in SshKeyActionEnum + if (isset(self::HISTORY_ACTION_SSH_MAP[$key])) { + return ['enum' => 'SshKeyActionEnum', 'code' => self::HISTORY_ACTION_SSH_MAP[$key]]; + } + + // No known mapping + return ['enum' => null, 'code' => null]; + } + + + /** + * Convenience: map from a row array. Tries 'action' first, then 'action_code'. + * + * @param array $row + * @param bool $enableOpinionated + * @return array{enum: string|null, code: string|null} + */ + protected function mapActionFromRow(array $row, bool $enableOpinionated = false): array + { + $code = null; + + if (isset($row['action']) && is_string($row['action'])) { + $code = $row['action']; + } + + if ($code === null) { + return ['enum' => null, 'code' => null]; + } + + return $this->mapActionCode($code, $enableOpinionated); + } + + + /** + * Map an SSH key history action to the current action code. + * + * Uses mapActionCode() so that legacy actions are normalized. + * For all other actions, returns the original action value unchanged. + * + * @param array $row Row data containing an 'action' key + * @return string|null Mapped action code or null if not set + */ + protected function mapHistoryAction(array $row): ?string + { + if (!isset($row['action']) || !is_string($row['action'])) { + return null; + } + + $action = (string)$row['action']; + + // Delegate to the generic mapper + $mapped = $this->mapActionCode($action); + + // If this is one of the SSH key legacy actions, use the mapped SSH key code + if ($mapped['enum'] !== null && $mapped['code'] !== null) { + return $mapped['code']; + } + + // Otherwise, return the original action unchanged + return $action; + } + + + /** + * Map a cm_co_notifications row’s action code to a v5 ActionEnum code. + * For the notifications we only accept ActionEnum; PetitionActionEnum mappings return null. + */ + protected function mapNotificationAction(array $row): ?string + { + $m = $this->mapActionFromRow($row); + + if ($m['enum'] === 'ActionEnum') { + return $m['code']; + } + + // No ActionEnum equivalent (eg, invitation events that moved to PetitionActionEnum) + $rawCode = null; + if (isset($row['action']) && is_string($row['action'])) { + $rawCode = $row['action']; + } + + if ($rawCode !== null && isset($this->cmdPrinter)) { + $unmapped = $this->listUnmappedActionCodes([$rawCode], false); + if (!empty($unmapped)) { + $this->cmdPrinter->warning(sprintf('Skipping notification with unmapped action code: %s', $unmapped[0])); + } + } + + return null; + } + + /** + * Report which v4 action codes won’t map under current settings. + * + * @param string[] $seenV4Codes + * @param bool $enableOpinionated + * @return string[] + */ + protected function listUnmappedActionCodes(array $seenV4Codes, bool $enableOpinionated = false): array + { + $unmapped = []; + + foreach ($seenV4Codes as $c) { + $m = $this->mapActionCode((string)$c, $enableOpinionated); + if ($m['enum'] === null) { + $unmapped[] = strtoupper(trim((string)$c)); + } + } + + return array_values(array_unique($unmapped)); + } +} diff --git a/app/availableplugins/Transmogrify/src/Lib/Traits/CacheTrait.php b/app/availableplugins/Transmogrify/src/Lib/Traits/CacheTrait.php new file mode 100644 index 000000000..198c51f85 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Traits/CacheTrait.php @@ -0,0 +1,315 @@ +cache[$table][$label] ??= []; + if (isset($row['id'])) { + $this->cache[$table][$label][$key] = $row['id']; + } + } + + /** + * Cache a simple field value under the row ID bucket (with merge semantics). + * + * If the id is missing, this is a no-op. + * + * @param string $table Logical table name + * @param array $row Current row data + * @param string $field Field name to cache + * @return void + */ + protected function cacheFieldById(string $table, array $row, string $field): void + { + if (!array_key_exists($field, $row)) { + return; + } + + $id = $row['id'] ?? null; + if ($id === null) { + return; + } + + // Ensure the id bucket is initialized + $this->cache[$table]['id'] ??= []; + $this->cache[$table]['id'][$id] ??= []; + + // If a value exists: + // - If both existing and incoming are arrays, merge recursively. + // - Otherwise, set only when the key is not present to avoid clobbering + // previously injected/nested data (like `enrollment_flow_steps`). + if (array_key_exists($field, $this->cache[$table]['id'][$id])) { + $existing = $this->cache[$table]['id'][$id][$field]; + $incoming = $row[$field]; + + if (is_array($existing) && is_array($incoming)) { + $this->cache[$table]['id'][$id][$field] = array_replace_recursive($existing, $incoming); + } else { + // Do not overwrite an existing non-array value or structure + // If you do want to force an overwrite for specific fields, handle by name here. + // e.g., if (in_array($field, ['co_id', ...], true)) { $this->cache[...] = $incoming; } + } + } else { + $this->cache[$table]['id'][$id][$field] = $row[$field]; + } + } + + /** + * Cache results as configured for the specified table. + * + * @since COmanage Registry v5.2.0 + * @param string $table Table to cache + * @param array $row Row of table data + * @param array $orinRow Original Row of table data + * @param array $cacheConfig Optional cache configuration (overrides tables.json) + */ + + protected function cacheResults(string $table, array $row, array $orinRow, ?array $cacheConfig = []): void + { + $config = !empty($cacheConfig) ? $cacheConfig : ($this->tables[$table]['cache'] ?? []); + + if (empty($config)) { + return; + } + + // Cache the requested fields. For now, at least, we key on row ID only. + foreach ($config as $field) { + if (is_array($field)) { + $this->cacheCompositeKey($table, $row, $field); + } else { + $effectiveRow = array_key_exists($field, $row) ? $row : $orinRow; + $this->cacheFieldById($table, $effectiveRow, $field); + } + } + } + + /** + * Find CO ID from related record data + * + * @param array $row Row data containing person_id, external_identity_id, group_id etc + * @return int Mapped CO ID + * @throws \InvalidArgumentException When CO not found + * @since COmanage Registry v5.2.0 + */ + protected function findCoId(array $row): int + { + // By the time we're called, we should have transmogrified the Org Identity + // and CO Person data, so we can just walk the caches + + if (isset($row['co_id'])) { + return (int)$row['co_id']; + } + + // Choose the resolution path by precedence using match(true) + $coId = match (true) { + isset($row['person_id']) => $this->getCoIdFromPersonId((int)$row['person_id']), + + // Map External Identity -> Person -> CO + isset($row['external_identity_id']) => $this->getCoIdFromExternalIdentityId((int)$row['external_identity_id']), + + isset($row['external_identity_source_id']) => $this->getCoIdFromExternalIdentitySourceId((int)$row['external_identity_source_id']), + + isset($row['group_id']) => $this->getCoIdFromGroupId((int)$row['group_id']), + + isset($row['match_server_id']) => $this->getCoIdFromMatchServer((int)$row['match_server_id']), + + isset($row['api_user_id']) => $this->getCoIdFromApiUserId((int)$row['api_user_id']), + + // Legacy/preRow: org_identity_id follows the same External Identity path + isset($row['org_identity_id']) => $this->getCoIdFromExternalIdentityId((int)$row['org_identity_id']), + + isset($row['org_identity_source_id']) => $this->getCoIdFromExternalIdentitySourceId((int)$row['org_identity_source_id']), + + isset($row['co_person_id']) => $this->getCoIdFromPersonId((int)$row['co_person_id']), + + isset($row['co_group_id']) => $this->getCoIdFromGroupId((int)$row['co_group_id']), + + isset($row['co_person_role_id']) => $this->getCoIdFromPersonRoleId((int)$row['co_person_role_id']), + + default => null, + }; + + if ($coId !== null) { + return $coId; + } + + // For the multiple value attributes we are going to get a lot of misses + // because we only move one org identity and only the latest one. No revisions. + // Which means that all the values that belong to deleted org identities are going + // to be misses. + throw new \InvalidArgumentException("CO not found for record"); + } + + /** + * Resolve a CO ID from a CO Person ID via cache. + * + * @param int $personId + * @return int|null + */ + private function getCoIdFromPersonId(int $personId): ?int + { + if (isset($this->cache['people']['id'][$personId]['co_id'])) { + return (int)$this->cache['people']['id'][$personId]['co_id']; + } + return null; + } + + /** + * Resolve a CO Person ID from an External Identity (or legacy OrgIdentity) ID via cache. + * + * @param int $externalIdentityId + * @return int|null + */ + private function getPersonIdFromExternalIdentity(int $externalIdentityId): ?int + { + $personId = $this->cache['external_identities']['id'][$externalIdentityId]['person_id'] ?? null; + return $personId !== null ? (int)$personId : null; + } + + /** + * Resolve a CO ID from an API User ID via cache. + * + * @param int $apiUserId API User ID to resolve + * @return int|null CO ID if found, null otherwise + * @since COmanage Registry v5.2.0 + */ + private function getCoIdFromApiUserId(int $apiUserId): ?int + { + if (isset($this->cache['api_users']['id'][$apiUserId]['co_id'])) { + return (int)$this->cache['api_users']['id'][$apiUserId]['co_id']; + } + return null; + } + + /** + * Resolve a CO ID from a Group ID via cache. + * + * @param int $groupId + * @return int|null + */ + private function getCoIdFromGroupId(int $groupId): ?int + { + if (isset($this->cache['groups']['id'][$groupId]['co_id'])) { + return (int)$this->cache['groups']['id'][$groupId]['co_id']; + } + return null; + } + + /** + * Resolve a CO ID starting from an External Identity (or legacy OrgIdentity) ID via cache. + * + * @param int $externalIdentityId + * @return int|null + */ + private function getCoIdFromExternalIdentityId(int $externalIdentityId): ?int + { + $personId = $this->getPersonIdFromExternalIdentity($externalIdentityId); + return $personId !== null ? $this->getCoIdFromPersonId($personId) : null; + } + + /** + * Resolve a CO ID starting from an External Identity Source (or legacy OrgIdentitySource) ID via cache. + * + * @param int $externalIdentitySourceId + * @return int|null + */ + private function getCoIdFromExternalIdentitySourceId(int $externalIdentitySourceId): ?int + { + $coId = $this->cache['external_identity_sources']['id'][$externalIdentitySourceId]['co_id'] ?? null; + return $coId !== null ? (int)$coId : null; + } + + /** + * Resolve a CO ID from a Match Server ID via cache. + * + * @param int $matchServerId Match Server ID to resolve + * @return int|null CO ID if found, null otherwise + * @since COmanage Registry v5.2.0 + */ + private function getCoIdFromMatchServer(int $matchServerId): ?int + { + if (isset($this->cache['match_servers']['id'][$matchServerId]['server_id'])) { + $serverId = (int)$this->cache['match_servers']['id'][$matchServerId]['server_id']; + return $this->cache['servers']['id'][$serverId]['co_id'] ?? null; + } + return null; + } + + /** + * Resolve a CO ID from a Person Role ID via cache. + * + * @param int $personRoleId Person Role ID to resolve + * @return int|null CO ID if found, null otherwise + * @since COmanage Registry v5.2.0 + */ + private function getCoIdFromPersonRoleId(int $personRoleId): ?int + { + if (isset($this->cache['person_roles']['id'][$personRoleId]['person_id'])) { + $personId = (int)$this->cache['person_roles']['id'][$personRoleId]['person_id']; + return $this->getCoIdFromPersonId($personId); + } + return null; + } +} \ No newline at end of file diff --git a/app/availableplugins/Transmogrify/src/Lib/Traits/HookRunnersTrait.php b/app/availableplugins/Transmogrify/src/Lib/Traits/HookRunnersTrait.php new file mode 100644 index 000000000..8ddf6c05c --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Traits/HookRunnersTrait.php @@ -0,0 +1,157 @@ +tables[$table]['preTable'])) { return; } + $method = $this->tables[$table]['preTable']; + if(!method_exists($this, $method)) { + throw new \RuntimeException("Unknown preTable hook: $method"); + } + + $this->cmdPrinter->verbose('Running pre-table hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); + $this->{$method}(); + } + + /** + * Run post-table hook if configured for given table + * + * @param string $table Table name + * @return void + * @throws \RuntimeException If hook method doesn't exist + * @since COmanage Registry v5.2.0 + */ + private function runPostTableHook(string $table): void { + if(empty($this->tables[$table]['postTable'])) { return; } + $method = $this->tables[$table]['postTable']; + if(!method_exists($this, $method)) { + throw new \RuntimeException("Unknown postTable hook: $method"); + } + $this->cmdPrinter->verbose('Running post-table hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); + $this->{$method}(); + } + + /** + * Run pre-row hook if configured for given table + * + * @param string $table Table name + * @param array $origRow Original row data by reference + * @param array $row Current row data by reference + * @return void + * @throws \RuntimeException If hook method doesn't exist + * @since COmanage Registry v5.2.0 + */ + private function runPreRowHook(string $table, array &$origRow, array &$row): void { + if(empty($this->tables[$table]['preRow'])) { return; } + $method = $this->tables[$table]['preRow']; + if(!method_exists($this, $method)) { + throw new \RuntimeException("Unknown preRow hook: $method"); + } + $this->cmdPrinter->verbose('Running pre-row hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); + $this->{$method}($origRow, $row); + } + + /** + * Run post-row hook if configured for given table + * + * @param string $table Table name + * @param array $origRow Original row data by reference + * @param array $row Current row data by reference + * @return void + * @throws \RuntimeException If hook method doesn't exist + * @since COmanage Registry v5.2.0 + */ + private function runPostRowHook(string $table, array &$origRow, array &$row): void { + if(empty($this->tables[$table]['postRow'])) { return; } + $method = $this->tables[$table]['postRow']; + if(!method_exists($this, $method)) { + throw new \RuntimeException("Unknown postRow hook: $method"); + } + $this->cmdPrinter->verbose('Running post-row hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); + $this->{$method}($origRow, $row); + } + + /** + * Run SQL select hook if configured for given table + * + * @param string $table Table name + * @param string $qualifiedTableName Fully qualified table name + * @return string SQL select statement + * @throws \RuntimeException If hook method doesn't exist + * @since COmanage Registry v5.2.0 + */ + private function runSqlSelectHook(string $table, string $qualifiedTableName): string { + $method = $this->tables[$table]['sqlSelect'] ?? ''; + if($method === '') { + return RawSqlQueries::buildSelectAllOrderedById($qualifiedTableName); + } + if(!method_exists(RawSqlQueries::class, $method)) { + throw new \RuntimeException("Unknown sqlSelect hook: $method"); + } + $this->cmdPrinter->verbose('Running SQL select hook: ' . ucfirst(preg_replace('/([A-Z])/', ' $1', $method))); + + // Special handling for MVEA-style selects: derive FK columns from fieldMap keys dynamically + if ($method === 'mveaSqlSelect') { + // Known FK candidates we care about for MVEA presence checks + $fkCandidates = [ + 'co_person_id', + 'co_person_role_id', + 'org_identity_id', + 'co_group_id', + 'co_department_id', + 'co_provisioning_target_id', + 'organization_id' + ]; + $fieldMap = $this->tables[$table]['fieldMap'] ?? []; + $presentFks = array_values(array_intersect($fkCandidates, array_keys($fieldMap))); + + // Fallback to names-like FKs if nothing matched (defensive) + if (empty($presentFks)) { + $presentFks = ['co_person_id', 'org_identity_id']; + } + + return RawSqlQueries::mveaSqlSelect( + $qualifiedTableName, + $this->inconn->isMySQL(), + $presentFks + ); + } + + return RawSqlQueries::{$method}($qualifiedTableName, $this->inconn->isMySQL()); + } +} \ No newline at end of file diff --git a/app/availableplugins/Transmogrify/src/Lib/Traits/ManageDefaultsTrait.php b/app/availableplugins/Transmogrify/src/Lib/Traits/ManageDefaultsTrait.php new file mode 100644 index 000000000..a346cd548 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Traits/ManageDefaultsTrait.php @@ -0,0 +1,342 @@ +get('Groups'); + + $iterator = new PaginatedSqlIterator($Groups, []); + + foreach($iterator as $k => $group) { + try { + // Because PaginatedSqlIterator will pick up new Groups as we create them, + // we need to check for any Owners groups (that we just created) and skip them. + if(!$group->isOwners()) { + $ownersGid = $Groups->createOwnersGroup($group); + + // We need to manually populate the cache + $this->cache['groups']['id'][$group->id]['owners_group_id'] = $ownersGid; + } + } + catch(\Exception $e) { + $this->cache['error'] += 1; + $this->cmdPrinter->error("Failed to create owners group for " + . $group->name . " (" . $group->id . "): " + . $e->getMessage()); + } + } + } + + /** + * Map to the default affiliation type ID ("member") for the row's CO. + * + * Used when a row does not carry an explicit affiliation value but we want to + * refer to the standard "member" affiliation type within the same CO. + * + * @param array $row Row data from which CO ID can be derived + * @return int|null + */ + protected function mapToDefaultAffiliationTypeId(array $row): ?int + { + // Let CacheTrait::findCoId decide how to resolve the CO ID + $coId = $this->findCoId($row); + + // Build a synthetic row with the default affiliation value + $defaultRow = [ + 'affiliation' => 'member', + ]; + + // Use the generic type mapper to get the extended type ID + return $this->mapType( + $defaultRow, + 'PersonRoles.affiliation_type', + $coId, + 'affiliation' + ); + } + + /** + * Default Address type ID (adjust value if a different default is desired). + * + * @param array $row + * @return int|null + */ + protected function mapToDefaultAddressTypeId(array $row): ?int + { + $coId = $this->findCoId($row); + + $defaultRow = [ + 'type' => 'office', + ]; + + return $this->mapType( + $defaultRow, + 'Addresses.type', + $coId + ); + } + + /** + * Default Email Address type ID. + * + * @param array $row + * @return int|null + */ + protected function mapToDefaultEmailAddressTypeId(array $row): ?int + { + $coId = $this->findCoId($row); + + $defaultRow = [ + 'type' => 'official', + ]; + + return $this->mapType( + $defaultRow, + 'EmailAddresses.type', + $coId + ); + } + + /** + * Default Name type ID. + * + * @param array $row + * @return int|null + */ + protected function mapToDefaultNameTypeId(array $row): ?int + { + $coId = $this->findCoId($row); + + $defaultRow = [ + 'type' => 'official', + ]; + + return $this->mapType( + $defaultRow, + 'Names.type', + $coId + ); + } + + /** + * Default Telephone Number type ID. + * + * @param array $row + * @return int|null + */ + protected function mapToDefaultTelephoneNumberTypeId(array $row): ?int + { + $coId = $this->findCoId($row); + + $defaultRow = [ + 'type' => 'office', + ]; + + return $this->mapType( + $defaultRow, + 'TelephoneNumbers.type', + $coId + ); + } + + + /** + * Determine the subject model ID ("People" or "Groups") for a given row. + * + * This method evaluates whether the row corresponds to a person or a group + * based on the presence of either `co_person_id` or `co_group_id`, ensuring + * that exactly one of these fields is populated. + * + * @param array $row Row data containing subject identifiers + * @return string Either "People" or "Groups" based on the input data + * @throws \RuntimeException If neither or both `co_person_id` and `co_group_id` are set + * @since COmanage Registry v5.2.0 + */ + protected function mapToSubjectModel(array $row): string + { + $personId = $row['co_person_id'] ?? null; + $groupId = $row['co_group_id'] ?? null; + + $hasPerson = ($personId !== null && $personId !== ''); + $hasGroup = ($groupId !== null && $groupId !== ''); + + if($hasPerson xor $hasGroup) { + return $hasPerson ? 'People' : 'Groups'; + } + + $exportId = $row['id'] ?? '(unknown)'; + + throw new \RuntimeException( + "Invalid cm_co_provisioning_exports row {$exportId}: exactly one of co_person_id or co_group_id must be set" + ); + } + + + /** + * Determine the subject ID ("People" or "Groups") for a given row. + * + * This method evaluates whether the row corresponds to a person or a group + * based on the presence of either `co_person_id` or `co_group_id`, ensuring + * that exactly one of these fields is populated, and returns the corresponding ID. + * + * @param array $row Row data containing subject identifiers + * @return int|null ID of the person or group, or null if neither can be determined + * @throws \RuntimeException If neither or both `co_person_id` and `co_group_id` are set + * @since COmanage Registry v5.2.0 + */ + protected function mapToSubjectId(array $row): ?int + { + $personId = $row['co_person_id'] ?? null; + $groupId = $row['co_group_id'] ?? null; + + $hasPerson = ($personId !== null && $personId !== ''); + $hasGroup = ($groupId !== null && $groupId !== ''); + + // Exactly one of co_person_id / co_group_id must be set. + if($hasPerson xor $hasGroup) { + return $hasPerson ? (int)$personId : (int)$groupId; + } + + $exportId = $row['id'] ?? '(unknown)'; + + throw new \RuntimeException( + "Invalid provisioning export row {$exportId}: exactly one of co_person_id or co_group_id must be set" + ); + } + + + /** + * Insert default CO Settings for COs that don't have settings. + * + * @since COmanage Registry v5.2.0 + * @return void + */ + protected function insertDefaultSettings(): void + { + // Create a CoSetting for any CO that didn't previously have one. + + $createdSettings = []; + $createdCos = array_keys($this->cache['cos']['id']); + + foreach($this->cache['co_settings']['id'] as $co_setting_id => $cached) { + $createdSettings[] = $cached['co_id']; + } + + $emptySettings = array_values(array_diff($createdCos, $createdSettings)); + + if(!empty($emptySettings)) { + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + foreach($emptySettings as $coId) { + // Insert a default row into CoSettings for this CO ID + try { + $CoSettings->addDefaults($coId); + } catch (\ConflictException $e) { + // skip + } + } + } + } + + /** + * Insert default Pronoun types for all COs. + * + * @since COmanage Registry v5.2.0 + * @return void + */ + protected function insertPronounTypes(): void + { + // Since the Pronoun MVEA didn't exist in v4, we'll need to create the + // default types for all COs. + + $Types = TableRegistry::getTableLocator()->get('Types'); + + foreach(array_keys($this->cache['cos']['id']) as $coId) { + $Types->addDefault($coId, 'Pronouns.type'); + } + + // After inserting the default Pronoun types, populate the type cache so that + // subsequent calls to mapType() for Pronouns.type can resolve the IDs. + $pronounTypes = $Types->find() + ->where([ + 'attribute' => 'Pronouns.type', + 'co_id IN' => array_keys($this->cache['cos']['id']), + ]) + ->all(); + + foreach ($pronounTypes as $type) { + $row = [ + 'id' => $type->id, + 'co_id' => $type->co_id, + 'attribute' => $type->attribute, + 'value' => $type->value, + ]; + + // This will populate: + // $this->cache['types']['co_id+attribute+value+']["+Pronouns.type++"] = + $this->cacheCompositeKey('types', $row, ['co_id', 'attribute', 'value']); + } + } + + /** + * Set a default value for CO Settings Permitted Telephone Number Fields. + * Returns CANE as the default permitted telephone number field value. + * + * @param array $row Row of table data + * @return string Default value CANE + * @since COmanage Registry v5.2.0 + */ + + protected function populateCoSettingsPhone(array $row): string + { + return PermittedTelephoneNumberFieldsEnum::CANE; + } +} diff --git a/app/availableplugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php b/app/availableplugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php new file mode 100644 index 000000000..fb0736bb2 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Traits/RowTransformationTrait.php @@ -0,0 +1,744 @@ +args?->getOption('groups-colon-replacement') ?? ''); + if ($replacement === '' && ($this->args?->getOption('groups-colon-replacement-dash') === true)) { + $replacement = '-'; + } + if ($replacement !== '') { + $newName = str_replace(':', $replacement, $name); + if ($newName === '') { + // Guard against accidental empty name + throw new \InvalidArgumentException('Replacing ":" produced an empty Standard CoGroup name; adjust --groups-colon-replacement'); + } + $row['name'] = $newName; + $this->cmdPrinter?->verbose(sprintf('Replaced ":" in Standard CoGroup name "%s" -> "%s"', $name, $newName)); + } else { + // Default: error out (no auto-replacement) + throw new \InvalidArgumentException('Standard CoGroup names cannot contain a colon by default: ' . $name); + } + } + } + } + + + /** + * Check if a group membership is actually asserted, and reassign ownerships. + * + * @since COmanage Registry v5.2.0 + * @param array $origRow Row of table data (original data) + * @param array $row Row of table data (post fixes) + * @throws InvalidArgumentException + */ + protected function reconcileGroupMembershipOwnership(array $origRow, array &$row): void + { + // We need to handle the various member+owner scenarios, but basically + // (1) If 'owner' is set, manually create a Group Membership in the appropriate + // Owners Group (we need to be called via preRow to do this) + // (2) If 'member' is NOT set, throw an exception so we don't create + // in invalid membership + // (3) Otherwise just return so the Membership gets created + + $tableName = 'group_members'; + if($origRow['owner'] && !$origRow['deleted'] && !$origRow['co_group_member_id']) { + // Create a membership in the appropriate owners group, but not + // on changelog entries + + if(!empty($this->cache['groups']['id'][ $origRow['co_group_id'] ]['owners_group_id'])) { + $ownerRow = [ + 'group_id' => $this->cache['groups']['id'][ $origRow['co_group_id'] ]['owners_group_id'], + 'person_id' => $origRow['co_person_id'], + 'created' => $origRow['created'], + 'modified' => $origRow['modified'], + 'group_member_id' => null, + 'revision' => 0, + 'deleted' => 'f', + 'actor_identifier' => $origRow['actor_identifier'] + ]; + + $qualifiedTableName = $this->outconn->qualifyTableName($tableName); + $this->outconn->insert($qualifiedTableName, $ownerRow); + } else { + $this->cmdPrinter->error("Could not find owners group for CoGroupMember " . $origRow['id']); + $this->cache['error'] += 1; + } + } + + if(!$row['member'] && !$row['owner']) { + // we are caching the rejected rows so we can reject the rows that reference them as well. + $this->cache['rejected'][$tableName][$row['id']] = $row; + throw new \InvalidArgumentException('member not set on GroupMember'); + } + } + + /** + * Filter Jobs. + * + * @since COmanage Registry v5.2.0 + * @param array $origRow Row of table data (original data) + * @param array $row Row of table data (post fixes) + * @throws InvalidArgumentException + */ + protected function validateJobIsTransmogrifiable(array $origRow, array &$row): void + { + // We don't update any of the attributes, but for rows with unsupported data + // we throw an exception so they don't transmogrify. + + if($row['status'] == 'GO' || $row['status'] == 'Q') { + throw new \InvalidArgumentException("Job is Queued or In Progress"); + } + + if($row['job_type'] == 'EX' || $row['job_type'] == 'OS') { + throw new \InvalidArgumentException("Legacy Job types cannot be transmogrified"); + } + } + + /** + * Translate booleans to string literals to work around DBAL Postgres boolean handling. + * + * @since COmanage Registry v5.2.0 + * @param string $table Table Name + * @param array $row Row of attributes, fixed in place + */ + protected function normalizeBooleanFieldsForDb(string $table, array &$row): void + { + $attrs = ['deleted']; + + // We could introspect this from the schema file... + if(!empty($this->tables[$table]['booleans'])) { + $attrs = array_merge($attrs, $this->tables[$table]['booleans']); + } + + foreach($attrs as $a) { + if(isset($row[$a]) && gettype($row[$a]) == 'boolean') { + // DBAL Postgres boolean handling seems to be somewhat buggy, see history in + // this issue: https://github.com/doctrine/dbal/issues/1847 + // We need to (more generically than this hack) convert from boolean to char + // to avoid errors on insert + if($this->outconn->isMySQL()) { + $row[$a] = ($row[$a] ? '1' : '0'); + } else { + $row[$a] = ($row[$a] ? 't' : 'f'); + } + } + } + } + + /** + * Populate empty Changelog data from legacy records + * + * @since COmanage Registry v5.2.0 + * @param string $table Table Name + * @param array $row Row of attributes, fixed in place + * @param bool $force If true, always create keys + */ + protected function populateChangelogDefaults(string $table, array &$row, bool $force=false): void + { + if ($force || (array_key_exists('deleted', $row) && is_null($row['deleted']))) { + $row['deleted'] = false; + } + + if ($force || (array_key_exists('revision', $row) && is_null($row['revision']))) { + $row['revision'] = 0; + } + + if ($force || (array_key_exists('actor_identifier', $row) && is_null($row['actor_identifier']))) { + $row['actor_identifier'] = 'Transmogrification'; + } + } + + /** + * Map fields that have been renamed from Registry Classic to Registry PE. + * + * IMPORTANT!!! + * + * When mapping legacy type fields, call the function-based mapper (eg, &map...Type) before configuring null + * for the old column name. The mapper still needs the original source column value, and performNoMapping() + * will unset that column once it sees a null mapping. In other words, always place the null mapping for + * the old column after the new field’s function mapping so the mapper can read the legacy value + * before it is removed. + * In general, keep all the `unset`, the keys with value null, at the bottom of the tables.json configuration + * + * @since COmanage Registry v5.2.0 + * @param string $table Table Name + * @param array $row Row of attributes, fixed in place + * @throws InvalidArgumentException + */ + protected function mapLegacyFieldNames(string $table, array &$row): void + { + // oldname => newname, or &newname, which is a function to call. + // Note functions can return more than one mapping + + if(empty($this->tables[$table]['fieldMap'])) { + return; + } + + $fields = $this->tables[$table]['fieldMap']; + + foreach ($fields as $oldname => $newname) { + // Determine the first character only if $newname is a non-empty string + $first = is_string($newname) && $newname !== '' ? $newname[0] : null; + + match (true) { + // No mapping: unset the legacy field + !$newname => $this->performNoMapping($row, $oldname), + + // Function mapping: compute value by helper method named after the &-prefixed token + $first === '&' => $this->performFunctionMapping($row, $oldname, substr((string)$newname, 1), $table), + + // Default value mapping: set only if current value is null + $first === '?' => $this->applyDefaultIfNull($row, $oldname, substr((string)$newname, 1)), + + // Default value mapping: force set the value to the current value + $first === '=' => $row[$oldname] = substr((string)$newname, 1), + + // Direct rename: copy to new name and unset the old one + default => $this->renameField($row, $oldname, (string)$newname), + }; + } + } + + /** + * Process Extended Attributes by converting them to Ad Hoc Attributes. + * + * @since COmanage Registry v5.2.0 + */ + protected function migrateExtendedAttributesToAdHocAttributes(): void + { + // This is intended to run AFTER AdHocAttributes so that we don't stomp on + // the row identifiers. + + // First, pull the old Extended Attribute configuration. + $extendedAttrs = []; + + $tableName = "cm_co_extended_attributes"; + $qualifiedTableName = $this->inconn->qualifyTableName($tableName); + $insql = RawSqlQueries::buildSelectAllOrderedById($qualifiedTableName); + $stmt = $this->inconn->query($insql); + + while($row = $stmt->fetch()) { + $extendedAttrs[ $row['co_id'] ][] = $row['name']; + } + + if(empty($extendedAttrs)) { + // No need to do anything further if no attributes are configured + return; + } + + foreach(array_keys($extendedAttrs) as $coId) { + $tableName = "cm_co" . $coId . "_person_extended_attributes"; + $qualifiedTableName = $this->inconn->qualifyTableName($tableName); + $insql = RawSqlQueries::buildSelectAll($qualifiedTableName); + $stmt = $this->inconn->query($insql); + + while($eaRow = $stmt->fetch()) { + // If we didn't transmogrify the parent row for some reason then trying + // to insert the ad_hoc_attributes will throw an error. + if(!empty($this->cache['person_roles']['id'][ $eaRow['co_person_role_id'] ])) { + foreach($extendedAttrs[$coId] as $ea) { + $adhocRow = [ + 'person_role_id' => $eaRow['co_person_role_id'], + 'tag' => $ea, + 'value' => $eaRow[$ea], + 'created' => $eaRow['created'], + 'modified' => $eaRow['modified'] + ]; + + // Extended Attributes were not changelog enabled + $this->populateChangelogDefaults('ad_hoc_attributes', $adhocRow, true); + $this->normalizeBooleanFieldsForDb('ad_hoc_attributes', $adhocRow); + + try { + $tableName = 'ad_hoc_attributes'; + $qualifiedTableName = $this->outconn->qualifyTableName($tableName); + $this->outconn->insert($qualifiedTableName, $adhocRow); + } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) { + $this->cmdPrinter->warning("record already exists: " . print_r($adhocRow, true)); + } + } + } + } + } + } + + /** + * Create a core API row for each ApiSource plugin row. + * + * Intended as a postRow hook for the "api_sources" table. + * + * @param array $origRow Original cm_api_sources row + * @param array $row Inserted api_sources row (already mapped) + * @return void + * @throws Exception + */ + protected function createApiForApiSource(array $origRow, array $row): void + { + // We need the target ApiSource ID on the plugin table + if (empty($origRow['id'])) { + return; + } + $apiSourceId = (int)$origRow['id']; + + // api_user_id is optional; if missing we can't derive co_id via API user + $apiUserId = $origRow['api_user_id'] ?? null; + if ($apiUserId === null) { + return; + } + + // Derive CO ID from api_user_id; may return null if user not mapped + $coId = $this->mapCoIdFromApiUserId(['api_user_id' => $apiUserId]); + if ($coId === null) { + return; + } + + $apisTable = $this->outconn->qualifyTableName('apis'); + + // Build API row + $apiRow = [ + 'co_id' => $coId, + 'description' => '(Transmogrify) API Source Plugin', + 'plugin' => 'ApiConnector.ApiSourceEndpoints', + 'status' => 'A', + 'api_user_id' => $apiUserId, + 'created' => $this->mapNow([]), + 'modified' => $this->mapNow([]), + ]; + + // Apply changelog defaults and boolean normalization + $this->populateChangelogDefaults('apis', $apiRow, true); + $this->normalizeBooleanFieldsForDb('apis', $apiRow); + + $this->outconn->beginTransaction(); + try { + // Insert into apis and cache mapping for later lookup + $this->outconn->insert($apisTable, $apiRow); + + if (!method_exists($this->outconn, 'lastInsertId')) { + throw new \RuntimeException('Could not retrieve API ID'); + } + $apiId = (int)$this->outconn->lastInsertId(); + + $this->outconn->commit(); + // Cache relation so mapApiIdFromCache can resolve api_id by source ID + $externalSourceId = $row['external_identity_source_id'] ?? $row['org_identity_source_id'] ?? null; + if ($externalSourceId !== null) { + $this->cache['apis']['id'][$apiId]['org_identity_source_id'] = $externalSourceId; + } + } catch (\Throwable $e) { + $this->outconn->rollBack(); + throw new \RuntimeException("Failed to create API record for API Source($apiSourceId): " . $e->getMessage()); + } + } + + /** + * Creates a new enrollment flow step for the given enrollment flow + * + * @param array $origRow Original row data from source database + * @param array $row Current row data for target database + * @return void + * @throws Exception When database operation fails + * @since COmanage Registry v5.2.0 + */ + protected function createEnrollmentFlowStep(array $origRow, array $row): void { + // Only for non-deleted, non-revision rows + if (!empty($origRow['deleted'])) { + return; + } + if (!empty($origRow['co_enrollment_flow_id'])) { + return; + } + + // We need the target Enrollment Flow ID (assumes ID is preserved/mapped on insert) + if (empty($row['id'])) { + // If the target ID is not present, we can't safely create the step + return; + } + $flowId = (int)$row['id']; + + // Qualified table names + $stepsTable = $this->outconn->qualifyTableName('enrollment_flow_steps'); + $viewerTable = $this->outconn->qualifyTableName('historic_petition_viewers'); + + // Build step row (bypass ORM behaviors -> set timestamps + changelog fields) + $step = [ + 'enrollment_flow_id' => $flowId, + 'description' => 'Petition Historic Data', + 'status' => 'A', // Active + 'actor_type' => 'E', // Enrollee + 'plugin' => 'HistoricPetitionViewer.HistoricPetitionViewers', + 'ordr' => 1, + 'created' => $this->mapNow([]), + 'modified' => $this->mapNow([]), + ]; + $this->populateChangelogDefaults('enrollment_flow_steps', $step, true); + $this->normalizeBooleanFieldsForDb('enrollment_flow_steps', $step); + + $this->outconn->beginTransaction(); + try { + // Insert Enrollment Flow Step + $this->outconn->insert($stepsTable, $step); + + if (!method_exists($this->outconn, 'lastInsertId')) { + throw new \RuntimeException('Could not retrieve Enrollment Flow Step ID'); + } + $stepId = (int)$this->outconn->lastInsertId(); + + // Create the Historic Petition Viewer row pointing to the step + $viewer = [ + 'enrollment_flow_step_id' => $stepId, + 'created' => $this->mapNow([]), + 'modified' => $this->mapNow([]), + ]; + $this->populateChangelogDefaults('historic_petition_viewers', $viewer, true); + $this->normalizeBooleanFieldsForDb('historic_petition_viewers', $viewer); + + $this->outconn->insert($viewerTable, $viewer); + + // Retrieve viewer ID + $viewerId = (int)$this->outconn->lastInsertId(); + + $this->outconn->commit(); + + // Cache both IDs under enrollment_flows + $this->cache['enrollment_flows']['id'][$flowId]['enrollment_flow_steps']['id'] = $stepId; + $this->cache['enrollment_flows']['id'][$flowId]['historic_petition_viewers']['id'] = $viewerId; + } catch (\Throwable $e) { + $this->outconn->rollBack(); + throw new \RuntimeException("Failed to create enrollment flow step for enrollment flow $flowId: " . $e->getMessage()); + } + } + + /** + * Post-row hook: create the FormatAssigner plugin record for an identifier assignment. + * + * Called after the base identifier_assignments row has been inserted. + * Reads format-related fields from the original v4 row and inserts a + * corresponding format_assigners record linked to the newly created + * identifier_assignment_id. + * + * Only runs for algorithm R (Random) or S (Sequential); skipped otherwise. + * + * @param array $originRow Original v4 row from cm_co_identifier_assignments + * @param array $row Mapped v5 row (post field mapping), must contain 'id' + * @return void + * @since COmanage Registry v5.2.0 + */ + protected function createIdentifierAssignmentPluginRecord(array $originRow, array $row): void + { + $algorithm = $originRow['algorithm'] ?? null; + + // Only FormatAssigners are handled here; Plugin algorithm is out of scope + if (!in_array($algorithm, ['R', 'S'], true)) { + return; + } + + $identifierAssignmentId = $row['id'] ?? null; + + if ($identifierAssignmentId === null) { + throw new \InvalidArgumentException( + 'createIdentifierAssignmentPluginRecord: missing id in mapped row' + ); + } + + // Manual normalization because 'format_assigners' is not in tables.json['booleans'] + $transliterate = !empty($originRow['transliterate']); + + // Convert PHP boolean to DB-friendly string + if ($this->outconn->isMySQL()) { + $transliterateVal = ($transliterate ? '1' : '0'); + } else { + $transliterateVal = ($transliterate ? 't' : 'f'); + } + + // Map timestamps to preserve history + $created = $originRow['created'] ?? $this->mapNow([]); + $modified = $originRow['modified'] ?? $this->mapNow([]); + + $formatAssignerRow = [ + 'identifier_assignment_id' => (int)$identifierAssignmentId, + 'format' => $originRow['format'] ?? null, + 'minimum_length' => isset($originRow['minimum_length']) + ? (int)$originRow['minimum_length'] + : null, + 'minimum' => isset($originRow['minimum']) + ? (int)$originRow['minimum'] + : null, + 'maximum' => isset($originRow['maximum']) + ? (int)$originRow['maximum'] + : null, + 'collision_mode' => $algorithm, + 'permitted_characters' => $originRow['permitted'] ?? null, + 'enable_transliteration' => $transliterateVal, + 'created' => $created, + 'modified' => $modified + ]; + + $this->populateChangelogDefaults('format_assigners', $formatAssignerRow, true); + $this->normalizeBooleanFieldsForDb('format_assigners', $formatAssignerRow); + + $qualifiedTableName = $this->outconn->qualifyTableName('format_assigners'); + + try { + $this->outconn->beginTransaction(); + $this->outconn->insert($qualifiedTableName, $formatAssignerRow); + // Get the ID of the inserted record + if (!method_exists($this->outconn, 'lastInsertId')) { + throw new \RuntimeException('Could not retrieve Format Assigner ID'); + } + $formatAssignerId = (int)$this->outconn->lastInsertId(); + + $this->outconn->commit(); + + // Cache the result so we can map it later (e.g. for format_assigner_sequences) + // We manually populate the cache since this insertion happens outside the main loop + + // Cache by ID + $this->cacheResults( + 'format_assigners', + array_merge(['id' => $formatAssignerId], $formatAssignerRow), + $formatAssignerRow, + ['identifier_assignment_id', 'id'], + ); + } catch (\Throwable $e) { + $this->outconn->rollBack(); + throw new \RuntimeException("Failed to create Format Assigner record for IdentifierAssignment $identifierAssignmentId: " . $e->getMessage()); + } + } + + /** + * Split an External Identity into an External Identity Role. + * + * @param array $origRow Row of table data (original data) + * @param array $row Row of table data (post fixes) + * @throws Exception + * @since COmanage Registry v5.2.0 + */ + + protected function mapExternalIdentityToExternalIdentityRole(array $origRow, array $row): void + { + // the original row has the co_id + $roleRow = []; + + // We could set the row ID to be the same as the original parent, but then + // we'd have to reset the sequence after the table is finished migrating. + + foreach([ + // Parent Key + 'id' => 'external_identity_id', + 'o' => 'organization', + 'ou' => 'department', + 'manager_identifier' => 'manager_identifier', + 'sponsor_identifier' => 'sponsor_identifier', + 'status' => 'status', + 'title' => 'title', + 'valid_from' => 'valid_from', + 'valid_through' => 'valid_through', + // Fix up changelog + 'org_identity_id' => 'external_identity_role_id', + 'revision' => 'revision', + 'deleted' => 'deleted', + 'actor_identifier' => 'actor_identifier', + 'created' => 'created', + 'modified' => 'modified' + ] as $oldKey => $newKey) { + $roleRow[$newKey] = $origRow[$oldKey]; + } + + // Rationale: mapOrgIdentityCoPersonId accepts only the first mapping it finds + // (later mappings are treated as legacy/unpooled anomalies and ignored). + // Therefore, each ExternalIdentity produces at most one ExternalIdentityRole. + // To avoid inserting a bogus self-referential changelog link, do not carry any + // legacy key into external_identity_role_id. Start a fresh changelog chain. + $roleRow['external_identity_role_id'] = null; + $roleRow['revision'] = 0; + + + try { + if (!empty($origRow['affiliation'])) { + $row['affiliation'] = $origRow['affiliation']; + $roleRow['affiliation_type_id'] = $this->mapAffiliationType( + row: $row, + coId: $origRow['co_id'] ?? null + ); + } + } catch (\Exception $e) { + $this->cmdPrinter->warning("Failed to map affiliation type: " . $e->getMessage()); + if ( + isset($origRow['co_id']) + && (!isset($this->cache['cos'][$origRow['co_id']]) + || ($this->cache['cos'][$origRow['co_id']]['status']) + && $this->cache['cos'][$origRow['co_id']]['status'] == 'TR') + ) { + // This CO has been deleted, so we can't map the type. We will return null + $roleRow['affiliation_type_id'] = null; + } else { + // Rethrow the exception + throw $e; + } + } + + $tableName = 'external_identity_roles'; + + // Fix up changelog and booleans prior to insert + $this->populateChangelogDefaults($tableName, $roleRow, true); + $this->normalizeBooleanFieldsForDb($tableName, $roleRow); + + $qualifiedTableName = $this->outconn->qualifyTableName($tableName); + + $this->outconn->insert($qualifiedTableName, $roleRow); + } + + /** + * Unset a legacy field when it has no mapping. + * + * @param array &$row Row data to modify + * @param string $oldname Name of field to remove + * @return void + */ + private function performNoMapping(array &$row, string $oldname): void + { + unset($row[$oldname]); + } + + + /** + * Compute value for a field via a mapping function name (without the leading &). + * Reuses the original field name in-place. Throws if the mapping yields a falsy value. + * + * @param array &$row Row data to modify + * @param string $oldname Name of field to map + * @param string $funcName Name of mapping function to call + * @param string $table Table name for error reporting + * @return void + * @throws \InvalidArgumentException When mapping returns the falsy value or function not found + */ + private function performFunctionMapping(array &$row, string $oldname, string $funcName, string $table): void + { + if (!method_exists($this, $funcName)) { + throw new \InvalidArgumentException("Mapping function {$funcName} does not exist for {$table} {$oldname}"); + } + + // We always pass the entire row so the mapping function can implement arbitrary logic + $row[$oldname] = $this->$funcName($row); + + // Allow specific mapping functions to return null (target columns are nullable) + $nullableFuncs = [ + 'mapAffiliationType', + 'mapHistoricPetitionViewerId', + 'mapCoIdFromApiUserId', + ]; + + // Tables that allow null types + $tablesWithNullableTypes = [ + 'pipelines', + 'identifier_assignments', + ]; + if (in_array($table, $tablesWithNullableTypes, true)) { + $nullableFuncs[] = "mapIdentifierType"; + $nullableFuncs[] = "mapEmailType"; + } + + if (!$row[$oldname] && !in_array($funcName, $nullableFuncs, true)) { + throw new \InvalidArgumentException("Could not find value for {$table} {$oldname}"); + } + } + + + /** + * Apply a default value only when the current value is strictly null. + * + * @param array &$row Row data to modify + * @param string $oldname Name of field to check/update + * @param string $default Default value to apply if field is null + * @return void + */ + private function applyDefaultIfNull(array &$row, string $oldname, string $default): void + { + if (array_key_exists($oldname, $row) && $row[$oldname] === null) { + $row[$oldname] = $default; + } + } + + + /** + * Rename a field by copying its value to a new key and removing the old key. + * + * @param array &$row Row data to modify + * @param string $oldname Original field name + * @param string $newname New field name + * @return void + */ + private function renameField(array &$row, string $oldname, string $newname): void + { + // Only copy if the old field exists to avoid notices + if (array_key_exists($oldname, $row)) { + $row[$newname] = $row[$oldname]; + unset($row[$oldname]); + } + } +} \ No newline at end of file diff --git a/app/availableplugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php b/app/availableplugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php new file mode 100644 index 000000000..96b57d386 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Traits/TypeMapperTrait.php @@ -0,0 +1,1002 @@ + + */ + + public const SERVER_TYPE_MAP = [ + 'HT' => 'CoreServer.HttpServers', + 'KA' => 'CoreServer.KafkaServers', + 'KC' => 'CoreServer.KdcServers', + 'LD' => 'CoreServer.LdapServers', + 'MT' => 'CoreServer.MatchServers', + 'O2' => 'CoreServer.Oauth2Servers', + 'SQ' => 'CoreServer.SqlServers', + ]; + + /** + * Map MessageTemplateEnum (v4) codes to MessageTemplateContextEnum (v5) codes + * + * Keys are v4 right-hand codes, values are v5 right-hand codes. + * Left-hand names are provided as context in comments. + * + * Notes: + * - EnrollmentApprover (AP) does not exist in v5; the closest functional + * equivalent is EnrollmentHandoff (EH). + * - ExpirationNotification (XN) is not present in v5; mapped to null. + * + * @var array + */ + public const MESSAGE_TEMPLATE_CONTEXT_MAP = [ + // Authenticator + 'AU' => 'AU', + // EnrollmentApprover -> EnrollmentHandoff + 'AP' => 'EH', + // EnrollmentApproval + 'EA' => 'EA', + // EnrollmentFinalization + 'EF' => 'EF', + // EnrollmentVerification -> Verification + 'EV' => 'V', + // ExpirationNotification (not used in v5 yet) + 'XN' => null, + // Plugin + 'PL' => 'PL', + ]; + + + /** + * Map match attribute types to corresponding model names + * + * @var array + * @since COmanage Registry v5.2.0 + */ + public const MATCH_ATTRIBUTE_TYPE_MAP = [ + 'emailAddress' => 'EmailAddresses', + 'name' => 'Names', + 'identifier' => 'Identifiers', + 'dateOfBirth' => '', + ]; + + + /** + * Find and set adopted person ID based on linked external/org identity ID + * + * Attempts to find the associated CO Person ID using either external_identity_id + * or org_identity_id from either original or current row data. If found, updates + * the co_person_id in the original row. + * + * @param array &$origRow Original row data by reference + * @param array &$row Current row data by reference + * @return void + * @throws Exception + * @since COmanage Registry v5.2.0 + */ + protected function findAdoptedPersonByLinkedOrgIdentityId(array &$origRow, array &$row): void + { + // Try to locate an external identity (or legacy org identity) id from either array + $externalIdentityId = null; + + if (!empty($row['external_identity_id'])) { + $externalIdentityId = (int)$row['external_identity_id']; + } elseif (!empty($origRow['org_identity_id'])) { + $externalIdentityId = (int)$origRow['org_identity_id']; + } elseif (!empty($row['org_identity_id'])) { + $externalIdentityId = (int)$row['org_identity_id']; + } + + $personId = null; + + if ($externalIdentityId !== null) { + $personId = $this->mapOrgIdentityCoPersonId(['id' => $externalIdentityId]); + } + + + $row['co_person_id'] = null; + if ($personId !== null) { + $row['co_person_id'] = $personId; + } + } + + /** + * Map address type to corresponding type ID + * + * @param array $row Row data containing address type + * @return int|null Mapped type ID + * @since COmanage Registry v5.2.0 + */ + protected function mapAddressType(array $row): ?int + { + $type = 'type'; + if (isset($row['address_type'])) { + $type = "address_type"; + } + return $this->mapType( + $row, + 'Addresses.type', + $this->findCoId($row), + $type + ); + } + + /** + * Map affiliation type to corresponding type ID + * + * @param array $row Row data containing affiliation + * @param int|null $coId CO Id or null if not known + * @return int|null Mapped type ID + * @since COmanage Registry v5.2.0 + */ + protected function mapAffiliationType(array $row, ?int $coId = null): ?int + { + $type = 'affiliation'; + if (isset($row['default_affiliation'])) { + $type = 'default_affiliation'; + } else if (isset($row['sync_affiliation'])) { + $type = 'sync_affiliation'; + } + + // No value assigned for the affiliation, return null + if (empty($row[$type])) { + return null; + } + + return $this->mapType( + $row, + 'PersonRoles.affiliation_type', + $coId ?? $this->findCoId($row), + $type + ); + } + + + /** + * Map API User ID to corresponding CO ID + * + * @param array $row Row data containing api_user_id + * @return int|null Mapped CO ID or null if not found + * @since COmanage Registry v5.2.0 + */ + protected function mapCoIdFromApiUserId(array $row): ?int { + return $this->getCoIdFromApiUserId($row['api_user_id']); + } + + + /** + * Maps organization identity source ID to API ID using cached API data. + * + * Looks up API ID from cached API data based on the organization identity source ID. + * Returns null if cache is empty or mapping not found. + * + * @param array $row Row data containing org_identity_source_id + * @return int|null Mapped API ID or null if not found + * @since COmanage Registry v5.2.0 + */ + protected function mapApiIdFromCache(array $row): ?int { + $apis = $this->cache['apis'] ?? null; + + if ($apis === null) { + return null; + } + + $orgIdentitySourceId = $row['org_identity_source_id'] ?? $row["external_identity_source_id"] ?? null; + + if ($orgIdentitySourceId === null) { + null; + } + + // [id..org_identity_source_id] => + $flattenedApis = Hash::flatten($apis); + $key = array_search($orgIdentitySourceId, $flattenedApis, true); + + if ($key === false) { + return null; + } + + $parts = explode('.', $key); + return (int)$parts[1] ?? null; + } + + + /** + * Generic mapper for v4 plugin names to v5 plugin model paths. + * + * Logic: + * - Only process plugin names that end with the given $suffix (eg, "Source" or "Authenticator") + * - If a plugin exists with the same name, return "Plugin.PluralTable" + * - Else, if a "Connector" plugin exists, return "BaseConnector.PluralTable" + * + * @param array $row Row data containing 'plugin' + * @param string $suffix Required suffix for the plugin name (eg, "Source", "Authenticator") + * @param string $context Human‑readable context for exception messages + * @return string|null + */ + protected function mapPlugin(array $row, string $suffix, string $context): ?string + { + $v4 = $row['plugin'] ?? null; + if (!$v4) { + return null; + } + + // Only process names that end with the expected suffix + if (!str_ends_with($v4, $suffix)) { + return null; + } + + $pluginsTable = TableRegistry::getTableLocator()->get('Plugins'); + + $pluginRows = $pluginsTable + ->find() + ->select(['plugin']) + ->where(fn($exp) => $exp->isNotNull('plugin')) + ->distinct() + ->disableHydration() + ->all(); + + $available = array_map(static fn(array $r) => (string)$r['plugin'], $pluginRows->toList()); + + // Pluralized Table name for the model side (eg, EnvSource -> EnvSources) + $pluralTable = Inflector::pluralize($v4); + + // 1) Direct plugin exists with the same name + if (in_array($v4, $available, true)) { + return $v4 . '.' . $pluralTable; + } + + // 2) Check for "Connector" + $base = substr($v4, 0, -strlen($suffix)); + $connector = $base . 'Connector'; + + if ($base !== '' && in_array($connector, $available, true)) { + if ($base !== '' && in_array($connector, $available, true)) { + $mapped = $connector . '.' . $pluralTable; + if (\Cake\Core\App::className($mapped, 'Model/Table', 'Table') !== null) { + return $mapped; + } + } + } + + // No mapping found + throw new \InvalidArgumentException( + "Unable to map {$context} plugin: {$v4}. Plugin not implemented in COmanage Registry v5." + ); + } + + + /** + * Map v4 External Identity Source plugin name to v5 plugin model path. + * + * Examples: + * EnvSource -> EnvSource.EnvSources + * FileSource -> FileConnector.FileSources + * + * @param array $row A row from cm_org_identity_sources containing 'plugin' + * @return string|null + */ + protected function mapExternalIdentitySourcePlugin(array $row): ?string + { + return $this->mapPlugin($row, 'Source', 'External Identity Source'); + } + + + /** + * Map v4 Authenticator plugin name to v5 plugin model path. + * + * Examples: + * SshKeyAuthenticator -> SshKeyAuthenticator.SshKeyAuthenticators + * + * @param array $row Row data containing 'plugin' + * @return string|null + */ + protected function mapAuthenticatorPlugin(array $row): ?string + { + return $this->mapPlugin($row, 'Authenticator', 'Authenticator'); + } + + /** + * Map v4 Provisioner plugin name to v5 plugin model path. + * + * Examples: + * LdapProvisioner -> LdapConnector.LdapProvisioner + * + * @param array $row Row data containing 'plugin' + * @return string|null + */ + protected function mapProvisionerPlugin(array $row): ?string + { + return $this->mapPlugin($row, 'Provisioner', 'Provisioner'); + } + + + /** + * Map email type to corresponding type ID + * + * @param array $row Row data containing email type + * @return int|null Mapped type ID + * @since COmanage Registry v5.2.0 + */ + protected function mapEmailType(array $row): ?int + { + $type = 'type'; + if (isset($row['match_type']) && $row['match_strategy'] === MatchStrategyEnum::EmailAddress) { + $type = 'match_type'; + } else if (isset($row['email_address_type'])) { + $type = 'email_address_type'; + } + + // No value assigned for the email address, return null + if (empty($row[$type])) { + return null; + } + + return $this->mapType( + $row, + 'EmailAddresses.type', + $this->findCoId($row), + $type + ); + } + + /** + * Map an Extended Type attribute name for model name changes. + * + * @since COmanage Registry v5.2.0 + * @param array $row Row of table data + * @return string Updated attribute name + */ + + protected function mapExtendedType(array $row): string + { + switch($row['attribute']) { + case 'CoDepartment.type': + return 'Departments.type'; + case 'CoPersonRole.affiliation': + return 'PersonRoles.affiliation_type'; + } + + // For everything else, we need to pluralize the model name + $bits = explode('.', $row['attribute'], 2); + + return Inflector::pluralize($bits[0]) . "." . $bits[1]; + } + + + /** + * Map a petition/attribute record to its associated historic petition viewer ID + * + * The mapping is done by: + * 1. Using enrollment_flow_id directly from row if available + * 2. Otherwise looking up flow ID via petition_id foreign key + * 3. Using flow ID to get viewer ID from enrollment_flows cache + * + * @param array $row Row data containing either enrollment_flow_id or petition_id + * @return int|null Historic petition viewer ID if mapping found, null otherwise + * @since COmanage Registry v5.2.0 + */ + protected function mapHistoricPetitionViewerId(array $row): ?int + { + // 1) Flow id available directly on the row (co_petitions) + $flowId = null; + if (!empty($row['enrollment_flow_id'])) { + $flowId = (int)$row['enrollment_flow_id']; + } + + // 2) Otherwise resolve via parent Petition (co_petition_attributes) + if ($flowId === null && isset($row['petition_id'])) { + $petitionId = (int)$row['petition_id']; + if ($petitionId > 0) { + // Query inbound petitions table to get its enrollment flow id + $qualified = $this->inconn->qualifyTableName('cm_co_petitions'); + $sql = "SELECT co_enrollment_flow_id FROM {$qualified} WHERE id = ?"; + $flowId = (int)$this->inconn->fetchOne($sql, [$petitionId]) ?: null; + } + } + + if ($flowId === null) { + // Not resolvable; the target column is nullable + return null; + } + + // 3) Map flow -> viewer id via cache created when steps/viewer row were inserted + $viewerId = $this->cache['enrollment_flows']['id'][$flowId]['historic_petition_viewers']['id'] ?? null; + + return $viewerId !== null ? (int)$viewerId : null; + } + + /** + * Map identifier type to corresponding type ID + * + * @param array $row Row data containing identifier type + * @return int|null Mapped type ID + * @since COmanage Registry v5.2.0 + */ + protected function mapIdentifierType(array $row): ?int + { + $type = 'type'; + if (isset($row['sync_identifier_type'])) { + $type = 'sync_identifier_type'; + } else if (isset($row['match_type']) && $row['match_strategy'] === MatchStrategyEnum::Identifier) { + $type = 'match_type'; + } else if (isset($row['identifier_type'])) { + $type = 'identifier_type'; + } + + // No value assigned for the identifier, return null + if (empty($row[$type])) { + return null; + } + + return $this->mapType( + $row, + 'Identifiers.type', + $this->findCoId($row), + $type + ); + } + + + /** + * Map match attribute type to corresponding type ID based on attribute key + * + * @param array $row Row data containing match attribute + * @return int|null Mapped type ID or null if attribute not found/mapped + * @since COmanage Registry v5.2.0 + */ + protected function mapMatchAttributeTypeId(array $row): ?int + { + // Determine the model based on the match attribute map (eg, emailAddress -> EmailAddresses) + $attributeKey = $row['attribute'] ?? null; + if (!$attributeKey || !array_key_exists($attributeKey, self::MATCH_ATTRIBUTE_TYPE_MAP)) { + return null; + } + + $model = self::MATCH_ATTRIBUTE_TYPE_MAP[$attributeKey]; + + // If the mapping is empty (eg, dateOfBirth), return null + if ($model === '') { + return null; + } + return $this->mapType($row, $model . '.type', $this->findCoId($row)); + } + + /** + * Map v4 MessageTemplateEnum code in $row['context'] to v5 MessageTemplateContextEnum code + * + * @param array $row Row data containing 'context' (v4 record) + * @return string|null Mapped v5 context code or null if not mapped + * @since COmanage Registry v5.2.0 + */ + protected function mapMessageTemplateContext(array $row): ?string + { + return (string)self::MESSAGE_TEMPLATE_CONTEXT_MAP[$row['context']] ?? null; + } + + /** + * Map login identifiers, in accordance with the configuration. + * + * @since COmanage Registry v5.2.0 + * @param array $origRow Row of table data (original data) + * @param array $row Row of table data (post fixes) + * @throws \InvalidArgumentException + */ + protected function mapLoginIdentifiers(array $origRow, array &$row): void { + // There might be multiple reasons to copy the row, but we only want to + // copy it once. + $copyRow = false; + + if(!empty($origRow['org_identity_id'])) { + if($this->args->getOption('login-identifier-copy') + && $origRow['login']) { + $copyRow = true; + } + + // Note the argument here is the old v4 string (eg "eppn") and not the + // PE foreign key + if($this->args->getOption('login-identifier-type') + && $origRow['type'] == $this->args->getOption('login-identifier-type')) { + $copyRow = true; + } + + // Identifiers attached to External Identities do not have login flags in PE + $row['login'] = false; + } + + if($copyRow) { + // Find the Person ID associated with this External Identity ID + + if(!empty($this->cache['external_identities']['id'][ $origRow['org_identity_id'] ]['person_id'])) { + // Insert a new row attached to the Person, leave the original record + // (ie: $row) untouched + + $copiedRow = [ + 'person_id' => $this->mapOrgIdentityCoPersonId(['id' => $origRow['org_identity_id']]), + 'identifier' => $origRow['identifier'], + 'type_id' => $this->mapIdentifierType($origRow), + 'status' => $origRow['status'], + 'login' => true, + 'created' => $origRow['created'], + 'modified' => $origRow['modified'] + ]; + + // Set up the changelog and fix booleans + $this->populateChangelogDefaults('identifiers', $copiedRow, true); + $this->normalizeBooleanFieldsForDb('identifiers', $copiedRow); + + try { + $tableName = 'identifiers'; + $qualifiedTableName = $this->outconn->qualifyTableName($tableName); + + // Check if the identifier already exists + $identifierLookupSql = "SELECT id FROM {$qualifiedTableName} + WHERE person_id = ? + AND type_id = ? + AND identifier = ? + AND login = ? + AND source_identifier_id IS NULL + LIMIT 1"; + $identifierLookupData = [ + $copiedRow['person_id'], + $copiedRow['type_id'], + $copiedRow['identifier'], + true + ]; + $this->normalizeBooleanFieldsForDb('identifiers', $identifierLookupData); + $existingIdentifierId = $this->outconn->fetchOne($identifierLookupSql, $identifierLookupData); + + if ($existingIdentifierId) { + // Already present; skip insert regardless of differing metadata + $this->cmdPrinter?->verbose("Duplicate login Identifier detected for person_id={$copiedRow['person_id']} type_id={$copiedRow['type_id']} identifier={$copiedRow['identifier']}; skipping copy."); + return; + } + + $this->outconn->insert($qualifiedTableName, $copiedRow); + } catch (UniqueConstraintViolationException $e) { + $this->cmdPrinter->warning("record already exists: " . print_r($copiedRow, true)); + } catch (\Doctrine\DBAL\Exception $e) { + throw new \InvalidArgumentException("Failed to fetch identifier record: " . $e->getMessage()); + } + } + } + } + + + /** + * Map name type to corresponding type ID + * + * @param array $row Row data containing name type + * @return int|null Mapped type ID + * @since COmanage Registry v5.2.0 + */ + protected function mapNameType(array $row): ?int + { + $type = 'type'; + if (isset($row['name_type'])) { + $type = 'name_type'; + } + + return $this->mapType( + $row, + 'Names.type', + $this->findCoId($row), + $type + ); + } + + /** + * Return a timestamp equivalent to now. + * + * @since COmanage Registry v5.2.0 + * @param array $row Row of table data (ignored) + * @return string Timestamp + */ + + protected function mapNow(array $row) { + if(empty($this->cache['now'])) { + $created = new \Datetime('now'); + $this->cache['now'] = $created->format('Y-m-d H:i:s'); + } + + return $this->cache['now']; + } + + /** + * Map an Org Identity ID to a CO Person ID + * + * @param array $row Row of Org Identity table data + * @return int|null CO Person ID + * @throws Exception + * @since COmanage Registry v5.2.0 + */ + protected function mapOrgIdentityCoPersonId(array $row): ?int + { + // PE eliminates OrgIdentityLink, so we need to map each Org Identity to + // a Person ID. Historically, an Org Identity could have been relinked and + // had multiple historical mappings. We now fetch only the current row, + // so we no longer need to track or select by revision. + + // Before Transmogrification, we require that Org Identities are unpooled. + // (This is probably how most deployments are set up, but there may be some + // legacy deployments out there.) This ensures whatever CO Person the Org + // Identity currently maps to through CoOrgIdentityLink is in the same CO. + + // There may be multiple mappings if the Org Identity was relinked. Basically + // we're going to lose the multiple mappings, since we can only return one + // value here. (Ideally, we would inject multiple OrgIdentities into the new + // table, but this ends up being rather tricky, since we have to figure out + // what row id to assign, and for the moment we don't have a mechanism to + // do that.) Historical information remains available in history_records, + // and if the deployer keeps an archive of the old database. + + $rowId = (int)$row['id']; + + if (empty($this->cache['org_identities']['co_people'])) { + $this->cache['org_identities']['co_people'] = []; + + // Build cache on first use + $this->cmdPrinter->verbose('Populating org identity map...'); + + $tableName = 'cm_co_org_identity_links'; + $changelogFK = 'co_org_identity_link_id'; + $qualifiedTableName = $this->inconn->qualifyTableName($tableName); + + // Only fetch current rows (historical/changelog rows are filtered out) + $mapsql = RawSqlQueries::buildSelectAllWithNoChangelong( + qualifiedTableName: $qualifiedTableName, + changelogFK: $changelogFK + ); + + $stmt = $this->inconn->executeQuery($mapsql); + + while ($r = $stmt->fetchAssociative()) { + $oid = $r['org_identity_id'] ?? null; + + if(!empty($oid)) { + $rowRev = $r['revision']; + if(isset($this->cache['org_identities']['co_people'][ $oid ][ $rowRev ])) { + // If for some reason we already have a record, it's probably due to + // improper unpooling from a legacy deployment. We'll accept only the + // first record and throw warnings on the others. + + $this->cmdPrinter->verbose("Found existing CO Person for Org Identity " . $oid . ", skipping"); + } else { + // Store as-is; we'll resolve the latest revision on lookup + $this->cache['org_identities']['co_people'][ $oid ][ $rowRev ] = (int)$r['co_person_id']; + } + } + } + + // Preserve keys while providing the deterministic order of revisions + foreach ($this->cache['org_identities']['co_people'] as &$revisions) { + if (is_array($revisions)) { + ksort($revisions, SORT_NUMERIC); + } + } + unset($revisions); + } + + if (!empty($this->cache['org_identities']['co_people'][$rowId])) { + // Return the record with the highest revision number + $revisions = $this->cache['org_identities']['co_people'][$rowId]; + $rev = max(array_keys($revisions)); + + return (int)$revisions[$rev]; + } + + // No current mapping found for this Org Identity + return null; + } + + + /** + * Maps v4 petition status codes to v5 PetitionStatusEnum values + * + * For in-progress/active v4 statuses (A,Y,C,CR,PA,PC,PV), maps to Terminated. + * For final/non-active outcomes (F,X,N,D2), preserves equivalent v5 status. + * Unknown/legacy values default to Terminated status. + * + * @param array $row Row data containing v4 petition status code in 'status' field + * @return string|null Mapped v5 PetitionStatusEnum value or null if status empty/missing + * @since COmanage Registry v5.2.0 + */ + protected function mapPetitionStatus(array $row): ?string + { + $v4 = $row['status'] ?? null; + if ($v4 === null || $v4 === '') { + return null; + } + + // Treat any in-progress/active statuses as Terminated in v5 + // Active (A), Approved (Y), Confirmed (C), Created (CR), + // Pending Approval (PA), Pending Confirmation (PC), Pending Vetting (PV) + $active = ['A', 'Y', 'C', 'CR', 'PA', 'PC', 'PV']; + if (in_array($v4, $active, true)) { + return PetitionStatusEnum::Terminated; // 'CX' + } + + // Preserve final/non-active outcomes + return match ($v4) { + 'F' => PetitionStatusEnum::Finalized, + 'X' => PetitionStatusEnum::Declined, + 'N' => PetitionStatusEnum::Denied, + 'D2' => PetitionStatusEnum::Duplicate, + default => PetitionStatusEnum::Terminated, + }; + } + + + /** + * Get a default Pronoun type ID + * + * @param array $row Row data containing name type + * @return int|null Mapped type ID + * @since COmanage Registry v5.2.0 + */ + protected function mapPronounsTypeDefault(array $row): ?int + { + $row['type'] = 'default'; + + return $this->mapType($row, 'Pronouns.type', $this->findCoId($row)); + } + + + /** + * Map v4 person status codes to v5 StatusEnum values + * + * Maps legacy/in-progress statuses to equivalent v5 statuses: + * - Approved (Y), Confirmed (C), Declined (X), Invited (I), + * PendingApproval (PA), PendingConfirmation (PC) -> Pending (P) + * - Denied (N) -> Suspended (S) + * - Otherwise returns original code if allowed + * + * @param array $row Row data containing v4 status code in 'status' field + * @return string|null Mapped v5 status code or null if status empty/missing + * @since COmanage Registry v5.2.0 + */ + protected function mapPersonStatus(array $row): ?string + { + $code = $row['status'] ?? null; + if ($code === null) { + return null; + } + + $code = strtoupper(trim((string)$code)); + + // Approved, Confirmed, Declined, Invited, PendingApproval, PendingConfirmation -> Pending + // Denied -> Suspended + $map = [ + 'Y' => 'P', // Approved + 'C' => 'P', // Confirmed + 'X' => 'P', // Declined + 'I' => 'P', // Invited + 'PA' => 'P', // PendingApproval + 'PC' => 'P', // PendingConfirmation + 'N' => 'S', // Denied + ]; + + if (isset($map[$code])) { + return $map[$code]; + } + + // If it is allowed, just return it + return $code; + } + + + /** + * Maps v4 status/sync_mode combination to v5 status values + * + * v4: + * - status: 'A' (active) or 'S' (suspended) + * - sync_mode: 'F' (Full), 'M' (Manual), 'Q' (Query), 'U' (Update) + * + * v5: + * - sync_mode and status are merged into the single "status" column + * - "Active" is no longer a separate thing + * - v4 suspended ('S') becomes v5 Disabled ('X') + * + * Logic: + * - If v4 status is suspended ('S'), always map to Disabled. + * - Otherwise, copy the sync_mode over: + * F -> Full + * M -> Manual + * U -> Update + * Q -> Disabled (not yet supported; see CFM-372). + * + * @param array $row Row data containing 'status' and 'sync_mode' fields from v4 + * @return string|null Mapped v5 status value or null if sync_mode missing/empty + * @since COmanage Registry v5.2.0 + */ + protected function mapStatusAndSyncToStatus(array $row): ?string + { + $v4Status = $row['status'] ?? null; + $v4SyncMode = $row['sync_mode'] ?? null; + + // Suspended in v4 becomes Disabled in v5 + if ($v4Status === 'S') { + return SyncModeEnum::Disabled; + } + + if ($v4SyncMode === null || $v4SyncMode === '') { + return null; + } + + return match ($v4SyncMode) { + 'F' => SyncModeEnum::Full, + 'M' => SyncModeEnum::Manual, + 'U' => SyncModeEnum::Update, + 'Q' => SyncModeEnum::Disabled, // XXX not yet supported; see CFM-372 + default => null, + }; + } + + + /** + * Map server type code to corresponding plugin path + * + * @param array $row Row data containing server type + * @return string|null Mapped plugin path or null if not found + * @since COmanage Registry v5.2.0 + */ + protected function mapServerTypeToPlugin(array $row): ?string + { + return (string)self::SERVER_TYPE_MAP[$row['server_type']] ?? null; + } + + /** + * Map telephone type to corresponding type ID + * + * @param array $row Row data containing telephone type + * @return int|null Mapped type ID + * @since COmanage Registry v5.2.0 + */ + protected function mapTelephoneType(array $row): ?int + { + $type = 'type'; + if (isset($row['telephone_number_type'])) { + $type = 'telephone_number_type'; + } + + return $this->mapType( + $row, + 'TelephoneNumbers.type', + $this->findCoId($row), + $type + ); + } + + /** + * Map v4 identifier assignment algorithm to v5 plugin name. + * + * v4 algorithm codes: + * R (Random) -> CoreAssigner.FormatAssigners + * S (Sequential) -> CoreAssigner.FormatAssigners + * P (Plugin) -> not handled here; requires separate plugin resolution + * + * @param array $row Row data containing 'algorithm' from cm_co_identifier_assignments + * @return string|null Fully qualified v5 plugin name, or null for Plugin algorithm + * @since COmanage Registry v5.2.0 + */ + protected function mapAlgorithmToPlugin(array $row): ?string + { + $algorithm = $row['algorithm'] ?? null; + + return match ($algorithm) { + 'R', 'S' => 'CoreAssigner.FormatAssigners', + default => null, + }; + } + + /** + * Map a type value to its corresponding ID + * + * @param array $row Row data containing the type value + * @param string $type Type attribute name + * @param int $coId CO ID + * @param string $attr Attribute name in row data + * @return int|null Mapped type ID + * @throws \InvalidArgumentException When type not found + * @since COmanage Registry v5.2.0 + */ + protected function mapType(array $row, string $type, int $coId, string $attr = 'type'): ?int + { + // If we delete a CO, we permanently delete the extended attributes. As a result, the type + // is no longer available because there is no changelog. Types are used by Person Roles and + // External Identity Roles. + if(!$coId) { + throw new \InvalidArgumentException("CO ID not provided for $type " . ($row['id'] ?? '')); + } + $value = $row[$attr] ?? null; + $key = $coId . "+" . $type . "+" . $value . "+"; + if(empty($this->cache['types']['co_id+attribute+value+'][$key])) { + if ( + !isset($this->cache['cos']['id'][$coId]) + || ($this->cache['cos']['id'][$coId]['status'] && in_array($this->cache['cos']['id'][$coId]['status'], ['TR'])) + ) { + // This CO has been deleted, so we can't map the type. We will return null + return null; + } + throw new \InvalidArgumentException("Type not found for " . $key); + } + return (int)$this->cache['types']['co_id+attribute+value+'][$key]; + } + + /** + * Map URL type to corresponding type ID + * + * @param array $row Row data containing URL type + * @return int|null Mapped type ID + * @since COmanage Registry v5.2.0 + */ + protected function mapUrlType(array $row): ?int + { + $type = 'type'; + if (isset($row['url_type'])) { + $type = 'url_type'; + } + + return $this->mapType( + $row, + 'Urls.type', + $this->findCoId($row), + $type + ); + } +} diff --git a/app/availableplugins/Transmogrify/src/Lib/Util/CommandLinePrinter.php b/app/availableplugins/Transmogrify/src/Lib/Util/CommandLinePrinter.php new file mode 100644 index 000000000..80f2f5a04 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Util/CommandLinePrinter.php @@ -0,0 +1,577 @@ +io = $io; + $this->barColor = in_array($barColor, ['blue', 'green'], true) ? $barColor : 'blue'; + $this->barWidth = max(10, $barWidth); + $this->useColors = $useColors; + + // Info is forced to be white + $this->io->setStyle('info', ['text' => '0;39']); + $this->io->setStyle('question', ['text' => '0;39']); + } + + /** + * Start displaying a new progress bar + * + * @param int $total Total number of steps + * @return void + * @since COmanage Registry v5.2.0 + */ + public function start(int $total): void + { + // When verbose is enabled, do not draw the progress bar at all + if ($this->getVerboseLevel() > 1) { + return; + } + + $this->total = max(0, $total); + $this->current = 0; + $this->messageLines = 0; + $this->barActive = true; + + // Hard reset the current line, then move to a brand new line + // \r -> move to column 0 + // \033[K -> clear to end of line + // \n -> go to next line + $this->rawWrite("\r\033[2K"); + + // Draw initial progress bar line (no leading \r, we are already at col 0) + $this->rawWrite($this->formatBar(0)); + + // Save cursor position at the end of the progress bar line + $this->rawWrite("\033[s"); + + // Move to message area (one line below the bar) + $this->rawWrite("\033[1B\r"); + } + + /** + * Update the progress bar to show current progress + * + * @param int $current Current step number + * @return void + * @since COmanage Registry v5.2.0 + */ + public function update(int $current): void + { + // When verbose is enabled, do not draw the progress bar at all + if ($this->getVerboseLevel() > 1 || !$this->barActive) { + return; + } + + $this->current = min(max(0, $current), $this->total); + + // Restore to bar line, redraw bar, save, then go back to message area + $this->rawWrite("\033[u"); // restore saved cursor (bar line end) + $this->rawWrite("\r" . $this->formatBar($this->current)); + $this->rawWrite("\033[s"); // save again at end of bar + $down = 1 + $this->messageLines; // one below bar + existing messages + $this->rawWrite("\033[" . $down . "B\r"); + } + + /** + * Complete and cleanup the progress bar display + * + * @return void + * @since COmanage Registry v5.2.0 + */ + public function finish(): void + { + // When verbose is enabled, do not draw the progress bar at all + if ($this->getVerboseLevel() > 1 || !$this->barActive) { + return; + } + + $this->update($this->total); + + // Move the cursor to the line AFTER the whole message area, + // so subsequent output doesn't overwrite the bar. + $this->rawWrite("\033[u"); // restore saved position at end of bar line + $down = 1 + $this->messageLines; // one below bar + all message lines + $this->rawWrite("\033[" . $down . "B\r"); // move down + $this->rawWrite(PHP_EOL); // clean newline below + + // Re-anchor saved cursor to this clean line so future restores are safe + $this->rawWrite("\033[s"); + + // Reset internal counters so next run starts fresh + $this->messageLines = 0; + $this->barActive = false; + $this->total = 0; + $this->current = 0; + } + + /** + * Display an out level message + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function out(string $message): void + { + $this->message($message, 'out'); + } + + /** + * Display an info level message + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function info(string $message): void + { + $this->message($message, 'info'); + } + + /** + * Display a warning level message (alias of warn()) + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function warning(string $message): void + { + $this->message($message, 'warn'); + } + + /** + * Display an error level message + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function error(string $message): void + { + $this->message($message, 'error'); + } + + /** + * Display a debug level message + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function debug(string $message): void + { + $this->message($message, 'debug'); + } + + /** + * Display a verbose level message + * + * @param string $message Message to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function verbose(string $message): void + { + $this->message($message, 'verbose'); + } + + /** + * Display a message with the specified level + * + * @param string $message Message to display + * @param string $level Message level (info, warn, error, debug, verbose) + * @return void + * @since COmanage Registry v5.2.0 + */ + public function message(string $message, string $level = 'info'): void + { + // Suppress verbose messages unless verbose mode is enabled + if (!$this->shouldPrintLevel($level)) { + return; + } + + $lines = $this->colorizeLevel($level, $message); + // Ensure the message ends with a newline so we can count line advances + if ($lines === '' || substr($lines, -1) !== "\n") { + $lines .= "\n"; + } + + + if ($this->barActive) { + // Always print below the bar + $this->rawWrite("\033[u"); // restore to end of bar line + $down = 1 + $this->messageLines; + $this->rawWrite("\033[" . $down . "B"); // move to message area line + // Clear the entire message line and reset cursor to column 0 + $this->rawWrite("\r\033[2K"); + $this->rawWrite($lines); + $this->messageLines += substr_count($lines, "\n"); + + // Return to bar line and re-save for next update + $this->rawWrite("\033[u"); + $this->rawWrite("\033[s"); + } else { + // No bar: clear current line completely and print from column 0 + $this->rawWrite("\r\033[2K"); + $this->rawWrite($lines); + } + } + + /** + * Prompt for user input + * + * @param string $prompt Prompt to display + * @param string|null $default Default value if no input provided + * @return string|null User input or default value + * @since COmanage Registry v5.2.0 + */ + public function ask(string $prompt, ?string $default = null): ?string + { + // Ensure the message area exists (one line below the bar) + if ($this->messageLines === 0) { + $this->rawWrite(PHP_EOL); + } + + $answer = null; + + if ($this->io) { + // ConsoleIo handles rendering the prompt and reading input + $answer = $this->io->ask($prompt, $default); + } else { + // Fallback to STDOUT/STDIN + $this->rawWrite($prompt . ' '); + $line = fgets(STDIN); + $answer = ($line === false) ? null : rtrim($line, "\r\n"); + if ($answer === null && $default !== null) { + $answer = $default; + } + // Ensure the cursor advances to the next line after the prompt + $this->rawWrite(PHP_EOL); + } + + // A prompt line was added to the message area + $this->messageLines += 1; + + // Redraw progress bar and return cursor to the end of the message area + $this->rawWrite("\033[u"); // restore to saved bar position + $this->rawWrite("\r" . $this->formatBar($this->current)); + $this->rawWrite("\033[s"); // save bar position again + $this->rawWrite("\033[" . $this->messageLines . "B\r"); // move down to message area + + return $answer; + } + + /** + * Pause execution until user presses enter + * + * @param string $prompt Prompt to display + * @return void + * @since COmanage Registry v5.2.0 + */ + public function pause(string $prompt = 'Press to continue...'): void + { + $this->ask($prompt, ''); + } + + /** + * Add color formatting to a message based on its level + * + * @param string $level Message level + * @param string $message Message to colorize + * @return string Formatted message + * @since COmanage Registry v5.2.0 + */ + private function colorizeLevel(string $level, string $message): string + { + $level = strtolower($level); + switch ($level) { + case 'warn': + case 'warning': + $prefix = '[WARN] '; + break; + case 'error': + $prefix = '[ERROR] '; + break; + case 'debug': + $prefix = '[DEBUG] '; + break; + case 'verbose': + $prefix = '[VERBOSE] '; + break; + case 'info': + $prefix = '[INFO] '; + break; + default: + $prefix = ''; + } + + // For INFO, render the label (up to the first colon) in white, value unchanged (or green if white info default) + if ($level === 'info' || $level === 'out') { + $formatted = $this->useColors ? $this->formatLabelWhite($message) : $message; + return $prefix . $formatted; + } + + $color = $this->defaultColorForLevel($level); + $text = $prefix . $message; + if ($this->useColors && $color) { + return $this->wrapColor($text, $color); + } + return $text; + } + + /** + * Get the default color for a message level + * + * @param string $level Message level + * @return string|null Color name or null if no color + * @since COmanage Registry v5.2.0 + */ + private function defaultColorForLevel(string $level): ?string + { + $level = strtolower($level); + return match ($level) { + 'warn', 'warning' => 'yellow', + 'error' => 'red', + 'debug' => 'cyan', + default => 'white', + }; + } + + /** + * Format the progress bar string + * + * @param int $current Current progress value + * @return string Formatted progress bar + * @since COmanage Registry v5.2.0 + */ + private function formatBar(int $current): string + { + $total = max(1, $this->total); + $pct = (int) floor(($current / $total) * 100); + + $width = $this->barWidth; + $filled = (int) floor(($pct / 100) * $width); + $remaining = max(0, $width - $filled); + + $filledStr = str_repeat('=', max(0, $filled - 1)) . ($filled > 0 ? '>' : ''); + $emptyStr = str_repeat('.', $remaining); + + $bar = sprintf('[%s%s] %3d%% (%d/%d)', $filledStr, $emptyStr, $pct, $current, $this->total); + + // Clear the line to the right to avoid remnants on shorter redraws + $bar .= "\033[K"; + + if ($this->useColors) { + $color = $this->barColor === 'green' ? 'green' : 'blue'; + $bar = $this->wrapColor($bar, $color); + } + return $bar; + } + + /** + * Wrap text in ANSI color codes + * + * @param string $text Text to colorize + * @param string $color Color name + * @return string Color-wrapped text + * @since COmanage Registry v5.2.0 + */ + private function wrapColor(string $text, string $color): string + { + $map = [ + 'red' => '0;31', + 'green' => '0;32', + 'yellow' => '0;33', + 'blue' => '0;34', + 'magenta'=> '0;35', + 'cyan' => '0;36', + 'white' => '0;39', + ]; + $code = $map[$color] ?? null; + if (!$code) { return $text; } + return "\033[{$code}m{$text}\033[0m"; + } + + /** + * Write raw string to output + * + * @param string $str String to write + * @return void + * @since COmanage Registry v5.2.0 + */ + private function rawWrite(string $str): void + { + if ($this->io) { + // ConsoleIo::out() defaults to a trailing newline; we want raw text + $this->io->out($str, 0, 0); + } else { + // Fallback to STDOUT + echo $str; + } + } + + /** + * Format info message with white label + * + * @param string $message Message to format + * @return string Formatted message + * @since COmanage Registry v5.2.0 + */ + private function formatLabelWhite(string $message): string + { + $lines = explode("\n", $message); + foreach ($lines as $i => $line) { + if ($line === '') { continue; } + if (preg_match('/^([^:\r\n]+:)(.*)$/', $line, $m)) { + $second = $m[2]; + if ( + $this->useColors + && ($this->defaultColorForLevel('info') === 'white' || $this->defaultColorForLevel('out') === 'white') + && $second !== '' + ) { + $second = $this->wrapColor($second, 'green'); + } + $lines[$i] = $this->wrapColor($m[1], 'white') . $second; + } + } + return implode("\n", $lines); + } + + /** + * Detect verbose level from ConsoleIO instance + * + * @return int Verbose level + * @since COmanage Registry v5.2.0 + */ + private function detectVerboseFromIo(): int + { + $default = 0; + if ($this->io === null) { + return $default; + } + if (method_exists($this->io, 'level')) { + try { + return $this->io->level(); + } catch (\Throwable $e) { + return $default; + } + } + return $default; + } + + /** + * Determine if a message of the given level should be printed based on verbosity setting + * + * @param string $level Message level to check + * @return bool Whether the message should be printed + * @since COmanage Registry v5.2.0 + */ + private function shouldPrintLevel(string $level): bool + { + $level = strtolower($level); + $verboseLevel = $this->detectVerboseFromIo(); + + return match ($verboseLevel) { + 0 => in_array($level, ['out', 'error'], true), // only errors and out + 1 => in_array($level, ['out', 'info', 'warn', 'warning', 'error'], true), // no verbose/debug + 2 => in_array($level, ['out', 'info', 'warn', 'warning', 'error', 'debug', 'verbose'], true), // all messages + default => true, + }; + } + + /** + * Get the current verbosity level + * + * @return int Verbosity level (0=quiet, 1=normal, 2=verbose) + * @since COmanage Registry v5.2.0 + */ + private function getVerboseLevel(): int + { + return $this->detectVerboseFromIo(); + } +} diff --git a/app/availableplugins/Transmogrify/src/Lib/Util/DbInfoPrinter.php b/app/availableplugins/Transmogrify/src/Lib/Util/DbInfoPrinter.php new file mode 100644 index 000000000..81ed82704 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Util/DbInfoPrinter.php @@ -0,0 +1,245 @@ +io = $io; + $this->service = $service; + $this->pluginRoot = dirname(__DIR__, 3); + } + + /** + * Initializes the singleton instance + * @param ConsoleIo $io Console IO instance for output + * @param DbInfoService $service Database info service + * @return self The singleton instance + */ + public static function initialize(ConsoleIo $io, DbInfoService $service): self + { + if (self::$instance === null) { + self::$instance = new self($io, $service); + } + return self::$instance; + } + + + /** + * Prints database connection and schema information + * @param bool $asJson Whether to output as JSON + * @param bool $withPing Whether to test database connectivity + * @param bool $withSchema Whether to include schema information + * @param string|null $schemaRole Limit schema info to specific role + */ + public function print(bool $asJson, bool $withPing, bool $withSchema = false, ?string $schemaRole = null): void + { + $aliases = [ 'source' => 'transmogrify', 'target' => 'default' ]; + $data = []; + + foreach ($aliases as $role => $alias) { + // Base connection info + $info = $this->service->getConnectionInfo($role); + + if ($withPing) { + $info['status'] = $this->service->ping($alias); + } + + if ($withSchema && ($schemaRole === null || $schemaRole === $role)) { + try { + + $info['schema'] = $this->service->loadSchemaInfo( + $alias, + $this->pluginRoot . DS . Transmogrify::TABLES_JSON_PATH); + } catch (\Throwable $e) { + $info['schema'] = [ 'error' => $e->getMessage() ]; + } + } + + $data[$role] = $info; + } + + if ($asJson) { + $this->io->out(json_encode($data, JSON_PRETTY_PRINT)); + return; + } + + foreach (['source', 'target'] as $role) { + $i = $data[$role] ?? []; + $header = ucfirst($role) . ':'; + $this->io->out(self::STYLE_BOLD . $header . self::COLOR_RESET); + $this->io->out(str_repeat('-', strlen($header))); + $this->io->out(' alias: ' . ($i['alias'] ?? '')); + $this->io->out(' configured:' . ((($i['configured'] ?? false)) ? ' yes' : ' no')); + if (!empty($i['driver'])) $this->io->out(' driver: ' . $i['driver']); + if (!empty($i['host'])) $this->io->out(' host: ' . $i['host']); + if (!empty($i['port'])) $this->io->out(' port: ' . $i['port']); + if (!empty($i['database'])) $this->io->out(' database: ' . $i['database']); + if (!empty($i['username'])) $this->io->out(' username: ' . $i['username']); + if (!empty($i['dsn'])) $this->io->out(' dsn: ' . $i['dsn']); + if (!empty($i['schema'])) { + // Determine a schema name (for PostgreSQL) and strip schema prefixes for sample tables display + $schemaName = null; + $bareSample = []; + $s = $i['schema']; + if (!empty($s['sample_tables'])) { + foreach ($s['sample_tables'] as $t) { + $dot = strrpos($t, '.'); + if ($dot !== false) { + $schemaName = $schemaName ?? substr($t, 0, $dot); + $bareSample[] = substr($t, $dot + 1); + } else { + $bareSample[] = $t; + } + } + } + $s = $i['schema']; + $schemaHeader = 'Schema:'; + $this->io->out(' ' . self::STYLE_BOLD . $schemaHeader . self::COLOR_RESET); + $this->io->out(' ' . str_repeat('-', strlen($schemaHeader))); + if (!empty($schemaName)) { + $this->io->out(' name: ' . $schemaName); + } + $this->io->out(' empty: ' . (($s['empty'] ?? false) ? 'yes' : 'no')); + $this->io->out(' tables: ' . ($s['table_count'] ?? 0)); + if (!empty($s['sample_tables'])) { + $sampleHeader = 'Sample tables:'; + $this->io->out(' ' . self::STYLE_BOLD . $sampleHeader . self::COLOR_RESET); + $this->io->out(' ' . str_repeat('-', strlen($sampleHeader))); + $list = !empty($bareSample) ? $bareSample : $s['sample_tables']; + foreach ($list as $t) { $this->io->out(' - ' . $t); } + } + if (!empty($s['tables_compare'])) { + $cmp = $s['tables_compare']; + // Add a blank line then print comparison headers at top level (aligned with SOURCE/TARGET) + $this->io->out(''); + $header = 'Tables present (json ∧ db):'; + $this->io->out(self::STYLE_BOLD . $header . self::COLOR_RESET); + $this->io->out(str_repeat('-', strlen($header))); + // Tables present in both JSON and DB + foreach (($cmp['both'] ?? []) as $t) { + $this->io->out(' ' . self::COLOR_GREEN . '✔' . self::COLOR_RESET . ' ' . $t); + } + // Only declared in tables.json + if (!empty($cmp['only_in_json'])) { + $onlyJsonHeader = 'Only in tables.json:'; + $this->io->out(self::STYLE_BOLD . $onlyJsonHeader . self::COLOR_RESET); + $this->io->out(str_repeat('-', strlen($onlyJsonHeader))); + foreach ($cmp['only_in_json'] as $t) { $this->io->out(' - ' . $t); } + } + // Present only in the database (render in 3 columns) + if (!empty($cmp['only_in_db'])) { + $onlyDbHeader = 'Only in database:'; + $this->io->out(self::STYLE_BOLD . $onlyDbHeader . self::COLOR_RESET); + $this->io->out(str_repeat('-', strlen($onlyDbHeader))); + $list = array_values($cmp['only_in_db']); + $cols = 3; + $rows = (int)ceil(count($list) / $cols); + // Determine max width per column + $widths = array_fill(0, $cols, 0); + for ($c = 0; $c < $cols; $c++) { + for ($r = 0; $r < $rows; $r++) { + $idx = $r + $rows * $c; + if ($idx < count($list)) { + $len = strlen((string)$list[$idx]); + if ($len > $widths[$c]) { $widths[$c] = $len; } + } + } + } + // Print rows + for ($r = 0; $r < $rows; $r++) { + $line = ' '; + for ($c = 0; $c < $cols; $c++) { + $idx = $r + $rows * $c; + if ($idx < count($list)) { + $cell = (string)$list[$idx]; + // No padding after last printed column + if ($c === $cols - 1 || ($r + $rows * ($c + 1)) >= count($list)) { + $line .= $cell; + } else { + $line .= str_pad($cell, $widths[$c]) . ' '; + } + } + } + $this->io->out($line); + } + } + } + } + + if (isset($i['status'])) { + $st = $i['status']; + + $this->io->out(' connectivity: ' . ($st['ok'] + ? self::COLOR_GREEN . '✔ OK' . self::COLOR_RESET + : self::COLOR_RED . '✘ ERROR' . self::COLOR_RESET)); + if (!empty($st['server'])) $this->io->out(' server: ' . $st['server']); + if (!$st['ok'] && !empty($st['error'])) $this->io->out(' error: ' . $st['error']); + } + $this->io->out(''); + } + } + +} diff --git a/app/availableplugins/Transmogrify/src/Lib/Util/GroupsHealth.php b/app/availableplugins/Transmogrify/src/Lib/Util/GroupsHealth.php new file mode 100644 index 000000000..5e7a68981 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Util/GroupsHealth.php @@ -0,0 +1,141 @@ +out('Running Groups health check (AR-Group-9)...'); + $sql = RawSqlQueries::STANDARD_GROUP_ARG9_SQL_QUERY; + + try { + $rows = $inconn->fetchAllAssociative($sql); + } catch (\Throwable $e) { + $io->err('Groups health check failed: ' . $e->getMessage()); + return; + } + + if (empty($rows)) { + $io->out('No results.'); + return; + } + + // Detect available columns + $first = $rows[0]; + $hasIncluded = array_key_exists('included_count', $first); + $hasExcluded = array_key_exists('excluded_count', $first); + $hasIndicator = array_key_exists('indicator', $first); + $hasCount = array_key_exists('count', $first); + + // Prepare headers based on detected columns + if ($hasIncluded || $hasExcluded) { + $headers = ['Reason', 'Included', 'Excluded']; + if ($hasIndicator) { + $headers[] = 'Indicator'; + } + } else { + // Fallback to simple reason + count (and indicator if present) + $headers = ['Reason', 'Count']; + if ($hasIndicator) { + $headers[] = 'Indicator'; + } + } + + // Compute column widths + $widths = array_fill(0, count($headers), 0); + $reasonIdx = 0; + $incIdx = array_search('Included', $headers, true); + $excIdx = array_search('Excluded', $headers, true); + $cntIdx = array_search('Count', $headers, true); + $indIdx = array_search('Indicator', $headers, true); + + // Initialize with header widths + foreach ($headers as $i => $h) { + $widths[$i] = max($widths[$i], mb_strlen($h)); + } + + // Measure data + foreach ($rows as $r) { + $reasonLen = mb_strlen((string)($r['reason'] ?? '')); + $widths[$reasonIdx] = max($widths[$reasonIdx], $reasonLen); + + if ($incIdx !== false) { + $widths[$incIdx] = max($widths[$incIdx], mb_strlen((string)($r['included_count'] ?? ''))); + } + if ($excIdx !== false) { + $widths[$excIdx] = max($widths[$excIdx], mb_strlen((string)($r['excluded_count'] ?? ''))); + } + if ($cntIdx !== false) { + $widths[$cntIdx] = max($widths[$cntIdx], mb_strlen((string)($r['count'] ?? ''))); + } + if ($indIdx !== false) { + $widths[$indIdx] = max($widths[$indIdx], mb_strlen((string)($r['indicator'] ?? ''))); + } + } + + // Helper to pad a cell + $pad = static function (string $s, int $w): string { + $len = mb_strlen($s); + if ($len >= $w) { + return $s; + } + return $s . str_repeat(' ', $w - $len); + }; + + // Print header + $lineParts = []; + foreach ($headers as $i => $h) { + $lineParts[] = $pad($h, $widths[$i]); + } + $io->out(implode(' | ', $lineParts)); + + // Print separator + $sepParts = array_map(static fn($w) => str_repeat('-', $w), $widths); + $io->out(implode('--+--', $sepParts)); + + // Print rows + foreach ($rows as $r) { + $rowParts = []; + $rowParts[] = $pad((string)($r['reason'] ?? ''), $widths[$reasonIdx]); + + if ($incIdx !== false) { + $rowParts[] = $pad((string)($r['included_count'] ?? ''), $widths[$incIdx]); + } + if ($excIdx !== false) { + $rowParts[] = $pad((string)($r['excluded_count'] ?? ''), $widths[$excIdx]); + } + if ($cntIdx !== false) { + $rowParts[] = $pad((string)($r['count'] ?? ''), $widths[$cntIdx]); + } + if ($indIdx !== false) { + $indRaw = (string)($r['indicator'] ?? ''); + $cell = $pad($indRaw, $widths[$indIdx]); + + // Colorize first visible char, leave padding spaces uncolored to preserve alignment + if ($indRaw === 'x' || $indRaw === '✓') { + $color = ($indRaw === 'x') ? "\033[31m" : "\033[32m"; // red for x, green for ✓ + $reset = "\033[0m"; + $first = mb_substr($cell, 0, 1); + $rest = mb_substr($cell, 1); + $cell = $color . $first . $reset . $rest; + } + + $rowParts[] = $cell; + } + + $io->out(implode(' | ', $rowParts)); + } + } +} diff --git a/app/availableplugins/Transmogrify/src/Lib/Util/IndexManager.php b/app/availableplugins/Transmogrify/src/Lib/Util/IndexManager.php new file mode 100644 index 000000000..3f56e6df0 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Util/IndexManager.php @@ -0,0 +1,329 @@ + + */ + protected array $savedIndexes = []; + + /** @var DBALConnection|null */ + protected ?DBALConnection $conn = null; + + /** @var CommandLinePrinter|null */ + protected ?CommandLinePrinter $printer = null; + + /** + * Initialize the manager with a live connection and optional printer. + * Must be called before disableIndexes()/enableIndexes(). + * + * @param DBALConnection $conn DBAL connection to the target database + * @param CommandLinePrinter|null $printer Optional printer for log output + * @return self Fluent interface + * @since COmanage Registry v5.2.0 + */ + public function initialize(DBALConnection $conn, ?CommandLinePrinter $printer = null): self + { + $this->conn = $conn; + $this->printer = $printer; + return $this; + } + + /** + * Drop non-primary, non-constraint indexes on a table, saving their definitions + * so they can be recreated later with enableIndexes(). + * + * Primary keys and indexes that back unique/foreign-key constraints are preserved + * to maintain referential integrity during the bulk load. + * + * @param string $qualifiedTableName Schema-qualified table name (e.g. "public.cos") + * @throws \RuntimeException If initialize() has not been called + * @since COmanage Registry v5.2.0 + */ + public function disableIndexes(string $qualifiedTableName): void + { + $this->assertInitialized(); + + if ($this->conn->isPostgreSQL()) { + $this->disablePostgresIndexes($qualifiedTableName); + } else { + $this->disableMysqlIndexes($qualifiedTableName); + } + } + + /** + * Recreate previously disabled indexes for a table. + * + * @param string $qualifiedTableName Schema-qualified table name (e.g. "public.cos") + * @throws \RuntimeException If initialize() has not been called + * @since COmanage Registry v5.2.0 + */ + public function enableIndexes(string $qualifiedTableName): void + { + $this->assertInitialized(); + + if ($this->conn->isPostgreSQL()) { + $this->enablePostgresIndexes($qualifiedTableName); + } else { + $this->enableMysqlIndexes($qualifiedTableName); + } + + unset($this->savedIndexes[$qualifiedTableName]); + } + + /** + * Check whether any saved index definitions exist for the given table. + * + * @param string $qualifiedTableName Schema-qualified table name + * @return bool + * @since COmanage Registry v5.2.0 + */ + public function hasSavedIndexes(string $qualifiedTableName): bool + { + return !empty($this->savedIndexes[$qualifiedTableName]); + } + + // ---------- PostgreSQL ---------- + + /** + * Drop non-PK, non-constraint indexes on a PostgreSQL table. + * + * We query pg_indexes and exclude any index name that appears in pg_constraint + * (which covers primary keys, unique constraints, and foreign keys). + * + * @param string $qualifiedTableName Schema-qualified table name + */ + protected function disablePostgresIndexes(string $qualifiedTableName): void + { + [$schema, $table] = $this->parseQualifiedName($qualifiedTableName, 'public'); + + $sql = <<conn->executeQuery($sql, ['schema' => $schema, 'table' => $table]); + $indexes = $stmt->fetchAllAssociative(); + + if (empty($indexes)) { + $this->printer?->verbose("No droppable indexes found on $qualifiedTableName"); + return; + } + + $this->savedIndexes[$qualifiedTableName] = $indexes; + $this->printer?->info(sprintf( + 'Dropping %d index(es) on %s for bulk load performance', + count($indexes), + $qualifiedTableName + )); + + foreach ($indexes as $idx) { + $dropSql = 'DROP INDEX IF EXISTS ' . $schema . '."' . $idx['indexname'] . '"'; + $this->printer?->verbose(' ' . $dropSql); + $this->conn->executeStatement($dropSql); + } + } + + /** + * Recreate previously saved PostgreSQL indexes. + * + * @param string $qualifiedTableName Schema-qualified table name + */ + protected function enablePostgresIndexes(string $qualifiedTableName): void + { + if (empty($this->savedIndexes[$qualifiedTableName])) { + return; + } + + $count = count($this->savedIndexes[$qualifiedTableName]); + $this->printer?->info(sprintf( + 'Recreating %d index(es) on %s', + $count, + $qualifiedTableName + )); + + foreach ($this->savedIndexes[$qualifiedTableName] as $idx) { + $this->printer?->verbose(' ' . $idx['indexdef']); + $this->conn->executeStatement($idx['indexdef']); + } + } + + // ---------- MySQL / MariaDB ---------- + + /** + * Drop non-PK, non-UNIQUE indexes on a MySQL/MariaDB table. + * + * UNIQUE indexes are preserved to maintain data integrity during the bulk load. + * + * @param string $qualifiedTableName Qualified table name (db.table) + */ + protected function disableMysqlIndexes(string $qualifiedTableName): void + { + [$database, $table] = $this->parseQualifiedName($qualifiedTableName); + + $whereDb = $database !== null + ? 'TABLE_SCHEMA = :database' + : 'TABLE_SCHEMA = DATABASE()'; + + $sql = << $table]; + if ($database !== null) { + $params['database'] = $database; + } + + $stmt = $this->conn->executeQuery($sql, $params); + $indexes = $stmt->fetchAllAssociative(); + + if (empty($indexes)) { + $this->printer?->verbose("No droppable indexes found on $qualifiedTableName"); + return; + } + + $this->savedIndexes[$qualifiedTableName] = $indexes; + $this->printer?->info(sprintf( + 'Dropping %d index(es) on %s for bulk load performance', + count($indexes), + $qualifiedTableName + )); + + $qualifiedTarget = $database !== null + ? '`' . $database . '`.`' . $table . '`' + : '`' . $table . '`'; + + foreach ($indexes as $idx) { + $dropSql = 'DROP INDEX `' . $idx['INDEX_NAME'] . '` ON ' . $qualifiedTarget; + $this->printer?->verbose(' ' . $dropSql); + $this->conn->executeStatement($dropSql); + } + } + + /** + * Recreate previously saved MySQL indexes. + * + * @param string $qualifiedTableName Qualified table name (db.table) + */ + protected function enableMysqlIndexes(string $qualifiedTableName): void + { + if (empty($this->savedIndexes[$qualifiedTableName])) { + return; + } + + [$database, $table] = $this->parseQualifiedName($qualifiedTableName); + + $qualifiedTarget = $database !== null + ? '`' . $database . '`.`' . $table . '`' + : '`' . $table . '`'; + + $count = count($this->savedIndexes[$qualifiedTableName]); + $this->printer?->info(sprintf( + 'Recreating %d index(es) on %s', + $count, + $qualifiedTableName + )); + + foreach ($this->savedIndexes[$qualifiedTableName] as $idx) { + $cols = '`' . implode('`, `', explode(',', $idx['idx_columns'])) . '`'; + + $createSql = 'CREATE INDEX `' . $idx['INDEX_NAME'] . '` ON ' . $qualifiedTarget . ' (' . $cols . ')'; + $this->printer?->verbose(' ' . $createSql); + $this->conn->executeStatement($createSql); + } + } + + // ---------- Helpers ---------- + + /** + * Split a possibly-qualified table name into [prefix, table]. + * + * @param string $qualifiedTableName + * @param string|null $default Default prefix when not qualified + * @return array{0: string|null, 1: string} + */ + protected function parseQualifiedName(string $qualifiedTableName, ?string $default = null): array + { + $parts = explode('.', $qualifiedTableName, 2); + if (count($parts) === 2) { + return [$parts[0], $parts[1]]; + } + return [$default, $parts[0]]; + } + + /** + * Guard that initialize() has been called. + * + * @throws \RuntimeException + */ + protected function assertInitialized(): void + { + if ($this->conn === null) { + throw new \RuntimeException('IndexManager::initialize() must be called before use.'); + } + } +} diff --git a/app/availableplugins/Transmogrify/src/Lib/Util/OrgIdentitiesHealth.php b/app/availableplugins/Transmogrify/src/Lib/Util/OrgIdentitiesHealth.php new file mode 100644 index 000000000..b585c071f --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Util/OrgIdentitiesHealth.php @@ -0,0 +1,141 @@ +out('Running Org Identities health check...'); + $sql = RawSqlQueries::ORGIDENTITIES_HEALTH_SQL_QUERY; + + try { + $rows = $inconn->fetchAllAssociative($sql); + } catch (\Throwable $e) { + $io->err('Org Identities health check failed: ' . $e->getMessage()); + return; + } + + if (empty($rows)) { + $io->out('No results.'); + return; + } + + // Detect available columns + $first = $rows[0]; + $hasIncluded = array_key_exists('included_count', $first); + $hasExcluded = array_key_exists('excluded_count', $first); + $hasIndicator = array_key_exists('indicator', $first); + $hasCount = array_key_exists('count', $first); + + // Prepare headers based on detected columns + if ($hasIncluded || $hasExcluded) { + $headers = ['Reason', 'Included', 'Excluded']; + if ($hasIndicator) { + $headers[] = 'Indicator'; + } + } else { + // Fallback to simple reason + count (and indicator if present) + $headers = ['Reason', 'Count']; + if ($hasIndicator) { + $headers[] = 'Indicator'; + } + } + + // Compute column widths + $widths = array_fill(0, count($headers), 0); + $reasonIdx = 0; + $incIdx = array_search('Included', $headers, true); + $excIdx = array_search('Excluded', $headers, true); + $cntIdx = array_search('Count', $headers, true); + $indIdx = array_search('Indicator', $headers, true); + + // Initialize with header widths + foreach ($headers as $i => $h) { + $widths[$i] = max($widths[$i], mb_strlen($h)); + } + + // Measure data + foreach ($rows as $r) { + $reasonLen = mb_strlen((string)($r['reason'] ?? '')); + $widths[$reasonIdx] = max($widths[$reasonIdx], $reasonLen); + + if ($incIdx !== false) { + $widths[$incIdx] = max($widths[$incIdx], mb_strlen((string)($r['included_count'] ?? ''))); + } + if ($excIdx !== false) { + $widths[$excIdx] = max($widths[$excIdx], mb_strlen((string)($r['excluded_count'] ?? ''))); + } + if ($cntIdx !== false) { + $widths[$cntIdx] = max($widths[$cntIdx], mb_strlen((string)($r['count'] ?? ''))); + } + if ($indIdx !== false) { + $widths[$indIdx] = max($widths[$indIdx], mb_strlen((string)($r['indicator'] ?? ''))); + } + } + + // Helper to pad a cell + $pad = static function (string $s, int $w): string { + $len = mb_strlen($s); + if ($len >= $w) { + return $s; + } + return $s . str_repeat(' ', $w - $len); + }; + + // Print header + $lineParts = []; + foreach ($headers as $i => $h) { + $lineParts[] = $pad($h, $widths[$i]); + } + $io->out(implode(' | ', $lineParts)); + + // Print separator + $sepParts = array_map(static fn($w) => str_repeat('-', $w), $widths); + $io->out(implode('--+--', $sepParts)); + + // Print rows + foreach ($rows as $r) { + $rowParts = []; + $rowParts[] = $pad((string)($r['reason'] ?? ''), $widths[$reasonIdx]); + + if ($incIdx !== false) { + $rowParts[] = $pad((string)($r['included_count'] ?? ''), $widths[$incIdx]); + } + if ($excIdx !== false) { + $rowParts[] = $pad((string)($r['excluded_count'] ?? ''), $widths[$excIdx]); + } + if ($cntIdx !== false) { + $rowParts[] = $pad((string)($r['count'] ?? ''), $widths[$cntIdx]); + } + if ($indIdx !== false) { + $indRaw = (string)($r['indicator'] ?? ''); + $cell = $pad($indRaw, $widths[$indIdx]); + + // Colorize first visible char, leave padding spaces uncolored to preserve alignment + if ($indRaw === 'x' || $indRaw === '✓') { + $color = ($indRaw === 'x') ? "\033[31m" : "\033[32m"; // red for x, green for ✓ + $reset = "\033[0m"; + $first = mb_substr($cell, 0, 1); + $rest = mb_substr($cell, 1); + $cell = $color . $first . $reset . $rest; + } + + $rowParts[] = $cell; + } + + $io->out(implode(' | ', $rowParts)); + } + } +} diff --git a/app/availableplugins/Transmogrify/src/Lib/Util/RawSqlQueries.php b/app/availableplugins/Transmogrify/src/Lib/Util/RawSqlQueries.php new file mode 100644 index 000000000..191386d39 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Lib/Util/RawSqlQueries.php @@ -0,0 +1,775 @@ +qualifyTableName($sourceTable); + $maxId = $inconn->fetchOne(self::buildSelectMaxId($qualifiedTableName)); + $maxId = ((int)($maxId ?? 0)) + 1; + + $qualifiedTableName = $outconn->qualifyTableName($targetTable); + $cmdPrinter->info("Resetting primary key sequence for $qualifiedTableName to $maxId"); + + // Strictly speaking we should use prepared statements, but we control the + // data here, and also we're executing a maintenance operation (so query + // optimization is less important). + $outsql = RawSqlQueries::buildSequenceReset($qualifiedTableName, $maxId, $outconn->isMySQL()); + try { + $outconn->executeQuery($outsql); + } catch (\Exception $e) { + return false; + } + + return true; + } + + /** + * Return SQL used to select COUs from inbound database. + * + * @since COmanage Registry v5.2.0 + * @param string $tableName Name of the SQL table + * @param bool $isMySQL Whether the database is MySQL + * @return string SQL string to select rows from inbound database + */ + public static function couSqlSelect(string $tableName, bool $isMySQL): string { + if($isMySQL) { + $sqlTemplate = RawSqlQueries::COU_SQL_SELECT_TEMPLATE_MYSQL; + } else { + $sqlTemplate = RawSqlQueries::COU_SQL_SELECT_TEMPLATE_POSTGRESQL; + } + + return str_replace('{table}', $tableName, $sqlTemplate); + } + + /** + * Select history records that are "current" (no changelog link) and whose + * org_identity_id is either NULL or refers to an included Org Identity + * (has a current link to a non-null co_person_id). + * + * @param string $tableName + * @param bool $isMySQL Unused here; kept for consistent signature + * @return string + */ + public static function historyRecordsSqlSelect(string $tableName, bool $isMySQL): string { + return str_replace('{table}', $tableName, RawSqlQueries::HISTORY_RECORDS_SQL_SELECT); + } + + /** + * Select job history records that are "current" (no changelog link) and whose + * org_identity_id is either NULL or refers to an included Org Identity + * (has a current link to a non-null co_person_id). + * + * @param string $tableName + * @param bool $isMySQL Unused here; kept for consistent signature + * @return string + */ + public static function jobHistoryRecordsSqlSelect(string $tableName, bool $isMySQL): string { + return str_replace('{table}', $tableName, RawSqlQueries::HISTORY_RECORDS_SQL_SELECT); + } + + /** + * Return SQL used to select COUs from inbound database. + * + * @since COmanage Registry v5.2.0 + * @param string $tableName Name of the SQL table + * @param bool $isMySQL Whether the database is MySQL + * @return string SQL string to select rows from inbound database + */ + public static function roleSqlSelect(string $tableName, bool $isMySQL): string { + return RawSqlQueries::ROLE_SQL_SELECT; + } + + + /** + * Builds SQL query to select Multiple Value Element Attributes (MVEAs) for valid org identities + * + * @param string $tableName Name of the database table containing MVEAs + * @param bool $isMySQL Whether the target database is MySQL (true) or PostgreSQL (false) + * @return string SQL query string to select MVEA rows that are linked to valid org identities + * @since COmanage Registry v5.2.0 + */ + public static function mveaSqlSelect(string $tableName, bool $isMySQL, array $fkColumns = []): string { + // Defaults to co_person_id and org_identity_id + if (empty($fkColumns)) { + $fkColumns = ['co_person_id', 'org_identity_id']; + } + + // XXX Unsupported FKs for now (until their models are implemented) + // co_department_id, co_provisioning_target_id, organization_id + $unsupportedFks = [ + 'co_department_id', + 'co_provisioning_target_id', + 'organization_id' + ]; + + // In full mode, treat all as supported; otherwise split into supported/unsupported + if (false /* $fullMode */) { + $supportedInUse = array_values($fkColumns); + $unsupportedInUse = []; + } else { + // Split provided FKs into supported (for OR non-null) and unsupported (must be NULL) + $supportedInUse = array_values(array_diff($fkColumns, $unsupportedFks)); + $unsupportedInUse = array_values(array_intersect($fkColumns, $unsupportedFks)); + } + + // Require at least one SUPPORTED FK is not NULL (unsupported FKs are excluded from this OR) + $nonnullClauses = array_map( + fn(string $c) => 'n.' . $c . ' IS NOT NULL', + $supportedInUse + ); + // Keep SQL valid even if no supported FKs present + $nonnullAny = empty($nonnullClauses) ? '1=1' : '(' . implode(' OR ', $nonnullClauses) . ')'; + + // Unsupported FKs (that are present) must be NULL (AND clause) + $unsupportedNullClause = ''; + if (!empty($unsupportedInUse)) { + $unsupportedNullClause = 'AND ' . implode( + ' AND ', + array_map(fn(string $c) => "n.$c IS NULL", $unsupportedInUse) + ); + } + + // If org_identity_id is one of the FKs, apply the org identity validity EXISTS + $orgIdCheck = ''; + if (in_array('org_identity_id', $fkColumns, true)) { + // If we decide to enable the soft delete we need to take into account + // the type of database + // - (p.deleted IS NULL OR p.deleted = false) on PostgreSQL + // - (p.deleted IS NULL OR p.deleted = 0) on MySQL + $orgIdCheck = <<loadGeneric($path); + // Filter out documentation or comment keys (eg, keys starting with "__") + $cfg = array_filter($cfg, static function ($value, $key) { + return !(is_string($key) && str_starts_with($key, '__')); + }, ARRAY_FILTER_USE_BOTH); + return $cfg; + } + + /** + * Generic config loader that supports JSON (.json) and XML (.xml) files. + * Returns associative array representation of the root object. + * + * @param string $path Absolute or project-relative path + * @return array + */ + public function loadGeneric(string $path): array + { + // Resolve path if relative + if (!is_readable($path)) { + if (defined('ROOT')) { + $alt = ROOT . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR); + if (is_readable($alt)) { + $path = $alt; + } + } + } + if (!is_readable($path)) { + throw new \RuntimeException('Config not readable: ' . $path); + } + + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $raw = file_get_contents($path); + if ($raw === false) { + throw new \RuntimeException('Failed to read config: ' . $path); + } + + if ($ext === 'json') { + // Decode JSON and detect corruption with detailed error messages + $data = json_decode($raw, true); + if ($data === null && json_last_error() !== JSON_ERROR_NONE) { + $err = function_exists('json_last_error_msg') ? json_last_error_msg() : ('code ' . json_last_error()); + throw new \RuntimeException('Invalid JSON config: ' . $path . ' Details: ' . $err); + } + if (!is_array($data)) { + // Enforce that root must be an object/array for config purposes + throw new \RuntimeException('Invalid JSON config: ' . $path . ' Details: Expected root object or array'); + } + return $data; + } + + if ($ext === 'xml') { + // Use libxml internal error handling instead of deprecated @ suppression + $prev = libxml_use_internal_errors(true); + $xml = simplexml_load_string($raw, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + if ($xml === false) { + $errors = libxml_get_errors(); + libxml_clear_errors(); + libxml_use_internal_errors($prev); + $messages = array_map(static function ($e) { + return trim($e->message ?? '') . ' at line ' . ($e->line ?? ''); + }, $errors ?: []); + $detail = $messages ? (' Details: ' . implode(' | ', $messages)) : ''; + throw new \RuntimeException('Invalid XML config: ' . $path . $detail); + } + // Clear any accumulated libxml errors and restore previous setting + libxml_clear_errors(); + libxml_use_internal_errors($prev); + + $json = json_encode($xml); + $arr = json_decode($json, true); + if (!is_array($arr)) { + throw new \RuntimeException('Failed to convert XML to array: ' . $path); + } + return $arr; + } + + throw new \RuntimeException('Unsupported config extension (expected .json or .xml): ' . $path); + } +} diff --git a/app/availableplugins/Transmogrify/src/Service/DbInfoService.php b/app/availableplugins/Transmogrify/src/Service/DbInfoService.php new file mode 100644 index 000000000..248a36841 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/Service/DbInfoService.php @@ -0,0 +1,185 @@ + 'transmogrify', 'target' => 'default' ]; + $role = $roleOrAlias; + $alias = $map[$roleOrAlias] ?? $roleOrAlias; + + $cfg = null; + try { + $cfg = ConnectionManager::getConfig($alias); + } catch (\Throwable $e) { + $cfg = null; + } + + return [ + 'alias' => $alias, + 'role' => ($alias === ($map['source'] ?? 'transmogrify')) ? 'source' : (($alias === ($map['target'] ?? 'default')) ? 'target' : $role), + 'configured' => $cfg !== null, + 'driver' => $cfg['driver'] ?? null, + 'host' => $cfg['host'] ?? ($cfg['hostname'] ?? null), + 'port' => $cfg['port'] ?? null, + 'database' => $cfg['database'] ?? ($cfg['dbname'] ?? null), + 'username' => $cfg['username'] ?? ($cfg['user'] ?? null), + 'password' => null, + 'dsn' => $cfg['url'] ?? null, + ]; + } + + /** + * Ping a connection alias and return status info. + * @param string $alias Cake connection alias + * @return array{ok:bool,server:?(string),error:?(string)} + */ + public function ping(string $alias): array + { + $status = [ 'ok' => false, 'error' => null, 'server' => null ]; + try { + $conn = DBALConnection::factory(connection: $alias); + if ($conn->isMySQL()) { + $ver = $conn->fetchOne('SELECT VERSION()'); + } else { + $ver = $conn->fetchOne('SHOW server_version'); + if (!$ver) { $ver = $conn->fetchOne('SELECT version()'); } + } + $status['ok'] = true; + $status['server'] = $ver; + } catch (\Throwable $e) { + $status['ok'] = false; + $status['error'] = $e->getMessage(); + } + return $status; + } + + /** + * Load schema information for a given alias. + * @param string $alias Cake connection alias + * @param string|null $tablesJsonPath Path to the tables.json file + * @return array{table_count:int,empty:bool,sample_tables:array,tables_compare:array,loaded_schema:?(string)} + */ + public function loadSchemaInfo(string $alias, ?string $tablesJsonPath = null): array + { + if ($tablesJsonPath === null) { + return []; + } + $conn = DBALConnection::factory(connection: $alias); + return $this->loadSchemaInfoFromConnection($conn, $tablesJsonPath); + } + + /** + * Exposed for reuse when a DBALConnection already exists. + * @param DBALConnection $conn The database connection + * @param string $tablesJsonPath Path to the tables.json file + * @return array Schema information array + * + * todo: The new version should render all the tables. And if i pass a the parameter transmogrify then + * it should render the ones that have been transmogrified. + */ + public function loadSchemaInfoFromConnection(DBALConnection $conn, string $tablesJsonPath): array + { + // Load declared tables from tables.json + $declared = []; + $loadedSchemaName = basename($tablesJsonPath); + try { + + if (is_readable($tablesJsonPath)) { + $json = file_get_contents($tablesJsonPath); + $cfg = json_decode($json, true); + if (is_array($cfg)) { + // Filter out documentation keys (eg, keys starting with "__") to avoid printing template entries + $cfg = array_filter($cfg, static function ($value, $key) { + return !(is_string($key) && str_starts_with($key, '__')); + }, ARRAY_FILTER_USE_BOTH); + $declared = array_keys($cfg); + } + } + } catch (\Throwable $e) { + // ignore; we'll fall back to empty list + } + + // Gather list of non-system tables + $tables = []; + if ($conn->isMySQL()) { + $db = $conn->fetchOne('SELECT DATABASE()'); + $rows = $conn->fetchAllAssociative('SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = ? ORDER BY table_name ASC', [$db, 'BASE TABLE']); + $tables = array_map(fn($r) => $r['table_name'], $rows); + } else { + // PostgreSQL + $rows = $conn->fetchAllAssociative("SELECT schemaname, tablename FROM pg_catalog.pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname, tablename"); + foreach ($rows as $r) { $tables[] = ($r['schemaname'] . '.' . $r['tablename']); } + } + + // Check if DB is empty (no tables) or all tables empty (zero rows) + $info = [ + 'table_count' => count($tables), + 'sample_tables' => array_slice($tables, 0, self::TABLE_SAMPLE_LIMIT), + 'empty' => false, + 'loaded_schema' => $loadedSchemaName, + ]; + + $info['empty'] = (count($tables) === 0); + if (!$info['empty']) { + // If there are tables, check if any table has rows; if none, still empty of data + $hasData = false; + foreach ($info['sample_tables'] as $t) { + try { + // For qualified names, don't double-quote + $count = (int)$conn->fetchOne('SELECT COUNT(*) FROM ' . $t); + if ($count > 0) { $hasData = true; break; } + } catch (\Throwable $e) { + // ignore per-table errors + } + } + $info['empty'] = !$hasData; + } + + // Build comparison lists between tables.json and actual DB tables, ignoring schema names + $normalize = function(string $t): string { + // Strip any schema prefix such as public.table or mysch.table + $p = strrpos($t, '.'); + return $p === false ? $t : substr($t, $p + 1); + }; + $actualBare = array_map($normalize, $tables); + $declaredBare = array_map($normalize, $declared); + // Now compute sets on bare names + $actual = $actualBare; + $declared = $declaredBare; + sort($actual); + sort($declared); + $both = array_values(array_unique(array_intersect($declared, $actual))); + $onlyJson = array_values(array_unique(array_diff($declared, $actual))); + $onlyDb = array_values(array_unique(array_diff($actual, $declared))); + + $info['tables_compare'] = [ + 'both' => $both, + 'only_in_json' => $onlyJson, + 'only_in_db' => $onlyDb, + ]; + + return $info; + } +} diff --git a/app/availableplugins/Transmogrify/src/TransmogrifyPlugin.php b/app/availableplugins/Transmogrify/src/TransmogrifyPlugin.php new file mode 100644 index 000000000..79d706339 --- /dev/null +++ b/app/availableplugins/Transmogrify/src/TransmogrifyPlugin.php @@ -0,0 +1,108 @@ +plugin( + 'Transmogrify', + ['path' => '/transmogrify'], + 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 + // remove this method hook if you don't need it + + 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 + { + $commands->add('transmogrify', TransmogrifyCommand::class); + $commands->add('transmogrify source-to-target', TransmogrifySourceToTargetCommand::class); + + return parent::console($commands); + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/5/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Register services so the container can resolve them (constructor autowiring) + $container->add(DbInfoService::class); + $container->add(ConfigLoaderService::class); + $container->add(IndexManager::class); + + $container->add(TransmogrifyCommand::class) + ->addArguments([DbInfoService::class, ConfigLoaderService::class, IndexManager::class]); + $container->add(TransmogrifySourceToTargetCommand::class) + ->addArgument(ConfigLoaderService::class); + } +} diff --git a/app/vendor/cakephp/migrations/docs/config/__init__.py b/app/availableplugins/Transmogrify/webroot/.gitkeep similarity index 100% rename from app/vendor/cakephp/migrations/docs/config/__init__.py rename to app/availableplugins/Transmogrify/webroot/.gitkeep diff --git a/app/composer.json b/app/composer.json index a77a69354..7d24106bb 100644 --- a/app/composer.json +++ b/app/composer.json @@ -1,27 +1,33 @@ { "name": "cakephp/app", - "description": "CakePHP skeleton app", - "homepage": "https://cakephp.org", + "description": "COmanage Registry PE", + "homepage": "https://incommon.org/software/comanage/", "type": "project", - "license": "MIT", + "license": "Apache 2", "require": { - "php": ">=8.0", - "cakephp/cakephp": "4.6.*", - "cakephp/migrations": "^3.2", - "cakephp/plugin-installer": "^1.3", - "doctrine/dbal": "^3.3", - "league/container": "^4.2.0", - "mobiledetect/mobiledetectlib": "^2.8", - "psr/log": "^2.0", - "symfony/html-sanitizer": "^7.2" + "php": ">=8.2", + "ext-intl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "cakephp/authentication": "~3.0", + "cakephp/cakephp": "5.3.*", + "cakephp/migrations": "^5.0", + "cakephp/plugin-installer": "^2.0", + "doctrine/dbal": "^3.10.1", + "league/container": "^5.1", + "mobiledetect/mobiledetectlib": "^4.8.03", + "psr/log": "^3.0", + "symfony/html-sanitizer": "^7.4.13" }, "require-dev": { - "cakephp/bake": "^2.6", - "cakephp/cakephp-codesniffer": "^4.5", - "cakephp/debug_kit": "^4.5", - "josegonzalez/dotenv": "^3.2", - "phpunit/phpunit": "~8.5.0 || ^9.3", - "psy/psysh": "@stable" + "cakephp/bake": "^3.6", + "cakephp/cakephp-codesniffer": "^5.3", + "cakephp/debug_kit": "^5.2", + "composer/composer": "^2.9.8", + "josegonzalez/dotenv": "^4.0", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0", + "psy/psysh": "^0.12.19", + "twig/twig": "^3.27" }, "suggest": { "markstory/asset_compress": "An asset compression plugin which provides file concatenation and a flexible filter system for preprocessing and minification.", @@ -30,31 +36,13 @@ }, "autoload": { "psr-4": { - "App\\": "src/", - "ApiConnector\\": "availableplugins/ApiConnector/src/", - "CoreAssigner\\": "plugins/CoreAssigner/src/", - "CoreEnroller\\": "plugins/CoreEnroller/src/", - "CoreServer\\": "plugins/CoreServer/src/", - "EnvSource\\": "plugins/EnvSource/src/", - "FileConnector\\": "availableplugins/FileConnector/src/", - "PipelineToolkit\\": "availableplugins/PipelineToolkit/src/", - "SqlConnector\\": "availableplugins/SqlConnector/src/", - "CoreJob\\": "plugins/CoreJob/src/" + "App\\": "src/" } }, "autoload-dev": { "psr-4": { "App\\Test\\": "tests/", - "ApiConnector\\Test\\": "availableplugins/ApiConnector/tests/", - "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", - "CoreAssigner\\Test\\": "plugins/CoreAssigner/tests/", - "CoreEnroller\\Test\\": "plugins/CoreEnroller/tests/", - "CoreServer\\Test\\": "plugins/CoreServer/tests/", - "EnvSource\\Test\\": "plugins/EnvSource/tests/", - "FileConnector\\Test\\": "availableplugins/FileConnector/tests/", - "PipelineToolkit\\Test\\": "availableplugins/PipelineToolkit/tests/", - "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/", - "CoreJob\\Test\\": "plugins/CoreJob/tests/" + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" } }, "scripts": { diff --git a/app/composer.lock b/app/composer.lock index 325a4aae3..b5b158d69 100644 --- a/app/composer.lock +++ b/app/composer.lock @@ -4,46 +4,117 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0903212b8cb5b43bd6eaa080bf0d70a4", + "content-hash": "3d2092107eba5bf1e3825d696b5903e1", "packages": [ + { + "name": "cakephp/authentication", + "version": "3.3.5", + "source": { + "type": "git", + "url": "https://github.com/cakephp/authentication.git", + "reference": "9a12edc97de43f95eb4fecf033b623d5b17b436c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/authentication/zipball/9a12edc97de43f95eb4fecf033b623d5b17b436c", + "reference": "9a12edc97de43f95eb4fecf033b623d5b17b436c", + "shasum": "" + }, + "require": { + "cakephp/http": "^5.0", + "laminas/laminas-diactoros": "^3.0", + "php": ">=8.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "cakephp/cakephp": "^5.1.0", + "cakephp/cakephp-codesniffer": "^5.0", + "firebase/php-jwt": "^6.2", + "phpunit/phpunit": "^10.5.58 || ^11.5.3 || ^12.4" + }, + "suggest": { + "cakephp/cakephp": "Install full core to use \"CookieAuthenticator\".", + "cakephp/orm": "To use \"OrmResolver\" (Not needed separately if using full CakePHP framework).", + "cakephp/utility": "Provides CakePHP security methods. Required for the JWT adapter and Legacy password hasher.", + "ext-ldap": "Make sure this php extension is installed and enabled on your system if you want to use the built-in LDAP adapter for \"LdapIdentifier\".", + "firebase/php-jwt": "If you want to use the JWT adapter add this dependency" + }, + "type": "cakephp-plugin", + "autoload": { + "psr-4": { + "Authentication\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/authentication/graphs/contributors" + } + ], + "description": "Authentication plugin for CakePHP", + "homepage": "https://cakephp.org", + "keywords": [ + "Authentication", + "auth", + "cakephp", + "middleware" + ], + "support": { + "docs": "https://book.cakephp.org/authentication/3/en/", + "forum": "https://discourse.cakephp.org/", + "issues": "https://github.com/cakephp/authentication/issues", + "source": "https://github.com/cakephp/authentication" + }, + "time": "2026-01-31T00:28:31+00:00" + }, { "name": "cakephp/cakephp", - "version": "4.6.0", + "version": "5.3.6", "source": { "type": "git", "url": "https://github.com/cakephp/cakephp.git", - "reference": "b8585672346c0654311c77500ce613cdf37687cc" + "reference": "cdaca8c3b710789e8545bff5a83194a6b19cad46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/cakephp/zipball/b8585672346c0654311c77500ce613cdf37687cc", - "reference": "b8585672346c0654311c77500ce613cdf37687cc", + "url": "https://api.github.com/repos/cakephp/cakephp/zipball/cdaca8c3b710789e8545bff5a83194a6b19cad46", + "reference": "cdaca8c3b710789e8545bff5a83194a6b19cad46", "shasum": "" }, "require": { - "cakephp/chronos": "^2.4.0-RC2", - "composer/ca-bundle": "^1.2", + "cakephp/chronos": "^3.3", + "composer/ca-bundle": "^1.5", "ext-intl": "*", "ext-json": "*", "ext-mbstring": "*", - "laminas/laminas-diactoros": "^2.2.2", - "laminas/laminas-httphandlerrunner": "^1.1 || ^2.0", - "league/container": "^4.2.0", - "php": ">=7.4.0,<9", + "laminas/laminas-diactoros": "^3.8", + "laminas/laminas-httphandlerrunner": "^2.6", + "league/container": "^5.1", + "php": ">=8.2", "psr/container": "^1.1 || ^2.0", - "psr/http-client": "^1.0", - "psr/http-server-handler": "^1.0", - "psr/http-server-middleware": "^1.0", - "psr/log": "^1.0 || ^2.0", - "psr/simple-cache": "^1.0 || ^2.0" + "psr/http-client": "^1.0.2", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0.2", + "psr/http-server-middleware": "^1.0.2", + "psr/log": "^3.0", + "psr/simple-cache": "^2.0 || ^3.0" }, "provide": { - "psr/container-implementation": "^1.0 || ^2.0", + "psr/container-implementation": "^2.0", "psr/http-client-implementation": "^1.0", + "psr/http-factory-implementation": "^1.0", "psr/http-server-handler-implementation": "^1.0", "psr/http-server-middleware-implementation": "^1.0", - "psr/log-implementation": "^1.0 || ^2.0", - "psr/simple-cache-implementation": "^1.0 || ^2.0" + "psr/log-implementation": "^3.0", + "psr/simple-cache-implementation": "^3.0" }, "replace": { "cakephp/cache": "self.version", @@ -53,7 +124,6 @@ "cakephp/database": "self.version", "cakephp/datasource": "self.version", "cakephp/event": "self.version", - "cakephp/filesystem": "self.version", "cakephp/form": "self.version", "cakephp/http": "self.version", "cakephp/i18n": "self.version", @@ -63,24 +133,31 @@ "cakephp/validation": "self.version" }, "require-dev": { - "cakephp/cakephp-codesniffer": "^4.5", - "mikey179/vfsstream": "^1.6.10", + "cakephp/cakephp-codesniffer": "^5.3", + "http-interop/http-factory-tests": "^2.0", + "mikey179/vfsstream": "^1.6.12", + "mockery/mockery": "^1.6", "paragonie/csp-builder": "^2.3 || ^3.0", - "phpunit/phpunit": "^8.5 || ^9.3" + "phpunit/phpunit": "^11.5.3 || ^12.1.3 || ^13.0" }, "suggest": { "ext-curl": "To enable more efficient network calls in Http\\Client.", "ext-openssl": "To use Security::encrypt() or have secure CSRF token generation.", - "lib-ICU": "To use locale-aware features in the I18n and Database packages", "paragonie/csp-builder": "CSP builder, to use the CSP Middleware" }, "type": "library", + "extra": { + "branch-alias": { + "dev-5.next": "5.4.x-dev" + } + }, "autoload": { "files": [ "src/Core/functions.php", "src/Error/functions.php", "src/Collection/functions.php", "src/I18n/functions.php", + "src/ORM/bootstrap.php", "src/Routing/functions.php", "src/Utility/bootstrap.php" ], @@ -112,39 +189,39 @@ "validation" ], "support": { - "forum": "https://stackoverflow.com/tags/cakephp", - "irc": "irc://irc.freenode.org/cakephp", + "forum": "https://discourse.cakephp.org/", "issues": "https://github.com/cakephp/cakephp/issues", "source": "https://github.com/cakephp/cakephp" }, - "time": "2025-03-23T02:36:48+00:00" + "time": "2026-05-23T16:55:57+00:00" }, { "name": "cakephp/chronos", - "version": "2.4.5", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/cakephp/chronos.git", - "reference": "b0321ab7658af9e7abcb3dd876f226e6f3dbb81f" + "reference": "e6e777b534244911566face8a5dbdbd7f7bda5a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/chronos/zipball/b0321ab7658af9e7abcb3dd876f226e6f3dbb81f", - "reference": "b0321ab7658af9e7abcb3dd876f226e6f3dbb81f", + "url": "https://api.github.com/repos/cakephp/chronos/zipball/e6e777b534244911566face8a5dbdbd7f7bda5a6", + "reference": "e6e777b534244911566face8a5dbdbd7f7bda5a6", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" }, "require-dev": { - "cakephp/cakephp-codesniffer": "^4.5", - "phpunit/phpunit": "^8.0 || ^9.0" + "cakephp/cakephp-codesniffer": "^5.0", + "phpunit/phpunit": "^10.5.58 || ^11.5.3 || ^12.1.3" }, "type": "library", "autoload": { - "files": [ - "src/carbon_compat.php" - ], "psr-4": { "Cake\\Chronos\\": "src/" } @@ -175,33 +252,33 @@ "issues": "https://github.com/cakephp/chronos/issues", "source": "https://github.com/cakephp/chronos" }, - "time": "2024-07-30T22:26:11+00:00" + "time": "2026-04-10T02:50:39+00:00" }, { "name": "cakephp/migrations", - "version": "3.9.0", + "version": "5.2.0", "source": { "type": "git", "url": "https://github.com/cakephp/migrations.git", - "reference": "58446fdd096087ddf7752c0317731b8725d1dc28" + "reference": "369d849a540a6815f402447119351ad6fe7894ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/migrations/zipball/58446fdd096087ddf7752c0317731b8725d1dc28", - "reference": "58446fdd096087ddf7752c0317731b8725d1dc28", + "url": "https://api.github.com/repos/cakephp/migrations/zipball/369d849a540a6815f402447119351ad6fe7894ac", + "reference": "369d849a540a6815f402447119351ad6fe7894ac", "shasum": "" }, "require": { - "cakephp/cache": "^4.3.0", - "cakephp/orm": "^4.3.0", - "php": ">=7.4.0", - "robmorgan/phinx": "^0.13.2" + "cakephp/cache": "^5.3.0", + "cakephp/database": "^5.3.2", + "cakephp/orm": "^5.3.0", + "php": ">=8.2" }, "require-dev": { - "cakephp/bake": "^2.6.0", - "cakephp/cakephp": "^4.3.0", - "cakephp/cakephp-codesniffer": "^4.1", - "phpunit/phpunit": "^9.5.0" + "cakephp/bake": "^3.3", + "cakephp/cakephp": "^5.3.0", + "cakephp/cakephp-codesniffer": "^5.0", + "phpunit/phpunit": "^11.5.3 || ^12.1.3 || ^13.0" }, "suggest": { "cakephp/bake": "If you want to generate migrations.", @@ -223,10 +300,11 @@ "homepage": "https://github.com/cakephp/migrations/graphs/contributors" } ], - "description": "Database Migration plugin for CakePHP based on Phinx", + "description": "Database Migration plugin for CakePHP", "homepage": "https://github.com/cakephp/migrations", "keywords": [ "cakephp", + "cli", "migrations" ], "support": { @@ -235,30 +313,30 @@ "issues": "https://github.com/cakephp/migrations/issues", "source": "https://github.com/cakephp/migrations" }, - "time": "2023-09-22T08:39:18+00:00" + "time": "2026-05-13T14:46:37+00:00" }, { "name": "cakephp/plugin-installer", - "version": "1.3.1", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/cakephp/plugin-installer.git", - "reference": "e27027aa2d3d8ab64452c6817629558685a064cb" + "reference": "40bfecb4565ab29cd8c34b0d02d0007d2d5ebd2b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/plugin-installer/zipball/e27027aa2d3d8ab64452c6817629558685a064cb", - "reference": "e27027aa2d3d8ab64452c6817629558685a064cb", + "url": "https://api.github.com/repos/cakephp/plugin-installer/zipball/40bfecb4565ab29cd8c34b0d02d0007d2d5ebd2b", + "reference": "40bfecb4565ab29cd8c34b0d02d0007d2d5ebd2b", "shasum": "" }, "require": { - "composer-plugin-api": "^1.0 || ^2.0", - "php": ">=5.6.0" + "composer-plugin-api": "^2.0", + "php": ">=8.1" }, "require-dev": { - "cakephp/cakephp-codesniffer": "^3.3", + "cakephp/cakephp-codesniffer": "^5.0", "composer/composer": "^2.0", - "phpunit/phpunit": "^5.7 || ^6.5 || ^8.5 || ^9.3" + "phpunit/phpunit": "^10.1.0 || ^11.1.3 || ^12.0 || ^13.0" }, "type": "composer-plugin", "extra": { @@ -279,25 +357,25 @@ "homepage": "https://cakephp.org" } ], - "description": "A composer installer for CakePHP 3.0+ plugins.", + "description": "A composer installer for CakePHP plugins.", "support": { "issues": "https://github.com/cakephp/plugin-installer/issues", - "source": "https://github.com/cakephp/plugin-installer/tree/1.3.1" + "source": "https://github.com/cakephp/plugin-installer/tree/2.0.2" }, - "time": "2020-10-29T04:00:42+00:00" + "time": "2026-04-10T02:55:04+00:00" }, { "name": "composer/ca-bundle", - "version": "1.5.6", + "version": "1.5.12", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "f65c239c970e7f072f067ab78646e9f0b2935175" + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/f65c239c970e7f072f067ab78646e9f0b2935175", - "reference": "f65c239c970e7f072f067ab78646e9f0b2935175", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", "shasum": "" }, "require": { @@ -344,7 +422,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.6" + "source": "https://github.com/composer/ca-bundle/tree/1.5.12" }, "funding": [ { @@ -354,143 +432,47 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2025-03-06T14:30:56+00:00" - }, - { - "name": "doctrine/cache", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/cache.git", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/1ca8f21980e770095a31456042471a57bc4c68fb", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb", - "shasum": "" - }, - "require": { - "php": "~7.1 || ^8.0" - }, - "conflict": { - "doctrine/common": ">2.2,<2.4" - }, - "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/coding-standard": "^9", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psr/cache": "^1.0 || ^2.0 || ^3.0", - "symfony/cache": "^4.4 || ^5.4 || ^6", - "symfony/var-exporter": "^4.4 || ^5.4 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", - "homepage": "https://www.doctrine-project.org/projects/cache.html", - "keywords": [ - "abstraction", - "apcu", - "cache", - "caching", - "couchdb", - "memcached", - "php", - "redis", - "xcache" - ], - "support": { - "issues": "https://github.com/doctrine/cache/issues", - "source": "https://github.com/doctrine/cache/tree/2.2.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", - "type": "tidelift" } ], - "time": "2022-05-20T20:07:39+00:00" + "time": "2026-05-19T11:26:22+00:00" }, { "name": "doctrine/dbal", - "version": "3.8.2", + "version": "3.10.5", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "a19a1d05ca211f41089dffcc387733a6875196cb" + "reference": "95d84866bf3c04b2ddca1df7c049714660959aef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/a19a1d05ca211f41089dffcc387733a6875196cb", - "reference": "a19a1d05ca211f41089dffcc387733a6875196cb", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef", + "reference": "95d84866bf3c04b2ddca1df7c049714660959aef", "shasum": "" }, "require": { "composer-runtime-api": "^2", - "doctrine/cache": "^1.11|^2.0", "doctrine/deprecations": "^0.5.3|^1", "doctrine/event-manager": "^1|^2", "php": "^7.4 || ^8.0", "psr/cache": "^1|^2|^3", "psr/log": "^1|^2|^3" }, + "conflict": { + "doctrine/cache": "< 1.11" + }, "require-dev": { - "doctrine/coding-standard": "12.0.0", + "doctrine/cache": "^1.11|^2.0", + "doctrine/coding-standard": "14.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.10.57", - "phpstan/phpstan-strict-rules": "^1.5", - "phpunit/phpunit": "9.6.16", - "psalm/plugin-phpunit": "0.18.4", - "slevomat/coding-standard": "8.13.1", - "squizlabs/php_codesniffer": "3.8.1", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/console": "^4.4|^5.4|^6.0|^7.0", - "vimeo/psalm": "4.30.0" + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "9.6.34", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", + "symfony/cache": "^5.4|^6.0|^7.0|^8.0", + "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." @@ -550,7 +532,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.8.2" + "source": "https://github.com/doctrine/dbal/tree/3.10.5" }, "funding": [ { @@ -566,33 +548,34 @@ "type": "tidelift" } ], - "time": "2024-02-12T18:36:36+00:00" + "time": "2026-02-24T08:03:57+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.3", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, "require-dev": { - "doctrine/coding-standard": "^9", - "phpstan/phpstan": "1.4.10 || 1.10.15", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psalm/plugin-phpunit": "0.18.4", - "psr/log": "^1 || ^2 || ^3", - "vimeo/psalm": "4.30.0 || 5.12.0" + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" @@ -600,7 +583,7 @@ "type": "library", "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + "Doctrine\\Deprecations\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -611,22 +594,22 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.3" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2024-01-30T19:34:25+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/event-manager", - "version": "2.0.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/event-manager.git", - "reference": "750671534e0241a7c50ea5b43f67e23eb5c96f32" + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/750671534e0241a7c50ea5b43f67e23eb5c96f32", - "reference": "750671534e0241a7c50ea5b43f67e23eb5c96f32", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", "shasum": "" }, "require": { @@ -636,10 +619,10 @@ "doctrine/common": "<2.9" }, "require-dev": { - "doctrine/coding-standard": "^10", - "phpstan/phpstan": "^1.8.8", - "phpunit/phpunit": "^9.5", - "vimeo/psalm": "^4.28" + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" }, "type": "library", "autoload": { @@ -688,7 +671,7 @@ ], "support": { "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/2.0.0" + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" }, "funding": [ { @@ -704,45 +687,45 @@ "type": "tidelift" } ], - "time": "2022-10-12T20:59:15+00:00" + "time": "2026-01-29T07:11:08+00:00" }, { "name": "laminas/laminas-diactoros", - "version": "2.26.0", + "version": "3.8.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-diactoros.git", - "reference": "6584d44eb8e477e89d453313b858daac6183cddc" + "reference": "60c182916b2749480895601649563970f3f12ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/6584d44eb8e477e89d453313b858daac6183cddc", - "reference": "6584d44eb8e477e89d453313b858daac6183cddc", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/60c182916b2749480895601649563970f3f12ec4", + "reference": "60c182916b2749480895601649563970f3f12ec4", "shasum": "" }, "require": { - "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1" + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0" }, "conflict": { - "zendframework/zend-diactoros": "*" + "amphp/amp": "<2.6.4" }, "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" + "psr/http-factory-implementation": "^1.0", + "psr/http-message-implementation": "^1.1 || ^2.0" }, "require-dev": { "ext-curl": "*", "ext-dom": "*", "ext-gd": "*", "ext-libxml": "*", - "http-interop/http-factory-tests": "^0.9.0", - "laminas/laminas-coding-standard": "^2.5", - "php-http/psr7-integration-tests": "^1.2", - "phpunit/phpunit": "^9.5.28", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.6" + "http-interop/http-factory-tests": "^2.2.0", + "laminas/laminas-coding-standard": "~3.1.0", + "php-http/psr7-integration-tests": "^1.4.0", + "phpunit/phpunit": "^10.5.36", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13" }, "type": "library", "extra": { @@ -757,18 +740,9 @@ "src/functions/marshal_headers_from_sapi.php", "src/functions/marshal_method_from_sapi.php", "src/functions/marshal_protocol_version_from_sapi.php", - "src/functions/marshal_uri_from_sapi.php", "src/functions/normalize_server.php", "src/functions/normalize_uploaded_files.php", - "src/functions/parse_cookie_header.php", - "src/functions/create_uploaded_file.legacy.php", - "src/functions/marshal_headers_from_sapi.legacy.php", - "src/functions/marshal_method_from_sapi.legacy.php", - "src/functions/marshal_protocol_version_from_sapi.legacy.php", - "src/functions/marshal_uri_from_sapi.legacy.php", - "src/functions/normalize_server.legacy.php", - "src/functions/normalize_uploaded_files.legacy.php", - "src/functions/parse_cookie_header.legacy.php" + "src/functions/parse_cookie_header.php" ], "psr-4": { "Laminas\\Diactoros\\": "src/" @@ -801,34 +775,34 @@ "type": "community_bridge" } ], - "time": "2023-10-29T16:17:44+00:00" + "time": "2025-10-12T15:31:36+00:00" }, { "name": "laminas/laminas-httphandlerrunner", - "version": "2.11.0", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-httphandlerrunner.git", - "reference": "c428d9f67f280d155637cbe2b7245b5188c8cdae" + "reference": "181eaeeb838ad3d80fbbcfb0657a46bc212bbd4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-httphandlerrunner/zipball/c428d9f67f280d155637cbe2b7245b5188c8cdae", - "reference": "c428d9f67f280d155637cbe2b7245b5188c8cdae", + "url": "https://api.github.com/repos/laminas/laminas-httphandlerrunner/zipball/181eaeeb838ad3d80fbbcfb0657a46bc212bbd4e", + "reference": "181eaeeb838ad3d80fbbcfb0657a46bc212bbd4e", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "psr/http-message": "^1.0 || ^2.0", "psr/http-message-implementation": "^1.0 || ^2.0", "psr/http-server-handler": "^1.0" }, "require-dev": { - "laminas/laminas-coding-standard": "~3.0.0", - "laminas/laminas-diactoros": "^3.4.0", - "phpunit/phpunit": "^10.5.36", - "psalm/plugin-phpunit": "^0.19.0", - "vimeo/psalm": "^5.26.1" + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-diactoros": "^3.6.0", + "phpunit/phpunit": "^10.5.46", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.10.3" }, "type": "library", "extra": { @@ -868,25 +842,26 @@ "type": "community_bridge" } ], - "time": "2024-10-17T20:37:17+00:00" + "time": "2025-10-12T20:58:29+00:00" }, { "name": "league/container", - "version": "4.2.0", + "version": "5.2.0", "source": { "type": "git", "url": "https://github.com/thephpleague/container.git", - "reference": "375d13cb828649599ef5d48a339c4af7a26cd0ab" + "reference": "58accbc032f0090a9bd08326f93062c5a658b2c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/container/zipball/375d13cb828649599ef5d48a339c4af7a26cd0ab", - "reference": "375d13cb828649599ef5d48a339c4af7a26cd0ab", + "url": "https://api.github.com/repos/thephpleague/container/zipball/58accbc032f0090a9bd08326f93062c5a658b2c5", + "reference": "58accbc032f0090a9bd08326f93062c5a658b2c5", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "psr/container": "^1.1 || ^2.0" + "php": "^8.1", + "psr/container": "^2.0.2", + "psr/event-dispatcher": "^1.0" }, "provide": { "psr/container-implementation": "^1.0" @@ -895,22 +870,23 @@ "orno/di": "~2.0" }, "require-dev": { - "nette/php-generator": "^3.4", - "nikic/php-parser": "^4.10", - "phpstan/phpstan": "^0.12.47", - "phpunit/phpunit": "^8.5.17", + "nette/php-generator": "^4.1", + "nikic/php-parser": "^5.0", + "phpstan/phpstan": "^2.1.11", + "phpunit/phpunit": "^10.5.45|^11.5.15|^12.0", "roave/security-advisories": "dev-latest", - "scrutinizer/ocular": "^1.8", - "squizlabs/php_codesniffer": "^3.6" + "scrutinizer/ocular": "^1.9", + "squizlabs/php_codesniffer": "^3.9" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.x-dev", - "dev-4.x": "4.x-dev", - "dev-3.x": "3.x-dev", + "dev-1.x": "1.x-dev", "dev-2.x": "2.x-dev", - "dev-1.x": "1.x-dev" + "dev-3.x": "3.x-dev", + "dev-4.x": "4.x-dev", + "dev-5.x": "5.x-dev", + "dev-master": "5.x-dev" } }, "autoload": { @@ -942,7 +918,7 @@ ], "support": { "issues": "https://github.com/thephpleague/container/issues", - "source": "https://github.com/thephpleague/container/tree/4.2.0" + "source": "https://github.com/thephpleague/container/tree/5.2.0" }, "funding": [ { @@ -950,37 +926,42 @@ "type": "github" } ], - "time": "2021-11-16T10:29:06+00:00" + "time": "2026-03-19T18:52:39+00:00" }, { "name": "league/uri", - "version": "7.5.1", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "81fb5145d2644324614cc532b28efd0215bda430" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", - "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.5", - "php": "^8.1" + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" }, "conflict": { "league/uri-schemes": "^1.0" }, "suggest": { "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", "ext-fileinfo": "to create Data URI from file contennts", "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", - "league/uri-components": "Needed to easily manipulate URI objects components", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -1008,6 +989,7 @@ "description": "URI manipulation library", "homepage": "https://uri.thephpleague.com", "keywords": [ + "URN", "data-uri", "file-uri", "ftp", @@ -1020,9 +1002,11 @@ "psr-7", "query-string", "querystring", + "rfc2141", "rfc3986", "rfc3987", "rfc6570", + "rfc8141", "uri", "uri-template", "url", @@ -1032,7 +1016,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.5.1" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -1040,26 +1024,25 @@ "type": "github" } ], - "time": "2024-12-08T08:40:02+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.5.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { "ext-filter": "*", "php": "^8.1", - "psr/http-factory": "^1", "psr/http-message": "^1.1 || ^2.0" }, "suggest": { @@ -1067,6 +1050,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -1091,7 +1075,7 @@ "homepage": "https://nyamsprod.com" } ], - "description": "Common interfaces and classes for URI representation and interaction", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", "homepage": "https://uri.thephpleague.com", "keywords": [ "data-uri", @@ -1116,7 +1100,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -1124,20 +1108,20 @@ "type": "github" } ], - "time": "2024-12-08T08:18:47+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "masterminds/html5", - "version": "2.9.0", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + "reference": "fcf91eb64359852f00d921887b219479b4f21251" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", "shasum": "" }, "require": { @@ -1189,38 +1173,40 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" }, - "time": "2024-03-31T07:05:07+00:00" + "time": "2025-07-25T09:04:22+00:00" }, { "name": "mobiledetect/mobiledetectlib", - "version": "2.8.45", + "version": "4.10.0", "source": { "type": "git", "url": "https://github.com/serbanghita/Mobile-Detect.git", - "reference": "96aaebcf4f50d3d2692ab81d2c5132e425bca266" + "reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/96aaebcf4f50d3d2692ab81d2c5132e425bca266", - "reference": "96aaebcf4f50d3d2692ab81d2c5132e425bca266", + "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/1473bd9d6aa40158f75f1e05116e6dd081148b2c", + "reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c", "shasum": "" }, "require": { - "php": ">=5.0.0" + "php": ">=8.2", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { - "phpunit/phpunit": "~4.8.36" + "friendsofphp/php-cs-fixer": "3.95.1", + "phpbench/phpbench": "1.6.1", + "phpstan/phpstan": "2.1.47", + "phpunit/phpunit": "9.6.34", + "squizlabs/php_codesniffer": "3.13.5" }, "type": "library", "autoload": { - "psr-0": { - "Detection": "namespaced/" - }, - "classmap": [ - "Mobile_Detect.php" - ] + "psr-4": { + "Detection\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1245,7 +1231,7 @@ ], "support": { "issues": "https://github.com/serbanghita/Mobile-Detect/issues", - "source": "https://github.com/serbanghita/Mobile-Detect/tree/2.8.45" + "source": "https://github.com/serbanghita/Mobile-Detect/tree/4.10.0" }, "funding": [ { @@ -1253,7 +1239,7 @@ "type": "github" } ], - "time": "2023-11-07T21:57:25+00:00" + "time": "2026-04-23T13:05:57+00:00" }, { "name": "psr/cache", @@ -1304,6 +1290,54 @@ }, "time": "2021-02-03T23:26:27+00:00" }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, { "name": "psr/container", "version": "2.0.2", @@ -1357,6 +1391,56 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, { "name": "psr/http-client", "version": "1.0.3", @@ -1466,16 +1550,16 @@ }, { "name": "psr/http-message", - "version": "1.1", + "version": "2.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", - "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { @@ -1484,7 +1568,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -1499,7 +1583,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], "description": "Common interface for HTTP messages", @@ -1513,9 +1597,9 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/1.1" + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2023-04-04T09:50:52+00:00" + "time": "2023-04-04T09:54:51+00:00" }, { "name": "psr/http-server-handler", @@ -1632,16 +1716,16 @@ }, { "name": "psr/log", - "version": "2.0.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "ef29f6d262798707a9edd554e2b82517ef3a9376" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/ef29f6d262798707a9edd554e2b82517ef3a9376", - "reference": "ef29f6d262798707a9edd554e2b82517ef3a9376", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { @@ -1650,7 +1734,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { @@ -1676,22 +1760,22 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/2.0.0" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2021-07-14T16:41:46+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { "name": "psr/simple-cache", - "version": "2.0.0", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/php-fig/simple-cache.git", - "reference": "8707bf3cea6f710bf6ef05491234e3ab06f6432a" + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/8707bf3cea6f710bf6ef05491234e3ab06f6432a", - "reference": "8707bf3cea6f710bf6ef05491234e3ab06f6432a", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", "shasum": "" }, "require": { @@ -1700,7 +1784,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "3.0.x-dev" } }, "autoload": { @@ -1727,131 +1811,106 @@ "simple-cache" ], "support": { - "source": "https://github.com/php-fig/simple-cache/tree/2.0.0" + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" }, - "time": "2021-10-29T13:22:09+00:00" + "time": "2021-10-29T13:26:27+00:00" }, { - "name": "robmorgan/phinx", - "version": "0.13.4", + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", "source": { "type": "git", - "url": "https://github.com/cakephp/phinx.git", - "reference": "18e06e4a2b18947663438afd2f467e17c62e867d" + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/phinx/zipball/18e06e4a2b18947663438afd2f467e17c62e867d", - "reference": "18e06e4a2b18947663438afd2f467e17c62e867d", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { - "cakephp/database": "^4.0", - "php": ">=7.2", - "psr/container": "^1.0 || ^2.0", - "symfony/config": "^3.4|^4.0|^5.0|^6.0", - "symfony/console": "^3.4|^4.0|^5.0|^6.0" - }, - "require-dev": { - "cakephp/cakephp-codesniffer": "^4.0", - "ext-json": "*", - "ext-pdo": "*", - "phpunit/phpunit": "^8.5|^9.3", - "sebastian/comparator": ">=1.2.3", - "symfony/yaml": "^3.4|^4.0|^5.0" - }, - "suggest": { - "ext-json": "Install if using JSON configuration format", - "ext-pdo": "PDO extension is needed", - "symfony/yaml": "Install if using YAML configuration format" + "php": ">=8.1" }, - "bin": [ - "bin/phinx" - ], "type": "library", - "autoload": { - "psr-4": { - "Phinx\\": "src/Phinx/" + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, + "autoload": { + "files": [ + "function.php" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { - "name": "Rob Morgan", - "email": "robbym@gmail.com", - "homepage": "https://robmorgan.id.au", - "role": "Lead Developer" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com", - "homepage": "https://shadowhand.me", - "role": "Developer" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "name": "Richard Quadling", - "email": "rquadling@gmail.com", - "role": "Developer" + "url": "https://github.com/fabpot", + "type": "github" }, { - "name": "CakePHP Community", - "homepage": "https://github.com/cakephp/phinx/graphs/contributors", - "role": "Developer" + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "description": "Phinx makes it ridiculously easy to manage the database migrations for your PHP app.", - "homepage": "https://phinx.org", - "keywords": [ - "database", - "database migrations", - "db", - "migrations", - "phinx" - ], - "support": { - "issues": "https://github.com/cakephp/phinx/issues", - "source": "https://github.com/cakephp/phinx/tree/0.13.4" - }, - "time": "2023-01-07T00:42:55+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { - "name": "symfony/config", - "version": "v6.4.4", + "name": "symfony/html-sanitizer", + "version": "v7.4.13", "source": { "type": "git", - "url": "https://github.com/symfony/config.git", - "reference": "6ea4affc27f2086c9d16b92ab5429ce1e3c38047" + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "761f6c49dfd103ee08b3cd09ece588b069e18ec9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/6ea4affc27f2086c9d16b92ab5429ce1e3c38047", - "reference": "6ea4affc27f2086c9d16b92ab5429ce1e3c38047", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/761f6c49dfd103ee08b3cd09ece588b069e18ec9", + "reference": "761f6c49dfd103ee08b3cd09ece588b069e18ec9", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^5.4|^6.0|^7.0", - "symfony/polyfill-ctype": "~1.8" - }, - "conflict": { - "symfony/finder": "<5.4", - "symfony/service-contracts": "<2.5" - }, - "require-dev": { - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "masterminds/html5": "^2.7.2", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Config\\": "" + "Symfony\\Component\\HtmlSanitizer\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1863,18 +1922,23 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", "homepage": "https://symfony.com", + "keywords": [ + "Purifier", + "html", + "sanitizer" + ], "support": { - "source": "https://github.com/symfony/config/tree/v6.4.4" + "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.13" }, "funding": [ { @@ -1885,138 +1949,99 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-02-26T07:52:26+00:00" - }, + "time": "2026-05-24T11:20:33+00:00" + } + ], + "packages-dev": [ { - "name": "symfony/console", - "version": "v6.4.4", + "name": "brick/varexporter", + "version": "0.7.0", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "0d9e4eb5ad413075624378f474c4167ea202de78" + "url": "https://github.com/brick/varexporter.git", + "reference": "b3a50b8f630a9ed5015ea3e1f00479af261ed80d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/0d9e4eb5ad413075624378f474c4167ea202de78", - "reference": "0d9e4eb5ad413075624378f474c4167ea202de78", + "url": "https://api.github.com/repos/brick/varexporter/zipball/b3a50b8f630a9ed5015ea3e1f00479af261ed80d", + "reference": "b3a50b8f630a9ed5015ea3e1f00479af261ed80d", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" - }, - "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" - }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" + "nikic/php-parser": "^5.0", + "php": "^8.2" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^11.0", + "vimeo/psalm": "6.14.3" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Console\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Brick\\VarExporter\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases the creation of beautiful and testable command line interfaces", - "homepage": "https://symfony.com", + "description": "A powerful alternative to var_export(), which can export closures and objects without __set_state()", "keywords": [ - "cli", - "command-line", - "console", - "terminal" + "var_export" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.4" + "issues": "https://github.com/brick/varexporter/issues", + "source": "https://github.com/brick/varexporter/tree/0.7.0" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/BenMorel", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2024-02-22T20:27:10+00:00" + "time": "2026-01-06T22:56:00+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v3.4.0", + "name": "cakephp/bake", + "version": "3.6.4", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf" + "url": "https://github.com/cakephp/bake.git", + "reference": "45139a702f039b64dcfb758bff60910861486060" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf", - "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf", + "url": "https://api.github.com/repos/cakephp/bake/zipball/45139a702f039b64dcfb758bff60910861486060", + "reference": "45139a702f039b64dcfb758bff60910861486060", "shasum": "" }, "require": { + "brick/varexporter": "^0.6.0 || ^0.7.0", + "cakephp/cakephp": "^5.1", + "cakephp/twig-view": "^2.0.2", + "nikic/php-parser": "^5.0.0", "php": ">=8.1" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.4-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } + "require-dev": { + "cakephp/cakephp-codesniffer": "^5.0.0", + "cakephp/debug_kit": "^5.0.0", + "phpunit/phpunit": "^10.5.40 || ^11.5.20 || ^12.2.4" }, + "type": "cakephp-plugin", "autoload": { - "files": [ - "function.php" - ] + "psr-4": { + "Bake\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2024,62 +2049,54 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/bake/graphs/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", + "description": "Bake plugin for CakePHP", + "homepage": "https://github.com/cakephp/bake", + "keywords": [ + "bake", + "cakephp", + "cli", + "dev" + ], "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.4.0" + "forum": "https://stackoverflow.com/tags/cakephp", + "issues": "https://github.com/cakephp/bake/issues", + "source": "https://github.com/cakephp/bake" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-05-23T14:45:45+00:00" + "time": "2026-05-08T13:56:13+00:00" }, { - "name": "symfony/filesystem", - "version": "v7.0.3", + "name": "cakephp/cakephp-codesniffer", + "version": "5.3.0", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "2890e3a825bc0c0558526c04499c13f83e1b6b12" + "url": "https://github.com/cakephp/cakephp-codesniffer.git", + "reference": "8c9481165b0f2819ac58894c9a7e5599a55ad678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/2890e3a825bc0c0558526c04499c13f83e1b6b12", - "reference": "2890e3a825bc0c0558526c04499c13f83e1b6b12", + "url": "https://api.github.com/repos/cakephp/cakephp-codesniffer/zipball/8c9481165b0f2819ac58894c9a7e5599a55ad678", + "reference": "8c9481165b0f2819ac58894c9a7e5599a55ad678", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" + "dealerdirect/phpcodesniffer-composer-installer": "^1.1.2", + "php": ">=8.1", + "phpstan/phpdoc-parser": "^2.1", + "slevomat/coding-standard": "^8.23", + "squizlabs/php_codesniffer": "^4.0" }, - "type": "library", + "require-dev": { + "phpunit/phpunit": "^10.5.32 || ^11.3.3" + }, + "type": "phpcodesniffer-standard", "autoload": { "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "CakePHP\\": "CakePHP/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2087,63 +2104,57 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/cakephp-codesniffer/graphs/contributors" } ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", + "description": "CakePHP CodeSniffer Standards", + "homepage": "https://cakephp.org", + "keywords": [ + "codesniffer", + "framework" + ], "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.0.3" + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/cakephp-codesniffer/issues", + "source": "https://github.com/cakephp/cakephp-codesniffer" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-01-23T15:02:46+00:00" + "time": "2025-09-19T15:47:28+00:00" }, { - "name": "symfony/html-sanitizer", - "version": "v7.2.2", + "name": "cakephp/debug_kit", + "version": "5.2.2", "source": { "type": "git", - "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "f6bc679b024e30f27e33815930a5b8b304c79813" + "url": "https://github.com/cakephp/debug_kit.git", + "reference": "72cc1b7a4ad9aaa2b841ade1d22aa649955bf017" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/f6bc679b024e30f27e33815930a5b8b304c79813", - "reference": "f6bc679b024e30f27e33815930a5b8b304c79813", + "url": "https://api.github.com/repos/cakephp/debug_kit/zipball/72cc1b7a4ad9aaa2b841ade1d22aa649955bf017", + "reference": "72cc1b7a4ad9aaa2b841ade1d22aa649955bf017", "shasum": "" }, "require": { - "ext-dom": "*", - "league/uri": "^6.5|^7.0", - "masterminds/html5": "^2.7.2", - "php": ">=8.2" + "cakephp/cakephp": "^5.1", + "composer/composer": "^2.7.7", + "doctrine/sql-formatter": "^1.1.3", + "php": ">=8.1" }, - "type": "library", + "require-dev": { + "cakephp/authorization": "^3.0", + "cakephp/cakephp-codesniffer": "^5.0", + "phpunit/phpunit": "^10.5.32 || ^11.1.3 || ^12.0.9" + }, + "suggest": { + "ext-pdo_sqlite": "DebugKit needs to store panel data in a database. SQLite is simple and easy to use." + }, + "type": "cakephp-plugin", "autoload": { "psr-4": { - "Symfony\\Component\\HtmlSanitizer\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "DebugKit\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2151,76 +2162,66 @@ ], "authors": [ { - "name": "Titouan Galopin", - "email": "galopintitouan@gmail.com" + "name": "Mark Story", + "homepage": "https://mark-story.com", + "role": "Author" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/debug_kit/graphs/contributors" } ], - "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", - "homepage": "https://symfony.com", + "description": "CakePHP Debug Kit", + "homepage": "https://github.com/cakephp/debug_kit", "keywords": [ - "Purifier", - "html", - "sanitizer" - ], + "cakephp", + "debug", + "dev", + "kit" + ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v7.2.2" + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/debug_kit/issues", + "source": "https://github.com/cakephp/debug_kit" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-12-30T18:35:15+00:00" + "time": "2026-05-01T06:20:22+00:00" }, { - "name": "symfony/polyfill-ctype", - "version": "v1.29.0", + "name": "cakephp/twig-view", + "version": "2.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4" + "url": "https://github.com/cakephp/twig-view.git", + "reference": "db51ee49c0cb9be4af39497f1724393b7ce51211" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4", - "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4", + "url": "https://api.github.com/repos/cakephp/twig-view/zipball/db51ee49c0cb9be4af39497f1724393b7ce51211", + "reference": "db51ee49c0cb9be4af39497f1724393b7ce51211", "shasum": "" }, "require": { - "php": ">=7.1" - }, - "provide": { - "ext-ctype": "*" + "cakephp/cakephp": "^5.0.0", + "jasny/twig-extensions": "^1.3", + "php": ">=8.1", + "twig/markdown-extra": "^3.0", + "twig/twig": "^3.11.1" }, - "suggest": { - "ext-ctype": "For best performance" + "conflict": { + "wyrihaximus/twig-view": "*" }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } + "require-dev": { + "cakephp/cakephp-codesniffer": "^5.0", + "cakephp/debug_kit": "^5.0", + "michelf/php-markdown": "^1.9", + "mikey179/vfsstream": "^1.6.10", + "phpunit/phpunit": "^11.5.3 | ^12.1.3" }, + "type": "cakephp-plugin", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" + "Cake\\TwigView\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2229,74 +2230,61 @@ ], "authors": [ { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/cakephp/graphs/contributors" } ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", + "description": "Twig powered View for CakePHP", "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" + "cakephp", + "template", + "twig", + "view" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0" + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/twig-view/issues", + "source": "https://github.com/cakephp/twig-view" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2026-01-05T22:35:06+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.29.0", + "name": "composer/class-map-generator", + "version": "1.7.3", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f" + "url": "https://github.com/composer/class-map-generator.git", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/32a9da87d7b3245e09ac426c83d334ae9f06f80f", - "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f", "shasum": "" }, "require": { - "php": ">=7.1" + "composer/pcre": "^2.1 || ^3.1", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8" }, - "suggest": { - "ext-intl": "For best performance" + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", + "phpunit/phpunit": "^8", + "symfony/filesystem": "^5.4 || ^6 || ^7 || ^8" }, "type": "library", "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "branch-alias": { + "dev-main": "1.x-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + "Composer\\ClassMapGenerator\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2305,80 +2293,102 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", - "homepage": "https://symfony.com", + "description": "Utilities to scan PHP code and generate class maps.", "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" + "classmap" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.29.0" + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.7.3" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://packagist.com", "type": "custom" }, { - "url": "https://github.com/fabpot", + "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2026-05-05T09:17:07+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.29.0", + "name": "composer/composer", + "version": "2.9.8", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "bc45c394692b948b4d383a08d7753968bed9a83d" + "url": "https://github.com/composer/composer.git", + "reference": "39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/bc45c394692b948b4d383a08d7753968bed9a83d", - "reference": "bc45c394692b948b4d383a08d7753968bed9a83d", + "url": "https://api.github.com/repos/composer/composer/zipball/39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2", + "reference": "39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2", "shasum": "" }, "require": { - "php": ">=7.1" + "composer/ca-bundle": "^1.5", + "composer/class-map-generator": "^1.4.0", + "composer/metadata-minifier": "^1.0", + "composer/pcre": "^2.3 || ^3.3", + "composer/semver": "^3.3", + "composer/spdx-licenses": "^1.5.7", + "composer/xdebug-handler": "^2.0.2 || ^3.0.3", + "ext-json": "*", + "justinrainbow/json-schema": "^6.5.1", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "react/promise": "^3.3", + "seld/jsonlint": "^1.4", + "seld/phar-utils": "^1.2", + "seld/signal-handler": "^2.0", + "symfony/console": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.1.10 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.1.10 || ^8.0", + "symfony/polyfill-php73": "^1.24", + "symfony/polyfill-php80": "^1.24", + "symfony/polyfill-php81": "^1.24", + "symfony/polyfill-php84": "^1.30", + "symfony/process": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11.8", + "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.0", + "phpstan/phpstan-symfony": "^1.4.0", + "symfony/phpunit-bridge": "^6.4.25 || ^7.3.3 || ^8.0" }, "suggest": { - "ext-intl": "For best performance" + "ext-curl": "Provides HTTP support (will fallback to PHP streams if missing)", + "ext-openssl": "Enables access to repositories and packages over HTTPS", + "ext-zip": "Allows direct extraction of ZIP archives (unzip/7z binaries will be used instead if available)", + "ext-zlib": "Enables gzip for HTTP requests" }, + "bin": [ + "bin/composer" + ], "type": "library", "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "phpstan": { + "includes": [ + "phpstan/rules.neon" + ] + }, + "branch-alias": { + "dev-main": "2.9-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Composer\\": "src/Composer/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2386,79 +2396,72 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "https://www.naderman.de" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", - "homepage": "https://symfony.com", + "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.", + "homepage": "https://getcomposer.org/", "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" + "autoload", + "dependency", + "package" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.29.0" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/composer/issues", + "security": "https://github.com/composer/composer/security/policy", + "source": "https://github.com/composer/composer/tree/2.9.8" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://packagist.com", "type": "custom" }, { - "url": "https://github.com/fabpot", + "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2026-05-13T07:28:38+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.29.0", + "name": "composer/metadata-minifier", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec" + "url": "https://github.com/composer/metadata-minifier.git", + "reference": "c549d23829536f0d0e984aaabbf02af91f443207" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec", - "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec", + "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207", + "reference": "c549d23829536f0d0e984aaabbf02af91f443207", "shasum": "" }, "require": { - "php": ">=7.1" - }, - "provide": { - "ext-mbstring": "*" + "php": "^5.3.2 || ^7.0 || ^8.0" }, - "suggest": { - "ext-mbstring": "For best performance" + "require-dev": { + "composer/composer": "^2", + "phpstan/phpstan": "^0.12.55", + "symfony/phpunit-bridge": "^4.2 || ^5" }, "type": "library", "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "branch-alias": { + "dev-main": "1.x-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" + "Composer\\MetadataMinifier\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2467,80 +2470,76 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" } ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", + "description": "Small utility library that handles metadata minification and expansion.", "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" + "composer", + "compression" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0" + "issues": "https://github.com/composer/metadata-minifier/issues", + "source": "https://github.com/composer/metadata-minifier/tree/1.0.0" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://packagist.com", "type": "custom" }, { - "url": "https://github.com/fabpot", + "url": "https://github.com/composer", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/composer/composer", "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2021-04-07T13:37:33+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.4.1", + "name": "composer/pcre", + "version": "3.3.2", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0" + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/fe07cbc8d837f60caf7018068e350cc5163681a0", - "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0" + "php": "^7.4 || ^8.0" }, "conflict": { - "ext-psr": "<1.1|>=2" + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.4-dev" + "phpstan": { + "includes": [ + "extension.neon" + ] }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "branch-alias": { + "dev-main": "3.x-dev" } }, "autoload": { "psr-4": { - "Symfony\\Contracts\\Service\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] + "Composer\\Pcre\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2548,85 +2547,69 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" } ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "PCRE", + "preg", + "regex", + "regular expression" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.4.1" + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://packagist.com", "type": "custom" }, { - "url": "https://github.com/fabpot", + "url": "https://github.com/composer", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/composer/composer", "type": "tidelift" } ], - "time": "2023-12-26T14:02:43+00:00" + "time": "2024-11-12T16:29:46+00:00" }, { - "name": "symfony/string", - "version": "v7.0.4", + "name": "composer/semver", + "version": "3.4.4", "source": { "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "f5832521b998b0bec40bee688ad5de98d4cf111b" + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f5832521b998b0bec40bee688ad5de98d4cf111b", - "reference": "f5832521b998b0bec40bee688ad5de98d4cf111b", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" - }, - "conflict": { - "symfony/translation-contracts": "<2.5" + "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "symfony/error-handler": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", - "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { - "files": [ - "Resources/functions.php" - ], "psr-4": { - "Symfony\\Component\\String\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Composer\\Semver\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2634,125 +2617,149 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" } ], - "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", - "homepage": "https://symfony.com", + "description": "Semver library that offers utilities, version constraint parsing and validation.", "keywords": [ - "grapheme", - "i18n", - "string", - "unicode", - "utf-8", - "utf8" + "semantic", + "semver", + "validation", + "versioning" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.0.4" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://packagist.com", "type": "custom" }, { - "url": "https://github.com/fabpot", + "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2024-02-01T13:17:36+00:00" - } - ], - "packages-dev": [ + "time": "2025-08-20T19:15:30+00:00" + }, { - "name": "brick/varexporter", - "version": "0.3.8", + "name": "composer/spdx-licenses", + "version": "1.6.0", "source": { "type": "git", - "url": "https://github.com/brick/varexporter.git", - "reference": "b5853edea6204ff8fa10633c3a4cccc4058410ed" + "url": "https://github.com/composer/spdx-licenses.git", + "reference": "5ecd0cb4177696f9fd48f1605dda81db3dee7889" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/varexporter/zipball/b5853edea6204ff8fa10633c3a4cccc4058410ed", - "reference": "b5853edea6204ff8fa10633c3a4cccc4058410ed", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/5ecd0cb4177696f9fd48f1605dda81db3dee7889", + "reference": "5ecd0cb4177696f9fd48f1605dda81db3dee7889", "shasum": "" }, "require": { - "nikic/php-parser": "^4.0", "php": "^7.2 || ^8.0" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^8.5 || ^9.0", - "vimeo/psalm": "4.23.0" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^6.4.25 || ^7.3.3 || ^8.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, "autoload": { "psr-4": { - "Brick\\VarExporter\\": "src/" + "Composer\\Spdx\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A powerful alternative to var_export(), which can export closures and objects without __set_state()", + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "SPDX licenses list and validation library.", "keywords": [ - "var_export" + "license", + "spdx", + "validator" ], "support": { - "issues": "https://github.com/brick/varexporter/issues", - "source": "https://github.com/brick/varexporter/tree/0.3.8" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/spdx-licenses/issues", + "source": "https://github.com/composer/spdx-licenses/tree/1.6.0" }, "funding": [ { - "url": "https://github.com/BenMorel", + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", "type": "github" } ], - "time": "2023-01-21T23:05:38+00:00" + "time": "2026-04-08T20:18:39+00:00" }, { - "name": "cakephp/bake", - "version": "2.9.3", + "name": "composer/xdebug-handler", + "version": "3.0.5", "source": { "type": "git", - "url": "https://github.com/cakephp/bake.git", - "reference": "a9b02fb6a5f96e8fb9887be55cccea501468907b" + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/bake/zipball/a9b02fb6a5f96e8fb9887be55cccea501468907b", - "reference": "a9b02fb6a5f96e8fb9887be55cccea501468907b", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { - "brick/varexporter": "^0.3.5", - "cakephp/cakephp": "^4.3.0", - "cakephp/twig-view": "^1.0.2", - "nikic/php-parser": "^4.13.2", - "php": ">=7.2" + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" }, "require-dev": { - "cakephp/cakephp-codesniffer": "^4.0", - "cakephp/debug_kit": "^4.1", - "cakephp/plugin-installer": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.3" + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, - "type": "cakephp-plugin", + "type": "library", "autoload": { "psr-4": { - "Bake\\": "src/" + "Composer\\XdebugHandler\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2761,50 +2768,70 @@ ], "authors": [ { - "name": "CakePHP Community", - "homepage": "https://github.com/cakephp/bake/graphs/contributors" + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" } ], - "description": "Bake plugin for CakePHP", - "homepage": "https://github.com/cakephp/bake", + "description": "Restarts a process without Xdebug.", "keywords": [ - "bake", - "cakephp" + "Xdebug", + "performance" ], "support": { - "forum": "https://stackoverflow.com/tags/cakephp", - "irc": "irc://irc.freenode.org/cakephp", - "issues": "https://github.com/cakephp/bake/issues", - "source": "https://github.com/cakephp/bake" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" }, - "time": "2023-03-18T19:26:16+00:00" + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" }, { - "name": "cakephp/cakephp-codesniffer", - "version": "4.7.0", + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", "source": { "type": "git", - "url": "https://github.com/cakephp/cakephp-codesniffer.git", - "reference": "24fa2321d54e5251ac2f59dd92dd2066f0b0bdae" + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/cakephp-codesniffer/zipball/24fa2321d54e5251ac2f59dd92dd2066f0b0bdae", - "reference": "24fa2321d54e5251ac2f59dd92dd2066f0b0bdae", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", "shasum": "" }, "require": { - "php": ">=7.2.0", - "slevomat/coding-standard": "^7.0 || ^8.0", - "squizlabs/php_codesniffer": "^3.6" + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" }, "require-dev": { - "phpunit/phpunit": "^7.1" + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" }, - "type": "phpcodesniffer-standard", "autoload": { "psr-4": { - "CakePHP\\": "CakePHP/" + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2813,58 +2840,90 @@ ], "authors": [ { - "name": "CakePHP Community", - "homepage": "https://github.com/cakephp/cakephp-codesniffer/graphs/contributors" + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" } ], - "description": "CakePHP CodeSniffer Standards", - "homepage": "https://cakephp.org", + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", "codesniffer", - "framework" + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" ], "support": { - "forum": "https://stackoverflow.com/tags/cakephp", - "irc": "irc://irc.freenode.org/cakephp", - "issues": "https://github.com/cakephp/cakephp-codesniffer/issues", - "source": "https://github.com/cakephp/cakephp-codesniffer" + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" }, - "time": "2023-04-10T06:35:04+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" }, { - "name": "cakephp/debug_kit", - "version": "4.10.2", + "name": "doctrine/sql-formatter", + "version": "1.5.4", "source": { "type": "git", - "url": "https://github.com/cakephp/debug_kit.git", - "reference": "49c841e4b2b89e4d1cb7c3ce00d27e3d5f2bdbd4" + "url": "https://github.com/doctrine/sql-formatter.git", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/debug_kit/zipball/49c841e4b2b89e4d1cb7c3ce00d27e3d5f2bdbd4", - "reference": "49c841e4b2b89e4d1cb7c3ce00d27e3d5f2bdbd4", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/9563949f5cd3bd12a17d12fb980528bc141c5806", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806", "shasum": "" }, "require": { - "cakephp/cakephp": "^4.5.0", - "cakephp/chronos": "^2.0", - "composer/composer": "^1.3 | ^2.0", - "doctrine/sql-formatter": "^1.1.3", - "php": ">=7.4" + "php": "^8.1" }, "require-dev": { - "cakephp/authorization": "^2.0", - "cakephp/cakephp-codesniffer": "^4.0", - "phpunit/phpunit": "~8.5.0 | ^9.3" - }, - "suggest": { - "ext-pdo_sqlite": "DebugKit needs to store panel data in a database. SQLite is simple and easy to use." + "doctrine/coding-standard": "^14", + "ergebnis/phpunit-slow-test-detector": "^2.20", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^10.5.58" }, - "type": "cakephp-plugin", + "bin": [ + "bin/sql-formatter" + ], + "type": "library", "autoload": { "psr-4": { - "DebugKit\\": "src/", - "DebugKit\\Test\\Fixture\\": "tests/Fixture/" + "Doctrine\\SqlFormatter\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2873,67 +2932,57 @@ ], "authors": [ { - "name": "Mark Story", - "homepage": "https://mark-story.com", - "role": "Author" - }, - { - "name": "CakePHP Community", - "homepage": "https://github.com/cakephp/debug_kit/graphs/contributors" + "name": "Jeremy Dorn", + "email": "jeremy@jeremydorn.com", + "homepage": "https://jeremydorn.com/" } ], - "description": "CakePHP Debug Kit", - "homepage": "https://github.com/cakephp/debug_kit", + "description": "a PHP SQL highlighting library", + "homepage": "https://github.com/doctrine/sql-formatter/", "keywords": [ - "cakephp", - "debug", - "dev", - "kit" + "highlight", + "sql" ], "support": { - "forum": "https://stackoverflow.com/tags/cakephp", - "irc": "irc://irc.freenode.org/cakephp", - "issues": "https://github.com/cakephp/debug_kit/issues", - "source": "https://github.com/cakephp/debug_kit" + "issues": "https://github.com/doctrine/sql-formatter/issues", + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.4" }, - "time": "2023-12-15T20:59:05+00:00" + "time": "2026-02-08T16:21:46+00:00" }, { - "name": "cakephp/twig-view", - "version": "1.3.0", + "name": "jasny/twig-extensions", + "version": "v1.3.1", "source": { "type": "git", - "url": "https://github.com/cakephp/twig-view.git", - "reference": "14df50360b809a171d0688020fbdfe513763f89b" + "url": "https://github.com/jasny/twig-extensions.git", + "reference": "8a5ca5f49317bf421a519556ad2e876820d41e01" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/twig-view/zipball/14df50360b809a171d0688020fbdfe513763f89b", - "reference": "14df50360b809a171d0688020fbdfe513763f89b", + "url": "https://api.github.com/repos/jasny/twig-extensions/zipball/8a5ca5f49317bf421a519556ad2e876820d41e01", + "reference": "8a5ca5f49317bf421a519556ad2e876820d41e01", "shasum": "" }, "require": { - "cakephp/cakephp": "^4.0", - "jasny/twig-extensions": "^1.3", - "php": ">=7.2", - "twig/markdown-extra": "^3.0", - "twig/twig": "^3.0" - }, - "conflict": { - "wyrihaximus/twig-view": "*" + "php": ">=7.4.0", + "twig/twig": "^2.7 | ^3.0" }, "require-dev": { - "cakephp/cakephp-codesniffer": "^4.0", - "cakephp/debug_kit": "^4.0", - "cakephp/plugin-installer": "^1.3", - "michelf/php-markdown": "^1.9", - "mikey179/vfsstream": "^1.6", - "phpunit/phpunit": "^8.5 || ^9.3" + "ext-intl": "*", + "ext-json": "*", + "ext-pcre": "*", + "phpstan/phpstan": "^1.12.0", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.10" }, - "type": "cakephp-plugin", + "suggest": { + "ext-intl": "Required for the use of the LocalDate Twig extension", + "ext-pcre": "Required for the use of the PCRE Twig extension" + }, + "type": "library", "autoload": { "psr-4": { - "Cake\\TwigView\\": "src/" + "Jasny\\Twig\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2942,61 +2991,60 @@ ], "authors": [ { - "name": "CakePHP Community", - "homepage": "https://github.com/cakephp/cakephp/graphs/contributors" + "name": "Arnold Daniels", + "email": "arnold@jasny.net", + "homepage": "http://www.jasny.net" } ], - "description": "Twig powered View for CakePHP", + "description": "A set of useful Twig filters", + "homepage": "http://github.com/jasny/twig-extensions#README", "keywords": [ - "cakephp", - "template", - "twig", - "view" + "PCRE", + "array", + "date", + "datetime", + "preg", + "regex", + "templating", + "text", + "time" ], "support": { - "forum": "https://stackoverflow.com/tags/cakephp", - "irc": "irc://irc.freenode.org/cakephp", - "issues": "https://github.com/cakephp/twig-view/issues", - "source": "https://github.com/cakephp/twig-view" + "issues": "https://github.com/jasny/twig-extensions/issues", + "source": "https://github.com/jasny/twig-extensions" }, - "time": "2021-09-17T14:07:52+00:00" + "time": "2024-09-03T09:04:53+00:00" }, { - "name": "composer/class-map-generator", - "version": "1.1.0", + "name": "josegonzalez/dotenv", + "version": "4.0.0", "source": { "type": "git", - "url": "https://github.com/composer/class-map-generator.git", - "reference": "953cc4ea32e0c31f2185549c7d216d7921f03da9" + "url": "https://github.com/josegonzalez/php-dotenv.git", + "reference": "e97dbd3db53508dcd536e73ec787a7f11458d41d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/class-map-generator/zipball/953cc4ea32e0c31f2185549c7d216d7921f03da9", - "reference": "953cc4ea32e0c31f2185549c7d216d7921f03da9", + "url": "https://api.github.com/repos/josegonzalez/php-dotenv/zipball/e97dbd3db53508dcd536e73ec787a7f11458d41d", + "reference": "e97dbd3db53508dcd536e73ec787a7f11458d41d", "shasum": "" }, "require": { - "composer/pcre": "^2.1 || ^3.1", - "php": "^7.2 || ^8.0", - "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7" + "m1/env": "2.*", + "php": ">=5.5.0" }, "require-dev": { - "phpstan/phpstan": "^1.6", - "phpstan/phpstan-deprecation-rules": "^1", - "phpstan/phpstan-phpunit": "^1", - "phpstan/phpstan-strict-rules": "^1.1", - "symfony/filesystem": "^5.4 || ^6", - "symfony/phpunit-bridge": "^5" + "php-coveralls/php-coveralls": "~2.0", + "php-mock/php-mock-phpunit": "~1.1||~2.0", + "squizlabs/php_codesniffer": "~2.9||~3.7" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, "autoload": { - "psr-4": { - "Composer\\ClassMapGenerator\\": "src" + "psr-0": { + "josegonzalez\\Dotenv": [ + "src", + "tests" + ] } }, "notification-url": "https://packagist.org/downloads/", @@ -3005,102 +3053,64 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "https://seld.be" + "name": "Jose Diaz-Gonzalez", + "email": "dotenv@josegonzalez.com", + "homepage": "http://josediazgonzalez.com", + "role": "Maintainer" } ], - "description": "Utilities to scan PHP code and generate class maps.", + "description": "dotenv file parsing for PHP", + "homepage": "https://github.com/josegonzalez/php-dotenv", "keywords": [ - "classmap" + "configuration", + "dotenv", + "php" ], "support": { - "issues": "https://github.com/composer/class-map-generator/issues", - "source": "https://github.com/composer/class-map-generator/tree/1.1.0" + "issues": "https://github.com/josegonzalez/php-dotenv/issues", + "source": "https://github.com/josegonzalez/php-dotenv/tree/4.0.0" }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2023-06-30T13:58:57+00:00" + "time": "2023-05-29T22:49:26+00:00" }, { - "name": "composer/composer", - "version": "2.7.6", + "name": "justinrainbow/json-schema", + "version": "6.8.2", "source": { "type": "git", - "url": "https://github.com/composer/composer.git", - "reference": "fabd995783b633829fd4280e272284b39b6ae702" + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/fabd995783b633829fd4280e272284b39b6ae702", - "reference": "fabd995783b633829fd4280e272284b39b6ae702", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76", "shasum": "" }, "require": { - "composer/ca-bundle": "^1.0", - "composer/class-map-generator": "^1.0", - "composer/metadata-minifier": "^1.0", - "composer/pcre": "^2.1 || ^3.1", - "composer/semver": "^3.2.5", - "composer/spdx-licenses": "^1.5.7", - "composer/xdebug-handler": "^2.0.2 || ^3.0.3", - "justinrainbow/json-schema": "^5.2.11", - "php": "^7.2.5 || ^8.0", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "react/promise": "^2.8 || ^3", - "seld/jsonlint": "^1.4", - "seld/phar-utils": "^1.2", - "seld/signal-handler": "^2.0", - "symfony/console": "^5.4.11 || ^6.0.11 || ^7", - "symfony/filesystem": "^5.4 || ^6.0 || ^7", - "symfony/finder": "^5.4 || ^6.0 || ^7", - "symfony/polyfill-php73": "^1.24", - "symfony/polyfill-php80": "^1.24", - "symfony/polyfill-php81": "^1.24", - "symfony/process": "^5.4 || ^6.0 || ^7" + "ext-json": "*", + "marc-mabe/php-enum": "^4.4", + "php": "^7.2 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.9.3", - "phpstan/phpstan-deprecation-rules": "^1", - "phpstan/phpstan-phpunit": "^1.0", - "phpstan/phpstan-strict-rules": "^1", - "phpstan/phpstan-symfony": "^1.2.10", - "symfony/phpunit-bridge": "^6.4.1 || ^7.0.1" - }, - "suggest": { - "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", - "ext-zip": "Enabling the zip extension allows you to unzip archives", - "ext-zlib": "Allow gzip compression of HTTP requests" + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "dev-main", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" }, "bin": [ - "bin/composer" + "bin/validate-json" ], "type": "library", "extra": { - "phpstan": { - "includes": [ - "phpstan/rules.neon" - ] - }, "branch-alias": { - "dev-main": "2.7-dev" + "dev-master": "6.x-dev" } }, "autoload": { "psr-4": { - "Composer\\": "src/Composer/" + "JsonSchema\\": "src/JsonSchema/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3109,76 +3119,64 @@ ], "authors": [ { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "https://www.naderman.de" + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" }, { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "https://seld.be" - } - ], - "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.", - "homepage": "https://getcomposer.org/", - "keywords": [ - "autoload", - "dependency", - "package" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/composer/issues", - "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.7.6" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" }, { - "url": "https://github.com/composer", - "type": "github" + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" }, { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" } ], - "time": "2024-05-04T21:03:15+00:00" + "description": "A library to validate a json schema.", + "homepage": "https://github.com/jsonrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.2" + }, + "time": "2026-05-05T05:39:01+00:00" }, { - "name": "composer/metadata-minifier", - "version": "1.0.0", + "name": "m1/env", + "version": "2.2.0", "source": { "type": "git", - "url": "https://github.com/composer/metadata-minifier.git", - "reference": "c549d23829536f0d0e984aaabbf02af91f443207" + "url": "https://github.com/m1/Env.git", + "reference": "5c296e3e13450a207e12b343f3af1d7ab569f6f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207", - "reference": "c549d23829536f0d0e984aaabbf02af91f443207", + "url": "https://api.github.com/repos/m1/Env/zipball/5c296e3e13450a207e12b343f3af1d7ab569f6f3", + "reference": "5c296e3e13450a207e12b343f3af1d7ab569f6f3", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" + "php": ">=5.3.0" }, "require-dev": { - "composer/composer": "^2", - "phpstan/phpstan": "^0.12.55", - "symfony/phpunit-bridge": "^4.2 || ^5" + "phpunit/phpunit": "4.*", + "scrutinizer/ocular": "~1.1", + "squizlabs/php_codesniffer": "^2.3" + }, + "suggest": { + "josegonzalez/dotenv": "For loading of .env", + "m1/vars": "For loading of configs" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, "autoload": { "psr-4": { - "Composer\\MetadataMinifier\\": "src" + "M1\\Env\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -3187,928 +3185,1036 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" + "name": "Miles Croxford", + "email": "hello@milescroxford.com", + "homepage": "http://milescroxford.com", + "role": "Developer" } ], - "description": "Small utility library that handles metadata minification and expansion.", + "description": "Env is a lightweight library bringing .env file parser compatibility to PHP. In short - it enables you to read .env files with PHP.", + "homepage": "https://github.com/m1/Env", "keywords": [ - "composer", - "compression" + ".env", + "config", + "dotenv", + "env", + "loader", + "m1", + "parser", + "support" ], "support": { - "issues": "https://github.com/composer/metadata-minifier/issues", - "source": "https://github.com/composer/metadata-minifier/tree/1.0.0" + "issues": "https://github.com/m1/Env/issues", + "source": "https://github.com/m1/Env/tree/2.2.0" }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2021-04-07T13:37:33+00:00" + "time": "2020-02-19T09:02:13+00:00" }, { - "name": "composer/pcre", - "version": "3.1.1", + "name": "marc-mabe/php-enum", + "version": "v4.7.2", "source": { "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "00104306927c7a0919b4ced2aaa6782c1e61a3c9" + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/00104306927c7a0919b4ced2aaa6782c1e61a3c9", - "reference": "00104306927c7a0919b4ced2aaa6782c1e61a3c9", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "ext-reflection": "*", + "php": "^7.1 | ^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.3", - "phpstan/phpstan-strict-rules": "^1.1", - "symfony/phpunit-bridge": "^5" + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.x-dev" + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" } }, "autoload": { "psr-4": { - "Composer\\Pcre\\": "src" - } + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" } ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" ], "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.1.1" + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2023-10-11T07:11:09+00:00" + "time": "2025-09-14T11:18:39+00:00" }, { - "name": "composer/semver", - "version": "3.4.0", + "name": "myclabs/deep-copy", + "version": "1.13.4", "source": { "type": "git", - "url": "https://github.com/composer/semver.git", - "reference": "35e8d0af4486141bc745f23a29cc2091eb624a32" + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/35e8d0af4486141bc745f23a29cc2091eb624a32", - "reference": "35e8d0af4486141bc745f23a29cc2091eb624a32", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.4", - "symfony/phpunit-bridge": "^4.2 || ^5" + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - } - }, "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], "psr-4": { - "Composer\\Semver\\": "src" + "DeepCopy\\": "src/DeepCopy/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" - } - ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Create deep copies (clones) of your objects", "keywords": [ - "semantic", - "semver", - "validation", - "versioning" + "clone", + "copy", + "duplicate", + "object", + "object graph" ], "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.0" + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", "type": "tidelift" } ], - "time": "2023-08-31T09:50:34+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { - "name": "composer/spdx-licenses", - "version": "1.5.8", + "name": "nikic/php-parser", + "version": "v5.7.0", "source": { "type": "git", - "url": "https://github.com/composer/spdx-licenses.git", - "reference": "560bdcf8deb88ae5d611c80a2de8ea9d0358cc0a" + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/560bdcf8deb88ae5d611c80a2de8ea9d0358cc0a", - "reference": "560bdcf8deb88ae5d611c80a2de8ea9d0358cc0a", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" }, "require-dev": { - "phpstan/phpstan": "^0.12.55", - "symfony/phpunit-bridge": "^4.2 || ^5" + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" }, + "bin": [ + "bin/php-parse" + ], "type": "library", "extra": { "branch-alias": { - "dev-main": "1.x-dev" + "dev-master": "5.x-dev" } }, "autoload": { "psr-4": { - "Composer\\Spdx\\": "src" + "PhpParser\\": "lib/PhpParser" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" + "name": "Nikita Popov" } ], - "description": "SPDX licenses list and validation library.", + "description": "A PHP parser written in PHP", "keywords": [ - "license", - "spdx", - "validator" + "parser", + "php" ], "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/spdx-licenses/issues", - "source": "https://github.com/composer/spdx-licenses/tree/1.5.8" + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2023-11-20T07:44:33+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { - "name": "composer/xdebug-handler", - "version": "3.0.3", + "name": "phar-io/manifest", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/composer/xdebug-handler.git", - "reference": "ced299686f41dce890debac69273b47ffe98a40c" + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/ced299686f41dce890debac69273b47ffe98a40c", - "reference": "ced299686f41dce890debac69273b47ffe98a40c", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { - "composer/pcre": "^1 || ^2 || ^3", - "php": "^7.2.5 || ^8.0", - "psr/log": "^1 || ^2 || ^3" - }, - "require-dev": { - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-strict-rules": "^1.1", - "symfony/phpunit-bridge": "^6.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", - "autoload": { - "psr-4": { - "Composer\\XdebugHandler\\": "src" + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "John Stevenson", - "email": "john-stevenson@blueyonder.co.uk" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Restarts a process without Xdebug.", - "keywords": [ - "Xdebug", - "performance" - ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "irc": "irc://irc.freenode.org/composer", - "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/3.0.3" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, "funding": [ { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", + "url": "https://github.com/theseer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2022-02-25T21:32:43+00:00" + "time": "2024-03-03T12:33:53+00:00" }, { - "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.0.0", + "name": "phar-io/version", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "4be43904336affa5c2f70744a348312336afd0da" + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/4be43904336affa5c2f70744a348312336afd0da", - "reference": "4be43904336affa5c2f70744a348312336afd0da", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { - "composer-plugin-api": "^1.0 || ^2.0", - "php": ">=5.4", - "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" - }, - "require-dev": { - "composer/composer": "*", - "ext-json": "*", - "ext-zip": "*", - "php-parallel-lint/php-parallel-lint": "^1.3.1", - "phpcompatibility/php-compatibility": "^9.0", - "yoast/phpunit-polyfills": "^1.0" - }, - "type": "composer-plugin", - "extra": { - "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + "php": "^7.2 || ^8.0" }, + "type": "library", "autoload": { - "psr-4": { - "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Franck Nijhof", - "email": "franck.nijhof@dealerdirect.com", - "homepage": "http://www.frenck.nl", - "role": "Developer / IT Manager" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" }, { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "PHP_CodeSniffer Standards Composer Installer Plugin", - "homepage": "http://www.dealerdirect.com", - "keywords": [ - "PHPCodeSniffer", - "PHP_CodeSniffer", - "code quality", - "codesniffer", - "composer", - "installer", - "phpcbf", - "phpcs", - "plugin", - "qa", - "quality", - "standard", - "standards", - "style guide", - "stylecheck", - "tests" - ], + "description": "Library for handling version information and constraints", "support": { - "issues": "https://github.com/PHPCSStandards/composer-installer/issues", - "source": "https://github.com/PHPCSStandards/composer-installer" + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "time": "2023-01-05T11:28:13+00:00" + "time": "2022-02-21T01:04:05+00:00" }, { - "name": "doctrine/instantiator", - "version": "2.0.0", + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", "source": { "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^7.4 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5.27", - "vimeo/psalm": "^5.4" + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + "PHPStan\\PhpDocParser\\": [ + "src/" + ] } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "14.1.9", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "655533a65696bbc4231cd8027af150dadc40ec88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/655533a65696bbc4231cd8027af150dadc40ec88", + "reference": "655533a65696bbc4231cd8027af150dadc40ec88", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "14.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], "authors": [ { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "constructor", - "instantiate" + "coverage", + "testing", + "xunit" ], "support": { - "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.1.9" }, "funding": [ { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" + "url": "https://github.com/sebastianbergmann", + "type": "github" }, { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", "type": "tidelift" } ], - "time": "2022-12-30T00:23:10+00:00" + "time": "2026-05-16T05:16:14+00:00" }, { - "name": "doctrine/sql-formatter", - "version": "1.2.0", + "name": "phpunit/php-file-iterator", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/doctrine/sql-formatter.git", - "reference": "a321d114e0a18e6497f8a2cd6f890e000cc17ecc" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/a321d114e0a18e6497f8a2cd6f890e000cc17ecc", - "reference": "a321d114e0a18e6497f8a2cd6f890e000cc17ecc", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": ">=8.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.4" + "phpunit/phpunit": "^13.0" }, - "bin": [ - "bin/sql-formatter" - ], "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\SqlFormatter\\": "src" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Jeremy Dorn", - "email": "jeremy@jeremydorn.com", - "homepage": "https://jeremydorn.com/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "a PHP SQL highlighting library", - "homepage": "https://github.com/doctrine/sql-formatter/", + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", "keywords": [ - "highlight", - "sql" + "filesystem", + "iterator" ], "support": { - "issues": "https://github.com/doctrine/sql-formatter/issues", - "source": "https://github.com/doctrine/sql-formatter/tree/1.2.0" + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" }, - "time": "2023-08-16T21:49:04+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" }, { - "name": "jasny/twig-extensions", - "version": "v1.3.0", + "name": "phpunit/php-invoker", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/jasny/twig-extensions.git", - "reference": "a694eb02f6fc14ff8e2fceb8b80882c0c926102b" + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jasny/twig-extensions/zipball/a694eb02f6fc14ff8e2fceb8b80882c0c926102b", - "reference": "a694eb02f6fc14ff8e2fceb8b80882c0c926102b", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", "shasum": "" }, "require": { - "php": ">=7.0.0", - "twig/twig": "^2.0 | ^3.0" + "php": ">=8.4" }, "require-dev": { - "ext-intl": "*", - "ext-pcre": "*", - "jasny/php-code-quality": "^2.5", - "php": ">=7.2.0" + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" }, "suggest": { - "ext-intl": "Required for the use of the LocalDate Twig extension", - "ext-pcre": "Required for the use of the PCRE Twig extension" + "ext-pcntl": "*" }, "type": "library", - "autoload": { - "psr-4": { - "Jasny\\Twig\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Arnold Daniels", - "email": "arnold@jasny.net", - "homepage": "http://www.jasny.net" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "A set of useful Twig filters", - "homepage": "http://github.com/jasny/twig-extensions#README", + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", "keywords": [ - "PCRE", - "array", - "date", - "datetime", - "preg", - "regex", - "templating", - "text", - "time" + "process" ], "support": { - "issues": "https://github.com/jasny/twig-extensions/issues", - "source": "https://github.com/jasny/twig-extensions" + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" }, - "time": "2019-12-10T16:04:23+00:00" - }, - { - "name": "josegonzalez/dotenv", - "version": "3.2.0", + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/josegonzalez/php-dotenv.git", - "reference": "f19174d9d7213a6c20e8e5e268aa7dd042d821ca" + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/josegonzalez/php-dotenv/zipball/f19174d9d7213a6c20e8e5e268aa7dd042d821ca", - "reference": "f19174d9d7213a6c20e8e5e268aa7dd042d821ca", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", "shasum": "" }, "require": { - "m1/env": "2.*", - "php": ">=5.5.0" + "php": ">=8.4" }, "require-dev": { - "php-mock/php-mock-phpunit": "^1.1", - "satooshi/php-coveralls": "1.*", - "squizlabs/php_codesniffer": "2.*" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-0": { - "josegonzalez\\Dotenv": [ - "src", - "tests" - ] + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Jose Diaz-Gonzalez", - "email": "dotenv@josegonzalez.com", - "homepage": "http://josediazgonzalez.com", - "role": "Maintainer" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "dotenv file parsing for PHP", - "homepage": "https://github.com/josegonzalez/php-dotenv", + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", "keywords": [ - "configuration", - "dotenv", - "php" + "template" ], "support": { - "issues": "https://github.com/josegonzalez/php-dotenv/issues", - "source": "https://github.com/josegonzalez/php-dotenv/tree/master" + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" }, - "time": "2017-09-19T15:49:58+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" }, { - "name": "justinrainbow/json-schema", - "version": "v5.2.13", + "name": "phpunit/php-timer", + "version": "9.0.0", "source": { "type": "git", - "url": "https://github.com/justinrainbow/json-schema.git", - "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793" + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/fbbe7e5d79f618997bc3332a6f49246036c45793", - "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=8.4" }, "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" + "phpunit/phpunit": "^13.0" }, - "bin": [ - "bin/validate-json" - ], "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0.x-dev" + "dev-main": "9.0-dev" } }, "autoload": { - "psr-4": { - "JsonSchema\\": "src/JsonSchema/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" }, { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" }, { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" } ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", - "keywords": [ - "json", - "schema" - ], - "support": { - "issues": "https://github.com/justinrainbow/json-schema/issues", - "source": "https://github.com/justinrainbow/json-schema/tree/v5.2.13" - }, - "time": "2023-09-26T02:20:38+00:00" + "time": "2026-02-06T04:37:53+00:00" }, { - "name": "m1/env", - "version": "2.2.0", + "name": "phpunit/phpunit", + "version": "13.1.11", "source": { "type": "git", - "url": "https://github.com/m1/Env.git", - "reference": "5c296e3e13450a207e12b343f3af1d7ab569f6f3" + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0f540976373361d1b4549adcb87913ce2116e904" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/m1/Env/zipball/5c296e3e13450a207e12b343f3af1d7ab569f6f3", - "reference": "5c296e3e13450a207e12b343f3af1d7ab569f6f3", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0f540976373361d1b4549adcb87913ce2116e904", + "reference": "0f540976373361d1b4549adcb87913ce2116e904", "shasum": "" }, "require": { - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "4.*", - "scrutinizer/ocular": "~1.1", - "squizlabs/php_codesniffer": "^2.3" - }, - "suggest": { - "josegonzalez/dotenv": "For loading of .env", - "m1/vars": "For loading of configs" + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.1.9", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.2.1", + "sebastian/diff": "^8.3.0", + "sebastian/environment": "^9.3.1", + "sebastian/exporter": "^8.1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.0", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" }, + "bin": [ + "phpunit" + ], "type": "library", - "autoload": { - "psr-4": { - "M1\\Env\\": "src" + "extra": { + "branch-alias": { + "dev-main": "13.1-dev" } }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Miles Croxford", - "email": "hello@milescroxford.com", - "homepage": "http://milescroxford.com", - "role": "Developer" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Env is a lightweight library bringing .env file parser compatibility to PHP. In short - it enables you to read .env files with PHP.", - "homepage": "https://github.com/m1/Env", + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", "keywords": [ - ".env", - "config", - "dotenv", - "env", - "loader", - "m1", - "parser", - "support" + "phpunit", + "testing", + "xunit" ], "support": { - "issues": "https://github.com/m1/Env/issues", - "source": "https://github.com/m1/Env/tree/2.2.0" + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.11" }, - "time": "2020-02-19T09:02:13+00:00" + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-05-21T12:38:47+00:00" }, { - "name": "myclabs/deep-copy", - "version": "1.11.1", + "name": "psy/psysh", + "version": "v0.12.23", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "url": "https://github.com/bobthecow/psysh.git", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" }, "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." }, + "bin": [ + "bin/psysh" + ], "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, "autoload": { "files": [ - "src/DeepCopy/deep_copy.php" + "src/functions.php" ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" + "Psy\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "REPL", + "console", + "interactive", + "shell" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2026-05-23T13:41:31+00:00" }, { - "name": "nikic/php-parser", - "version": "v4.18.0", + "name": "react/promise", + "version": "v3.3.0", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "1bcbb2179f97633e98bbbc87044ee2611c7d7999" + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/1bcbb2179f97633e98bbbc87044ee2611c7d7999", - "reference": "1bcbb2179f97633e98bbbc87044ee2611c7d7999", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", "shasum": "" }, "require": { - "ext-tokenizer": "*", - "php": ">=7.0" + "php": ">=7.1.0" }, "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" }, - "bin": [ - "bin/php-parse" - ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.9-dev" - } - }, "autoload": { + "files": [ + "src/functions_include.php" + ], "psr-4": { - "PhpParser\\": "lib/PhpParser" + "React\\Promise\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Nikita Popov" + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "A PHP parser written in PHP", + "description": "A lightweight implementation of CommonJS Promises/A for PHP", "keywords": [ - "parser", - "php" + "promise", + "promises" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.18.0" + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" }, - "time": "2023-12-10T21:03:43+00:00" + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" }, { - "name": "phar-io/manifest", - "version": "2.0.4", + "name": "sebastian/cli-parser", + "version": "5.0.0", "source": { "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "54750ef60c58e43759730615a392c31c80e23176" + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", - "reference": "54750ef60c58e43759730615a392c31c80e23176", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -4121,53 +4227,72 @@ "BSD-3-Clause" ], "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de", - "role": "Developer" + "role": "lead" } ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.4" + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" }, "funding": [ { - "url": "https://github.com/theseer", + "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2024-03-03T12:33:53+00:00" + "time": "2026-02-06T04:39:44+00:00" }, { - "name": "phar-io/version", - "version": "3.2.1", + "name": "sebastian/comparator", + "version": "8.2.1", "source": { "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/ce999bf08b2c387a5423fe56961c32eed3f88089", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^8.3", + "sebastian/exporter": "^8.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.2-dev" + } + }, "autoload": { "classmap": [ "src/" @@ -4179,115 +4304,79 @@ ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" } ], - "description": "Library for handling version information and constraints", + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" - }, - "time": "2022-02-21T01:04:05+00:00" - }, - { - "name": "phpstan/phpdoc-parser", - "version": "1.26.0", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "231e3186624c03d7e7c890ec662b81e6b0405227" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/231e3186624c03d7e7c890ec662b81e6b0405227", - "reference": "231e3186624c03d7e7c890ec662b81e6b0405227", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^4.15", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.5", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5", - "symfony/process": "^5.2" + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.2.1" }, - "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.26.0" - }, - "time": "2024-02-23T16:05:55+00:00" + "time": "2026-05-21T04:46:40+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "9.2.31", + "name": "sebastian/complexity", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "48c34b5d8d983006bd2adc2d0de92963b9155965" + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/48c34b5d8d983006bd2adc2d0de92963b9155965", - "reference": "48c34b5d8d983006bd2adc2d0de92963b9155965", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-xmlwriter": "*", - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.3", - "phpunit/php-text-template": "^2.0.2", - "sebastian/code-unit-reverse-lookup": "^2.0.2", - "sebastian/complexity": "^2.0", - "sebastian/environment": "^5.1.2", - "sebastian/lines-of-code": "^1.0.3", - "sebastian/version": "^3.0.1", - "theseer/tokenizer": "^1.2.0" + "nikic/php-parser": "^5.0", + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-pcov": "PHP extension that provides line coverage", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "9.2-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -4306,50 +4395,58 @@ "role": "lead" } ], - "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", - "homepage": "https://github.com/sebastianbergmann/php-code-coverage", - "keywords": [ - "coverage", - "testing", - "xunit" - ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", "support": { - "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.31" + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" } ], - "time": "2024-03-02T06:37:42+00:00" + "time": "2026-02-06T04:41:32+00:00" }, { - "name": "phpunit/php-file-iterator", - "version": "3.0.6", + "name": "sebastian/diff", + "version": "8.3.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "8.3-dev" } }, "autoload": { @@ -4364,56 +4461,73 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" } ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "filesystem", - "iterator" + "diff", + "udiff", + "unidiff", + "unified diff" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2021-12-02T12:48:52+00:00" + "time": "2026-05-15T04:58:09+00:00" }, { - "name": "phpunit/php-invoker", - "version": "3.1.1", + "name": "sebastian/environment", + "version": "9.3.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a15fa79a5f5cfd0e9f6817dbcdb0048e99efa146" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a15fa79a5f5cfd0e9f6817dbcdb0048e99efa146", + "reference": "a15fa79a5f5cfd0e9f6817dbcdb0048e99efa146", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4" }, "require-dev": { - "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^13.1.10" }, "suggest": { - "ext-pcntl": "*" + "ext-posix": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-main": "9.3-dev" } }, "autoload": { @@ -4428,51 +4542,67 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "Invoke callables with a timeout", - "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ - "process" + "Xdebug", + "environment", + "hhvm" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2026-05-21T08:47:00+00:00" }, { - "name": "phpunit/php-text-template", - "version": "2.0.4", + "name": "sebastian/exporter", + "version": "8.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", "shasum": "" }, "require": { - "php": ">=7.3" + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^13.1.10" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -4487,51 +4617,80 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" } ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", "keywords": [ - "template" + "export", + "exporter" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2026-05-21T11:50:56+00:00" }, { - "name": "phpunit/php-timer", - "version": "5.0.3", + "name": "sebastian/git-state", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "1.0-dev" } }, "autoload": { @@ -4550,83 +4709,63 @@ "role": "lead" } ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", - "keywords": [ - "timer" - ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", "support": { - "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2026-03-21T12:54:28+00:00" }, { - "name": "phpunit/phpunit", - "version": "9.6.17", + "name": "sebastian/global-state", + "version": "9.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "1a156980d78a6666721b7e8e8502fe210b587fcd" + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1a156980d78a6666721b7e8e8502fe210b587fcd", - "reference": "1a156980d78a6666721b7e8e8502fe210b587fcd", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.3.1 || ^2", - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-xml": "*", - "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.10.1", - "phar-io/manifest": "^2.0.3", - "phar-io/version": "^3.0.2", - "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.28", - "phpunit/php-file-iterator": "^3.0.5", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.3", - "phpunit/php-timer": "^5.0.2", - "sebastian/cli-parser": "^1.0.1", - "sebastian/code-unit": "^1.0.6", - "sebastian/comparator": "^4.0.8", - "sebastian/diff": "^4.0.3", - "sebastian/environment": "^5.1.3", - "sebastian/exporter": "^4.0.5", - "sebastian/global-state": "^5.0.1", - "sebastian/object-enumerator": "^4.0.3", - "sebastian/resource-operations": "^3.0.3", - "sebastian/type": "^3.2", - "sebastian/version": "^3.0.2" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.0" }, - "bin": [ - "phpunit" - ], "type": "library", "extra": { "branch-alias": { - "dev-master": "9.6-dev" + "dev-main": "9.0-dev" } }, "autoload": { - "files": [ - "src/Framework/Assert/Functions.php" - ], "classmap": [ "src/" ] @@ -4638,214 +4777,203 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ - "phpunit", - "testing", - "xunit" + "global state" ], "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.17" + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0" }, "funding": [ - { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, { "url": "https://github.com/sebastianbergmann", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", "type": "tidelift" } ], - "time": "2024-02-23T13:14:51+00:00" + "time": "2026-02-06T04:45:13+00:00" }, { - "name": "psy/psysh", - "version": "v0.12.0", + "name": "sebastian/lines-of-code", + "version": "5.0.1", "source": { "type": "git", - "url": "https://github.com/bobthecow/psysh.git", - "reference": "750bf031a48fd07c673dbe3f11f72362ea306d0d" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/750bf031a48fd07c673dbe3f11f72362ea306d0d", - "reference": "750bf031a48fd07c673dbe3f11f72362ea306d0d", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", "shasum": "" }, "require": { - "ext-json": "*", - "ext-tokenizer": "*", - "nikic/php-parser": "^5.0 || ^4.0", - "php": "^8.0 || ^7.4", - "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" - }, - "conflict": { - "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + "nikic/php-parser": "^5.7.0", + "php": ">=8.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2" + "phpunit/phpunit": "^13.1.10" }, - "suggest": { - "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", - "ext-pdo-sqlite": "The doc command requires SQLite to work.", - "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." - }, - "bin": [ - "bin/psysh" - ], "type": "library", "extra": { "branch-alias": { - "dev-main": "0.12.x-dev" - }, - "bamarni-bin": { - "bin-links": false, - "forward-command": false + "dev-main": "5.0-dev" } }, "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Psy\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "An interactive shell for modern PHP.", - "homepage": "http://psysh.org", - "keywords": [ - "REPL", - "console", - "interactive", - "shell" - ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { - "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.0" + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" }, - "time": "2023-12-20T15:28:09+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:23:37+00:00" }, { - "name": "react/promise", - "version": "v3.1.0", + "name": "sebastian/object-enumerator", + "version": "8.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "e563d55d1641de1dea9f5e84f3cccc66d2bfe02c" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/e563d55d1641de1dea9f5e84f3cccc66d2bfe02c", - "reference": "e563d55d1641de1dea9f5e84f3cccc66d2bfe02c", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", "shasum": "" }, "require": { - "php": ">=7.1.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpstan/phpstan": "1.10.39 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", - "keywords": [ - "promise", - "promises" - ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.1.0" + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" } ], - "time": "2023-11-16T16:21:57+00:00" + "time": "2026-02-06T04:46:36+00:00" }, { - "name": "sebastian/cli-parser", - "version": "1.0.2", + "name": "sebastian/object-reflector", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -4860,48 +4988,60 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "Library for parsing CLI options", - "homepage": "https://github.com/sebastianbergmann/cli-parser", + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" } ], - "time": "2024-03-02T06:27:43+00:00" + "time": "2026-02-06T04:47:13+00:00" }, { - "name": "sebastian/code-unit", - "version": "1.0.8", + "name": "sebastian/recursion-context", + "version": "8.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -4916,48 +5056,68 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2020-10-26T13:08:54+00:00" + "time": "2026-02-06T04:51:28+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", + "name": "sebastian/type", + "version": "7.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fee0309275847fefd7636167085e379c1dbf6990" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^13.1.10" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -4972,49 +5132,58 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2026-05-20T06:49:11+00:00" }, { - "name": "sebastian/comparator", - "version": "4.0.8", + "name": "sebastian/version", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "php": ">=8.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -5029,791 +5198,921 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", - "keywords": [ - "comparator", - "compare", - "equality" - ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { - "name": "sebastian/complexity", - "version": "2.0.3", + "name": "seld/jsonlint", + "version": "1.11.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + "url": "https://github.com/Seldaek/jsonlint.git", + "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/1748aaf847fc731cfad7725aec413ee46f0cc3a2", + "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "php": "^5.3 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpstan/phpstan": "^1.11", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" }, + "bin": [ + "bin/jsonlint" + ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Seld\\JsonLint\\": "src/Seld/JsonLint/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" } ], - "description": "Library for calculating the complexity of PHP code units", - "homepage": "https://github.com/sebastianbergmann/complexity", + "description": "JSON Linter", + "keywords": [ + "json", + "linter", + "parser", + "validator" + ], "support": { - "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + "issues": "https://github.com/Seldaek/jsonlint/issues", + "source": "https://github.com/Seldaek/jsonlint/tree/1.11.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/Seldaek", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", + "type": "tidelift" } ], - "time": "2023-12-22T06:19:30+00:00" + "time": "2024-07-11T14:55:45+00:00" }, { - "name": "sebastian/diff", - "version": "4.0.6", + "name": "seld/phar-utils", + "version": "1.2.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + "url": "https://github.com/Seldaek/phar-utils.git", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", "shasum": "" }, "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3", - "symfony/process": "^4.2 || ^5" + "php": ">=5.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "1.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Seld\\PharUtils\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", + "description": "PHAR file format utilities, for when PHP phars you up", "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "phar" ], "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + "issues": "https://github.com/Seldaek/phar-utils/issues", + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-03-02T06:30:58+00:00" + "time": "2022-08-31T10:31:18+00:00" }, { - "name": "sebastian/environment", - "version": "5.1.5", + "name": "seld/signal-handler", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + "url": "https://github.com/Seldaek/signal-handler.git", + "reference": "04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "url": "https://api.github.com/repos/Seldaek/signal-handler/zipball/04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98", + "reference": "04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=7.2.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-posix": "*" + "phpstan/phpstan": "^1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^7.5.20 || ^8.5.23", + "psr/log": "^1 || ^2 || ^3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-main": "2.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Seld\\Signal\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" } ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "description": "Simple unix signal handler that silently fails where signals are not supported for easy cross-platform development", "keywords": [ - "Xdebug", - "environment", - "hhvm" + "posix", + "sigint", + "signal", + "sigterm", + "unix" ], "support": { - "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + "issues": "https://github.com/Seldaek/signal-handler/issues", + "source": "https://github.com/Seldaek/signal-handler/tree/2.0.2" }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2023-02-03T06:03:51+00:00" + "time": "2023-09-03T09:24:00+00:00" }, { - "name": "sebastian/exporter", - "version": "4.0.6", + "name": "slevomat/coding-standard", + "version": "8.29.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "url": "https://github.com/slevomat/coding-standard.git", + "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", + "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" + "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.2.1", + "php": "^7.4 || ^8.0", + "phpstan/phpdoc-parser": "^2.3.2", + "squizlabs/php_codesniffer": "^4.0.1" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "phing/phing": "3.0.1|3.1.2", + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/phpstan": "2.1.54", + "phpstan/phpstan-deprecation-rules": "2.0.4", + "phpstan/phpstan-phpunit": "2.0.16", + "phpstan/phpstan-strict-rules": "2.0.11", + "phpunit/phpunit": "9.6.34|10.5.63|11.4.4|11.5.55|12.5.24" }, - "type": "library", + "type": "phpcodesniffer-standard", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "8.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "SlevomatCodingStandard\\": "SlevomatCodingStandard/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, + "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", + "keywords": [ + "dev", + "phpcs" + ], + "support": { + "issues": "https://github.com/slevomat/coding-standard/issues", + "source": "https://github.com/slevomat/coding-standard/tree/8.29.0" + }, + "funding": [ { - "name": "Volker Dusch", - "email": "github@wallbash.com" + "url": "https://github.com/kukulich", + "type": "github" }, { - "name": "Adam Harvey", - "email": "aharvey@php.net" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "https://www.github.com/sebastianbergmann/exporter", - "keywords": [ - "export", - "exporter" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2026-05-07T05:48:08+00:00" }, { - "name": "sebastian/global-state", - "version": "5.0.7", + "name": "squizlabs/php_codesniffer", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0525c73950de35ded110cffafb9892946d7771b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", + "reference": "0525c73950de35ded110cffafb9892946d7771b5", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=7.2.0" }, "require-dev": { - "ext-dom": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-uopz": "*" + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], - "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ - "global state" + "phpcs", + "standards", + "static analysis" ], "support": { - "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2025-11-10T16:43:36+00:00" }, { - "name": "sebastian/lines-of-code", - "version": "1.0.4", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, "autoload": { "classmap": [ - "src/" + "lib/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" ], - "description": "Library for counting the lines of code in PHP source code", - "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { - "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/staabm", "type": "github" } ], - "time": "2023-12-22T06:20:34+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { - "name": "sebastian/object-enumerator", - "version": "4.0.4", + "name": "symfony/console", + "version": "v8.0.11", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "url": "https://github.com/symfony/console.git", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "source": "https://github.com/symfony/console/tree/v8.0.11" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { - "name": "sebastian/object-reflector", - "version": "2.0.4", + "name": "symfony/filesystem", + "version": "v8.0.11", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "url": "https://github.com/symfony/filesystem.git", + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "symfony/process": "^7.4|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "source": "https://github.com/symfony/filesystem/tree/v8.0.11" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2026-05-11T16:39:47+00:00" }, { - "name": "sebastian/recursion-context", - "version": "4.0.5", + "name": "symfony/finder", + "version": "v8.0.8", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "url": "https://github.com/symfony/finder.git", + "reference": "8da41214757b87d97f181e3d14a4179286151007" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", + "reference": "8da41214757b87d97f181e3d14a4179286151007", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Adam Harvey", - "email": "aharvey@php.net" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "source": "https://github.com/symfony/finder/tree/v8.0.8" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-02-03T06:07:39+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { - "name": "sebastian/resource-operations", - "version": "3.0.3", + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", - "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=7.2" }, - "require-dev": { - "phpunit/phpunit": "^9.0" + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], "support": { - "issues": "https://github.com/sebastianbergmann/resource-operations/issues", - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-09-28T06:45:17+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "sebastian/type", - "version": "3.2.1", + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=7.2" }, - "require-dev": { - "phpunit/phpunit": "^9.5" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.2-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { - "name": "sebastian/version", - "version": "3.0.2", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { - "name": "seld/jsonlint", - "version": "1.10.2", + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "9bb7db07b5d66d90f6ebf542f09fc67d800e5259" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9bb7db07b5d66d90f6ebf542f09fc67d800e5259", - "reference": "9bb7db07b5d66d90f6ebf542f09fc67d800e5259", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { - "php": "^5.3 || ^7.0 || ^8.0" + "ext-iconv": "*", + "php": ">=7.2" }, - "require-dev": { - "phpstan/phpstan": "^1.5", - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" }, - "bin": [ - "bin/jsonlint" - ], "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Seld\\JsonLint\\": "src/Seld/JsonLint/" + "Symfony\\Polyfill\\Mbstring\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -5822,61 +6121,80 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "https://seld.be" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "JSON Linter", + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", "keywords": [ - "json", - "linter", - "parser", - "validator" + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.10.2" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { - "url": "https://github.com/Seldaek", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-02-07T12:57:50+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "seld/phar-utils", - "version": "1.2.1", + "name": "symfony/polyfill-php73", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/Seldaek/phar-utils.git", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb", + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb", "shasum": "" }, "require": { - "php": ">=5.3" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "1.x-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Seld\\PharUtils\\": "src/" - } + "Symfony\\Polyfill\\Php73\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5884,253 +6202,162 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be" - } - ], - "description": "PHAR file format utilities, for when PHP phars you up", - "keywords": [ - "phar" - ], - "support": { - "issues": "https://github.com/Seldaek/phar-utils/issues", - "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" - }, - "time": "2022-08-31T10:31:18+00:00" - }, - { - "name": "seld/signal-handler", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/Seldaek/signal-handler.git", - "reference": "04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Seldaek/signal-handler/zipball/04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98", - "reference": "04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98", - "shasum": "" - }, - "require": { - "php": ">=7.2.0" - }, - "require-dev": { - "phpstan/phpstan": "^1", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^7.5.20 || ^8.5.23", - "psr/log": "^1 || ^2 || ^3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "Seld\\Signal\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "Simple unix signal handler that silently fails where signals are not supported for easy cross-platform development", - "keywords": [ - "posix", - "sigint", - "signal", - "sigterm", - "unix" - ], - "support": { - "issues": "https://github.com/Seldaek/signal-handler/issues", - "source": "https://github.com/Seldaek/signal-handler/tree/2.0.2" - }, - "time": "2023-09-03T09:24:00+00:00" - }, - { - "name": "slevomat/coding-standard", - "version": "8.14.1", - "source": { - "type": "git", - "url": "https://github.com/slevomat/coding-standard.git", - "reference": "fea1fd6f137cc84f9cba0ae30d549615dbc6a926" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/fea1fd6f137cc84f9cba0ae30d549615dbc6a926", - "reference": "fea1fd6f137cc84f9cba0ae30d549615dbc6a926", - "shasum": "" - }, - "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0", - "php": "^7.2 || ^8.0", - "phpstan/phpdoc-parser": "^1.23.1", - "squizlabs/php_codesniffer": "^3.7.1" - }, - "require-dev": { - "phing/phing": "2.17.4", - "php-parallel-lint/php-parallel-lint": "1.3.2", - "phpstan/phpstan": "1.10.37", - "phpstan/phpstan-deprecation-rules": "1.1.4", - "phpstan/phpstan-phpunit": "1.3.14", - "phpstan/phpstan-strict-rules": "1.5.1", - "phpunit/phpunit": "8.5.21|9.6.8|10.3.5" - }, - "type": "phpcodesniffer-standard", - "extra": { - "branch-alias": { - "dev-master": "8.x-dev" - } - }, - "autoload": { - "psr-4": { - "SlevomatCodingStandard\\": "SlevomatCodingStandard/" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" ], - "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", "keywords": [ - "dev", - "phpcs" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.14.1" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.37.0" }, "funding": [ { - "url": "https://github.com/kukulich", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-10-08T07:28:08+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { - "name": "squizlabs/php_codesniffer", - "version": "3.9.0", + "name": "symfony/polyfill-php80", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "d63cee4890a8afaf86a22e51ad4d97c91dd4579b" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/d63cee4890a8afaf86a22e51ad4d97c91dd4579b", - "reference": "d63cee4890a8afaf86a22e51ad4d97c91dd4579b", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { - "ext-simplexml": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + "php": ">=7.2" }, - "bin": [ - "bin/phpcbf", - "bin/phpcs" - ], "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.x-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Greg Sherwood", - "role": "Former lead" + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" }, { - "name": "Juliette Reinders Folmer", - "role": "Current lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", "keywords": [ - "phpcs", - "standards", - "static analysis" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", - "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", - "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { - "url": "https://github.com/PHPCSStandards", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://github.com/jrfnl", + "url": "https://github.com/nicolas-grekas", "type": "github" }, { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-02-16T15:06:51+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/finder", - "version": "v7.0.0", + "name": "symfony/polyfill-php81", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "6e5688d69f7cfc4ed4a511e96007e06c2d34ce56" + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/6e5688d69f7cfc4ed4a511e96007e06c2d34ce56", - "reference": "6e5688d69f7cfc4ed4a511e96007e06c2d34ce56", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Finder\\": "" + "Symfony\\Polyfill\\Php81\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6139,18 +6366,24 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/finder/tree/v7.0.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0" }, "funding": [ { @@ -6161,35 +6394,39 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-10-31T17:59:56+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/polyfill-php73", - "version": "v1.29.0", + "name": "symfony/polyfill-php84", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "21bd091060673a1177ae842c0ef8fe30893114d2" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/21bd091060673a1177ae842c0ef8fe30893114d2", - "reference": "21bd091060673a1177ae842c0ef8fe30893114d2", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6197,7 +6434,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, "classmap": [ "Resources/stubs" @@ -6217,7 +6454,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -6226,7 +6463,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" }, "funding": [ { @@ -6237,46 +6474,41 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { - "name": "symfony/polyfill-php80", - "version": "v1.29.0", + "name": "symfony/process", + "version": "v8.0.11", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b" + "url": "https://github.com/symfony/process.git", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", - "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.4" }, "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" + "Symfony\\Component\\Process\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6285,28 +6517,18 @@ ], "authors": [ { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.29.0" + "source": "https://github.com/symfony/process/tree/v8.0.11" }, "funding": [ { @@ -6317,46 +6539,55 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2026-05-11T16:56:32+00:00" }, { - "name": "symfony/polyfill-php81", - "version": "v1.29.0", + "name": "symfony/service-contracts", + "version": "v3.7.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "c565ad1e63f30e7477fc40738343c62b40bc672d" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/c565ad1e63f30e7477fc40738343c62b40bc672d", - "reference": "c565ad1e63f30e7477fc40738343c62b40bc672d", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" + "Symfony\\Contracts\\Service\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6373,16 +6604,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "description": "Generic abstractions related to writing services", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.29.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -6393,34 +6626,55 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { - "name": "symfony/process", - "version": "v7.2.5", + "name": "symfony/string", + "version": "v8.0.11", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "87b7c93e57df9d8e39a093d32587702380ff045d" + "url": "https://github.com/symfony/string.git", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/87b7c93e57df9d8e39a093d32587702380ff045d", - "reference": "87b7c93e57df9d8e39a093d32587702380ff045d", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { + "files": [ + "Resources/functions.php" + ], "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Component\\String\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -6432,18 +6686,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Executes commands in sub-processes", + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], "support": { - "source": "https://github.com/symfony/process/tree/v7.2.5" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -6454,41 +6716,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-13T12:21:46+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "e03ad7c1535e623edbb94c22cc42353e488c6670" + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e03ad7c1535e623edbb94c22cc42353e488c6670", - "reference": "e03ad7c1535e623edbb94c22cc42353e488c6670", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" }, "require-dev": { - "ext-iconv": "*", - "symfony/console": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", - "twig/twig": "^3.0.4" + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12" }, "bin": [ "Resources/bin/var-dump-server" @@ -6526,7 +6792,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.0.4" + "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" }, "funding": [ { @@ -6537,32 +6803,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-02-15T11:33:06+00:00" + "time": "2026-03-31T07:15:36+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -6584,7 +6854,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -6592,35 +6862,39 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-12-08T11:19:18+00:00" }, { "name": "twig/markdown-extra", - "version": "v3.8.0", + "version": "v3.26.0", "source": { "type": "git", "url": "https://github.com/twigphp/markdown-extra.git", - "reference": "b6e4954ab60030233df5d293886b5404558daac8" + "reference": "e3f3fd0836eb6c39457da22c8a76abaac62692b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/b6e4954ab60030233df5d293886b5404558daac8", - "reference": "b6e4954ab60030233df5d293886b5404558daac8", + "url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/e3f3fd0836eb6c39457da22c8a76abaac62692b9", + "reference": "e3f3fd0836eb6c39457da22c8a76abaac62692b9", "shasum": "" }, "require": { - "php": ">=7.2.5", - "twig/twig": "^3.0" + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "twig/twig": "^3.13|^4.0" }, "require-dev": { - "erusev/parsedown": "^1.7", - "league/commonmark": "^1.0|^2.0", + "erusev/parsedown": "dev-master as 1.x-dev", + "league/commonmark": "^2.7", "league/html-to-markdown": "^4.8|^5.0", "michelf/php-markdown": "^1.8|^2.0", "symfony/phpunit-bridge": "^6.4|^7.0" }, "type": "library", "autoload": { + "files": [ + "Resources/functions.php" + ], "psr-4": { "Twig\\Extra\\Markdown\\": "" }, @@ -6648,7 +6922,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/markdown-extra/tree/v3.8.0" + "source": "https://github.com/twigphp/markdown-extra/tree/v3.26.0" }, "funding": [ { @@ -6660,20 +6934,20 @@ "type": "tidelift" } ], - "time": "2023-11-21T14:02:01+00:00" + "time": "2026-05-15T13:14:02+00:00" }, { "name": "twig/twig", - "version": "v3.20.0", + "version": "v3.27.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "3468920399451a384bef53cf7996965f7cd40183" + "reference": "04ae1bfe9463c816cf72ca0abe7eae2c77a9a9ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/3468920399451a384bef53cf7996965f7cd40183", - "reference": "3468920399451a384bef53cf7996965f7cd40183", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/04ae1bfe9463c816cf72ca0abe7eae2c77a9a9ed", + "reference": "04ae1bfe9463c816cf72ca0abe7eae2c77a9a9ed", "shasum": "" }, "require": { @@ -6683,7 +6957,8 @@ "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { - "phpstan/phpstan": "^2.0", + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", "psr/container": "^1.0|^2.0", "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, @@ -6727,7 +7002,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.20.0" + "source": "https://github.com/twigphp/Twig/tree/v3.27.0" }, "funding": [ { @@ -6739,19 +7014,20 @@ "type": "tidelift" } ], - "time": "2025-02-13T08:34:43+00:00" + "time": "2026-05-27T13:05:51+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "psy/psysh": 0 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": ">=8.0" + "php": ">=8.2", + "ext-intl": "*", + "ext-json": "*", + "ext-mbstring": "*" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/app/config/app.php b/app/config/app.php index 58739ebd2..e666069b8 100644 --- a/app/config/app.php +++ b/app/config/app.php @@ -25,8 +25,17 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ +use Cake\Cache\Engine\FileEngine; +use Cake\Database\Connection; +use Cake\Database\Driver\Mysql; +use Cake\Log\Engine\FileLog; +use Cake\Log\Engine\ConsoleLog; +use Cake\Console\ConsoleOutput; +use Cake\Mailer\Transport\MailTransport; +use function Cake\Core\env; + return [ - /** + /* * Debug Level: * * Production Mode: @@ -37,14 +46,14 @@ */ 'debug' => filter_var(env('DEBUG', false), FILTER_VALIDATE_BOOLEAN), - /** + /* * Configure basic information about the application. * * - namespace - The namespace to find app classes under. * - defaultLocale - The default locale for translation, formatting currencies and numbers, date and time. * - encoding - The encoding used for HTML + database connections. * - base - The base directory the app resides in. If false this - * will be auto detected. + * will be auto-detected. * - dir - Name of app directory. * - webroot - The webroot directory. * - wwwRoot - The file path to webroot. @@ -58,10 +67,10 @@ * CakePHP generates required value based on `HTTP_HOST` environment variable. * However, you can define it manually to optimize performance or if you * are concerned about people manipulating the `Host` header. - * - imageBaseUrl - Web path to the public images directory under webroot. - * - cssBaseUrl - Web path to the public css directory under webroot. - * - jsBaseUrl - Web path to the public js directory under webroot. - * - paths - Configure paths for non class based resources. Supports the + * - imageBaseUrl - Web path to the public images/ directory under webroot. + * - cssBaseUrl - Web path to the public css/ directory under webroot. + * - jsBaseUrl - Web path to the public js/ directory under webroot. + * - paths - Configure paths for non class-based resources. Supports the * `plugins`, `templates`, `locales` subkeys, which allow the definition of * paths for plugins, view templates and locale files respectively. */ @@ -89,11 +98,11 @@ LOCAL . DS . 'plugins' . DS ], 'templates' => [ROOT . DS . 'templates' . DS], - 'locales' => [ROOT . DS . 'resources' . DS . 'locales' . DS], + 'locales' => [RESOURCES . 'locales' . DS], ], ], - /** + /* * Security and encryption configuration * * - salt - A random string used in security hashing methods. @@ -101,11 +110,11 @@ * You should treat it as extremely sensitive data. */ 'Security' => [ - // Note that we (COmanage) override this in bootstrap.php - //'salt' => env('SECURITY_SALT', 'd6ded009aad8fe73e7ebf4f9c170e39e3b0ed0ab9253ed3eb4db03ad6fc07ab4'), + // Note that we (COmanage) override this in bootstrap.php + //'salt' => env('SECURITY_SALT', 'd6ded009aad8fe73e7ebf4f9c170e39e3b0ed0ab9253ed3eb4db03ad6fc07ab4'), ], - /** + /* * Apply timestamps with the last modified time to static assets (js, css, images). * Will append a querystring parameter containing the time the file was modified. * This is useful for busting browser caches. @@ -118,41 +127,41 @@ // 'cacheTime' => '+1 year' ], - /** + /* * Configure the cache adapters. */ 'Cache' => [ 'default' => [ - 'className' => 'Cake\Cache\Engine\FileEngine', + 'className' => FileEngine::class, 'path' => CACHE, 'url' => env('CACHE_DEFAULT_URL', null), ], - /** + /* * Configure the cache used for general framework caching. * Translation cache files are stored with this configuration. * Duration will be set to '+2 minutes' in bootstrap.php when debug = true * If you set 'className' => 'Null' core cache will be disabled. */ - '_cake_core_' => [ - 'className' => 'Cake\Cache\Engine\FileEngine', - 'prefix' => 'myapp_cake_core_', - 'path' => CACHE . 'persistent/', + '_cake_translations_' => [ + 'className' => FileEngine::class, + 'prefix' => 'myapp_cake_translations_', + 'path' => CACHE . 'persistent' . DS, 'serialize' => true, 'duration' => '+1 years', 'url' => env('CACHE_CAKECORE_URL', null), ], - /** + /* * Configure the cache for model and datasource caches. This cache * configuration is used to store schema descriptions, and table listings * in connections. * Duration will be set to '+2 minutes' in bootstrap.php when debug = true */ '_cake_model_' => [ - 'className' => 'Cake\Cache\Engine\FileEngine', + 'className' => FileEngine::class, 'prefix' => 'myapp_cake_model_', - 'path' => CACHE . 'models/', + 'path' => CACHE . 'models' . DS, 'serialize' => true, 'duration' => '+1 years', 'url' => env('CACHE_CAKEMODEL_URL', null), @@ -164,27 +173,27 @@ * Duration will be set to '+2 seconds' in bootstrap.php when debug = true */ '_cake_routes_' => [ - 'className' => 'Cake\Cache\Engine\FileEngine', + 'className' => FileEngine::class, 'prefix' => 'myapp_cake_routes_', 'path' => CACHE, 'serialize' => true, 'duration' => '+1 years', 'url' => env('CACHE_CAKEROUTES_URL', null), ], - /** - * Configure the cache for html elements. - * Duration will be set to '+2 seconds' in bootstrap.php when debug = true - */ + /** + * Configure the cache for html elements. + * Duration will be set to '+2 seconds' in bootstrap.php when debug = true + */ '_html_elements' => [ - 'className' => 'Cake\Cache\Engine\FileEngine', - 'prefix' => 'myapp_cake_elements_', - 'duration' => '+1 week', - 'probability' => 100, - 'url' => env('CACHE_HTMLELEMENT_URL', null), + 'className' => FileEngine::class, + 'prefix' => 'myapp_cake_elements_', + 'duration' => '+1 week', + 'probability' => 100, + 'url' => env('CACHE_HTMLELEMENT_URL', null), ] ], - /** + /* * Configure the Error and Exception handlers used by your application. * * By default errors are displayed using Debugger, when debug is true and logged @@ -198,31 +207,54 @@ * Options: * * - `errorLevel` - int - The level of errors you are interested in capturing. - * - `trace` - boolean - Whether or not backtraces should be included in + * - `trace` - boolean - Whether backtraces should be included in * logged errors/exceptions. - * - `log` - boolean - Whether or not you want exceptions logged. - * - `exceptionRenderer` - string - The class responsible for rendering - * uncaught exceptions. If you choose a custom class you should place - * the file for that class in src/Error. This class needs to implement a - * render method. + * - `log` - boolean - Whether you want exceptions logged. + * - `exceptionRenderer` - string - The class responsible for rendering uncaught exceptions. + * The chosen class will be used for both CLI and web environments. If you want different + * classes used in CLI and web environments you'll need to write that conditional logic as well. + * The conventional location for custom renderers is in `src/Error`. Your exception renderer needs to + * implement the `render()` method and return either a string or Http\Response. + * `errorRenderer` - string - The class responsible for rendering PHP errors. The selected + * class will be used for both web and CLI contexts. If you want different classes for each environment + * you'll need to write that conditional logic as well. Error renderers need to + * to implement the `Cake\Error\ErrorRendererInterface`. * - `skipLog` - array - List of exceptions to skip for logging. Exceptions that * extend one of the listed exceptions will also be skipped for logging. * E.g.: * `'skipLog' => ['Cake\Http\Exception\NotFoundException', 'Cake\Http\Exception\UnauthorizedException']` - * - `extraFatalErrorMemory` - int - The number of megabytes to increase - * the memory limit by when a fatal error is encountered. This allows + * - `extraFatalErrorMemory` - int - The number of megabytes to increase the memory limit by + * when a fatal error is encountered. This allows * breathing room to complete logging or error handling. + * - `ignoredDeprecationPaths` - array - A list of glob-compatible file paths that deprecations + * should be ignored in. Use this to ignore deprecations for plugins or parts of + * your application that still emit deprecations. */ 'Error' => [ 'errorLevel' => E_ALL, -// This is deprecated and could probably be completely removed -// 'exceptionRenderer' => 'Cake\Error\ExceptionRenderer', 'skipLog' => [], 'log' => true, 'trace' => true, + 'ignoredDeprecationPaths' => [], ], - /** + /* + * Debugger configuration + * + * Define development error values for Cake\Error\Debugger + * + * - `editor` Set the editor URL format you want to use. + * By default atom, emacs, macvim, phpstorm, sublime, textmate, and vscode are + * available. You can add additional editor link formats using + * `Debugger::addEditor()` during your application bootstrap. + * - `outputMask` A mapping of `key` to `replacement` values that + * `Debugger` should replace in dumped data and logs generated by `Debugger`. + */ + 'Debugger' => [ + 'editor' => 'phpstorm', + ], + + /* * Email configuration. * * By defining transports separately from delivery profiles you can easily @@ -240,87 +272,94 @@ * You can add custom transports (or override existing transports) by adding the * appropriate file to src/Mailer/Transport. Transports should be named * 'YourTransport.php', where 'Your' is the name of the transport. - * - * Note Registry uses dynamic configuration for EmailTransport. */ 'EmailTransport' => [ 'default' => [ - 'className' => 'Cake\Mailer\Transport\MailTransport', + 'className' => MailTransport::class, /* - * The following keys are used in SMTP transports: + * The keys host, port, timeout, username, password, client and tls + * are used in SMTP transports */ 'host' => 'localhost', 'port' => 25, 'timeout' => 30, - 'username' => null, - 'password' => null, + /* + * It is recommended to set these options through your environment or app_local.php + */ + //'username' => null, + //'password' => null, 'client' => null, - 'tls' => null, + 'tls' => false, 'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null), ], ], - /** + /* * Email delivery profiles * * Delivery profiles allow you to predefine various properties about email * messages from your application and give the settings a name. This saves * duplication across your application and makes maintenance and development - * easier. Each profile accepts a number of keys. See `Cake\Mailer\Email` + * easier. Each profile accepts a number of keys. See `Cake\Mailer\Mailer` * for more information. - * - * Note Registry uses dynamic configuration for Email. */ 'Email' => [ 'default' => [ 'transport' => 'default', 'from' => 'you@localhost', + /* + * Will by default be set to config value of App.encoding, if that exists otherwise to UTF-8. + */ //'charset' => 'utf-8', //'headerCharset' => 'utf-8', ], ], - /** + /* * Connection information used by the ORM to connect * to your application's datastores. * * ### Notes * - Drivers include Mysql Postgres Sqlite Sqlserver - * See vendor\cakephp\cakephp\src\Database\Driver for complete list - * - Do not use periods in database name - it may lead to error. + * See vendor\cakephp\cakephp\src\Database\Driver for the complete list + * - Do not use periods in database name - it may lead to errors. * See https://github.com/cakephp/cakephp/issues/6471 for details. * - 'encoding' is recommended to be set to full UTF-8 4-Byte support. * E.g set it to 'utf8mb4' in MariaDB and MySQL and 'utf8' for any * other RDBMS. - * - * Note for COmanage we read in local/Config/database.php instead */ 'Datasources' => [ + /* + * These configurations should contain permanent settings used + * by all environments. + * + * The values in app_local.php will override any values set here + * and should be used for local and per-environment configurations. + * + * Environment variable-based configurations can be loaded here or + * in app_local.php depending on the application's needs. + */ 'default' => [ - 'className' => 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Mysql', + 'className' => Connection::class, + 'driver' => Mysql::class, 'persistent' => false, - 'host' => 'localhost', + 'timezone' => 'UTC', + /* - * CakePHP will use the default DB port based on the driver selected - * MySQL on MAMP uses port 8889, MAMP users will want to uncomment - * the following line and set the port accordingly + * For MariaDB/MySQL the internal default changed from utf8 to utf8mb4, aka full utf-8 support */ - //'port' => 'non_standard_port_number', - 'username' => 'my_app', - 'password' => 'secret', - 'database' => 'my_app', + 'encoding' => 'utf8mb4', + /* - * You do not need to set this flag to use full utf-8 encoding (internal default since CakePHP 3.6). + * If your MySQL server is configured with `skip-character-set-client-handshake` + * then you MUST use the `flags` config to set your charset encoding. + * For e.g. `'flags' => [\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4']` */ - //'encoding' => 'utf8mb4', - 'timezone' => 'UTC', 'flags' => [], 'cacheMetadata' => true, - // Set to true to get query log for debugging 'log' => false, - /** + /* * Set identifier quoting to true if you are using reserved words or * special characters in your table or column names. Enabling this * setting will result in queries built using the Query Builder having @@ -330,7 +369,7 @@ */ 'quoteIdentifiers' => false, - /** + /* * During development, if using MySQL < 5.6, uncommenting the * following line could boost the speed at which schema metadata is * fetched from the database. It can also be set directly with the @@ -338,111 +377,104 @@ * which is the recommended value in production environments */ //'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'], - - 'url' => env('DATABASE_URL', null), ], - /** + /* * The test connection is used during the test suite. */ 'test' => [ - 'className' => 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Mysql', + 'className' => Connection::class, + 'driver' => Mysql::class, 'persistent' => false, - 'host' => 'localhost', - //'port' => 'non_standard_port_number', - 'username' => 'my_app', - 'password' => 'secret', - 'database' => 'test_myapp', - //'encoding' => 'utf8mb4', 'timezone' => 'UTC', + 'encoding' => 'utf8mb4', + 'flags' => [], 'cacheMetadata' => true, 'quoteIdentifiers' => false, 'log' => false, //'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'], - 'url' => env('DATABASE_TEST_URL', null), ], ], - /** + /* * Configures logging options */ 'Log' => null !== env('COMANAGE_REGISTRY_CONTAINER', null) - // Configuration for container deployments - ? [ - 'debug' => [ - 'className' => 'Cake\Log\Engine\ConsoleLog', - 'stream' => 'php://stdout', - 'outputAs' => 0, - 'scopes' => false, - 'levels' => ['notice', 'info', 'debug'], - ], - 'error' => [ - 'className' => 'Cake\Log\Engine\ConsoleLog', - 'stream' => 'php://stderr', - 'outputAs' => 0, - 'scopes' => false, - 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], - ], - 'queries' => [ - 'className' => 'Cake\Log\Engine\ConsoleLog', - 'stream' => 'php://stdout', - 'outputAs' => 0, - 'scopes' => ['queriesLog'] - ], - 'trace' => [ - 'className' => 'Cake\Log\Engine\ConsoleLog', - 'stream' => 'php://stdout', - 'outputAs' => 0, - 'scopes' => ['trace'], - ] - ] - // Configuration for tranditional deployments - : [ - 'debug' => [ - 'className' => 'Cake\Log\Engine\FileLog', - 'path' => LOGS, - 'file' => 'debug', - 'url' => env('LOG_DEBUG_URL', null), - 'scopes' => false, - 'levels' => ['notice', 'info', 'debug'], - ], - 'error' => [ - 'className' => 'Cake\Log\Engine\FileLog', - 'path' => LOGS, - 'file' => 'error', - 'url' => env('LOG_ERROR_URL', null), - 'scopes' => false, - 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], - ], - // To enable this dedicated query log, you need set your datasource's log flag to true - 'queries' => [ - 'className' => 'Cake\Log\Engine\FileLog', - 'path' => LOGS, - 'file' => 'queries', - 'url' => env('LOG_QUERIES_URL', null), - 'scopes' => ['queriesLog'], - ], - // We define a trace level for what is really debugging, except debug level - // will write to stdout instead of the log when debug=true - 'trace' => [ - 'className' => 'Cake\Log\Engine\FileLog', - 'path' => LOGS, - 'file' => 'trace', - 'url' => env('LOG_TRACE_URL', null), - 'scopes' => ['trace'], + // Configuration for container deployments + ? [ + 'debug' => [ + 'className' => ConsoleLog::class, + 'stream' => 'php://stdout', + 'outputAs' => ConsoleOutput::PLAIN, + 'scopes' => null, + 'levels' => ['notice', 'info', 'debug'], + ], + 'error' => [ + 'className' => ConsoleLog::class, + 'stream' => 'php://stderr', + 'outputAs' => ConsoleOutput::PLAIN, + 'scopes' => null, + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ], + 'queries' => [ + 'className' => ConsoleLog::class, + 'stream' => 'php://stdout', + 'outputAs' => ConsoleOutput::PLAIN, + 'scopes' => ['queriesLog'] + ], + 'trace' => [ + 'className' => ConsoleLog::class, + 'stream' => 'php://stdout', + 'outputAs' => ConsoleOutput::PLAIN, + 'scopes' => ['trace'], + ] + ] + // Configuration for tranditional deployments + : [ + 'debug' => [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => 'debug', + 'url' => env('LOG_DEBUG_URL', null), + 'scopes' => null, + 'levels' => ['notice', 'info', 'debug'], + ], + 'error' => [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => 'error', + 'url' => env('LOG_ERROR_URL', null), + 'scopes' => null, + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ], + // To enable this dedicated query log, you need to set your datasource's log flag to true + 'queries' => [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => 'queries', + 'url' => env('LOG_QUERIES_URL', null), + 'scopes' => ['cake.database.queries'], + ], + // We define a trace level for what is really debugging, except debug level + // will write to stdout instead of the log when debug=true + 'trace' => [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => 'trace', + 'url' => env('LOG_TRACE_URL', null), + 'scopes' => ['trace'], + ], + // We define a rules level to record application rule execution + 'rule' => [ + 'className' => FileLog::class, + 'path' => LOGS, + 'file' => 'rule', + 'url' => env('LOG_TRACE_URL', null), + 'scopes' => ['rule'], + ] ], - // We define a rules level to record application rule execution - 'rule' => [ - 'className' => 'Cake\Log\Engine\FileLog', - 'path' => LOGS, - 'file' => 'rule', - 'url' => env('LOG_TRACE_URL', null), - 'scopes' => ['rule'], - ] - ], - /** + /* * Session configuration. * * Contains an array of settings to use for session configuration. The @@ -451,22 +483,27 @@ * * ## Options * - * - `cookie` - The name of the cookie to use. Defaults to 'CAKEPHP'. Avoid using `.` in cookie names, - * as PHP will drop sessions from cookies with `.` in the name. + * - `cookie` - The name of the cookie to use. Defaults to value set for `session.name` php.ini config. + * Avoid using `.` in cookie names, as PHP will drop sessions from cookies with `.` in the name. * - `cookiePath` - The url path for which session cookie is set. Maps to the * `session.cookie_path` php.ini config. Defaults to base path of app. - * - `timeout` - The time in minutes the session should be valid for. - * Pass 0 to disable checking timeout. - * Please note that php.ini's session.gc_maxlifetime must be equal to or greater - * than the largest Session['timeout'] in all served websites for it to have the - * desired effect. + * - `timeout` - The time in minutes a session can be 'idle'. If no request is received in + * this duration, the session will be expired and rotated. Pass 0 to disable idle timeout checks. * - `defaults` - The default configuration set to use as a basis for your session. * There are four built-in options: php, cake, cache, database. * - `handler` - Can be used to enable a custom session handler. Expects an * array with at least the `engine` key, being the name of the Session engine * class to use for managing the session. CakePHP bundles the `CacheSession` * and `DatabaseSession` engines. - * - `ini` - An associative array of additional ini values to set. + * - `ini` - An associative array of additional 'session.*` ini values to set. + * + * Within the `ini` key, you will likely want to define: + * + * - `session.cookie_lifetime` - The number of seconds that cookies are valid for. This + * should be longer than `Session.timeout`. + * - `session.gc_maxlifetime` - The number of seconds after which a session is considered 'garbage' + * that can be deleted by PHP's session cleanup behavior. This value should be greater than both + * `Sesssion.timeout` and `session.cookie_lifetime`. * * The built-in `defaults` options are: * @@ -475,7 +512,7 @@ * - 'database' - Uses CakePHP's database sessions. * - 'cache' - Use the Cache class to save sessions. * - * To define a custom session handler, save it at src/Network/Session/.php. + * To define a custom session handler, save it at src/Http/Session/.php. * Make sure the class implements PHP's `SessionHandlerInterface` and set * Session.handler to * @@ -487,4 +524,46 @@ // Note this name must match the name used in webroot/auth/*/* 'cookie' => 'REGISTRYPECAKEPHP' ], + + /** + * DebugKit configuration. + * + * Contains an array of configurations to apply to the DebugKit plugin, if loaded. + * Documentation: https://book.cakephp.org/debugkit/5/en/index.html#configuration + * + * ## Options + * + * - `panels` - Enable or disable panels. The key is the panel name, and the value is true to enable, + * or false to disable. + * - `includeSchemaReflection` - Set to true to enable logging of schema reflection queries. Disabled by default. + * - `safeTld` - Set an array of whitelisted TLDs for local development. + * - `forceEnable` - Force DebugKit to display. Careful with this, it is usually safer to simply whitelist + * your local TLDs. + * - `ignorePathsPattern` - Regex pattern (including delimiter) to ignore paths. + * DebugKit won’t save data for request URLs that match this regex. + * - `ignoreAuthorization` - Set to true to ignore Cake Authorization plugin for DebugKit requests. + * Disabled by default. + * - `maxDepth` - Defines how many levels of nested data should be shown in general for debug output. + * Default is 5. WARNING: Increasing the max depth level can lead to an out of memory error. + * - `variablesPanelMaxDepth` - Defines how many levels of nested data should be shown in the variables tab. + * Default is 5. WARNING: Increasing the max depth level can lead to an out of memory error. + */ + 'DebugKit' => [ + 'forceEnable' => filter_var(env('DEBUG_KIT_FORCE_ENABLE', false), FILTER_VALIDATE_BOOLEAN), + 'safeTld' => env('DEBUG_KIT_SAFE_TLD', null), + 'ignoreAuthorization' => env('DEBUG_KIT_IGNORE_AUTHORIZATION', false), + ], + + /** + * TestSuite configuration. + * + * ## Options + * + * - `errorLevel` - Defaults to `E_ALL`. Can be set to `false` to disable overwrite error level. + * - `fixtureStrategy` - Defaults to TruncateStrategy. Can be set to any class implementing FixtureStrategyInterface. + */ + 'TestSuite' => [ + 'errorLevel' => null, + 'fixtureStrategy' => null, + ], ]; diff --git a/app/config/bootstrap.php b/app/config/bootstrap.php index 2d59fca24..f4dcc6ecc 100644 --- a/app/config/bootstrap.php +++ b/app/config/bootstrap.php @@ -15,27 +15,26 @@ * @license https://opensource.org/licenses/mit-license.php MIT License */ +/* + * This file is loaded by your src/Application.php bootstrap method. + * Feel free to extend/extract parts of the bootstrap process into your own files + * to suit your needs/preferences. + */ + /* * Configure paths required to find CakePHP + general filepath constants */ -require __DIR__ . DIRECTORY_SEPARATOR . '/paths.php'; +require __DIR__ . DIRECTORY_SEPARATOR . 'paths.php'; /* - * Bootstrap CakePHP. - * - * Does the various bits of setup that CakePHP needs to do. - * This includes: - * - * - Registering the CakePHP autoloader. - * - Setting the default application paths. + * Bootstrap CakePHP + * Currently all this does is initialize the router (without loading your routes) */ require CORE_PATH . 'config' . DS . 'bootstrap.php'; use Cake\Cache\Cache; use Cake\Core\Configure; use Cake\Core\Configure\Engine\PhpConfig; -use Cake\Database\TypeFactory; -use Cake\Database\Type\StringType; use Cake\Datasource\ConnectionManager; use Cake\Error\ErrorTrap; use Cake\Error\ExceptionTrap; @@ -44,8 +43,13 @@ use Cake\Mailer\Mailer; use Cake\Mailer\TransportFactory; use Cake\Routing\Router; -use Cake\Utility\Inflector; use Cake\Utility\Security; +use function Cake\Core\env; + +/* + * Load global functions for collections, translations, debugging etc. + */ +require CAKE . 'functions.php'; /* * See https://github.com/josegonzalez/php-dotenv for API details. @@ -70,12 +74,11 @@ // } /* - * Read configuration file and inject configuration into various - * CakePHP classes. + * Initializes default Config store and loads the main configuration file (app.php) * - * By default there is only one configuration file. It is often a good - * idea to create multiple configuration files, and separate the configuration - * that changes from configuration that does not. This makes deployment simpler. + * CakePHP contains 2 configuration files after project creation: + * - `config/app.php` for the default application configuration. + * - `config/app_local.php` for environment specific configuration. */ try { Configure::config('default', new PhpConfig()); @@ -89,27 +92,27 @@ /* * Load an environment local configuration file to provide overrides to your configuration. - * Notice: For security reasons app_local.php will not be included in your git repo. + * Notice: For security reasons app_local.php **should not** be included in your git repo. * if (file_exists(CONFIG . 'app_local.php')) { Configure::load('app_local', 'default'); }*/ /* - * When debug = true the metadata cache should only last - * for a short time. + * When debug = true the metadata cache should only last for a short time. */ if (Configure::read('debug')) { Cache::disable(); //Configure::write('Cache._cake_model_.duration', '+2 minutes'); - //Configure::write('Cache._cake_core_.duration', '+2 minutes'); + //Configure::write('Cache._cake_translations_.duration', '+2 minutes'); // disable router cache during development //Configure::write('Cache._cake_routes_.duration', '+2 seconds'); + Configure::write('DebugKit.forceEnable', true); } /* * Set the default server timezone. Using UTC makes time calculations / conversions easier. - * Check http://php.net/manual/en/timezones.php for list of valid timezone strings. + * Check https://php.net/manual/en/timezones.php for list of valid timezone strings. */ date_default_timezone_set(Configure::read('App.defaultTimezone')); @@ -127,29 +130,49 @@ /* * Register application error and exception handlers. */ -$isCli = PHP_SAPI === 'cli'; (new ErrorTrap(Configure::read('Error')))->register(); (new ExceptionTrap(Configure::read('Error')))->register(); /* - * Include the CLI bootstrap overrides. + * CLI/Command specific configuration. */ -if ($isCli) { - require CONFIG . 'bootstrap_cli.php'; +if (PHP_SAPI === 'cli') { + // Set the fullBaseUrl to allow URLs to be generated in commands. + // This is useful when sending email from commands. + // Configure::write('App.fullBaseUrl', php_uname('n')); + + // Set logs to different files so they don't have permission conflicts. + if (Configure::check('Log.debug')) { + Configure::write('Log.debug.file', 'cli-debug'); + } + if (Configure::check('Log.error')) { + Configure::write('Log.error.file', 'cli-error'); + } } /* * Set the full base URL. * This URL is used as the base of all absolute links. + * Can be very useful for CLI/Commandline applications. */ $fullBaseUrl = Configure::read('App.fullBaseUrl'); if (!$fullBaseUrl) { + /* + * When using proxies or load balancers, SSL/TLS connections might + * get terminated before reaching the server. If you trust the proxy, + * you can enable `$trustProxy` to rely on the `X-Forwarded-Proto` + * header to determine whether to generate URLs using `https`. + * + * See also https://book.cakephp.org/5/en/controllers/request-response.html#trusting-proxy-headers + */ + $trustProxy = false; + $s = null; - if (env('HTTPS')) { + if (env('HTTPS') || ($trustProxy && env('HTTP_X_FORWARDED_PROTO') === 'https')) { $s = 's'; } $httpHost = env('HTTP_HOST'); - if (isset($httpHost)) { + if ($httpHost) { $fullBaseUrl = 'http' . $s . '://' . $httpHost; } unset($httpHost, $s); @@ -159,11 +182,16 @@ } unset($fullBaseUrl); +/* + * Apply the loaded configuration settings to their respective systems. + * This will also remove the loaded config data from memory. + */ Cache::setConfig(Configure::consume('Cache')); ConnectionManager::setConfig(Configure::consume('Datasources')); TransportFactory::setConfig(Configure::consume('EmailTransport')); Mailer::setConfig(Configure::consume('Email')); Log::setConfig(Configure::consume('Log')); +//Security::setSalt(Configure::consume('Security.salt')); // Set the salt from the environment if available, else from the filesystem, // and if the salt cannot be determined we're probably in SetupCommand, @@ -183,6 +211,8 @@ /* * Setup detectors for mobile and tablet. + * If you don't use these checks you can safely remove this code + * and the mobiledetect package from composer.json. */ ServerRequest::addDetector('mobile', function ($request) { $detector = new \Detection\MobileDetect(); @@ -196,47 +226,34 @@ }); /* - * You can set whether the ORM uses immutable or mutable Time types. - * The default changed in 4.0 to immutable types. You can uncomment - * below to switch back to mutable types. - * * You can enable default locale format parsing by adding calls * to `useLocaleParser()`. This enables the automatic conversion of - * locale specific date formats. For details see - * @link https://book.cakephp.org/4/en/core-libraries/internationalization-and-localization.html#parsing-localized-datetime-data + * locale specific date formats when processing request data. For details see + * @link https://book.cakephp.org/5/en/core-libraries/internationalization-and-localization.html#parsing-localized-datetime-data */ -// \Cake\Database\TypeFactory::build('time') -// ->useMutable(); -// \Cake\Database\TypeFactory::build('date') -// ->useMutable(); -// \Cake\Database\TypeFactory::build('datetime') -// ->useMutable(); -// \Cake\Database\TypeFactory::build('timestamp') -// ->useMutable(); -// \Cake\Database\TypeFactory::build('datetimefractional') -// ->useMutable(); -// \Cake\Database\TypeFactory::build('timestampfractional') -// ->useMutable(); -// \Cake\Database\TypeFactory::build('datetimetimezone') -// ->useMutable(); -// \Cake\Database\TypeFactory::build('timestamptimezone') -// ->useMutable(); -// There is no time-specific type in Cake -TypeFactory::map('time', StringType::class); +// \Cake\Database\TypeFactory::build('time')->useLocaleParser(); +// \Cake\Database\TypeFactory::build('date')->useLocaleParser(); +// \Cake\Database\TypeFactory::build('datetime')->useLocaleParser(); +// \Cake\Database\TypeFactory::build('timestamp')->useLocaleParser(); +// \Cake\Database\TypeFactory::build('datetimefractional')->useLocaleParser(); +// \Cake\Database\TypeFactory::build('timestampfractional')->useLocaleParser(); +// \Cake\Database\TypeFactory::build('datetimetimezone')->useLocaleParser(); +// \Cake\Database\TypeFactory::build('timestamptimezone')->useLocaleParser(); /* * Custom Inflector rules, can be set to correctly pluralize or singularize * table, model, controller names or whatever other string is passed to the * inflection functions. */ -//Inflector::rules('plural', ['/^(inflect)or$/i' => '\1ables']); -//Inflector::rules('irregular', ['red' => 'redlings']); -//Inflector::rules('uninflected', ['dontinflectme']); -//Inflector::rules('transliteration', ['/å/' => 'aa']); - -Inflector::rules('irregular', ['co_terms_and_condition' => 'co_terms_and_conditions']); -Inflector::rules('irregular', ['cou' => 'cous']); -Inflector::rules('irregular', ['meta' => 'meta']); +// \Cake\Utility\Inflector::rules('plural', ['/^(inflect)or$/i' => '\1ables']); +// \Cake\Utility\Inflector::rules('irregular', ['red' => 'redlings']); +// \Cake\Utility\Inflector::rules('uninflected', ['dontinflectme']); + +\Cake\Utility\Inflector::rules('irregular', ['cou' => 'cous']); +\Cake\Utility\Inflector::rules('uninflected', ['cous' => 'cous']); +\Cake\Utility\Inflector::rules('irregular', ['meta' => 'meta']); +// \Cake\Utility\Inflector::rules('irregular', ['terms_and_condition' => 'terms_and_conditions']); +\Cake\Utility\Inflector::rules('uninflected', ['terms_and_conditions' => 'terms_and_conditions']); /* * Define some constants diff --git a/app/config/bootstrap_cli.php b/app/config/bootstrap_cli.php deleted file mode 100644 index fc0dc30bb..000000000 --- a/app/config/bootstrap_cli.php +++ /dev/null @@ -1,35 +0,0 @@ -applyMiddleware('bodyparser'); // Use setPass to make parameter show up as function parameter // Model specific actions, which will usually have more specific URLs: + // Note that while the UI uses dashes in URL paths, because we use {model} for the generic + // CRUD operations below we use underscores for consistency. Also, actions used here must + // be authorized in the relevant model permissions (even though they'll be used by ApiV2Controller + // and not the model's native controller). $builder->post( '/api_users/generate/{id}', ['controller' => 'ApiV2', 'action' => 'generateApiKey', 'model' => 'api_users']) ->setPass(['id']) ->setPatterns(['id' => '[0-9]+']); + $builder->post( + '/provisioning_targets/provision/{id}', + ['controller' => 'ApiV2', 'action' => 'provision', 'model' => 'provisioning_targets']) + ->setPass(['id']) + ->setPatterns(['id' => '[0-9]+']); // These establish the usual CRUD options on all models: $builder->delete( '/{model}/{id}', ['controller' => 'ApiV2', 'action' => 'delete']) @@ -96,7 +105,12 @@ ['_namePrefix' => 'apiAjaxV2:'], function (RouteBuilder $builder) { // Register scoped middleware for in scopes. - $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware(['httponly' => true])); + $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware([ + 'httponly' => true, + 'secure' => true, + 'cookieName' => 'csrfToken', + 'accessibleHeaders' => ['X-CSRF-Token'], + ])); // BodyParserMiddleware will automatically parse JSON bodies, but we only // want that for API transactions, so we only apply it to the /api scope. $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index dc3080a8a..5a847a4ff 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -10,7 +10,9 @@ "columns": { "action": { "type": "string", "size": 4 }, + "api_id": { "type": "integer", "foreignkey": { "table": "apis", "column": "id" } }, "api_user_id": { "type": "integer", "foreignkey": { "table": "api_users", "column": "id" } }, + "authenticator_id": { "type": "integer", "foreignkey": { "table": "authenticators", "column": "id" }, "notnull": true }, "co_id": { "type": "integer", "foreignkey": { "table": "cos", "column": "id" }, "notnull": true }, "comment": { "type": "string", "size": 256 }, "context": { "type": "string", "size": 2 }, @@ -27,6 +29,7 @@ "language": { "type": "string", "size": 16 }, "mail": { "type": "string", "size": 256 }, "message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" } }, + "mostly_static_page_id": { "type": "integer", "foreignkey": { "table": "mostly_static_pages", "column": "id" } }, "name": { "type": "string", "size": 128, "notnull": true }, "ordr": { "type": "integer" }, "password": { "type": "string", "size": 400 }, @@ -101,7 +104,8 @@ "types_i1": { "columns": [ "co_id" ] }, "types_i2": { "columns": [ "co_id", "attribute" ] }, "types_i3": { "columns": [ "co_id", "attribute", "value" ] } - } + }, + "clonable": true }, "servers": { @@ -114,12 +118,11 @@ }, "indexes": { "servers_i1": { "columns": [ "co_id" ] } - } + }, + "clonable": true }, "co_settings": { - "comment": "Table definition not yet complete (CFM-80)", - "columns": { "id": {}, "co_id": {}, @@ -130,6 +133,7 @@ "default_pronoun_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, "default_telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, "default_url_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "authn_events_api_disable": { "type": "boolean" }, "email_delivery_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, "email_smtp_server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" } }, "permitted_fields_name": { "type": "string", "size": 160 }, @@ -140,7 +144,10 @@ "search_global_limited_models": { "type": "boolean" }, "person_picker_email_address_type_id": { "type": "integer" }, "person_picker_identifier_type_id": { "type": "integer" }, - "person_picker_display_types": { "type": "boolean" } + "person_picker_display_types": { "type": "boolean" }, + "platform_env_mfa": { "type": "string", "size": 80 }, + "platform_env_mfa_value": { "type": "string", "size": 80 }, + "platform_env_mfa_enable_eg": { "type": "boolean" } }, "indexes": { "co_settings_i1": { "columns": [ "co_id" ]}, @@ -191,7 +198,8 @@ "indexes": { "api_users_i1": { "columns": [ "co_id" ] }, "api_users_i2": { "columns": [ "username" ] } - } + }, + "clonable": true }, "cous": { @@ -199,17 +207,15 @@ "id": {}, "co_id": {}, "name": {}, - "description": {}, - "parent_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, - "lft": { "type": "integer" }, - "rght": { "type": "integer" } + "description": {} }, "indexes": { "cous_i1": { "columns": [ "co_id" ] }, "cous_i2": { "columns": [ "name" ] }, - "cous_i3": { "columns": [ "co_id", "name" ] }, - "cous_i4": { "needed": false, "columns": [ "parent_id" ] } - } + "cous_i3": { "columns": [ "co_id", "name" ] } + }, + "clonable": true, + "tree": true }, "dashboards": { @@ -319,9 +325,11 @@ "groups_i2": { "columns": [ "co_id", "name" ] }, "groups_i3": { "columns": [ "co_id", "group_type" ] }, "groups_i4": { "columns": [ "cou_id", "group_type" ] }, - "groups_i5": { "needed": false, "columns": [ "cou_id" ]}, + "groups_i5": { "columns": [ "cou_id" ]}, "groups_i6": { "needed": false, "columns": [ "owners_group_id" ]} - } + }, + "clonable": true, + "tree": true }, "group_nestings": { @@ -420,7 +428,8 @@ "indexes": { "email_addresses_i1": { "columns": [ "mail", "type_id", "person_id" ] }, "email_addresses_i2": { "columns": [ "mail", "type_id", "external_identity_id" ] }, - "email_addresses_i3": { "columns": [ "type_id" ] } + "email_addresses_i3": { "columns": [ "type_id" ] }, + "email_addresses_i4": { "columns": [ "type_id", "person_id" ] } }, "mvea": [ "person", "external_identity" ], "sourced": true @@ -435,12 +444,14 @@ "status": {}, "provisioning_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, "retry_interval": { "type": "integer" }, + "max_retry": { "type": "integer" }, "ordr": {} }, "indexes": { "provisioning_targets_i1": { "columns": [ "co_id" ]}, "provisioning_targets_i2": { "needed": false, "columns": [ "provisioning_group_id" ] } - } + }, + "clonable": true }, "provisioning_history_records": { @@ -474,7 +485,8 @@ "identifiers_i1": { "columns": [ "identifier", "type_id", "person_id" ] }, "identifiers_i2": { "columns": [ "identifier", "type_id", "external_identity_id" ] }, "identifiers_i3": { "columns": [ "type_id" ] }, - "identifiers_i4": { "needed": false, "columns": [ "provisioning_target_id" ] } + "identifiers_i4": { "needed": false, "columns": [ "provisioning_target_id" ] }, + "identifiers_i5": { "columns": [ "type_id", "person_id" ] } }, "mvea": [ "person", "external_identity", "group" ], "sourced": true @@ -573,10 +585,10 @@ "columns": { "id": {}, "subject_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, - "subject_group_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "subject_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, "actor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, "recipient_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, - "recipient_group_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "recipient_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, "resolver_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, "action": { "comment": "revert this to use the library definition after feature-cfm31 merge", @@ -634,12 +646,18 @@ "authz_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }}, "collect_enrollee_email": { "type": "boolean" }, "redirect_on_duplicate": { "type": "string", "size": 256 }, - "redirect_on_finalize": { "type": "string", "size": 256 } + "redirect_on_finalize": { "type": "string", "size": 256 }, + "finalization_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }}, + "notification_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }}, + "notification_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }} }, "indexes": { "enrollment_flows_i1": { "columns": [ "co_id" ]}, "enrollment_flows_i2": { "needed": false, "columns": [ "authz_cou_id" ]}, - "enrollment_flows_i3": { "needed": false, "columns": [ "authz_group_id" ]} + "enrollment_flows_i3": { "needed": false, "columns": [ "authz_group_id" ]}, + "enrollment_flows_i4": { "needed": false, "columns": [ "finalization_message_template_id" ]}, + "enrollment_flows_i5": { "needed": false, "columns": [ "notification_group_id" ]}, + "enrollment_flows_i6": { "needed": false, "columns": [ "notification_message_template_id" ]} } }, @@ -652,12 +670,18 @@ "plugin": {}, "ordr": {}, "actor_type": { "type": "string", "size": 2 }, + "approver_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }}, "message_template_id": {}, - "redirect_on_handoff": { "type": "string", "size": 256 } + "redirect_on_handoff": { "type": "string", "size": 256 }, + "notification_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }}, + "notification_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }} }, "indexes": { "enrollment_flow_steps_i1": { "columns": [ "enrollment_flow_id" ]}, - "enrollment_flow_steps_i2": { "needed": false, "columns": [ "message_template_id" ]} + "enrollment_flow_steps_i2": { "needed": false, "columns": [ "message_template_id" ]}, + "enrollment_flow_steps_i3": { "needed": false, "columns": [ "notification_group_id" ]}, + "enrollment_flow_steps_i4": { "needed": false, "columns": [ "notification_message_template_id" ]}, + "enrollment_flow_steps_i5": { "needed": false, "columns": [ "approver_group_id" ]} } }, @@ -736,7 +760,9 @@ "parameters": { "type": "text" }, "requeue_interval": { "type": "integer" }, "retry_interval": { "type": "integer" }, + "max_retry": { "type": "integer" }, "requeued_from_job_id": { "type": "integer", "foreignkey": { "table": "jobs", "column": "id" }}, + "retry_count": { "type": "integer" }, "status": {}, "assigned_host": { "type": "string", "size": 64 }, "assigned_pid": { "type": "integer" }, @@ -763,7 +789,7 @@ "columns": { "id": {}, "job_id": { "type": "integer", "foreignkey": { "table": "jobs", "column": "id" }}, - "record_key": { "type": "string", "size": 64 }, + "record_key": { "type": "string", "size": 512 }, "person_id": {}, "external_identity_id": {}, "comment": {}, @@ -796,7 +822,8 @@ "identifier_assignments_i1": { "columns": [ "co_id" ] }, "identifier_assignments_i2": { "needed": false, "columns": [ "email_address_type_id" ] }, "identifier_assignments_i3": { "needed": false, "columns": [ "identifier_type_id" ] } - } + }, + "clonable": true }, "pipelines": { @@ -809,6 +836,7 @@ "match_email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, "match_identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, "match_server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" } }, + "sync_status_on_create": { "type": "string", "size": 2 }, "sync_status_on_delete": { "type": "string", "size": 2 }, "sync_affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, "sync_cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, @@ -825,7 +853,8 @@ "pipelines_i6": { "needed": false, "columns": [ "sync_replace_cou_id" ] }, "pipelines_i7": { "needed": false, "columns": [ "sync_identifier_type_id" ] }, "pipelines_i8": { "needed": false, "columns": [ "match_identifier_type_id" ] } - } + }, + "clonable": true }, "flanges": { @@ -839,7 +868,8 @@ }, "indexes": { "flanges_i1": { "columns": [ "pipeline_id" ] } - } + }, + "clone_relation": true }, "external_identity_sources": { @@ -861,7 +891,8 @@ "external_identity_sources_i1": { "columns": [ "co_id" ] }, "external_identity_sources_i2": { "columns": [ "sor_label"] }, "external_identity_sources_i3": { "needed": false, "columns": [ "pipeline_id" ] } - } + }, + "clonable": true }, "ext_identity_source_records": { @@ -899,6 +930,22 @@ } }, + "apis": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {}, + "api_user_id": {} + }, + "indexes": { + "apis_i1": { "columns": [ "co_id" ] }, + "apis_i2": { "needed": false, "columns": [ "api_user_id" ]} + }, + "clonable": true + }, + "traffic_detours": { "columns": { "id": {}, @@ -909,6 +956,70 @@ }, "indexes": { } + }, + + "authenticators": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {}, + "enable_ptp": { "type": "boolean" }, + "message_template_id": {} + }, + "indexes": { + "authenticators_i1": { "columns": [ "co_id" ] }, + "authenticators_i2": { "needed": false, "columns": [ "message_template_id" ] } + } + }, + + "authenticator_statuses": { + "columns": { + "id": {}, + "authenticator_id": {}, + "person_id": {}, + "locked": { "type": "boolean" } + }, + "indexes": { + "authenticator_statuses_i1": { "columns": [ "authenticator_id", "person_id" ] }, + "authenticator_statuses_i2": { "needed": false, "columns": [ "authenticator_id"] }, + "authenticator_statuses_i3": { "needed": false, "columns": [ "person_id"] } + } + }, + + "terms_and_conditions": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "url": { "type": "url" }, + "mostly_static_page_id": {}, + "cou_id": {}, + "status": {}, + "ordr": {}, + "agreement_duration": { "type": "integer" }, + "agree_to_updates": { "type": "boolean" } + }, + "indexes": { + "terms_and_conditions_i1": { "columns": [ "co_id" ] }, + "terms_and_conditions_i2": { "needed": false, "columns": [ "cou_id" ] }, + "terms_and_conditions_i3": { "needed": false, "columns": [ "mostly_static_page_id" ] } + } + }, + + "t_and_c_agreements": { + "columns": { + "id": {}, + "terms_and_conditions_id": { "type": "integer", "foreignkey": { "table": "terms_and_conditions", "column": "id" } }, + "person_id": {}, + "agreement_time": { "type": "datetime" }, + "identifier": { "type": "string", "size": 512 } + }, + "indexes": { + "t_and_c_agreements_i1": { "columns": [ "terms_and_conditions_id" ]}, + "t_and_c_agreements_i2": { "columns": [ "person_id" ]} + } } }, diff --git a/app/plugins/CoreApi/README.md b/app/plugins/CoreApi/README.md new file mode 100644 index 000000000..50517a3e4 --- /dev/null +++ b/app/plugins/CoreApi/README.md @@ -0,0 +1,11 @@ +# CoreApi 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-api +``` diff --git a/app/plugins/CoreApi/composer.json b/app/plugins/CoreApi/composer.json new file mode 100644 index 000000000..d3b2e6d93 --- /dev/null +++ b/app/plugins/CoreApi/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/core-api", + "description": "CoreApi plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.6.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "CoreApi\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CoreApi\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/CoreApi/config/plugin.json b/app/plugins/CoreApi/config/plugin.json new file mode 100644 index 000000000..ccd52e420 --- /dev/null +++ b/app/plugins/CoreApi/config/plugin.json @@ -0,0 +1,25 @@ +{ + "types": { + "api": [ + "MatchCallbacks" + ] + }, + "schema": { + "tables": { + "match_callbacks": { + "columns": { + "id": {}, + "api_id": {}, + "server_id": { + "notnull": false, + "comment": "server_id isn't available on the initial row insert by StandardPluggableController::instantiatePlugin" + } + }, + "indexes": { + "match_callbacks_i1": { "columns": [ "api_id" ]}, + "match_callbacks_i2": { "needed": false, "columns": [ "server_id" ]} + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreApi/config/routes.php b/app/plugins/CoreApi/config/routes.php new file mode 100644 index 000000000..dcb615142 --- /dev/null +++ b/app/plugins/CoreApi/config/routes.php @@ -0,0 +1,60 @@ +scope('/api/match', function (RouteBuilder $builder) { + // Register scoped middleware for in scopes. +// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients +// $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware(['httponly' => true])); + // BodyParserMiddleware will automatically parse JSON bodies, but we only + // want that for API transactions, so we only apply it to the /api scope. + $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); + /* + * Apply a middleware to the current route scope. + * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()` + */ +// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients +// $builder->applyMiddleware('csrf'); + $builder->setExtensions(['json']); + $builder->applyMiddleware('bodyparser'); + + $builder->post( + '/{coid}/v1/resolution', + ['plugin' => 'CoreApi', 'controller' => 'MatchCallbackApiV1', 'action' => 'resolveMatch'] + ) + ->setPass(['coid']) + ->setPatterns(['coid' => '[0-9]+']); +}); \ No newline at end of file diff --git a/app/plugins/CoreApi/phpunit.xml.dist b/app/plugins/CoreApi/phpunit.xml.dist new file mode 100644 index 000000000..a1b1a0f7a --- /dev/null +++ b/app/plugins/CoreApi/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/CoreApi/resources/locales/en_US/core_api.po b/app/plugins/CoreApi/resources/locales/en_US/core_api.po new file mode 100644 index 000000000..fb902f2b1 --- /dev/null +++ b/app/plugins/CoreApi/resources/locales/en_US/core_api.po @@ -0,0 +1,38 @@ +# COmanage Registry Localizations (core_api 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.2.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.MatchCallbacks" +msgstr "{0,plural,=1{Match Callback} other{Match Callbacks}}" + +msgid "error.MatchCallbacks.json.invalid" +msgstr "JSON document missing required fields" + +msgid "error.MatchCallbacks.match.resolved" +msgstr "Match resolution notification received" + +msgid "error.MatchCallbacks.sor.notfound" +msgstr "Requested SOR Label not found" + +msgid "information.endpoint.match.callback" +msgstr "The Match Resolution Notification (Callback) Endpoint for using this API is {0}" diff --git a/app/plugins/CoreApi/src/Controller/AppController.php b/app/plugins/CoreApi/src/Controller/AppController.php new file mode 100644 index 000000000..867598718 --- /dev/null +++ b/app/plugins/CoreApi/src/Controller/AppController.php @@ -0,0 +1,10 @@ + 'MatchCallbacks' + ]; + + /** + * Handle a Match Resolution Callback Notification + * + * @since COmanage Registry v5.2.0 + */ + + public function resolveMatch() { + $payload = $this->request->getData(); + + if(empty($payload['sor']) + || empty($payload['sorid']) + || empty($payload['referenceId'])) { + $this->response = $this->response->withStatus(400); + $this->set('vv_results', ['error' => __d('core_api', 'error.MatchCallbacks.json.invalid')]); + return; + } + + // Find the EIS associated with this sor label. There should be exactly one. + + $EISTable = TableRegistry::getTableLocator()->get('ExternalIdentitySources'); + + $eis = $EISTable->find() + ->where([ + 'co_id' => $this->request->getParam('coid'), + 'sor_label' => $payload['sor'], + 'status <>' => SyncModeEnum::Disabled + ]) + ->first(); + + // We check for the EIS rather than let find throw an exception so we can return + // a 400 (client error) instead of 500 (server error). + + if(empty($eis)) { + $this->response = $this->response->withStatus(400); + $this->set('vv_results', ['error' => __d('core_api', 'error.MatchCallbacks.sor.notfound')]); + return; + } + + // We register a Job rather than process the record directly for a few of reasons. + // (1) A record could take "too long" to process (usually due to slow provisioning), + // resulting in a web server timeout for the request from Match. + // (2) There's fairly complicated logic in SyncJob to process the record, and there's + // not a compelling reason to (partially) duplicate that here. + // (3) By processing via the Job infrastructure, the artificats from processing via + // this callback will be available alongside where the rest of the artifacts are. + // The primary downside is that processing isn't immediate, but this can be managed + // by setting the queue runner to run at a reasonable frequency. + + // If an EIS sync fails because of multiple choices returned by the Match Server, + // there will be an EIS Record, but no indication of the multiple choice status is + // retained (and no External Identity has yet been created). This implies we don't + // have a reliable way to validate the SORID (source key) provided in this request + // but the Job can do that. (We could in theory record the Match Reference ID and + // link on that, but SOR Label + Source Key is good enough.) We also don't try to + // map the Reference ID here, since that could theoretically change before the Job + // actually runs (but probably it won't). + + try { + $JobTable = TableRegistry::getTableLocator()->get("Jobs"); + + $JobTable->register( + coId: (int)$this->request->getParam('coid'), + plugin: 'CoreJob.SyncJob', + parameters: [ + 'external_identity_source_id' => $eis->id, + 'source_keys' => $payload['sorid'], + 'reference_id' => $payload['referenceId'] + ], + registerSummary: __d('core_api', 'error.MatchCallbacks.match.resolved') + ); + + $this->response = $this->response->withStatus(202); + } + catch(\Exception $e) { + // We catch and rethrow any errors to make sure the formatting is compatible with + // the API + $this->response = $this->response->withStatus(500); + $this->set('vv_results', ['error' => $e->getMessage()]); + return; + } + + // Note there's nothing to attach a history record to yet, so we don't + } +} diff --git a/app/plugins/CoreApi/src/Controller/MatchCallbacksController.php b/app/plugins/CoreApi/src/Controller/MatchCallbacksController.php new file mode 100644 index 000000000..ca18887cb --- /dev/null +++ b/app/plugins/CoreApi/src/Controller/MatchCallbacksController.php @@ -0,0 +1,65 @@ + [ + 'MatchCallbacks.server_id' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.2.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->MatchCallbacks->Apis->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->MatchCallbacks->Apis->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->MatchCallbacks->Apis->getPrimaryKey()); + } + + // Generate the callback URL + $this->set('vv_api_endpoint', \Cake\Routing\Router::url('/', true) . 'api/match/' . $this->getCOID() . '/v1/resolution'); + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/CoreApi/src/CoreApiPlugin.php b/app/plugins/CoreApi/src/CoreApiPlugin.php new file mode 100644 index 000000000..b42497b8d --- /dev/null +++ b/app/plugins/CoreApi/src/CoreApiPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'CoreApi', + ['path' => '/core-api'], + 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/CoreApi/src/Model/Entity/MatchCallback.php b/app/plugins/CoreApi/src/Model/Entity/MatchCallback.php new file mode 100644 index 000000000..07979e05d --- /dev/null +++ b/app/plugins/CoreApi/src/Model/Entity/MatchCallback.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreApi/src/Model/Table/MatchCallbacksTable.php b/app/plugins/CoreApi/src/Model/Table/MatchCallbacksTable.php new file mode 100644 index 000000000..1ed67d392 --- /dev/null +++ b/app/plugins/CoreApi/src/Model/Table/MatchCallbacksTable.php @@ -0,0 +1,119 @@ +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('Apis'); + $this->belongsTo('Servers'); + + $this->setDisplayField('api_id'); + + $this->setPrimaryLink('api_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + + $this->setAutoViewVars([ + 'servers' => [ + 'type' => 'plugin', + 'model' => 'CoreServer.MatchServers' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('api_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('api_id'); + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('server_id'); + + return $validator; + } +} diff --git a/app/plugins/CoreApi/templates/MatchCallbackApiV1/json/resolve_match.php b/app/plugins/CoreApi/templates/MatchCallbackApiV1/json/resolve_match.php new file mode 100644 index 000000000..3d6605db5 --- /dev/null +++ b/app/plugins/CoreApi/templates/MatchCallbackApiV1/json/resolve_match.php @@ -0,0 +1,30 @@ + $vv_results]); +} \ No newline at end of file diff --git a/app/plugins/CoreApi/templates/MatchCallbacks/fields.inc b/app/plugins/CoreApi/templates/MatchCallbacks/fields.inc new file mode 100644 index 000000000..4f17564f4 --- /dev/null +++ b/app/plugins/CoreApi/templates/MatchCallbacks/fields.inc @@ -0,0 +1,47 @@ + 'information', + 'message' => __d('core_api', 'information.endpoint.match.callback', [$vv_api_endpoint]) + ] + ]; +} + +$fields = [ + 'server_id' +]; + +$subnav = [ + 'tabs' => ['Apis', 'CoreApi.MatchCallbacks'], + 'action' => [ + 'Apis' => ['edit'], + 'CoreApi.MatchCallbacks' => ['edit'] + ] +]; diff --git a/app/plugins/CoreApi/tests/bootstrap.php b/app/plugins/CoreApi/tests/bootstrap.php new file mode 100644 index 000000000..e8f54348d --- /dev/null +++ b/app/plugins/CoreApi/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/CoreApi/tests/schema.sql b/app/plugins/CoreApi/tests/schema.sql new file mode 100644 index 000000000..1d268db90 --- /dev/null +++ b/app/plugins/CoreApi/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for CoreApi diff --git a/app/vendor/robmorgan/phinx/docs/config/__init__.py b/app/plugins/CoreApi/webroot/.gitkeep similarity index 100% rename from app/vendor/robmorgan/phinx/docs/config/__init__.py rename to app/plugins/CoreApi/webroot/.gitkeep diff --git a/app/plugins/CoreAssigner/config/plugin.json b/app/plugins/CoreAssigner/config/plugin.json new file mode 100644 index 000000000..ac0e35660 --- /dev/null +++ b/app/plugins/CoreAssigner/config/plugin.json @@ -0,0 +1,62 @@ +{ + "types": { + "identifier_assignment": [ + "FormatAssigners", + "SqlAssigners" + ] + }, + "schema": { + "tables": { + "format_assigners": { + "columns": { + "id": {}, + "identifier_assignment_id": {}, + "format": { "type": "string", "size": 256 }, + "minimum_length": { "type": "integer" }, + "minimum": { "type": "integer" }, + "maximum": { "type": "integer" }, + "collision_mode": { "type": "string", "size": 2 }, + "permitted_characters": { "type": "string", "size": 2 }, + "enable_transliteration": { "type": "boolean" } + }, + "indexes": { + "format_assigners_i1": { "columns": [ "identifier_assignment_id" ]} + } + }, + "format_assigner_sequences": { + "columns": { + "id": {}, + "format_assigner_id": { "type": "integer", "foreignkey": { "table": "format_assigners", "column": "id" } }, + "affix": { "type": "string", "size": 256 }, + "last": { "type": "integer" } + }, + "indexes": { + "format_assigner_sequences_i1": { "columns": [ "format_assigner_id", "affix" ], "unique": true }, + "format_assigner_sequences_i2": { "needed": false, "columns": [ "format_assigner_id" ] } + }, + "changelog": false, + "clone_relation": true + }, + "sql_assigners": { + "columns": { + "id": {}, + "identifier_assignment_id": {}, + "server_id": { + "notnull": false, + "comment": "type_id isn't available on the initial row insert by StandardPluggableController::instantiatePlugin" + }, + "source_table": { "type": "string", "size": 80 }, + "type_id": { + "notnull": false, + "comment": "type_id isn't available on the initial row insert by StandardPluggableController::instantiatePlugin" + } + }, + "indexes": { + "sql_assigners_i1": { "columns": [ "identifier_assignment_id" ] }, + "sql_assigners_i2": { "columns": [ "type_id" ] }, + "sql_assigners_i3": { "needed": false, "columns": [ "server_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po b/app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po index cb54c17b3..6b2316d6f 100644 --- a/app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po +++ b/app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po @@ -34,17 +34,11 @@ msgstr "Random" msgid "enumeration.CollisionModeEnum.S" msgstr "Sequential" -msgid "enumeration.PermittedCharactersEnum.AN" -msgstr "AlphaNumeric Only" +msgid "error.SqlAssignerJob.context" +msgstr "SqlAssignerJob does not support Identifier Assignment context {0}" -msgid "enumeration.PermittedCharactersEnum.AD" -msgstr "AlphaNumeric and Dot, Dash, Underscore" - -msgid "enumeration.PermittedCharactersEnum.AQ" -msgstr "AlphaNumeric and Dot, Dash, Underscore, Apostrophe" - -msgid "enumeration.PermittedCharactersEnum.AL" -msgstr "Any" +msgid "error.SqlAssignerJob.plugin" +msgstr "Plugin for requested Identifier Assignment is {0}" msgid "error.SqlAssigners.failed" msgstr "Could not map key Identifier to target Identifier" @@ -95,4 +89,43 @@ msgid "field.SqlAssigners.type_id" msgstr "Key Identifier Type" msgid "field.SqlAssigners.type_id.desc" -msgstr "Type of existing Identifier used to query the Source Table" \ No newline at end of file +msgstr "Type of existing Identifier used to query the Source Table" + +msgid "opt.sqlassigner.identifier_assignment_id" +msgstr "Identifier Assignment ID" + +msgid "opt.sqlassigner.lookback" +msgstr "Lookback interval in seconds for modified records" + +msgid "opt.sqlassigner.reprocess_assignments" +msgstr "Reprocess other Identifier Assignments" + +msgid "opt.sqlassigner.suppress_noops" +msgstr "Do not record history when the record is unchanged" + +msgid "result.SqlAssignerJob.assigned" +msgstr "{0} assigned from source" + +msgid "result.SqlAssignerJob.derived" +msgstr "Deleting derived {0} {1}" + +msgid "result.SqlAssignerJob.finish_summary" +msgstr "Reviewed {0} People: {1} assigned and {2} updated Identifier(s), {3} error(s)" + +msgid "result.SqlAssignerJob.finish_summary.changelist" +msgstr "Reviewed {0} source identifiers: {1} assigned and {2} updated Identifier(s), {3} error(s)" + +msgid "result.SqlAssignerJob.ia" +msgstr "Re-running all Identifier Assignments" + +msgid "result.SqlAssignerJob.start_summary.changelist" +msgstr "Syncing Identifiers for {0} changed record(s)" + +msgid "result.SqlAssignerJob.start_summary" +msgstr "Syncing Identifiers for {0} People" + +msgid "result.SqlAssignerJob.unchanged" +msgstr "{0} unchanged in source" + +msgid "result.SqlAssignerJob.updated" +msgstr "{0} updated from source" \ No newline at end of file diff --git a/app/plugins/CoreAssigner/src/Controller/FormatAssignersController.php b/app/plugins/CoreAssigner/src/Controller/FormatAssignersController.php index 24909d8ec..f1336602e 100644 --- a/app/plugins/CoreAssigner/src/Controller/FormatAssignersController.php +++ b/app/plugins/CoreAssigner/src/Controller/FormatAssignersController.php @@ -30,11 +30,33 @@ namespace CoreAssigner\Controller; use App\Controller\StandardPluginController; +use Cake\Event\EventInterface; class FormatAssignersController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'FormatAssigners.format' => 'asc' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->FormatAssigners->IdentifierAssignments->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->FormatAssigners->IdentifierAssignments->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->FormatAssigners->IdentifierAssignments->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } diff --git a/app/plugins/CoreAssigner/src/Controller/SqlAssignersController.php b/app/plugins/CoreAssigner/src/Controller/SqlAssignersController.php index 22609df97..47a5c67e5 100644 --- a/app/plugins/CoreAssigner/src/Controller/SqlAssignersController.php +++ b/app/plugins/CoreAssigner/src/Controller/SqlAssignersController.php @@ -30,11 +30,33 @@ namespace CoreAssigner\Controller; use App\Controller\StandardPluginController; +use Cake\Event\EventInterface; class SqlAssignersController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'SqlAssigners.server_id' => 'asc' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->SqlAssigners->IdentifierAssignments->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->SqlAssigners->IdentifierAssignments->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->SqlAssigners->IdentifierAssignments->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } diff --git a/app/plugins/CoreAssigner/src/Lib/Jobs/SqlAssignerJob.php b/app/plugins/CoreAssigner/src/Lib/Jobs/SqlAssignerJob.php new file mode 100644 index 000000000..85abe18bc --- /dev/null +++ b/app/plugins/CoreAssigner/src/Lib/Jobs/SqlAssignerJob.php @@ -0,0 +1,405 @@ + [], + 'Identifiers' => [] + ]; + protected $ia = null; + protected $job = null; + protected $parameters = null; + + // Result counts + protected $count = 0; + protected $assigned = 0; + protected $updated = 0; + protected $error = 0; + + /** + * Obtain the list of parameters supported by this Job. + * + * @since COmanage Registry v5.2.0 + * @return Array Array of supported parameters. + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + + public function parameterFormat(): array { + return [ + 'identifier_assignment_id' => [ + 'help' => __d('core_assigner', 'opt.sqlassigner.identifier_assignment_id'), + 'type' => 'fk', + 'required' => true + ], + 'lookback' => [ + 'help' => __d('core_assigner', 'opt.sqlassigner.lookback'), + 'type' => 'int', + 'required' => false + ], + 'reprocess_assignments' => [ + 'help' => __d('core_assigner', 'opt.sqlassigner.reprocess_assignments'), + 'type' => 'bool', + 'required' => false + ], + 'suppress_noops' => [ + 'help' => __d('core_assigner', 'opt.sqlassigner.suppress_noops'), + 'type' => 'bool', + 'required' => false + ] + ]; + } + + /** + * Process the sync of a single Person. + * + * @since COmanage Registry v5.2.0 + * @param Person $person Person to sync + */ + + protected function processSync(Person $person) { + try { + // We need to pull the Identifiers associated with $person, but we're only + // interested in those associated with the configured source Identifier type. + + $person->identifiers = $this->People->Identifiers->find()->where([ + 'person_id' => $person->id, + 'type_id' => $this->ia->sql_assigner->type_id + ])->toArray(); + + // We can just let SqlAssignersTable::assign() do the heavy lifting in finding a + // candidate Identifier. $identifier will be non-empty, otherwise an Exception is + // thrown. Note we currently don't handle deletion of an existing identifier (if + // it is removed from the source database), see PAR-SqlAssignerJob-2. + + $identifier = $this->SqlAssigners->assign($this->ia, $person); + + // We now have $identifier from the data source, we'll use upsert so we + // don't have to check for an existing identifier. We do have to pay attention + // to Email Address vs Identifier. + + // Construct $data as if it were a new entity. Upsert will patch only the changed + // fields if it's an update instead. + $data = [ + 'person_id' => $person->id, + 'type_id' => $this->ia->email_address_type_id ?? $this->ia->identifier_type_id + ]; + + $fieldName = 'identifier'; + + if($this->ia->email_address_type_id) { + $data['mail'] = $identifier; + // PAR-SqlAssignerJob-4 If the SQL Assigner configuration is used to generate an + // Email Address, any new or updated Email Address will be considered verified. + $data['verified'] = true; + $fieldName = 'mail'; + } elseif($this->ia->identifier_type_id) { + $data['identifier'] = $identifier; + $data['login'] = $this->ia->login; + // PAR-SqlAssignerJob-1 If the SQL Assigner updates an existing Suspended Identifier, + // the updated Identifier will become Active. + $data['status'] = SuspendableStatusEnum::Active; + } + + // Note we're specifically _not_ querying on $identifier because we might be updating + // an older value that was then changed in the data source. + $whereClause = [ + 'person_id' => $person->id, + 'type_id' => $this->ia->email_address_type_id ?? $this->ia->identifier_type_id + ]; + + $entity = $this->TargetModel->upsertOrFail($data, $whereClause); + + // Record history based on whether we inserted, updated, or did nothing. + // Unforunately the metadata on $entity (eg: isNew()) isn't accurate since + // it's returned to us post-save, so instead we'll use _upsertStatus. + $msg = "(?)"; + + switch($entity->_upsertStatus) { + case 'insert': + // This is a new identifier, not previously synced + $msg = __d('core_assigner', 'result.SqlAssignerJob.assigned', [$this->ia->description]); + $this->assigned++; + break; + case 'update': + // This is a different identifier from what we previously had + $msg = __d('core_assigner', 'result.SqlAssignerJob.updated', [$this->ia->description]); + $this->updated++; + break; + case 'unchanged': + // The identifier is unchanged + $msg = __d('core_assigner', 'result.SqlAssignerJob.unchanged', [$this->ia->description]); + break; + } + + if($entity->_upsertStatus != 'unchanged' + || !isset($this->parameters['suppress_noops']) + || !$this->parameters['suppress_noops']) { + $this->JobHistoryRecordsTable->record( + jobId: $this->job->id, + recordKey: (string)$person->id, + comment: $msg, + status: JobStatusEnum::Complete + ); + } + + if($entity->_upsertStatus != 'unchanged') { + // We also want to record Person History, but not if the identifier is unchanged + $this->People->recordHistory(entity: $entity, comment: $msg); + + // Handle additional Identifier Assignments if set + if(isset($this->parameters['reprocess_assignments']) + && $this->parameters['reprocess_assignments']) { + // First we delete any Identifiers or Email Addresses of the found derived types + + foreach(array_keys($this->derivedTypeIds) as $modelName) { + foreach($this->derivedTypeIds[$modelName] as $tid) { + // We don't use deleteAll because we want callbacks to run + $toDelete = $this->People->$modelName->find()->where([ + 'person_id' => $person->id, + 'type_id' => $tid + ])->all(); + + foreach($toDelete as $td) { + $this->JobHistoryRecordsTable->record( + jobId: $this->job->id, + recordKey: (string)$person->id, + comment: __d('core_assigner', 'result.SqlAssignerJob.derived', [$modelName, $td->identifier ?? $td->mail]), + status: JobStatusEnum::Notice + ); + + $this->People->$modelName->delete($td); + } + } + } + + // Then we run Identifier Assignment. We just invoke a full re-assign all + // for this Person and let the existing code do the work. We skip provisioning + // here because we're going to run it below. + + $this->JobHistoryRecordsTable->record( + jobId: $this->job->id, + recordKey: (string)$person->id, + comment: __d('core_assigner', 'result.SqlAssignerJob.ia'), + status: JobStatusEnum::Notice + ); + + $this->IdentifierAssignments->assign( + entityType: 'People', + entityId: $person->id, + provision: false + ); + } + + // And reprovision (PAR-SqlAssignerJob-3). + $this->People->requestProvisioning(id: $person->id, context: ProvisioningContextEnum::Automatic); + } + } + catch(\Exception $e) { + // We'll get InvalidArgumentException if $person doesn't have a valid key Identifier + // or if the key doesn't map to a value + + $this->JobHistoryRecordsTable->record( + jobId: $this->job->id, + recordKey: (string)$person->id, + comment: $e->getMessage(), + status: JobStatusEnum::Failed + ); + + $this->error++; + } + + $this->count++; + } + + /** + * Run the requested Job. + * + * @since COmanage Registry v5.2.0 + * @param JobsTable $JobsTable Jobs table, for updating the Job status + * @param JobHistoryRecordsTable $JobHistoryRecordsTable Job History Records table, for recording additional history + * @param Job $job Job entity + * @param array $parameters Parameters for this Job + */ + + public function run( + \App\Model\Table\JobsTable $JobsTable, + \App\Model\Table\JobHistoryRecordsTable $JobHistoryRecordsTable, + \App\Model\Entity\Job $job, + array $parameters + ) { + // Make parameters available to processSync + $this->JobHistoryRecordsTable = $JobHistoryRecordsTable; + $this->job = $job; + $this->parameters = $parameters; + + // First pull the requested Identifier Assignment configuration and make sure it + // has a SqlAssigner configuration. + + $this->IdentifierAssignments = TableRegistry::getTableLocator()->get('IdentifierAssignments'); + + $this->ia = $this->IdentifierAssignments->get( + $parameters['identifier_assignment_id'], + contain: ['SqlAssigners', 'IdentifierTypes'] + ); + + if($this->ia->plugin != 'CoreAssigner.SqlAssigners') { + throw new \InvalidArgumentException(__d('core_assigner', 'error.SqlAssignerJob.plugin', [$this->ia->plugin])); + } + + if($this->ia->status != SuspendableStatusEnum::Active) { + throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'IdentifierAssignments', [1]), $this->ia->id])); + } + + if($this->ia->context != IdentifierAssignmentContextEnum::Person) { + throw new \InvalidArgumentException(__d('core_assigner', 'error.SqlAssignerJob.context', [$this->ia->context])); + } + + // If reprocess_assignments is true, figure out which Identifier types are derived + // from the current Identifier Assignment configuration. + + if(isset($parameters['reprocess_assignments']) + && $parameters['reprocess_assignments']) { + // We only look at Identifier Assignments that run after us (since earlier ones can't be + // derived from this one) and only Format Assigners (since that's the only one we know of + // that supports derived Identifiers). + $ias = $this->IdentifierAssignments->find()->where([ + 'co_id' => $this->ia->co_id, + 'status' => SuspendableStatusEnum::Active, + 'plugin' => 'CoreAssigner.FormatAssigners', + 'ordr >' => $this->ia->ordr + ])->contain('FormatAssigners')->all(); + + // The "database name" of the Identifier we are generating, which we need to check the + // Format Assigner configuration, embedded into a Substitution string. + $sub = "(I/" . $this->ia->identifier_type->value . ")"; + + foreach($ias as $iax) { + if(!empty($iax->format_assigner->format) + && str_contains($iax->format_assigner->format, $sub)) { + // $iax is derived from $ia + if(!empty($iax->email_address_type_id)) { + $this->derivedTypeIds['EmailAddresses'][] = $iax->email_address_type_id; + } elseif(!empty($iax->identifier_type_id)) { + $this->derivedTypeIds['Identifiers'][] = $iax->identifier_type_id; + } + } + } + } + + // What we do next depends on whether a lookback window was specified. + + $this->SqlAssigners = TableRegistry::getTableLocator()->get('CoreAssigner.SqlAssigners'); + $this->People = TableRegistry::getTableLocator()->get('People'); + $this->TargetModel = TableRegistry::getTableLocator()->get($this->ia->email_address_type_id ? "EmailAddresses" : "Identifiers"); + + if(!empty($parameters['lookback'])) { + // Query the SqlAssigners source table for any records that changed within + // the specified lookback window. This approach will result in some duplicate + // queries in order to reuse code, but should overall be more efficient for + // large sets of Person records vs iterating over all People (when most are + // unchanged). + + $changelist = $this->SqlAssigners->getChangedRecords($this->ia, $parameters['lookback']); + + $JobsTable->start(job: $job, summary: __d('core_assigner', 'result.SqlAssignerJob.start_summary.changelist', [$changelist->count()])); + + foreach($changelist as $row) { + // Find a Person corresponding to the key Identifier + + try { + $pid = $this->People->Identifiers->lookupPerson($this->ia->sql_assigner->type_id, $row->key); + + $person = $this->People->get($pid); + + $this->processSync($person); + } + catch(\Exception $e) { + // Most likely the identifier did not map to a Person + + $JobHistoryRecordsTable->record( + jobId: $job->id, + recordKey: $row->key, + comment: $e->getMessage(), + status: JobStatusEnum::Failed + ); + + $this->error++; + $this->count++; + } + } + + $JobsTable->finish( + job: $job, + summary: __d('core_assigner', 'result.SqlAssignerJob.finish_summary.changelist', + [$this->count, $this->assigned, $this->updated, $this->error]) + ); + } else { + // Obtain a Person iterator and walk through all available People + + // Note getMembers() excludes Archived People, which is probably ok for us + $iterator = $this->People->getMembers($this->ia->co_id); + + // Note the count returned by $iterator->count() could change during iteration + $JobsTable->start(job: $job, summary: __d('core_assigner', 'result.SqlAssignerJob.start_summary', [$iterator->count()])); + + foreach($iterator as $person) { + $this->processSync($person); + } + + $JobsTable->finish( + job: $job, + summary: __d('core_assigner', 'result.SqlAssignerJob.finish_summary', + [$this->count, $this->assigned, $this->updated, $this->error]) + ); + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreAssigner/src/Model/Entity/FormatAssigner.php b/app/plugins/CoreAssigner/src/Model/Entity/FormatAssigner.php index ee6d6c913..a90cdd12f 100644 --- a/app/plugins/CoreAssigner/src/Model/Entity/FormatAssigner.php +++ b/app/plugins/CoreAssigner/src/Model/Entity/FormatAssigner.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class FormatAssigner extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class FormatAssigner extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php b/app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php index de4e99e81..98a8b0f09 100644 --- a/app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php +++ b/app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class FormatAssignerSequence extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,9 +43,11 @@ class FormatAssignerSequence extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, ]; + + public array $_supportedMetadata = ['clonable-related']; } diff --git a/app/plugins/CoreAssigner/src/Model/Entity/SqlAssigner.php b/app/plugins/CoreAssigner/src/Model/Entity/SqlAssigner.php index 3e8e70149..ca375cecd 100644 --- a/app/plugins/CoreAssigner/src/Model/Entity/SqlAssigner.php +++ b/app/plugins/CoreAssigner/src/Model/Entity/SqlAssigner.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class SqlAssigner extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class SqlAssigner extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php b/app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php index a16a26c94..a541c0dfc 100644 --- a/app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php +++ b/app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php @@ -29,14 +29,10 @@ namespace CoreAssigner\Model\Table; -use Cake\Datasource\ConnectionManager; -use Cake\ORM\Query; -use Cake\ORM\RulesChecker; +use App\Lib\Enum\PermittedCharactersEnum; use Cake\ORM\Table; use Cake\Validation\Validator; - use CoreAssigner\Lib\Enum\CollisionModeEnum; -use CoreAssigner\Lib\Enum\PermittedCharactersEnum; class FormatAssignersTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; @@ -83,7 +79,7 @@ public function initialize(array $config): void { ], 'permittedCharacters' => [ 'type' => 'enum', - 'class' => 'CoreAssigner.PermittedCharactersEnum' + 'class' => 'PermittedCharactersEnum' ] ]); @@ -345,13 +341,12 @@ protected function selectSequences( * Perform parameter substitution on an identifier format to generate the base * string used in identifier assignment. * - * @since COmanage Registry v5.0.0 - * @param EntityInterface $entity Entity to assign Identifier for - * @param string $format Identifier assignment format - * @param PermittedCharactersEnum $permitted Acceptable characters for substituted parameters - * @param boolean $transliterate Whether to apply transliteration in constructing the identifier base + * @param EntityInterface $entity Entity to assign Identifier for + * @param string $format Identifier assignment format + * @param string $permitted Acceptable characters for substituted parameters + * @param boolean $transliterate Whether to apply transliteration in constructing the identifier base * @return string Identifier with paramaters substituted - * @throws RuntimeException + * @since COmanage Registry v5.0.0 */ protected function substituteParameters( diff --git a/app/plugins/CoreAssigner/src/Model/Table/SqlAssignersTable.php b/app/plugins/CoreAssigner/src/Model/Table/SqlAssignersTable.php index e5e850e9b..bc67efc66 100644 --- a/app/plugins/CoreAssigner/src/Model/Table/SqlAssignersTable.php +++ b/app/plugins/CoreAssigner/src/Model/Table/SqlAssignersTable.php @@ -31,6 +31,7 @@ use Cake\Datasource\ConnectionManager; use Cake\ORM\Query; +use Cake\ORM\ResultSet; use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\ORM\TableRegistry; @@ -75,9 +76,8 @@ public function initialize(array $config): void { $this->setAutoViewVars([ 'servers' => [ - 'type' => 'select', - 'model' => 'Servers', - 'where' => ['plugin' => 'CoreServer.SqlServers'] + 'type' => 'plugin', + 'model' => 'CoreServer.SqlServers' ], 'types' => [ 'type' => 'type', @@ -112,6 +112,10 @@ public function initialize(array $config): void { */ public function assign($ia, $entity): string { + // We follow the same pattern as SqlSourcesTable::getRecordTable to create + // a backend-specific connection label in case we're instantiated multiple + // times to different servers. + // Find the key identifier type in the $entity data $keyIdentifier = Hash::extract($entity->identifiers, '{n}[type_id='.$ia->sql_assigner->type_id.']'); @@ -119,30 +123,61 @@ public function assign($ia, $entity): string { throw new \InvalidArgumentException(__d('core_assigner', 'error.SqlAssigners.key.none')); } + $SourceTable = $this->connect($ia->sql_assigner); + + $identifier = $SourceTable->find() + ->where(['key' => $keyIdentifier[0]->identifier]) + ->first(); + + if(!empty($identifier->identifier)) { + return $identifier->identifier; + } + + throw new \InvalidArgumentException(__d('core_assigner', 'error.SqlAssigners.failed')); + } + + /** + * Connect to the SQL Server for this SQL Assigner. + * + * @since COmanage Registry v5.2.0 + * @param SqlAssigner $sa SQL Assigner describing the requested configuration + * @return Table Dynamic Table for the SQL Assigner source table + */ + + protected function connect($sa): Table { $SqlServer = TableRegistry::getTableLocator()->get('CoreServer.SqlServers'); + $cxnLabel = "sqlassigner" . $sa->server_id; + $sourceAlias = "SqlAssignerIdentifiers" . $sa->server_id; - $SqlServer->connect($ia->sql_assigner->server_id, 'sqlassigner'); + $SqlServer->connect($sa->server_id, $cxnLabel); $options = [ - 'table' => $ia->sql_assigner->source_table, - 'alias' => 'SqlSourceIdentifiers', - 'connection' => ConnectionManager::get('sqlassigner') + 'table' => $sa->source_table, + 'alias' => $sourceAlias, + 'connection' => ConnectionManager::get($cxnLabel) ]; - $SourceTable = TableUtilities::getTableFromRegistry( - alias: 'SqlSourceIdentifiers', + return TableUtilities::getTableFromRegistry( + alias: $sourceAlias, options: $options ); + } - $identifier = $SourceTable->find() - ->where(['key' => $keyIdentifier[0]->identifier]) - ->first(); + /** + * Obtain the set of changed records within the specified lookback window. + * + * @since COmanage Registry v5.2.0 + * @param IdentifierAssignment $ia Identifier Assignment describing the requested configuration + * @param int $interval Lookback interval, in seconds + * @return ResultSet ResultSet of records modified in $interval + */ - if(!empty($identifier->identifier)) { - return $identifier->identifier; - } + public function getChangedRecords($ia, $interval): ResultSet { + $SourceTable = $this->connect($ia->sql_assigner); - throw new \InvalidArgumentException(__d('core_assigner', 'error.SqlAssigners.failed')); + return $SourceTable->find() + ->where(['modified >' => time()-$interval]) + ->all(); } /** diff --git a/app/plugins/CoreAssigner/src/config/plugin.json b/app/plugins/CoreAssigner/src/config/plugin.json deleted file mode 100644 index b94193d5c..000000000 --- a/app/plugins/CoreAssigner/src/config/plugin.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "types": { - "assigner": [ - "FormatAssigners", - "SqlAssigners" - ] - }, - "schema": { - "tables": { - "format_assigners": { - "columns": { - "id": {}, - "identifier_assignment_id": {}, - "format": { "type": "string", "size": 256 }, - "minimum_length": { "type": "integer" }, - "minimum": { "type": "integer" }, - "maximum": { "type": "integer" }, - "collision_mode": { "type": "string", "size": 2 }, - "permitted_characters": { "type": "string", "size": 2 }, - "enable_transliteration": { "type": "boolean" } - }, - "indexes": { - "format_assigners_i1": { "columns": [ "identifier_assignment_id" ]} - } - }, - "format_assigner_sequences": { - "columns": { - "id": {}, - "format_assigner_id": { "type": "integer", "foreignkey": { "table": "format_assigners", "column": "id" } }, - "affix": { "type": "string", "size": 256 }, - "last": { "type": "integer" } - }, - "indexes": { - "format_assigner_sequences_i1": { "columns": [ "format_assigner_id", "affix" ], "unique": true }, - "format_assigner_sequences_i2": { "needed": false, "columns": [ "format_assigner_id" ] } - }, - "changelog": false - }, - "sql_assigners": { - "columns": { - "id": {}, - "identifier_assignment_id": {}, - "server_id": { - "notnull": false, - "comment": "type_id isn't available on the initial row insert by StandardPluggableController::instantiatePlugin" - }, - "source_table": { "type": "string", "size": 80 }, - "type_id": { - "notnull": false, - "comment": "type_id isn't available on the initial row insert by StandardPluggableController::instantiatePlugin" - } - }, - "indexes": { - "sql_assigners": { "columns": [ "identifier_assignment_id" ] } - } - } - } - } -} \ No newline at end of file diff --git a/app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc b/app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc index 681a95a7b..a4d041694 100644 --- a/app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc +++ b/app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc @@ -24,14 +24,34 @@ * @since COmanage Registry v5.0.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ + +$fields = [ + 'format', + 'collision_mode', + 'permitted_characters', + 'minimum_length', + 'minimum', + 'maximum', + 'enable_transliteration' +]; + +$subnav = [ + 'tabs' => ['IdentifierAssignments', 'CoreAssigner.FormatAssigners'], + 'action' => [ + 'IdentifierAssignments' => ['edit'], + 'CoreAssigner.FormatAssigners' => ['edit'] + ] +]; + ?> - - -element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'format' - ] - ]); - - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'collision_mode', - 'fieldOptions' => [ - 'onChange' => 'updateGadgets()' - ] - ] - ]); - foreach([ - 'permitted_characters', - 'minimum_length', - 'minimum', - 'maximum', - 'enable_transliteration' - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field, - ] - ]); - } -} + // register onchange events + $('#collision-mode').change(function() { + updateGadgets(); + }); + }); + diff --git a/app/plugins/CoreAssigner/templates/SqlAssigners/fields.inc b/app/plugins/CoreAssigner/templates/SqlAssigners/fields.inc index d2ede3a3d..3aec604fa 100644 --- a/app/plugins/CoreAssigner/templates/SqlAssigners/fields.inc +++ b/app/plugins/CoreAssigner/templates/SqlAssigners/fields.inc @@ -25,17 +25,16 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only, and add is not used -if($vv_action == 'edit') { - foreach([ - 'server_id', - 'source_table', - 'type_id', - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field, - ] - ]); - } -} +$fields = [ + 'server_id', + 'source_table', + 'type_id' +]; + +$subnav = [ + 'tabs' => ['IdentifierAssignments', 'CoreAssigner.SqlAssigners'], + 'action' => [ + 'IdentifierAssignments' => ['edit'], + 'CoreAssigner.SqlAssigners' => ['edit'] + ] +]; diff --git a/app/plugins/CoreEnroller/config/plugin.json b/app/plugins/CoreEnroller/config/plugin.json new file mode 100644 index 000000000..cc4283a14 --- /dev/null +++ b/app/plugins/CoreEnroller/config/plugin.json @@ -0,0 +1,198 @@ +{ + "types": { + "enrollment_flow_step": [ + "ApprovalCollectors", + "AttributeCollectors", + "BasicAttributeCollectors", + "EmailVerifiers", + "IdentifierCollectors", + "InvitationAccepters" + ] + }, + "schema": { + "tables": { + "approval_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "require_comment": { "type": "boolean" }, + "denial_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }}, + "redirect_on_denial": { "type": "string", "size": 256 } + }, + "indexes": { + "approval_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "approval_collectors_i2": { "needed": false, "columns": [ "denial_message_template_id" ] } + } + }, + "attribute_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "description": { "temporary": true, "type": "string", "size": 80 }, + "enable_person_find": { "type": "boolean" } + }, + "indexes": { + "attribute_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] } + } + }, + "basic_attribute_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } } + }, + "indexes": { + "basic_attribute_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "basic_attribute_collectors_i2": { "needed": false, "columns": [ "name_type_id" ] }, + "basic_attribute_collectors_i3": { "needed": false, "columns": [ "email_address_type_id" ] }, + "basic_attribute_collectors_i4": { "needed": false, "columns": [ "affiliation_type_id" ] }, + "basic_attribute_collectors_i5": { "needed": false, "columns": [ "cou_id" ] } + } + }, + "email_verifiers": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "mode": { "type": "string", "size": 2 }, + "message_template_id": {}, + "request_validity": { "type": "integer" }, + "enable_blockonfailure": { "type": "boolean" }, + "verification_code_length": { "type": "integer" }, + "verification_code_charset": { "type": "string", "size": 64 }, + "verification_code_regex": { "type": "string", "size": 2 } + }, + "indexes": { + "email_verifiers_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "email_verifiers_i2": { "needed": false, "columns": [ "message_template_id" ] } + } + }, + "enrollment_attributes": { + "columns": { + "id": {}, + "attribute_collector_id": { "type": "integer", "foreignkey": { "table": "attribute_collectors", "column": "id" } }, + "attribute": { "type": "string", "size": 80 }, + "attribute_type": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "attribute_language": { "type": "string", "size": 16 }, + "attribute_mvea_parent": { "type": "string", "size": 32 }, + "attribute_tag": { "type": "string", "size": 128 }, + "status": {}, + "label": { "type": "string", "size": 80 }, + "description": {}, + "required": { "type": "boolean" }, + "ordr": {}, + "default_value": { "type": "string", "size": 160 }, + "default_value_datetime": { "type": "datetime" }, + "default_value_env_name": { "type": "string", "size": 80 }, + "modifiable": { "type": "boolean" }, + "hidden": { "type": "boolean" } + }, + "indexes": { + "enrollment_attributes_i1": { "columns": [ "attribute_collector_id" ] } + } + }, + "identifier_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "type_id": { "notnull": false, "comment": "Skeletal row needs to be created without type" } + }, + "indexes": { + "identifier_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "identifier_collectors_i2": { "needed": false, "columns": [ "type_id" ] } + } + }, + "invitation_accepters": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "invitation_validity": { "type": "integer" }, + "welcome_message": { "type": "text" } + }, + "indexes": { + "invitation_accepters_i1": { "columns": [ "enrollment_flow_step_id" ] } + } + }, + "petition_acceptances": { + "columns": { + "id": {}, + "petition_id": {}, + "accepted": { "type": "boolean" } + }, + "indexes": { + "petition_acceptances_i1": { "columns": [ "petition_id" ] } + } + }, + "petition_approvals": { + "columns": { + "id": {}, + "petition_id": {}, + "approval_collector_id": { "type": "integer", "foreignkey": { "table": "approval_collectors", "column": "id" } }, + "approver_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "approved": { "type": "boolean" }, + "comment": {} + }, + "indexes": { + "petition_approvals_i1": { "columns": [ "petition_id" ] }, + "petition_approvals_i2": { "needed": false, "columns": [ "approval_collector_id" ] }, + "petition_approvals_i3": { "needed": false, "columns": [ "approver_person_id" ] } + } + }, + "petition_attributes": { + "columns": { + "id": {}, + "petition_id": {}, + "enrollment_attribute_id": { "type": "integer", "foreignkey": { "table": "enrollment_attributes", "column": "id" } }, + "value": { "type": "string", "size": 160 }, + "column_name": { "notnull": false, "type": "string", "size": 64 } + }, + "indexes": { + "petition_attributes_i1": { "columns": [ "petition_id" ] }, + "petition_attributes_i2": { "needed": false, "columns": [ "enrollment_attribute_id" ] } + } + }, + "petition_basic_attribute_sets": { + "columns": { + "id": {}, + "petition_id": {}, + "basic_attribute_collector_id": { "type": "integer", "foreignkey": { "table": "basic_attribute_collectors", "column": "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 }, + "mail": { "type": "string", "size": 256 } + }, + "indexes": { + "petition_basic_attribute_sets_i1": { "columns": [ "petition_id" ] }, + "petition_basic_attribute_sets_i2": { "columns": [ "basic_attribute_collector_id" ] } + } + }, + "petition_identifiers": { + "columns": { + "id": {}, + "petition_id": {}, + "identifier": { "type": "string", "size": 512 } + }, + "indexes": { + "petition_identifiers_i1": { "columns": [ "petition_id" ] } + } + }, + "petition_verifications": { + "columns": { + "id": {}, + "petition_id": {}, + "mail": {}, + "attempts_count": { "type": "integer" }, + "verification_id": { "type": "integer", "foreignkey": { "table": "verifications", "column": "id" } } + }, + "indexes": { + "petition_verifications_i1": { "columns": [ "petition_id" ] }, + "petition_verifications_i2": { "needed": false, "columns": [ "verification_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po index f333a118d..a1d2aa8d6 100644 --- a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po +++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po @@ -55,14 +55,17 @@ msgstr "{0,plural,=1{Petition Identifier} other{Petition Identifiers}}" msgid "controller.PetitionVerifications" msgstr "{0,plural,=1{Petition Verification} other{Petition Verifications}}" -msgid "enumeration.VerificationModeEnum.0" -msgstr "None" +msgid "enumeration.VerificationDefaultsEnum.234679CDFGHJKLMNPQRTVWXZ" +msgstr "DefaultCharset" -msgid "enumeration.VerificationModeEnum.1" -msgstr "One" +msgid "enumeration.VerificationDefaultsEnum.8" +msgstr "DefaultCodeLength" -msgid "enumeration.VerificationModeEnum.A" -msgstr "All" +msgid "enumeration.VerificationDefaultsEnum.60" +msgstr "DefaultVerificationValidity" + +msgid "error.ApprovalCollectors.comment" +msgstr "Comment is required" msgid "error.EmailVerifiers.candidate" msgstr "Requested address is not a valid candidate" @@ -70,6 +73,24 @@ msgstr "Requested address is not a valid candidate" msgid "error.EmailVerifiers.minimum" msgstr "The required number of verified Email Addresses has not been met" +msgid "error.EmailVerifiers.code_length.content" +msgstr "The code length must be a numeric value." + +msgid "error.EmailVerifiers.code_length.comparison_max" +msgstr "Code length must not be less than 4 characters." + +msgid "error.EmailVerifiers.code_length.step_four" +msgstr "Code length must be increased by 4 characters, e.g. 8, 12, 16, 20." + +msgid "error.EmailVerifiers.code_length.comparison_less" +msgstr "Code length must not exceed 20 characters." + +msgid "error.EmailVerifiers.verification_code_charset.content" +msgstr "Letters and numbers only." + +msgid "error.EmailVerifiers.verification_code_charset.is_upper_case" +msgstr "Charset must consist of UPPER case characters only." + msgid "error.EmailVerifiers.verified" msgstr "Requested address is already verified" @@ -82,6 +103,9 @@ msgstr "This Invitation has expired" msgid "error.PetitionAcceptances.processed" msgstr "This Invitation has already been processed" +msgid "information.ApprovalCollectors.review" +msgstr "Please approve or deny Petition {0}." + msgid "information.EmailVerifiers.done" msgstr "All email addresses in this Petition have been verified. You may continue on to the next Enrollment Step." @@ -115,9 +139,36 @@ msgstr "New code sent" msgid "information.EmailVerifiers.abort" msgstr "Abort" +msgid "field.ApprovalCollectors.denial_message_template_id" +msgstr "Denial Message Template" + +msgid "field.ApprovalCollectors.denial_message_template_id.desc" +msgstr "Message Template to use when notifying the Enrollee of a denial (no denial message is sent if not set)" + +msgid "field.ApprovalCollectors.mode.desc" +msgstr "How many members of the Approver Group must approve Petitions for this Enrollment Flow Step" + +msgid "field.ApprovalCollectors.redirect_on_denial" +msgstr "Redirect on Denial" + +msgid "field.ApprovalCollectors.redirect_on_denial.desc" +msgstr "If the Petition is denied, the Approver will be redirected here instead of the default handoff page" + +msgid "field.ApprovalCollectors.require_comment" +msgstr "Require Comment" + +msgid "field.ApprovalCollectors.require_comment.desc" +msgstr "If set, the Approver must add a comment when approving or denying Petitions for this Enrollment Flow Step" + msgid "field.AttributeCollectors.valid_through.default.after.desc" msgstr "Days After Finalization" +msgid "field.AttributeCollectors.enable_person_find" +msgstr "Enable People Picker for Unauthenticated Enrollments" + +msgid "field.AttributeCollectors.enable_person_find.desc" +msgstr "Enable people picker for self-service enrollments, see Attribute Collector documentation for privacy considerations" + msgid "field.BasicAttributeCollectors.affiliation_type_id" msgstr "Affiliation Type" @@ -157,6 +208,27 @@ msgstr "Request Validity" msgid "field.EmailVerifiers.request_validity.desc" msgstr "Duration, in minutes, of the verification request before it expires" +msgid "field.EmailVerifiers.verification_code_charset" +msgstr "Verification Code Character Set" + +msgid "field.EmailVerifiers.verification_code_charset.other_value" +msgstr "Other value" + +msgid "field.EmailVerifiers.verification_code_charset.desc" +msgstr "Set of characters for generating the verification code. Numbers and uppercase letters only." + +msgid "field.EmailVerifiers.verification_code_length" +msgstr "Verification Code Length" + +msgid "field.EmailVerifiers.verification_code_length.desc" +msgstr "Set the verification code length. Default length is 8." + +msgid "field.EmailVerifiers.enable_blockonfailure" +msgstr "Enable Attempt Blocker" + +msgid "field.EmailVerifiers.enable_blockonfailure.desc" +msgstr "Enables blocking after consecutive failed attempts. Blocking duration increases exponentially based on the number of failed attempts." + msgid "field.EnrollmentAttributes.address_required_fields" msgstr "Required Address Fields" @@ -256,6 +328,15 @@ msgstr "Petition Attributes recorded" msgid "result.basicattr.finalized" msgstr "Name, Email Address, and Person Role created during finalization" +msgid "result.ApprovalCollectors.approved" +msgstr "Petition Approved" + +msgid "result.ApprovalCollectors.denied" +msgstr "Petition Denied" + +msgid "result.ApprovalCollectors.status" +msgstr "Petition {0} by {1} at {2} ({3})" + msgid "result.EmailVerifiers.verified" msgstr "Verified {0} of {1} available {2}" diff --git a/app/plugins/CoreEnroller/src/Controller/ApprovalCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/ApprovalCollectorsController.php new file mode 100644 index 000000000..22c6d53fb --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/ApprovalCollectorsController.php @@ -0,0 +1,160 @@ + [ + 'ApprovalCollectors.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.2.0 + * @param string $id Approval Collector ID + */ + + public function dispatch(string $id) { + $request = $this->getRequest(); + $session = $request->getSession(); + // $username = $session->read('Auth.external.user'); + + $petition = $this->getPetition(); + $coId = $this->getCOID(); + + // We need the petition to have more information in the approval view, so + // build a view variable that holds more than vv_petition. + $Petition = TableRegistry::getTableLocator()->get('Petitions'); + if(!empty($petition->id)) { + $this->set('vv_approval_petition', $Petition->get($petition->id, contain: $Petition->getViewContains())); + } else { + $this->set('vv_approval_petition', null); + } + + if($request->is('post')) { + $cfg = $this->ApprovalCollectors->get($id); + + try { + // Record approval or denial + + $approved = $this->requestParam('approved'); + $comment = $this->requestParam('comment'); + + // record() will handle updatind the Petition status and performing other + // recordkeeping transactions, including enforcing comment if required + + $this->ApprovalCollectors->record( + petitionId: $petition->id, + approvalCollectorId: (int)$id, + approverPersonId: $this->RegistryAuth->getPersonID($coId), + approved: $approved == PetitionStatusEnum::Approved, + comment: $comment + ); + + if($approved != PetitionStatusEnum::Approved) { + // If we have a denial Message Template, send the notification to the enrollee + // email address. We don't currently support using a Notification, since in most + // cases the Enrollee will not have a Person record yet. (There are some edge + // cases around processes like Additional Role Enrollment where we might want + // to be able to Notify the Person using their existing preferred Email Address, + // but for now we don't support that.) + + if(!empty($cfg->denial_message_template_id) + && !empty($petition->enrollee_email)) { + $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + + // Generate the message and send + + $template = $MessageTemplates->get($cfg->denial_message_template_id); + + $template->setContextPetition($petition); + + $template->generateMessage(); + + // Send the message. sendEmailToAddress will throw an Exception if SMTP failed, + // but if there is no SMTP server configured we'll just get false back. + + if(!DeliveryUtilities::sendEmailToAddress( + coId: $this->getCOID(), + recipient: $petition->enrollee_email, + subject: $template->getMessagePart('subject'), + body_text: $template->getMessagePart('body_text'), + body_html: $template->getMessagePart('body_html') + )) { + throw new \RuntimeException("Message delivery failed"); // XXX I18n. can we get an exception from sendEmailToAddress instead? + } + } + + // If we have a redirect on denial configured, send the Approver there + if(!empty($cfg->redirect_on_denial)) { + return $this->redirect($cfg->redirect_on_denial); + } else { + // Redirect to the default Enrollment Handoff URL for this CO + return $this->redirect("/$coId/default-handoff"); + } + } + + // Where do we redirect? On approval, it's possible that the next step has the + // same Approver's group on handoff, in which case we just let the flow continue. + // However on denial, we need to stop the flow. So basically we need a separate + // "redirect on denial" target (or we use the default Enrollment Flow handoff if + // not configured). + + // Redirect to the next step + + return $this->finishStep( + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + petitionId: $petition->id, + comment: __d('core_enroller', 'result.ApprovalCollectors.' . ($approved == PetitionStatusEnum::Approved ? 'approved' : 'denied')) + ); + } + catch(\Exception $e) { + $this->llog('error', $e->getMessage()); + + $this->Flash->error($e->getMessage()); + } + } + + // Check for existing values in case we're re-running the step + $this->set('petition_approvals', + $this->ApprovalCollectors->PetitionApprovals->find() + ->where(['petition_id' => $petition->id, 'approval_collector_id' => $id]) + ->first()); + + $this->render('/Standard/dispatch'); + } +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/src/Controller/AttributeCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/AttributeCollectorsController.php index 7e1631bd3..e9640bd71 100644 --- a/app/plugins/CoreEnroller/src/Controller/AttributeCollectorsController.php +++ b/app/plugins/CoreEnroller/src/Controller/AttributeCollectorsController.php @@ -35,33 +35,12 @@ use Cake\ORM\TableRegistry; class AttributeCollectorsController extends StandardEnrollerController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'AttributeCollectors.id' => 'asc' ] ]; - /** - * Callback run prior to the request render. - * - * @param EventInterface $event Cake Event - * - * @return Response|void - * @since COmanage Registry v5.0.0 - */ - - public function beforeRender(EventInterface $event) { - $link = $this->getPrimaryLink(true); - - if(!empty($link->value)) { - $this->set('vv_bc_parent_obj', $this->AttributeCollectors->EnrollmentFlowSteps->get($link->value)); - $this->set('vv_bc_parent_displayfield', $this->AttributeCollectors->EnrollmentFlowSteps->getDisplayField()); - $this->set('vv_bc_parent_primarykey', $this->AttributeCollectors->EnrollmentFlowSteps->getPrimaryKey()); - } - - return parent::beforeRender($event); - } - /** * Dispatch an Enrollment Flow Step. * diff --git a/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php index 285a7fb9b..4e3496057 100644 --- a/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php +++ b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php @@ -33,33 +33,12 @@ use App\Controller\StandardEnrollerController; class BasicAttributeCollectorsController extends StandardEnrollerController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'BasicAttributeCollectors.id' => 'asc' ] ]; - /** - * Callback run prior to the request render. - * - * @param EventInterface $event Cake Event - * - * @return Response|void - * @since COmanage Registry v5.1.0 - */ - - public function beforeRender(\Cake\Event\EventInterface $event) { - $link = $this->getPrimaryLink(true); - - if(!empty($link->value)) { - $this->set('vv_bc_parent_obj', $this->BasicAttributeCollectors->EnrollmentFlowSteps->get($link->value)); - $this->set('vv_bc_parent_displayfield', $this->BasicAttributeCollectors->EnrollmentFlowSteps->getDisplayField()); - $this->set('vv_bc_parent_primarykey', $this->BasicAttributeCollectors->EnrollmentFlowSteps->getPrimaryKey()); - } - - return parent::beforeRender($event); - } - /** * Dispatch an Enrollment Flow Step. * @@ -78,7 +57,6 @@ public function dispatch(string $id) { $this->set('vv_required_name_fields', $settings->name_required_fields_array()); if($this->request->is(['post', 'put'])) { - try { $this->BasicAttributeCollectors->upsert( id: (int)$id, @@ -102,6 +80,12 @@ public function dispatch(string $id) { $this->Flash->error($e->getMessage()); } } + + // Check for existing values in case we're re-running the step + $this->set('petition_basic_attribute_sets', + $this->BasicAttributeCollectors->PetitionBasicAttributeSets->find() + ->where(['petition_id' => $petition->id, 'basic_attribute_collector_id' => $id]) + ->first()); // Fall through and let the form render diff --git a/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php index cbfd00a6b..6e683e9f9 100644 --- a/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php +++ b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php @@ -30,15 +30,19 @@ namespace CoreEnroller\Controller; use App\Controller\StandardEnrollerController; +use App\Lib\Enum\ApplicationStateEnum; use App\Lib\Enum\PetitionStatusEnum; +use App\Lib\Traits\ApplicationStatesTrait; use App\Lib\Util\StringUtilities; use Cake\Http\Exception\BadRequestException; use Cake\ORM\TableRegistry; -use CoreEnroller\Lib\Enum\VerificationModeEnum; +use \App\Lib\Enum\AllTernaryEnum; use \App\Lib\Enum\HttpStatusCodesEnum; class EmailVerifiersController extends StandardEnrollerController { - public $paginate = [ + use ApplicationStatesTrait; + + protected array $paginate = [ 'order' => [ 'EmailVerifiers.id' => 'asc' ] @@ -54,14 +58,6 @@ class EmailVerifiersController extends StandardEnrollerController { */ public function beforeRender(\Cake\Event\EventInterface $event) { - $link = $this->getPrimaryLink(true); - - if(!empty($link->value)) { - $this->set('vv_bc_parent_obj', $this->EmailVerifiers->EnrollmentFlowSteps->get($link->value)); - $this->set('vv_bc_parent_displayfield', $this->EmailVerifiers->EnrollmentFlowSteps->getDisplayField()); - $this->set('vv_bc_parent_primarykey', $this->EmailVerifiers->EnrollmentFlowSteps->getPrimaryKey()); - } - // We use the viewvar to determine the op since 'index' isn't always present $op = $this->viewBuilder()->getVar('vv_op'); @@ -124,6 +120,10 @@ public function resend($id) { */ public function dispatch(string $id) { + $request = $this->getRequest(); + $session = $request->getSession(); + $username = $session->read('Auth.external.user'); + $op = $this->requestParam('op'); if(!$op) { @@ -139,6 +139,7 @@ public function dispatch(string $id) { $candidateAddresses = $this->EmailVerifiers->assembleVerifiableAddresses($cfg, $petition); $this->set('vv_config', $cfg); + $this->set('controller', $this); $this->set('vv_email_addresses', $candidateAddresses); // To make things easier for the view, we'll create a separate view var with the @@ -159,10 +160,10 @@ public function dispatch(string $id) { $doneCount = count($verifiedAddresses); $totalCount = count($candidateAddresses); $allDone = $doneCount == $totalCount; - $minimumMet = $cfg->mode == VerificationModeEnum::None - || ($cfg->mode == VerificationModeEnum::One + $minimumMet = $cfg->mode == AllTernaryEnum::None + || ($cfg->mode == AllTernaryEnum::One && $doneCount > 0) - || ($cfg->mode == VerificationModeEnum::All + || ($cfg->mode == AllTernaryEnum::All && $allDone); $this->set('vv_all_done', $allDone); @@ -183,18 +184,31 @@ public function dispatch(string $id) { $this->Flash->error(__d('core_enroller', 'error.EmailVerifiers.verified')); } else { + $PetitionVerifications = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionVerifications'); + $pVerification = $PetitionVerifications->getPetitionVerification($petition->id, $mail, false); + // Reset the counter if nothing happened for the last 30 minutes + if (!empty($pVerification->modified) && !$pVerification->modified->wasWithinLast('30 minute')) { + $pVerification->attempts_count = 0; + $PetitionVerifications->save($pVerification); + } + + // Tell dispatch.inc to render a verification form + $this->set('vv_verify_address', $mail); + $this->set('vv_attempts_count', $pVerification->attempts_count ?? 0); + if($this->request->is('post')) { - $PetitionVerifications = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionVerifications'); // We're back with the code. Note many parameters (but not code) will be in // both the URL and the post body because of how dispatch.php sets up // FormHelper. $code = $this->requestParam('code'); + // Strip any dashes from the code + $code = str_replace('-', '', $code); try { $PetitionVerifications->verifyCode( - $petition->id, + $petition->id, $cfg->enrollment_flow_step_id, $mail, $code @@ -227,16 +241,30 @@ public function dispatch(string $id) { catch(\Exception $e) { $this->llog('error', $e->getMessage()); $this->Flash->error($e->getMessage()); - } + + if ($e->getMessage() === __d('error', 'Verifications.code')) { + // Add a flag to the session to instruct the UI to handle blocking. + $this->request->getSession()->write('verification_error', 1); + // Get preferences if we have an Auth.User.co_person_id + if(!empty($username)) { + $ApplicationStates = $this->fetchTable('ApplicationStates'); + $columnStatement = $this->viewBuilder()->getVar('vv_person_id') === null ? 'person_id IS' : 'person_id'; + $data = [ + 'tag' => ApplicationStateEnum::VerifyEmailBlocked, + 'username' => $username, + 'co_id' => $this->getCOID(), + $columnStatement => $this->viewBuilder()->getVar('vv_person_id') ?? null + ]; + $ApplicationStates->createOrUpdate($data, 'lock'); + } + } + } } else { // Generate a Verification request, then render a form to collect it. // If there is already a pending request, overwrite it (generate a new code). $this->EmailVerifiers->sendVerificationRequest($cfg, $petition, $mail); } - - // Tell dispatch.inc to render a verification form - $this->set('vv_verify_address', $mail); } } elseif($op == 'finish') { if($minimumMet) { diff --git a/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php b/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php index 0f423e5e0..b86691aeb 100644 --- a/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php +++ b/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php @@ -34,7 +34,7 @@ use \App\Lib\Util\StringUtilities; class EnrollmentAttributesController extends StandardEnrollerController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'EnrollmentAttributes.ordr' => 'asc' ] @@ -51,14 +51,6 @@ class EnrollmentAttributesController extends StandardEnrollerController { public function beforeRender(\Cake\Event\EventInterface $event) { $this->set('vv_supported_attributes', $this->EnrollmentAttributes->supportedAttributes()); - $link = $this->getPrimaryLink(true); - - if(!empty($link->value)) { - $this->set('vv_bc_parent_obj', $this->EnrollmentAttributes->AttributeCollectors->get($link->value)); - $this->set('vv_bc_parent_displayfield', $this->EnrollmentAttributes->AttributeCollectors->getDisplayField()); - $this->set('vv_bc_parent_primarykey', $this->EnrollmentAttributes->AttributeCollectors->getPrimaryKey()); - } - $ret = parent::beforeRender($event); $attributes = $this->viewBuilder()->getVar('attributes'); diff --git a/app/plugins/CoreEnroller/src/Controller/IdentifierCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/IdentifierCollectorsController.php index 743bad07f..0a532ecb3 100644 --- a/app/plugins/CoreEnroller/src/Controller/IdentifierCollectorsController.php +++ b/app/plugins/CoreEnroller/src/Controller/IdentifierCollectorsController.php @@ -33,32 +33,11 @@ use App\Controller\StandardEnrollerController; class IdentifierCollectorsController extends StandardEnrollerController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'IdentifierCollectors.id' => 'asc' ] ]; - - /** - * Callback run prior to the request render. - * - * @param EventInterface $event Cake Event - * - * @return Response|void - * @since COmanage Registry v5.0.0 - */ - - public function beforeRender(\Cake\Event\EventInterface $event) { - $link = $this->getPrimaryLink(true); - - if(!empty($link->value)) { - $this->set('vv_bc_parent_obj', $this->IdentifierCollectors->EnrollmentFlowSteps->get($link->value)); - $this->set('vv_bc_parent_displayfield', $this->IdentifierCollectors->EnrollmentFlowSteps->getDisplayField()); - $this->set('vv_bc_parent_primarykey', $this->IdentifierCollectors->EnrollmentFlowSteps->getPrimaryKey()); - } - - return parent::beforeRender($event); - } /** * Dispatch an Enrollment Flow Step. diff --git a/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php b/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php index 90d05ba78..17535e9c0 100644 --- a/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php +++ b/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php @@ -34,32 +34,11 @@ use App\Lib\Enum\PetitionActionEnum; class InvitationAcceptersController extends StandardEnrollerController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'InvitationAccepters.id' => 'asc' ] ]; - - /** - * Callback run prior to the request render. - * - * @param EventInterface $event Cake Event - * - * @return Response|void - * @since COmanage Registry v5.0.0 - */ - - public function beforeRender(\Cake\Event\EventInterface $event) { - $link = $this->getPrimaryLink(true); - - if(!empty($link->value)) { - $this->set('vv_bc_parent_obj', $this->InvitationAccepters->EnrollmentFlowSteps->get($link->value)); - $this->set('vv_bc_parent_displayfield', $this->InvitationAccepters->EnrollmentFlowSteps->getDisplayField()); - $this->set('vv_bc_parent_primarykey', $this->InvitationAccepters->EnrollmentFlowSteps->getPrimaryKey()); - } - - return parent::beforeRender($event); - } /** * Dispatch an Enrollment Flow Step. diff --git a/app/plugins/CoreEnroller/src/Lib/Enum/MagicEnvNameFieldsEnum.php b/app/plugins/CoreEnroller/src/Lib/Enum/MagicEnvNameFieldsEnum.php new file mode 100644 index 000000000..01ab8ea66 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Lib/Enum/MagicEnvNameFieldsEnum.php @@ -0,0 +1,67 @@ + MagicEnvNameFieldsEnum::HonorificSuffix, + 'given' => MagicEnvNameFieldsEnum::GivenSuffix, + 'middle' => MagicEnvNameFieldsEnum::MiddleSuffix, + 'family' => MagicEnvNameFieldsEnum::FamilySuffix, + 'suffix' => MagicEnvNameFieldsEnum::SuffixSuffix, + default => null + }; + + if($suffix === null || $baseEnvName === '') { + return null; + } + + $v = getenv($baseEnvName . $suffix); + + if($v === false || $v === '') { + return null; + } + + return (string)$v; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/ApprovalCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/ApprovalCollector.php new file mode 100644 index 000000000..d392a1508 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/ApprovalCollector.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/AttributeCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/AttributeCollector.php index 4a1432d03..40e296e6f 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/AttributeCollector.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/AttributeCollector.php @@ -43,7 +43,7 @@ class AttributeCollector extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Entity/BasicAttributeCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/BasicAttributeCollector.php index 675282e93..5c7e6a630 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/BasicAttributeCollector.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/BasicAttributeCollector.php @@ -43,7 +43,7 @@ class BasicAttributeCollector extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Entity/EmailVerifier.php b/app/plugins/CoreEnroller/src/Model/Entity/EmailVerifier.php index b22c3ebae..3dc27771b 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/EmailVerifier.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/EmailVerifier.php @@ -43,7 +43,7 @@ class EmailVerifier extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php b/app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php index 5e4976e9b..ddb4baec2 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class EnrollmentAttribute extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class EnrollmentAttribute extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Entity/IdentifierCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/IdentifierCollector.php index 024e76937..5c3068d2d 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/IdentifierCollector.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/IdentifierCollector.php @@ -43,7 +43,7 @@ class IdentifierCollector extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php b/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php index b5ac09d28..6a5f43375 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php @@ -43,7 +43,7 @@ class InvitationAccepter extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionAcceptance.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAcceptance.php index cc3d84c55..559cf30b8 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/PetitionAcceptance.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAcceptance.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class PetitionAcceptance extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class PetitionAcceptance extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionApproval.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionApproval.php new file mode 100644 index 000000000..c57a8670d --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionApproval.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php index 558ac97bc..108651f0a 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class PetitionAttribute extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class PetitionAttribute extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php index d506679eb..bee6d19f9 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class PetitionBasicAttributeSet extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class PetitionBasicAttributeSet extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionIdentifier.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionIdentifier.php index 7045282fe..1f9f36014 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/PetitionIdentifier.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionIdentifier.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class PetitionIdentifier extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class PetitionIdentifier extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionVerification.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionVerification.php index ee9e173a8..f36f6f978 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/PetitionVerification.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionVerification.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class PetitionVerification extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class PetitionVerification extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreEnroller/src/Model/Table/ApprovalCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/ApprovalCollectorsTable.php new file mode 100644 index 000000000..9b0b20a94 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/ApprovalCollectorsTable.php @@ -0,0 +1,280 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('Groups'); + $this->belongsTo('MessageTemplates') + ->setForeignKey('denial_message_template_id') + ->setProperty('denial_message_template'); + + $this->hasMany('CoreEnroller.PetitionApprovals') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setAutoViewVars([ + 'modes' => [ + 'type' => 'enum', + 'class' => 'AllTernaryEnum' + ], + 'denialMessageTemplates' => [ + 'type' => 'select', + 'model' => 'MessageTemplates', + 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentApproval] + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + // 'resend' => true, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform steps necessary to hydrate the Person record as part of Petition finalization. + * + * @since COmanage Registry v5.2.0 + * @param int $id Approval Collector ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function hydrate(int $id, \App\Model\Entity\Petition $petition) { + // $cfg = $this->get($id); + // Approval Collector just affects the flow as it happens, and so nothing is + // currently required at finalization. + + return true; + } + + /** + * Perform tasks prior to transitioning to this step. + * + * @since COmanage Registry v5.2.0 + * @param EnrollmentFlowStep $step Enrollment Flow Step + * @param Petition $petition Petition + * @return bool true on success + */ + + public function prepare( + \App\Model\Entity\EnrollmentFlowStep $step, + \App\Model\Entity\Petition $petition + ): bool { + // Set this petition to Pending Verification + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition->status = PetitionStatusEnum::PendingApproval; + + $Petitions->saveOrFail($petition); + + return true; + } + + /** + * Record an Approval (or denial). + * + * @since COmanage Registry v5.2.0 + * @param int $petitionId Petition ID + * @param int $approvalCollectorId Approval Collector ID + * @param int $approverPersonId Approver Person ID + * @param bool $approved true if the Approval is granted, false otherwise + * @param string $comment Approval (or denial) Comment + * @throws \InvalidArgumentException + */ + + public function record( + int $petitionId, + int $approvalCollectorId, + int $approverPersonId, + bool $approved=true, + ?string $comment=null + ) { + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $cfg = $this->get($approvalCollectorId); + + $petition = $Petitions->get($petitionId); + + // First check if require_comment but no comment was provided + + if($cfg->require_comment && (!$comment || strlen(trim($comment)) == 0)) { + throw new \InvalidArgumentException(__d('core_enroller', 'error.ApprovalCollectors.comment')); + } + + // Record a PetitionApproval (which is also used for denials). + + $pa = [ + 'petition_id' => $petitionId, + 'approval_collector_id' => $approvalCollectorId, + 'approver_person_id' => $approverPersonId, + 'approved' => $approved, + 'comment' => $comment + ]; + + $this->PetitionApprovals->upsertOrFail( + $pa, + ['petition_id' => $petitionId, 'approval_collector_id' => $approvalCollectorId] + ); + + // Next, update the Petition status. + + // If there'a another Approval step after this one we'll bounce back to Pending + // Approval as soon as we switch to it, which creates a bit of noise but in some + // ways is sort of correct. We could try to calculate if there is another Approval + // step, but that's a bit complicated and not really worth the effort. We don't + // calculate Approval during finalization because the status wouldn't stick around + // long enough to be useful for an administrator. + $petition->status = $approved ? PetitionStatusEnum::Approved : PetitionStatusEnum::Denied; + + $Petitions->saveOrFail($petition); + + // Record PetitionHistory. + + $this->llog('debug', "Petition " . $petition->id . ($approved ? " approved" : " denied") . " by Person " . $approverPersonId); + + $Petitions->PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + action: $approved ? PetitionActionEnum::Approved : PetitionActionEnum::Denied, + comment: __d('core_enroller', 'result.ApprovalCollectors.' . ($approved ? 'approved' : 'denied')) + ); + + // Finally, resolved the Notification created by the handoff. + + $Notifications = TableRegistry::getTableLocator()->get('Notifications'); + + // The URL was originally created by $EnrollmentFlows->calculateNextStep, but we + // can easily reconstruct what it should be + + $url = [ + 'plugin' => 'CoreEnroller', + 'controller' => 'approval_collectors', + 'action' => 'dispatch', + $approvalCollectorId, + '?' => ['petition_id' => $petitionId] + ]; + + $Notifications->resolveFromSource( + source: $url, + resolution: NotificationStatusEnum::Resolved, + resolverPersonId: $approverPersonId + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('require_comment', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('require_comment'); + + $validator->add('denial_message_template_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('denial_message_template_id'); + + $validator->add('redirect_on_denial', [ + 'content' => ['rule' => 'url'] + ]); + $validator->allowEmptyString('redirect_on_denial'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php index a37f486ba..ad96cc05a 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php @@ -50,7 +50,6 @@ class AttributeCollectorsTable extends Table { use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; - use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -83,23 +82,6 @@ public function initialize(array $config): void { $this->setRequiresCO(true); $this->setAllowLookupPrimaryLink(['dispatch', 'display']); - // All the tabs share the same configuration in the ModelTable file - $this->setTabsConfig( - [ - // Ordered list of Tabs - 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.AttributeCollectors', 'CoreEnroller.EnrollmentAttributes'], - // What actions will include the subnavigation header - 'action' => [ - // If a model renders in a subnavigation mode in edit/view mode, it cannot - // render in index mode for the same use case/context - // XXX edit should go first. - 'EnrollmentFlowSteps' => ['edit', 'view'], - 'CoreEnroller.AttributeCollectors' => ['edit'], - 'CoreEnroller.EnrollmentAttributes' => ['index'], - ] - ] - ); - $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ @@ -122,6 +104,94 @@ public function initialize(array $config): void { ]); } + /** + * Obtain a name (as a string) for the Enrollee associated with the specified Petition. + * + * Not all Petitions will have Enrollee Names, and some Petitions could have more than one + * Name. This function will try to find a suitable Name, but no guarantees can be made as + * to the result; different values can be returned across subsequent calls, especially if + * the Petition state changes. + * + * @since COmanage Registry v5.2.0 + * @param EntityInterface $config Configuration entity for this plugin + * @param int $petitionId Petition ID + * @return string|null Name, or null if there is no name data + */ + public function enrolleeName( + EntityInterface $config, + int $petitionId + ): ?string { + // Identify active "name" EnrollmentAttributes for this collector (in configured order) + $nameEnrollmentAttributeIds = $this->EnrollmentAttributes + ->find() + ->select(['id']) + ->where([ + 'attribute_collector_id' => $config->id, + 'attribute' => 'name', + 'status' => StatusEnum::Active, + ]) + ->orderBy(['ordr' => 'ASC']) + ->all() + ->extract('id') + ->toList(); + + if (empty($nameEnrollmentAttributeIds)) { + return null; + } + + // Pull all PetitionAttributes rows for those EnrollmentAttributes + $rows = $this->EnrollmentAttributes + ->PetitionAttributes + ->find() + ->where(['petition_id' => $petitionId]) + ->where(fn(QueryExpression $exp, Query $q) => $exp->in('enrollment_attribute_id', $nameEnrollmentAttributeIds)) + ->orderBy(['enrollment_attribute_id' => 'ASC', 'id' => 'ASC']) + ->toArray(); + + if (empty($rows)) { + return null; + } + + // Choose the first EnrollmentAttribute (by ordr) that has any stored rows + $rowsByEnrollmentAttributeId = []; + foreach ($rows as $r) { + $rowsByEnrollmentAttributeId[$r->enrollment_attribute_id][] = $r; + } + + $chosenEnrollmentAttributeId = null; + foreach ($nameEnrollmentAttributeIds as $eaId) { + if (!empty($rowsByEnrollmentAttributeId[$eaId])) { + $chosenEnrollmentAttributeId = $eaId; + break; + } + } + + if ($chosenEnrollmentAttributeId === null) { + return null; + } + + // Map PetitionAttributes.column_name => PetitionAttributes.value into Name fields + $allowedColumns = ['honorific', 'given', 'middle', 'family', 'suffix']; + $nameData = []; + + foreach ($rowsByEnrollmentAttributeId[$chosenEnrollmentAttributeId] as $r) { + $column = $r->column_name ?? ''; + if ($column !== '' && in_array($column, $allowedColumns, true) && !empty($r->value)) { + $nameData[$column] = $r->value; + } + } + + if (empty($nameData)) { + return null; + } + + // Construct a throw-away Name entity so we can use its full_name virtual field + $Names = TableRegistry::getTableLocator()->get('Names'); + $name = $Names->newEntity($nameData); + + return !empty($name->full_name) ? $name->full_name : null; + } + /** * Perform steps necessary to hydrate the Person record as part of Petition finalization. * @@ -158,7 +228,6 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition): bool // Get the collection object $attributesCollection = new Collection($attributes); - /*********** PERSON ROLE ***************/ // Filter the Person Role Attributes and keep the field name $personRoleAttributes = (new Collection($supportedAttributes))->filter(function($attr, $key) { @@ -175,9 +244,11 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition): bool $cxn = $this->getConnection(); $cxn->begin(); - // Save the Person Role - $personRoleObj = TableRegistry::getTableLocator()->get('PersonRoles'); - $role = $personRoleObj->saveAttributeCollectorPetitionAttributes((int)$person->id, $fieldsForPersonRole); + // Save the Person Role only if we collected something meaningful for it, + if(!empty($fieldsForPersonRole)) { + $personRoleObj = TableRegistry::getTableLocator()->get('PersonRoles'); + $role = $personRoleObj->saveAttributeCollectorPetitionAttributes((int)$person->id, $fieldsForPersonRole); + } /********* MVEAS **************/ // Filter the MVEAS Attributes and keep the field name @@ -197,14 +268,17 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition): bool ); // MVEAs for Role - $this->handleMveaAttributes( - $person, - $role, - 'PersonRole', - $mveaAttributes, - $attributes, - $cxn - ); + // Save the MVEAs for the PersonRole only if we collected something meaningful for it, and the role was created + if(!empty($fieldsForPersonRole)) { + $this->handleMveaAttributes( + $person, + $role, + 'PersonRole', + $mveaAttributes, + $attributes, + $cxn + ); + } /****** PERSON ******/ // Keep the person attributes @@ -317,10 +391,10 @@ public function upsert(int $id, int $petitionId, array $attributes) { // particular Attribute Collector; however we'll only look at the attributes // we need below. $currentAttributes = $this->EnrollmentAttributes - ->PetitionAttributes->find('list', [ - 'keyField' => 'enrollment_attribute_id', - 'valueField' => 'id' - ]) + ->PetitionAttributes->find('list', + keyField: 'enrollment_attribute_id', + valueField: 'id', + ) ->where(['petition_id' => $petitionId]) ->toArray(); @@ -434,15 +508,14 @@ public function verifiableEmailAddresses( ): array { // First get the Enrollment Attributes for this petition $vv_enrollment_attributes = $this->EnrollmentAttributes->find('list', - [ - 'keyField' => 'id', - 'valueField' => 'attribute_type' - ])->where([ + keyField: 'id', + valueField: 'attribute_type', + )->where([ 'attribute_collector_id' => $config->id, 'attribute' => 'emailAddress', 'status' => StatusEnum::Active, ]) - ->order(['ordr' => 'ASC']) + ->orderBy(['ordr' => 'ASC']) ->toArray(); if (empty($vv_enrollment_attributes)) { diff --git a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php index 0a99fa869..99da23e1d 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php @@ -46,7 +46,6 @@ class BasicAttributeCollectorsTable extends Table { use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; - use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -92,22 +91,6 @@ public function initialize(array $config): void { $this->setRequiresCO(true); $this->setAllowLookupPrimaryLink(['dispatch', 'display']); - // All the tabs share the same configuration in the ModelTable file - $this->setTabsConfig( - [ - // Ordered list of Tabs - 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.BasicAttributeCollectors'], - // What actions will include the subnavigation header - 'action' => [ - // If a model renders in a subnavigation mode in edit/view mode, it cannot - // render in index mode for the same use case/context - // XXX edit should go first. - 'EnrollmentFlowSteps' => ['edit', 'view'], - 'CoreEnroller.BasicAttributeCollectors' => ['edit'], - ] - ] - ); - $this->setAutoViewVars([ 'affiliationTypes' => [ 'type' => 'type', @@ -149,6 +132,46 @@ public function initialize(array $config): void { ]); } + /** + * Obtain a name (as a string) for the Enrollee associated with the specified Petition. + * + * @since COmanage Registry v5.2.0 + * @param EntityInterface $config Configuration entity for this plugin + * @param int $petitionId Petition ID + * @return string Name, or null if thre is no name data + */ + + public function enrolleeName( + EntityInterface $config, + int $petitionId + ): ?string { + $set = $this->PetitionBasicAttributeSets->find() + ->where([ + 'basic_attribute_collector_id' => $config->id, + 'petition_id' => $petitionId + ]) + ->first(); + + if(!empty($set)) { + // We construct a throw-away Name entity so we can use it to generate the string + // representation of the name data we have. + + $Names = TableRegistry::getTableLocator()->get('Names'); + + $name = $Names->newEntity([ + 'honorific' => $set->honorific, + 'given' => $set->given, + 'middle' => $set->middle, + 'family' => $set->family, + 'suffix' => $set->suffix + ]); + + return $name->full_name; + } + + return null; + } + /** * Perform steps necessary to hydrate the Person record as part of Petition finalization. * @@ -232,8 +255,6 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition) { enrollmentFlowStepId: $cfg->enrollment_flow_step_id, action: PetitionActionEnum::Finalized, comment: __d('core_enroller', 'result.basicattr.finalized') -// We don't have $actorPersonId yet... -// ?int $actorPersonId=null ); return true; @@ -253,35 +274,35 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition) { public function upsert(int $id, int $petitionId, array $attributes) { $basicAttributeCollector = $this->get($id); - // Do we have existing attributes for this petition? Note this will pull - // _all_ attributes for the Petition, not just those associated with this - // particular Attribute Collector; however we'll only look at the attributes - // we need below. - $entity = $this->PetitionBasicAttributeSets - ->find() - ->where([ - 'petition_id' => $petitionId, - // Strictly speaking we only support one instance per Flow, - // but we'll filter on the $id anyway since we have it - 'basic_attribute_collector_id' => $id - ]) - ->first(); - - if(!$entity) { - // insert, not update - - $entity = $this->PetitionBasicAttributeSets->newEntity([ - 'basic_attribute_collector_id' => $id, - 'petition_id' => $petitionId - ]); - } + $data = [ + 'basic_attribute_collector_id' => $id, + 'petition_id' => $petitionId + ]; foreach(['honorific', 'given', 'middle', 'family', 'suffix', 'mail'] as $f) { // XXX we should probably check CoSettings for name settings - $entity->$f = $attributes[$f] ?? null; + $data[$f] = $attributes[$f] ?? null; } - $this->PetitionBasicAttributeSets->saveOrFail($entity); + $pei = $this->PetitionBasicAttributeSets->upsertOrFail( + data: $data, + whereClause: [ + 'petition_id' => $petitionId, + 'basic_attribute_collector_id' => $id, + ] + ); + + if(!empty($basicAttributeCollector->cou_id)) { + // Insert the COU ID into the primary Petition artifact + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition = $Petitions->get($petitionId); + + $petition->cou_id = $basicAttributeCollector->cou_id; + + $Petitions->save($petition); + } // Record Petition History @@ -292,8 +313,6 @@ public function upsert(int $id, int $petitionId, array $attributes) { enrollmentFlowStepId: $basicAttributeCollector->enrollment_flow_step_id, action: PetitionActionEnum::AttributesUpdated, comment: __d('core_enroller', 'result.attr.saved') -// We don't have $actorPersonId yet... -// ?int $actorPersonId=null ); return true; diff --git a/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php b/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php index 4f64ed4ef..df33a5a6d 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php @@ -29,19 +29,18 @@ namespace CoreEnroller\Model\Table; +use App\Lib\Enum\AllTernaryEnum; use App\Lib\Enum\EnrollmentActorEnum; +use App\Lib\Enum\PermittedCharactersEnum; use App\Lib\Enum\PetitionStatusEnum; use App\Lib\Enum\SuspendableStatusEnum; +use App\Lib\Enum\TableTypeEnum; use App\Lib\Util\StringUtilities; use App\Model\Entity\Petition; -use Cake\Datasource\ConnectionManager; -use Cake\Datasource\EntityInterface; -use Cake\ORM\Query; -use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\Validation\Validator; -use CoreEnroller\Lib\Enum\VerificationModeEnum; +use CoreEnroller\Lib\Enum\VerificationDefaultsEnum; use CoreEnroller\Model\Entity\EmailVerifier; class EmailVerifiersTable extends Table { @@ -52,7 +51,6 @@ class EmailVerifiersTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; - use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\ValidationTrait; /** @@ -69,7 +67,7 @@ public function initialize(array $config): void { $this->addBehavior('Log'); $this->addBehavior('Timestamp'); - $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + $this->setTableType(TableTypeEnum::Configuration); // Define associations $this->belongsTo('EnrollmentFlowSteps'); @@ -86,27 +84,15 @@ public function initialize(array $config): void { $this->setPrimaryLink('enrollment_flow_step_id'); $this->setRequiresCO(true); $this->setAllowLookupPrimaryLink(['dispatch', 'display', 'resend']); - - // All the tabs share the same configuration in the ModelTable file - $this->setTabsConfig( - [ - // Ordered list of Tabs - 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.EmailVerifiers'], - // What actions will include the subnavigation header - 'action' => [ - // If a model renders in a subnavigation mode in edit/view mode, it cannot - // render in index mode for the same use case/context - // XXX edit should go first. - 'EnrollmentFlowSteps' => ['edit', 'view'], - 'CoreEnroller.EmailVerifiers' => ['edit'] - ] - ] - ); $this->setAutoViewVars([ 'modes' => [ 'type' => 'enum', - 'class' => 'CoreEnroller.VerificationModeEnum' + 'class' => 'AllTernaryEnum' + ], + 'defaults' => [ + 'type' => 'enum', + 'class' => 'CoreEnroller.VerificationDefaultsEnum' ], 'messageTemplates' => [ 'type' => 'select', @@ -120,6 +106,10 @@ public function initialize(array $config): void { 'types' => [ 'type' => 'auxiliary', 'model' => 'Types' + ], + 'permittedCharacters' => [ + 'type' => 'enum', + 'class' => 'PermittedCharactersEnum' ] ]); @@ -179,10 +169,10 @@ public function assembleVerifiableAddresses( $steps = $this->EnrollmentFlowSteps->find() ->where([ - 'enrollment_flow_id' => $petition->enrollment_flow_id, - 'status' => SuspendableStatusEnum::Active + 'EnrollmentFlowSteps.enrollment_flow_id' => $petition->enrollment_flow_id, + 'EnrollmentFlowSteps.status' => SuspendableStatusEnum::Active ]) - ->order(['EnrollmentFlowSteps.ordr' => 'ASC']) + ->orderBy(['EnrollmentFlowSteps.ordr' => 'ASC']) ->contain($this->EnrollmentFlowSteps->getPluginRelations()) ->all(); @@ -209,26 +199,61 @@ public function assembleVerifiableAddresses( if(!$verified) { // We can consider this address verified if there was a transition _to_ a Step - // with an Enrollee actor no later than the current Step. + // with an Enrollee actor no later than the current Step. In order to allow this + // we need to confirm that the Petitioner is not also the Enrollee. We can't rely + // on the Enrollment Flow Petitioner Authorization because for any possible setting + // the Enrollee could also be the Petitioner (eg: Additional Role Enrollment, + // Account Linking, etc). + + // We can definitively compare petitioner_identifier with enrollee_identifier + // (if both are set) or petitioner_person_id and enrollee_person_id (if both + // are set), or if neither petitioner value is set (unauthenticated enrollments + // are presumed to be self signups). + + // We default to the initial Actor being Enrollee to require an Actor flip + // if we can't otherwise determine that the Petitioner is the Enrollee. + $lastActor = EnrollmentActorEnum::Enrollee; + + // We basically look to see if we can confirm the Petitioner is _not_ the + // Enrollee, and if we can then we flip $lastActor to Petitioner. + + if(!empty($petition->petitioner_person_id)) { + // This Petitioner is an authenticated, registered Person, and is not + // the Enrollee. + + if(empty($petition->enrollee_person_id) + || $petition->petitioner_person_id != $petition->enrollee_person_id) { + $lastActor = EnrollmentActorEnum::Petitioner; + } - foreach($steps as $step) { - if($step->status == SuspendableStatusEnum::Active - && $step->actor_type == EnrollmentActorEnum::Enrollee) { - $this->llog('debug', "Flagging " . $petition->enrollee_email . " as verified via Handoff"); + // There could potentially be other scenarios, but currently the above is + // the only one we can confirm. + } - $ret[ $petition->enrollee_email ] = $PetitionVerifications->verifyFromHandoff( - $petition->id, - $emailVerifier->enrollment_flow_step_id, - $petition->enrollee_email - ); + $petitionerIsEnrollee = false; - $verified = true; - break; - } + foreach($steps as $step) { + if($step->status == SuspendableStatusEnum::Active) { + if($lastActor != EnrollmentActorEnum::Enrollee + && $step->actor_type == EnrollmentActorEnum::Enrollee) { + $this->llog('debug', "Flagging " . $petition->enrollee_email . " as verified via Handoff"); + + $ret[ $petition->enrollee_email ] = $PetitionVerifications->verifyFromHandoff( + $petition->id, + $emailVerifier->enrollment_flow_step_id, + $petition->enrollee_email + ); + + $verified = true; + break; + + if($step->id == $emailVerifier->enrollment_flow_step_id) { + // Don't check future Steps + break; + } + } - if($step->id == $emailVerifier->enrollment_flow_step_id) { - // Don't check future Steps - break; + $lastActor = $step->actor_type; } } } @@ -438,16 +463,26 @@ public function sendVerificationRequest( ]) ->first(); + [$charset, $regex] = $this->calculateVerificationCodeCharsetMode( + $emailVerifier->verification_code_charset, + $emailVerifier->verification_code_regex + ); + if (empty($pVerification)) { // Request Verification and create an associated Petition Verification $this->llog('debug', "Sending verification code to $mail for Petition " . $petition->id); + // I need to figure out the allowed characters for the code. + $verificationId = $Verifications->requestCodeForPetition( - $petition->id, - $mail, - $emailVerifier->message_template_id, - $emailVerifier->request_validity + petitionId: $petition->id, + mail: $mail, + messageTemplateId: $emailVerifier->message_template_id, + validity: $emailVerifier->request_validity, + codeLength: !empty($emailVerifier->verification_code_length) ? $emailVerifier->verification_code_length : VerificationDefaultsEnum::DefaultCodeLength, + codeCharset: $charset, + codeRegex: $regex, ); $pVerification = $PetitionVerifications->saveOrFail( @@ -465,11 +500,14 @@ public function sendVerificationRequest( $this->llog('debug', "Sending replacement verification code to $mail for Petition " . $petition->id); $verificationId = $Verifications->requestCodeForPetition( - $petition->id, - $mail, - $emailVerifier->message_template_id, - $emailVerifier->request_validity, - $pVerification->verification_id + petitionId: $petition->id, + mail: $mail, + messageTemplateId: $emailVerifier->message_template_id, + validity: $emailVerifier->request_validity, + codeLength: !empty($emailVerifier->verification_code_length) ? $emailVerifier->verification_code_length : VerificationDefaultsEnum::DefaultCodeLength, + codeCharset: $charset, + codeRegex: $regex, + verificationId: $pVerification->verification_id, ); // There's nothing to update in the Petition Verification @@ -479,6 +517,30 @@ public function sendVerificationRequest( return false; } + + /** + * Calculate verification code charset based on provided charset or permitted characters. + * Returns default charset if neither is provided. + * + * @param ?string $charset Custom charset for verification code + * @param ?string $permitted Permitted characters enum value + * @return array Resolved charset to use for verification code + * @since COmanage Registry v5.1.0 + */ + protected function calculateVerificationCodeCharsetMode( + ?string $charset, + ?string $permitted + ): array { + if (empty($charset) && empty($permitted)) { + return [VerificationDefaultsEnum::DefaultCharset, null]; + } + if (!empty($permitted)) { + return [null, PermittedCharactersEnum::getPermittedCharacters(enum: $permitted)]; + } + + return [$charset, null]; + } + /** * Set validation rules. * @@ -496,7 +558,7 @@ public function validationDefault(Validator $validator): Validator { $validator->notEmptyString('enrollment_flow_step_id'); $validator->add('mode', [ - 'content' => ['rule' => ['inList', VerificationModeEnum::getConstValues()]] + 'content' => ['rule' => ['inList', AllTernaryEnum::getConstValues()]] ]); $validator->notEmptyString('mode'); @@ -510,6 +572,55 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('request_validity'); + $validator + ->add('verification_code_charset', [ + 'content' => [ + 'rule' => 'alphaNumeric', + 'last' => true, + 'message' => __d('core_enroller', 'error.EmailVerifiers.verification_code_charset.content'), + ], + 'is_upper_case' => [ + 'rule' => fn($value, $context) => $value === strtoupper($value), + 'message' => __d('core_enroller', 'error.EmailVerifiers.verification_code_charset.is_upper_case'), + 'last' => true, + ], + ]); + $validator->allowEmptyString('verification_code_charset'); + + $validator->add('verification_code_regex', [ + 'content' => ['rule' => ['inList', PermittedCharactersEnum::getConstValues()]] + ]); + $validator->allowEmptyString('verification_code_regex'); + + $validator + ->add('verification_code_length', 'content', [ + 'rule' => 'isInteger', + 'last' => true, + 'message' => __d('core_enroller', 'error.EmailVerifiers.code_length.content'), + ]) + ->add('verification_code_length', 'comparison_max', [ + 'rule' => ['comparison', '>=', 1], + 'last' => true, + 'message' => __d('core_enroller', 'error.EmailVerifiers.code_length.comparison_max'), + ]) + ->add('verification_code_length', 'comparison_less', [ + 'rule' => ['comparison', '<=', 20], + 'last' => true, + 'message' => __d('core_enroller', 'error.EmailVerifiers.code_length.comparison_less'), + ]) + ->add('verification_code_length', 'step_four', [ + 'rule' => ['validateIncreaseStep', 4], + 'provider' => 'table', + 'last' => true, + 'message' => __d('core_enroller', 'error.EmailVerifiers.code_length.step_four'), + ]); + $validator->allowEmptyString('verification_code_length'); + + $validator->add('enable_blockonfailure', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('enable_blockonfailure'); + return $validator; } } diff --git a/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php b/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php index 97307f214..69fb10619 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php @@ -41,7 +41,6 @@ class EnrollmentAttributesTable extends Table { use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; - use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -179,24 +178,6 @@ public function initialize(array $config): void { 'view' => 'iframe', ]); - // All the tabs share the same configuration in the ModelTable file - $this->setTabsConfig( - [ - // Ordered list of Tabs - 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.AttributeCollectors', 'CoreEnroller.EnrollmentAttributes'], - // What actions will inlcude the subnavigation header - 'action' => [ - // If a model renders in a subnavigation mode in edit/view mode, it cannot - // render in index mode for the same use case/context - // XXX edit should go first. - 'EnrollmentFlowSteps' => ['edit', 'view'], - 'CoreEnroller.AttributeCollectors' => ['edit'], - 'CoreEnroller.EnrollmentAttributes' => ['index'], - ], - 'skipTab' => ['CoreEnroller.AttributeCollectors'] - ] - ); - $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ diff --git a/app/plugins/CoreEnroller/src/Model/Table/IdentifierCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/IdentifierCollectorsTable.php index fbc2c3335..cf34d2185 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/IdentifierCollectorsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/IdentifierCollectorsTable.php @@ -44,7 +44,6 @@ class IdentifierCollectorsTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; - use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\ValidationTrait; /** @@ -78,22 +77,6 @@ public function initialize(array $config): void { $this->setRequiresCO(true); $this->setAllowLookupPrimaryLink(['dispatch', 'display']); - // All the tabs share the same configuration in the ModelTable file - $this->setTabsConfig( - [ - // Ordered list of Tabs - 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.IdentifierCollectors'], - // What actions will include the subnavigation header - 'action' => [ - // If a model renders in a subnavigation mode in edit/view mode, it cannot - // render in index mode for the same use case/context - // XXX edit should go first. - 'EnrollmentFlowSteps' => ['edit', 'view'], - 'CoreEnroller.IdentifierCollectors' => ['edit'] - ] - ] - ); - $this->setAutoViewVars([ 'types' => [ 'type' => 'type', diff --git a/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php index 21587b228..93f60647e 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php @@ -45,7 +45,6 @@ class InvitationAcceptersTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; - use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\ValidationTrait; /** @@ -77,22 +76,6 @@ public function initialize(array $config): void { $this->setPrimaryLink('enrollment_flow_step_id'); $this->setRequiresCO(true); $this->setAllowLookupPrimaryLink(['dispatch', 'display']); - - // All the tabs share the same configuration in the ModelTable file - $this->setTabsConfig( - [ - // Ordered list of Tabs - 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.InvitationAccepters'], - // What actions will include the subnavigation header - 'action' => [ - // If a model renders in a subnavigation mode in edit/view mode, it cannot - // render in index mode for the same use case/context - // XXX edit should go first. - 'EnrollmentFlowSteps' => ['edit', 'view'], - 'CoreEnroller.InvitationAccepters' => ['edit'] - ] - ] - ); $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php index 1c5e47831..9a0afedbd 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php @@ -144,10 +144,8 @@ public function processReply(int $petitionId, int $enrollmentFlowStepId, bool $a $this->Petitions->PetitionHistoryRecords->record( petitionId: $petitionId, enrollmentFlowStepId: $enrollmentFlowStepId, - action: PetitionActionEnum::StatusUpdated, + action: $accepted ? PetitionActionEnum::Accepted : PetitionActionEnum::Declined, comment: __d('core_enroller', $accepted ? 'result.accept.accepted' : 'result.accept.declined') -// We don't have $actorPersonId yet... -// ?int $actorPersonId=null ); } diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionApprovalsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionApprovalsTable.php new file mode 100644 index 000000000..f348589a3 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionApprovalsTable.php @@ -0,0 +1,127 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('CoreEnroller.ApprovalCollectors'); + $this->belongsTo('Petitions'); + $this->belongsTo('ApproverPeople') + ->setClassName('People') + ->setForeignKey('approver_person_id') + ->setProperty('approver_person'); + + $this->setDisplayField('comment'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('approval_collector_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('approval_collector_id'); + + $validator->add('approver_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('approver_person_id'); + + $validator->add('approved', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('approved'); + + $this->registerStringValidation($validator, $schema, 'comment', false); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php index a51e31976..7cc0662a7 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php @@ -39,6 +39,7 @@ class PetitionBasicAttributeSetsTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; + use \App\Lib\Traits\UpsertTrait; use \App\Lib\Traits\ValidationTrait; /** diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php index ecf39b690..21dd4ba13 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php @@ -115,13 +115,11 @@ public function verifyCode(int $petitionId, int $enrollmentFlowStepId, string $m // Find the PetitionVerification for the requested petition and address, // then use the verification ID to process the code. - $pVerification = $this->find() - ->where([ - 'petition_id' => $petitionId, - 'mail' => $mail - ]) - ->firstOrFail(); - + $pVerification = $this->getPetitionVerification($petitionId, $mail); + // Increase the attempt counter and save regardless of the code validation + $pVerification->attempts_count = ($pVerification->attempts_count ?? 0) + 1; + $this->save($pVerification); + // This will throw an error on failure $this->Verifications->verifyCode($pVerification->verification_id, $code); @@ -202,4 +200,25 @@ public function validationDefault(Validator $validator): Validator { return $validator; } + + + /** + * Retrieve a PetitionVerification entity based on a petition ID and email address. + * + * @since COmanage Registry v5.2.0 + * @param integer $petitionId Petition ID + * @param string $mail Email Address associated with the PetitionVerification + * @param bool $strict Whether to throw an error if no result is found (default: true) + * @return \CoreEnroller\Model\Entity\PetitionVerification|null PetitionVerification entity if found, or null + * @throws \Cake\Datasource\Exception\RecordNotFoundException If $strict is true and no result is found + */ + public function getPetitionVerification(int $petitionId, string $mail, bool $strict = true): ?PetitionVerification + { + $query = $this->find() + ->where([ + 'petition_id' => $petitionId, + 'mail' => $mail + ]); + return $strict ? $query->firstOrFail() : $query->first(); + } } diff --git a/app/plugins/CoreEnroller/src/View/Cell/ApprovalCollectorsCell.php b/app/plugins/CoreEnroller/src/View/Cell/ApprovalCollectorsCell.php new file mode 100644 index 000000000..3eaabd466 --- /dev/null +++ b/app/plugins/CoreEnroller/src/View/Cell/ApprovalCollectorsCell.php @@ -0,0 +1,96 @@ + + */ + protected array $_validCellOptions = [ + 'vv_obj', + 'vv_step', + 'viewVars', + ]; + + /** + * Initialization logic run at the end of object construction. + * + * @return void + */ + public function initialize(): void + { + } + + /** + * Default display method. + * + * @since COmanage Registry v5.2.0 + * @param int $petitionId + * @return void + */ + + public function display(int $petitionId): void { + $vv_pa = $this->fetchTable('CoreEnroller.PetitionApprovals') + ->find() + ->where([ + 'approval_collector_id' => $this->vv_step->approval_collector->id, + 'petition_id' => $this->vv_obj->id + ]) + ->contain(['ApproverPeople' => 'PrimaryName']) + ->first(); + + $this->set('vv_pa', $vv_pa); + } +} diff --git a/app/plugins/CoreEnroller/src/View/Cell/AttributeCollectorsCell.php b/app/plugins/CoreEnroller/src/View/Cell/AttributeCollectorsCell.php index 61665e0d1..0c7d3ed51 100644 --- a/app/plugins/CoreEnroller/src/View/Cell/AttributeCollectorsCell.php +++ b/app/plugins/CoreEnroller/src/View/Cell/AttributeCollectorsCell.php @@ -43,13 +43,28 @@ */ class AttributeCollectorsCell extends Cell { + /** + * @var mixed + */ + public $vv_obj; + + /** + * @var mixed + */ + public $vv_step; + + /** + * @var mixed + */ + public $viewVars; + /** * List of valid options that can be passed into this * cell's constructor. * * @var array */ - protected $_validCellOptions = [ + protected array $_validCellOptions = [ 'vv_obj', 'vv_step', 'viewVars', @@ -73,7 +88,7 @@ public function initialize(): void */ public function display(int $petitionId): void { - $vv_enrollment_atttributes_ids = Hash::extract($this->vv_obj->petition_attributes, '{n}.enrollment_attribute_id'); + $vv_enrollment_atttributes_ids = Hash::extract($this->vv_obj->petition_attributes ?? [], '{n}.enrollment_attribute_id'); $vv_enrollment_atttributes_ids = array_unique($vv_enrollment_atttributes_ids); $vv_enrollment_attributes = []; @@ -81,7 +96,7 @@ public function display(int $petitionId): void $vv_enrollment_attributes = $this->fetchTable('EnrollmentAttributes') ->find() ->where(fn(QueryExpression $exp, Query $q) => $exp->in('id', $vv_enrollment_atttributes_ids)) - ->order(['ordr' => 'ASC']) + ->orderBy(['ordr' => 'ASC']) ->toArray(); } diff --git a/app/plugins/CoreEnroller/src/View/Cell/BasicAttributeCollectorsCell.php b/app/plugins/CoreEnroller/src/View/Cell/BasicAttributeCollectorsCell.php index 16e12361b..b7cef6401 100644 --- a/app/plugins/CoreEnroller/src/View/Cell/BasicAttributeCollectorsCell.php +++ b/app/plugins/CoreEnroller/src/View/Cell/BasicAttributeCollectorsCell.php @@ -40,26 +40,41 @@ */ class BasicAttributeCollectorsCell extends Cell { - /** - * List of valid options that can be passed into this - * cell's constructor. - * - * @var array - */ - protected $_validCellOptions = [ + /** + * @var mixed + */ + public $vv_obj; + + /** + * @var mixed + */ + public $vv_step; + + /** + * @var mixed + */ + public $viewVars; + + /** + * List of valid options that can be passed into this + * cell's constructor. + * + * @var array + */ + protected array $_validCellOptions = [ 'vv_obj', 'vv_step', 'viewVars', ]; - /** - * Initialization logic run at the end of object construction. - * - * @return void - */ - public function initialize(): void - { - } + /** + * Initialization logic run at the end of object construction. + * + * @return void + */ + public function initialize(): void + { + } /** * Default display method. @@ -68,14 +83,14 @@ public function initialize(): void * @return string * @since COmanage Registry v5.1.0 */ - public function display(int $petitionId): void - { - $vv_petition_basic_attribute_set = $this->fetchTable('PetitionBasicAttributeSets') - ->find() - ->where(['PetitionBasicAttributeSets.petition_id' => $this->vv_obj->id]) - ->firstOrFail(); - - $this->set('vv_petition_basic_attribute_set', $vv_petition_basic_attribute_set); - $this->set('vv_obj', $this->vv_obj); - } + public function display(int $petitionId): void + { + $vv_petition_basic_attribute_set = $this->fetchTable('PetitionBasicAttributeSets') + ->find() + ->where(['PetitionBasicAttributeSets.petition_id' => $this->vv_obj->id]) + ->firstOrFail(); + + $this->set('vv_petition_basic_attribute_set', $vv_petition_basic_attribute_set); + $this->set('vv_obj', $this->vv_obj); + } } diff --git a/app/plugins/CoreEnroller/src/View/Cell/EmailVerifiersCell.php b/app/plugins/CoreEnroller/src/View/Cell/EmailVerifiersCell.php index e855b6e2a..fd64e416e 100644 --- a/app/plugins/CoreEnroller/src/View/Cell/EmailVerifiersCell.php +++ b/app/plugins/CoreEnroller/src/View/Cell/EmailVerifiersCell.php @@ -40,13 +40,28 @@ */ class EmailVerifiersCell extends Cell { + /** + * @var mixed + */ + public $vv_obj; + + /** + * @var mixed + */ + public $vv_step; + + /** + * @var mixed + */ + public $viewVars; + /** * List of valid options that can be passed into this * cell's constructor. * * @var array */ - protected $_validCellOptions = [ + protected array $_validCellOptions = [ 'vv_obj', 'vv_step', 'viewVars', diff --git a/app/plugins/CoreEnroller/src/View/Cell/IdentifierCollectorsCell.php b/app/plugins/CoreEnroller/src/View/Cell/IdentifierCollectorsCell.php index af18eb85b..be102d0e6 100644 --- a/app/plugins/CoreEnroller/src/View/Cell/IdentifierCollectorsCell.php +++ b/app/plugins/CoreEnroller/src/View/Cell/IdentifierCollectorsCell.php @@ -40,13 +40,28 @@ */ class IdentifierCollectorsCell extends Cell { + /** + * @var mixed + */ + public $vv_obj; + + /** + * @var mixed + */ + public $vv_step; + + /** + * @var mixed + */ + public $viewVars; + /** * List of valid options that can be passed into this * cell's constructor. * * @var array */ - protected $_validCellOptions = [ + protected array $_validCellOptions = [ 'vv_obj', 'vv_step', 'viewVars', diff --git a/app/plugins/CoreEnroller/src/View/Cell/InvitationAcceptersCell.php b/app/plugins/CoreEnroller/src/View/Cell/InvitationAcceptersCell.php index 320b8dc87..9b643ef83 100644 --- a/app/plugins/CoreEnroller/src/View/Cell/InvitationAcceptersCell.php +++ b/app/plugins/CoreEnroller/src/View/Cell/InvitationAcceptersCell.php @@ -40,13 +40,28 @@ */ class InvitationAcceptersCell extends Cell { + /** + * @var mixed + */ + public $vv_obj; + + /** + * @var mixed + */ + public $vv_step; + + /** + * @var mixed + */ + public $viewVars; + /** * List of valid options that can be passed into this * cell's constructor. * * @var array */ - protected $_validCellOptions = [ + protected array $_validCellOptions = [ 'vv_obj', 'vv_step', 'viewVars', diff --git a/app/plugins/CoreEnroller/src/config/plugin.json b/app/plugins/CoreEnroller/src/config/plugin.json deleted file mode 100644 index bb58ddb56..000000000 --- a/app/plugins/CoreEnroller/src/config/plugin.json +++ /dev/null @@ -1,163 +0,0 @@ -{ - "types": { - "enroller": [ - "AttributeCollectors", - "BasicAttributeCollectors", - "EmailVerifiers", - "IdentifierCollectors", - "InvitationAccepters" - ] - }, - "schema": { - "tables": { - "attribute_collectors": { - "columns": { - "id": {}, - "enrollment_flow_step_id": {}, - "description": { "temporary": true, "type": "string", "size": 80 } - }, - "indexes": { - "attribute_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] } - } - }, - "basic_attribute_collectors": { - "columns": { - "id": {}, - "enrollment_flow_step_id": {}, - "name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } } - }, - "indexes": { - "basic_attribute_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, - "basic_attribute_collectors_i2": { "needed": false, "columns": [ "name_type_id" ] }, - "basic_attribute_collectors_i3": { "needed": false, "columns": [ "email_address_type_id" ] }, - "basic_attribute_collectors_i4": { "needed": false, "columns": [ "affiliation_type_id" ] }, - "basic_attribute_collectors_i5": { "needed": false, "columns": [ "cou_id" ] } - } - }, - "email_verifiers": { - "columns": { - "id": {}, - "enrollment_flow_step_id": {}, - "mode": { "type": "string", "size": 2 }, - "message_template_id": {}, - "request_validity": { "type": "integer" } - }, - "indexes": { - "email_verifiers_i1": { "columns": [ "enrollment_flow_step_id" ] }, - "email_verifiers_i2": { "needed": false, "columns": [ "message_template_id" ] } - } - }, - "enrollment_attributes": { - "columns": { - "id": {}, - "attribute_collector_id": { "type": "integer", "foreignkey": { "table": "attribute_collectors", "column": "id" } }, - "attribute": { "type": "string", "size": 80 }, - "attribute_type": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "attribute_language": { "type": "string", "size": 16 }, - "attribute_mvea_parent": { "type": "string", "size": 32 }, - "attribute_tag": { "type": "string", "size": 128 }, - "status": {}, - "label": { "type": "string", "size": 80 }, - "description": {}, - "required": { "type": "boolean" }, - "ordr": {}, - "default_value": { "type": "string", "size": 160 }, - "default_value_datetime": { "type": "datetime" }, - "default_value_env_name": { "type": "string", "size": 80 }, - "modifiable": { "type": "boolean" }, - "hidden": { "type": "boolean" } - }, - "indexes": { - "enrollment_attributes_i1": { "columns": [ "attribute_collector_id" ] } - } - }, - "identifier_collectors": { - "columns": { - "id": {}, - "enrollment_flow_step_id": {}, - "type_id": { "notnull": false, "comment": "Skeletal row needs to be created without type" } - }, - "indexes": { - "identifier_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, - "identifier_collectors_i2": { "needed": false, "columns": [ "type_id" ] } - } - }, - "invitation_accepters": { - "columns": { - "id": {}, - "enrollment_flow_step_id": {}, - "invitation_validity": { "type": "integer" }, - "welcome_message": { "type": "text" } - }, - "indexes": { - "invitation_accepters_i1": { "columns": [ "enrollment_flow_step_id" ] } - } - }, - "petition_acceptances": { - "columns": { - "id": {}, - "petition_id": {}, - "accepted": { "type": "boolean" } - }, - "indexes": { - "petition_acceptances_i1": { "columns": [ "petition_id" ] } - } - }, - "petition_attributes": { - "columns": { - "id": {}, - "petition_id": {}, - "enrollment_attribute_id": { "type": "integer", "foreignkey": { "table": "enrollment_attributes", "column": "id" } }, - "value": { "type": "string", "size": 160 }, - "column_name": { "notnull": false, "type": "string", "size": 64 } - }, - "indexes": { - "petition_attributes_i1": { "columns": [ "petition_id" ] }, - "petition_attributes_i2": { "needed": false, "columns": [ "enrollment_attribute_id" ] } - } - }, - "petition_basic_attribute_sets": { - "columns": { - "id": {}, - "petition_id": {}, - "basic_attribute_collector_id": { "type": "integer", "foreignkey": { "table": "basic_attribute_collectors", "column": "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 }, - "mail": { "type": "string", "size": 256 } - }, - "indexes": { - "petition_basic_attribute_sets_i1": { "columns": [ "petition_id" ] }, - "petition_basic_attribute_sets_i2": { "columns": [ "basic_attribute_collector_id" ] } - } - }, - "petition_identifiers": { - "columns": { - "id": {}, - "petition_id": {}, - "identifier": { "type": "string", "size": 512 } - }, - "indexes": { - "petition_identifiers_i1": { "columns": [ "petition_id" ] } - } - }, - "petition_verifications": { - "columns": { - "id": {}, - "petition_id": {}, - "mail": {}, - "verification_id": { "type": "integer", "foreignkey": { "table": "verifications", "column": "id" } } - }, - "indexes": { - "petition_verifications_i1": { "columns": [ "petition_id" ] }, - "petition_verifications_i2": { "needed": false, "columns": [ "verification_id" ] } - } - } - } - } -} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/ApprovalCollectors/dispatch.inc b/app/plugins/CoreEnroller/templates/ApprovalCollectors/dispatch.inc new file mode 100644 index 000000000..4eefa1eae --- /dev/null +++ b/app/plugins/CoreEnroller/templates/ApprovalCollectors/dispatch.inc @@ -0,0 +1,103 @@ +Field->enableFormEditMode(); +?> + +
  • + + Html->link( + 'open_in_new ' . + __d('operation', 'view.Petitions.a',[$vv_petition->id]), + [ + 'plugin' => null, + 'controller' => 'petitions', + 'action' => 'view', + $vv_petition->id + ], + [ + 'id' => 'view-petition-button', + 'class' => 'btn btn-sm btn-primary', + 'target' => 'co-petition-window', + 'escape' => false + ]); + ?> + +
    + +
    +
  • + +element('CoreEnroller.listItem', [ + 'arguments' => [ + 'fieldName' => 'approved', + 'fieldLabel' => __d('core_enroller', 'information.ApprovalCollectors.review', [$vv_petition->id]), + 'fieldOptions' => [ + 'type' => 'radio', + 'options' => [ + PetitionStatusEnum::Approved => __d('enumeration', 'PetitionStatusEnum.'.PetitionStatusEnum::Approved), + PetitionStatusEnum::Denied => __d('enumeration', 'PetitionStatusEnum.'.PetitionStatusEnum::Denied) + ], + 'value' => !empty($petition_approvals) + ? ($petition_approvals['approved'] === true + ? PetitionStatusEnum::Approved + : ($petition_approvals['approved'] === false + ? PetitionStatusEnum::Denied + : "")) + : "", + 'empty' => false, + 'required' => true, + 'class' => 'form-check-input' + ] + ]]); + + print $this->element('CoreEnroller.listItem', [ + 'arguments' => [ + 'fieldName' => 'comment', + 'fieldOptions' => [ + 'default' => $petition_approvals['comment'] ?? "", + 'required' => $vv_step_config['require_comment'] ?? false + ], + ]]); +?> + + diff --git a/app/plugins/CoreEnroller/templates/ApprovalCollectors/fields.inc b/app/plugins/CoreEnroller/templates/ApprovalCollectors/fields.inc new file mode 100644 index 000000000..c99e19cd8 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/ApprovalCollectors/fields.inc @@ -0,0 +1,40 @@ + ['EnrollmentFlowSteps', 'CoreEnroller.ApprovalCollectors'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.ApprovalCollectors' => ['edit'] + ], +]; diff --git a/app/plugins/CoreEnroller/templates/AttributeCollectors/fields-nav.inc b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields-nav.inc deleted file mode 100644 index 45b5bbb81..000000000 --- a/app/plugins/CoreEnroller/templates/AttributeCollectors/fields-nav.inc +++ /dev/null @@ -1,39 +0,0 @@ - 'edit_attributes', - 'order' => 'Default', - 'label' => __d('core_enroller', 'controller.EnrollmentAttributes', [99]), - 'link' => [ - 'plugin' => 'CoreEnroller', - 'controller' => 'enrollment_attributes', - 'action' => 'index', - 'attribute_collector_id' => $vv_obj->id - ], - 'class' => '' -]; diff --git a/app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc index 8d3afbdee..4ec246ca0 100644 --- a/app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc +++ b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc @@ -27,15 +27,29 @@ declare(strict_types = 1); -// Currently this Configuration View has no fields +$fields = [ + 'enable_person_find', +]; -$this->Field->disableFormEditMode(); - -?> - -
  • -
    - -
    -
  • +// Top Links +$topLinks[] = [ + 'icon' => 'edit_attributes', + 'order' => 'Default', + 'label' => __d('core_enroller', 'controller.EnrollmentAttributes', [99]), + 'link' => [ + 'plugin' => 'CoreEnroller', + 'controller' => 'enrollment_attributes', + 'action' => 'index', + 'attribute_collector_id' => $vv_obj->id + ], + 'class' => '' +]; +$subnav = [ + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.AttributeCollectors', 'EnrollmentFlowSteps.Hierarchy'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.AttributeCollectors' => ['edit'], + 'EnrollmentFlowSteps.Hierarchy' => ['index'] + ], +]; diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc index 564c21b3a..e823825e6 100644 --- a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc +++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc @@ -27,10 +27,8 @@ declare(strict_types = 1); -// This view is intended to work with dispatch -if($vv_action == 'dispatch') { - // Make the Form fields editable - $this->Field->enableFormEditMode(); +// Make the Form fields editable +$this->Field->enableFormEditMode(); ?>
  • @@ -46,9 +44,9 @@ if($vv_action == 'dispatch') { element('form/infoDiv/default', [ 'vv_field_arguments' => [ 'fieldName' => $n, - 'fieldLabel' => __d('field', $n), 'fieldOptions' => [ - 'required' => in_array($n, $vv_required_name_fields) + 'required' => in_array($n, $vv_required_name_fields), + 'default' => $petition_basic_attribute_sets[$n] ?? "" ] ]]); ?> @@ -61,9 +59,10 @@ if($vv_action == 'dispatch') {
  • element('form/listItem', [ + print $this->element('CoreEnroller.listItem', [ 'arguments' => [ 'fieldName' => 'mail', - 'fieldLabel' => __d('field', 'mail') + 'fieldOptions' => [ + 'default' => $petition_basic_attribute_sets['mail'] ?? "" + ] ]]); -} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc index 23c5a986c..3a05d030e 100644 --- a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc +++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc @@ -25,21 +25,17 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view only supports edit -if($vv_action == 'edit') { - print $this->element('form/listItem', [ - 'arguments' => ['fieldName' => 'name_type_id'] - ]); +$fields = [ + 'name_type_id', + 'email_address_type_id', + 'affiliation_type_id', + 'cou_id' +]; - print $this->element('form/listItem', [ - 'arguments' => ['fieldName' => 'email_address_type_id'] - ]); - - print $this->element('form/listItem', [ - 'arguments' => ['fieldName' => 'affiliation_type_id'] - ]); - - print $this->element('form/listItem', [ - 'arguments' => ['fieldName' => 'cou_id'] - ]); -} +$subnav = [ + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.BasicAttributeCollectors'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.BasicAttributeCollectors' => ['edit'] + ], +]; diff --git a/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc b/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc index 10c07364b..f28464a8a 100644 --- a/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc +++ b/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc @@ -25,21 +25,103 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view only supports edit -if($vv_action == 'edit') { - foreach(['mode', - 'message_template_id' - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => ['fieldName' => $field] - ]); - } +// Used for placeholders +$defaultValues = array_combine( + array_values($defaults), + array_keys($defaults) +); - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'request_validity', - 'fieldOptions' => [ - 'default' => 60 - ]] - ]); -} +// Establish the before and after field-supplements for the 'verification_code_regex' +$checked = !empty($vv_obj?->verification_code_charset) ? 'checked' : ''; +$otherLabel = __d('core_enroller','field.EmailVerifiers.verification_code_charset.other_value'); +$beforeField = << +
    + + +
    + +OTHER; + +$afterField = <<<'JS' + +JS; + +$regexDisplayFirstTime = empty($vv_obj?->verification_code_charset) && empty($vv_obj?->verification_code_regex); + +// List the fields +$fields = [ + 'mode', + 'message_template_id', + 'request_validity' => [ + 'placeholder' => $defaultValues['DefaultVerificationValidity'] + ], + 'verification_code_regex' => [ + 'fieldLabel' => __d('core_enroller','field.EmailVerifiers.verification_code_charset'), + 'groupedControls' => [ + 'verification_code_regex' => [ + 'id' => 'verification-code-regex', + 'required' => false, + 'empty' => true, + 'style' => $regexDisplayFirstTime || !empty($vv_obj?->verification_code_regex) ? '' : 'display: none;', + 'type' => 'select', + 'options' => $permittedCharacters + ], + 'verification_code_charset' => [ + 'type' => 'text', + 'label' => false, + 'id' => 'verification-code-charset', + 'style' => !empty($vv_obj?->verification_code_charset) ? '' : 'display: none;', + 'required' => false, + 'checked' => !empty($vv_obj?->verification_code_charset) ? 'checked' : '' + ] + ], + 'beforeField' => $beforeField, + 'afterField' => $afterField + ], + 'verification_code_length' => [ + 'required' => false, + 'placeholder' => $defaultValues['DefaultCodeLength'] + ], + 'enable_blockonfailure' => [ + 'default' => true + ] +]; + +$subnav = [ + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.EmailVerifiers'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.EmailVerifiers' => ['edit'] + ], +]; diff --git a/app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc index 2a4a83c39..df2fe70a3 100644 --- a/app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc +++ b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc @@ -56,6 +56,15 @@ $indexColumns = [ ] ]; +$subnav = [ + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.AttributeCollectors', 'EnrollmentFlowSteps.Hierarchy'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.AttributeCollectors' => ['edit'], + 'EnrollmentFlowSteps.Hierarchy' => ['index'] + ], +]; + if(empty($attributes)) { return; @@ -64,7 +73,7 @@ if(empty($attributes)) { // Map attributes to icons $attributeToIcons = [ - 'adHocAttribute' => 'grass', + 'adHocAttribute' => 'user_attributes', 'address' => 'home', 'affiliation_type_id' => 'person_search', 'cou_id' => 'supervised_user_circle', @@ -76,7 +85,7 @@ $attributeToIcons = 'manager_person_id' => 'supervisor_account', 'name' => 'face', 'organization' => 'corporate_fare', - 'pronoun' => 'transgender', + 'pronoun' => '3p', 'sponsor_person_id' => 'person', 'telephoneNumber' => 'call', 'petition_textarea' => 'text_fields', @@ -119,7 +128,7 @@ foreach ($attributes as $attr => $hr_name) { $action_args['vv_actions'][] = [ 'order' => $actionOrder, 'icon' => $actionIcon, - 'iconClass' => '', + 'iconClass' => 'material-symbols-outlined', 'url' => $actionUrl, 'class' => $actionClass ?? '', 'label' => $actionLabel, diff --git a/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc index 05ddb6644..db39b8702 100644 --- a/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc +++ b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc @@ -62,24 +62,15 @@ $this->set('vv_include_cancel', true); * - order * - required */ -foreach ( ['label', - 'description', - 'status', - 'ordr' - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field - ]]); -} - -print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'required', - 'fieldOptions' => [ - 'default' => true - ] - ]]); +$fields = [ + 'label', + 'description', + 'status', + 'ordr', + 'required' => [ + 'default' => true + ] +]; /* @@ -96,20 +87,14 @@ if ( !empty($vv_supported_attributes[$attribute_type]['mveaParents']) && count($vv_supported_attributes[$attribute_type]['mveaParents']) > 0 ) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'attribute_mvea_parent', - 'fieldOptions' => [ - 'empty' => false - ], - 'fieldType' => 'select', - 'fieldSelectOptions' => array_combine( - $vv_supported_attributes[$attribute_type]['mveaParents'], - $vv_supported_attributes[$attribute_type]['mveaParents'] - ) - ] - ]); - + $fields['attribute_mvea_parent'] = [ + 'empty' => false, + 'type' => 'select', + 'options' => array_combine( + $vv_supported_attributes[$attribute_type]['mveaParents'], + $vv_supported_attributes[$attribute_type]['mveaParents'] + ) + ]; // This field is called attribute_type and not attribute_type_id because we want this // to behave as a hidden value populated by the appropriate select, and we don't want @@ -118,17 +103,12 @@ if ( // Check if this mvea model supports types, if it does then render the attribute type // dropdown list if ($this->get($mveaAutoPopulatedVariable) !== null) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'attribute_type', - 'fieldLabel' => $attributes[$attribute_type] . ' Type', - 'fieldOptions' => [ - 'empty' => false - ], - 'fieldType' => 'select', - 'fieldSelectOptions' => $this->get($mveaAutoPopulatedVariable) - ] - ]); + $fields['attribute_type'] = [ + 'fieldLabel' => $attributes[$attribute_type] . ' Type', + 'empty' => false, + 'type' => 'select', + 'options' => $this->get($mveaAutoPopulatedVariable) + ]; } } @@ -141,16 +121,11 @@ if(str_ends_with($attribute_type, '_id')) { $suffix = Inflector::pluralize(Inflector::camelize($suffix)) ; $defaultValuesPopulated = 'defaultValue' . $suffix; if ($this->get($defaultValuesPopulated) !== null) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'default_value', - 'fieldOptions' => [ - 'empty' => true - ], - 'fieldType' => 'select', - 'fieldSelectOptions' => $this->get($defaultValuesPopulated) - ] - ]); + $fields['default_value'] = [ + 'empty' => true, + 'type' => 'select', + 'options' => $this->get($defaultValuesPopulated) + ]; } } @@ -160,23 +135,16 @@ if(str_ends_with($attribute_type, '_id')) { * Supported for attributes: adHocAttribute */ if (\in_array($attribute_type, ['adHocAttribute'], true)) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'attribute_tag', - 'fieldOptions' => [ - 'required' => true - ] - ]]); + $fields['attribute_tag'] = [ + 'required' => true + ]; } /* * Attribute Language field */ if(\in_array($attribute_type, ['name', 'address', 'pronoun'], true)) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'attribute_language', - ]]); + $fields[] = 'attribute_language'; } @@ -197,10 +165,7 @@ if ( && !\in_array($attribute_type, $personRoleExclude, true) ) ) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'default_value_env_name', - ]]); + $fields[] = 'default_value_env_name'; } /* @@ -208,25 +173,19 @@ if ( * */ if($attribute_type === 'valid_from') { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'default_value_datetime', - 'fieldType' => 'datetime' - ]]); + $fields['default_value_datetime'] = [ + 'type' => 'datetime' + ]; } /* * The valid through attribute is a textbox/number field */ if($attribute_type === 'valid_through') { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'default_value', - 'fieldDescription' => __d('core_enroller', 'field.AttributeCollectors.valid_through.default.after.desc'), - 'fieldOptions' => [ - 'type' => 'number' - ] - ]]); + $fields['default_value'] = [ + 'fieldDescription' => __d('core_enroller', 'field.AttributeCollectors.valid_through.default.after.desc'), + 'type' => 'number' + ]; } /* @@ -237,17 +196,18 @@ if ( isset($vv_supported_attributes[$attribute_type]['model']) && \in_array($vv_supported_attributes[$attribute_type]['model'], ['PersonRole', 'Person', 'Group'], true) ) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'modifiable', - 'fieldOptions' => [ - 'default' => true - ] - ] - ]); - - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'hidden' - ]]); + $fields['modifiable'] = [ + 'default' => true + ]; + + $fields[] = 'hidden'; } + +$subnav = [ + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.AttributeCollectors', 'EnrollmentFlowSteps.Hierarchy'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.AttributeCollectors' => ['edit'], + 'EnrollmentFlowSteps.Hierarchy' => ['index'] + ], +]; diff --git a/app/plugins/CoreEnroller/templates/IdentifierCollectors/fields.inc b/app/plugins/CoreEnroller/templates/IdentifierCollectors/fields.inc index d2eaa73be..183a8a858 100644 --- a/app/plugins/CoreEnroller/templates/IdentifierCollectors/fields.inc +++ b/app/plugins/CoreEnroller/templates/IdentifierCollectors/fields.inc @@ -25,9 +25,14 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view only supports edit -if($vv_action == 'edit') { - print $this->element('form/listItem', [ - 'arguments' => ['fieldName' => 'type_id'] - ]); -} +$fields = [ + 'type_id' +]; + +$subnav = [ + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.IdentifierCollectors'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.IdentifierCollectors' => ['edit'] + ], +]; diff --git a/app/plugins/CoreEnroller/templates/InvitationAccepters/dispatch.inc b/app/plugins/CoreEnroller/templates/InvitationAccepters/dispatch.inc index c3f704581..0e9a2b328 100644 --- a/app/plugins/CoreEnroller/templates/InvitationAccepters/dispatch.inc +++ b/app/plugins/CoreEnroller/templates/InvitationAccepters/dispatch.inc @@ -36,7 +36,7 @@ if($vv_action == 'dispatch') { // Make the Form fields editable $this->Field->enableFormEditMode(); - print $this->element('form/listItem', [ + print $this->element('CoreEnroller.listItem', [ 'arguments' => [ 'fieldName' => 'accepted', 'fieldLabel' => __d('operation','accept.invitation'), diff --git a/app/plugins/CoreEnroller/templates/InvitationAccepters/fields.inc b/app/plugins/CoreEnroller/templates/InvitationAccepters/fields.inc index 211e9c435..8a317ee61 100644 --- a/app/plugins/CoreEnroller/templates/InvitationAccepters/fields.inc +++ b/app/plugins/CoreEnroller/templates/InvitationAccepters/fields.inc @@ -25,18 +25,17 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view only supports edit -if($vv_action == 'edit') { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'invitation_validity', - 'fieldOptions' => [ - 'default' => 1440 - ] - ] - ]); +$fields = [ + 'invitation_validity' => [ + 'default' => 1440 + ], + 'welcome_message' +]; - print $this->element('form/listItem', [ - 'arguments' => ['fieldName' => 'welcome_message'] - ]); -} +$subnav = [ + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.InvitationAccepters'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.InvitationAccepters' => ['edit'] + ], +]; diff --git a/app/plugins/CoreEnroller/templates/cell/ApprovalCollectors/display.php b/app/plugins/CoreEnroller/templates/cell/ApprovalCollectors/display.php new file mode 100644 index 000000000..c56c210af --- /dev/null +++ b/app/plugins/CoreEnroller/templates/cell/ApprovalCollectors/display.php @@ -0,0 +1,64 @@ +approver_person_id)) { + $enum = ($vv_pa->approved ? PetitionStatusEnum::Approved : PetitionStatusEnum::Denied); + + $approver = $this->Html->link( + $vv_pa->approver_person->primary_name->full_name, + [ + 'plugin' => null, + 'controller' => 'people', + 'action' => 'edit', + $vv_pa->approver_person_id + ] + ); + + $status = __d('core_enroller', 'result.ApprovalCollectors.status', [ + __d('enumeration', 'PetitionStatusEnum.'.$enum), + $approver, + $vv_pa->modified, + $vv_pa->comment + ]); +} +?> + +
      +
    • + +
    • +
    + \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php b/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php index 2c068d4e3..f4a44b24b 100644 --- a/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php +++ b/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php @@ -27,7 +27,7 @@ declare(strict_types = 1); -use CoreEnroller\Lib\Enum\VerificationModeEnum; +use App\Lib\Enum\AllTernaryEnum; use App\Lib\Util\StringUtilities; // Render the list of known email addresses and their verification statuses. @@ -38,12 +38,12 @@ $title = __d('core_enroller', 'information.EmailVerifiers.done'); } else { $title = match ($vv_config->mode) { - VerificationModeEnum::All => __d('core_enroller', 'information.EmailVerifiers.A'), - VerificationModeEnum::None => __d('core_enroller', 'information.EmailVerifiers.0'), - VerificationModeEnum::One => $vv_minimum_met + AllTernaryEnum::All => __d('core_enroller', 'information.EmailVerifiers.A'), + AllTernaryEnum::None => __d('core_enroller', 'information.EmailVerifiers.0'), + AllTernaryEnum::One => $vv_minimum_met ? __d('core_enroller', 'information.EmailVerifiers.1.met') : __d('core_enroller', 'information.EmailVerifiers.1.none'), - default => 'Unknown Verification Mode' // Optional fallback for unexpected cases + default => __d('error', 'Verifications.mode.unknown', [$vv_config->mode]) // Optional fallback for unexpected cases }; } diff --git a/app/plugins/CoreEnroller/templates/element/emailVerifiers/verify.php b/app/plugins/CoreEnroller/templates/element/emailVerifiers/verify.php index 75081bf52..080bd9b6a 100644 --- a/app/plugins/CoreEnroller/templates/element/emailVerifiers/verify.php +++ b/app/plugins/CoreEnroller/templates/element/emailVerifiers/verify.php @@ -27,7 +27,35 @@ declare(strict_types = 1); +use App\Lib\Enum\ApplicationStateEnum; use App\Lib\Util\StringUtilities; +use Cake\Routing\Router; + + +$session = $this->getRequest()->getSession(); +$hasVerificationError = $session->read('verification_error') ?? 0; // Replace 'keyName' with the actual session key you want to access +if ( + filter_var($vv_config->enable_blockonfailure, FILTER_VALIDATE_BOOLEAN) + && filter_var($hasVerificationError, FILTER_VALIDATE_BOOLEAN) +) { + $session->delete('verification_error'); + $stateAttr = ApplicationStateEnum::VerifyEmailBlocked; + $appStateValue = $this->ApplicationState->getValue($stateAttr, 'lock', true); + $appStateId = $this->ApplicationState->getId($stateAttr, true); + + if ($vv_attempts_count > 0 && $appStateValue !== 'unlock') { + $currentUrl = Router::url(null, true); + print $this->element('notify/blockUser', compact( + 'vv_attempts_count', + 'currentUrl', + 'stateAttr', + 'appStateId', + )); + return; + } +} + +$this->Field->enableFormEditMode(); if(empty($vv_verify_address)) { print __d('core_enroller', 'information.EmailVerifiers.done'); @@ -36,17 +64,15 @@ // Render a form prompting for the code that was sent to the Enrollee -print __d('core_enroller', 'information.EmailVerifiers.code_sent', [$vv_verify_address]); - -$this->Field->enableFormEditMode(); - $m = StringUtilities::urlbase64encode($vv_verify_address); print $this->Form->hidden('op', ['default' => 'verify']); print $this->Form->hidden('co_id', ['default' => $vv_cur_co->id]); print $this->Form->hidden('m', ['default' => $m]); -print $this->element('form/listItem', [ +print __d('core_enroller', 'information.EmailVerifiers.code_sent', [$vv_verify_address]); + +print $this->element('CoreEnroller.listItem', [ 'arguments' => [ 'fieldName' => 'code', 'fieldLabel' => __d('field', 'code'), @@ -62,6 +88,7 @@ ); ?> + Field->isEditable()): ?>
  • @@ -89,3 +116,35 @@ 'vv_config' => $vv_config, ]) ?> + diff --git a/app/plugins/CoreEnroller/templates/element/field.php b/app/plugins/CoreEnroller/templates/element/field.php index 80a3b8cd4..2cd01cc27 100644 --- a/app/plugins/CoreEnroller/templates/element/field.php +++ b/app/plugins/CoreEnroller/templates/element/field.php @@ -40,9 +40,13 @@ // Do we have a default value configured? // Either a value or an Environmental Variable, // Each default value is mutually exclusive to the rest. We do not have to worry about a conflict. +// +// NOTE: name/address/telephoneNumber are MVEAs and are rendered as grouped sub-fields. +// Their per-component defaults must be applied at the sub-field level, not here. $options['default'] = match(true) { isset($attr->default_value) => $attr->default_value, isset($attr->default_value_env_name) + && $attr->attribute !== 'name' && getenv($attr->default_value_env_name) !== false => getenv($attr->default_value_env_name), isset($attr->default_value_datetime) => $attr->default_value_datetime, default => '' @@ -119,5 +123,5 @@ 'formArguments' => $formArguments ]), // Default use case - default => $this->Field->getElementsForDisabledInput('form/listItem', $formArguments) + default => $this->Field->getElementsForDisabledInput('CoreEnroller.listItem', $formArguments) }; diff --git a/app/plugins/CoreEnroller/templates/element/fieldDiv.php b/app/plugins/CoreEnroller/templates/element/fieldDiv.php new file mode 100644 index 000000000..0f1c84764 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/fieldDiv.php @@ -0,0 +1,75 @@ + + +
    + element('form/nameDiv'); + + // This configuration isn't necessary anymore. + if(isset($vv_field_arguments['fieldDescription'])) { + unset($vv_field_arguments['fieldDescription']); + $this->set('vv_field_arguments', $vv_field_arguments); + } + + // Info Div + ?> +
    + element('form/infoDiv/withPrefix'); + } elseif(isset($vv_field_arguments['autocomplete'])) { + print $this->element('form/infoDiv/autocomplete'); + } elseif(isset($vv_field_arguments['status'])) { + print $this->element('form/infoDiv/status'); + } elseif(isset($vv_field_arguments['groupedControls'])) { + print $this->element('form/infoDiv/grouped'); + } elseif(isset($vv_field_arguments['entity'])) { + print $this->element('form/infoDiv/source'); + } elseif(isset($vv_field_arguments['groupmember'])) { + print $this->element('form/infoDiv/groupMember'); + } else { + print $this->element('form/infoDiv/default'); + } + + // Insert the afterField supplement: + if(!empty($vv_after_field)) { + print $vv_after_field; + } + ?> +
    +
    \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/element/listItem.php b/app/plugins/CoreEnroller/templates/element/listItem.php new file mode 100644 index 000000000..7c02fd4f3 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/listItem.php @@ -0,0 +1,88 @@ +set('fieldName', $arguments['fieldName']); + $fieldName = $arguments['fieldName']; + $this->set('vv_field_arguments', $arguments); + + // Pass along the field supplements if they are configured. + $this->set('vv_before_field', $beforeField ?? ''); + $this->set('vv_after_field', $afterField ?? ''); + + // If an attribute is frozen, inject a special link to unfreeze it, since + // the attribute is read-only and the admin can't simply uncheck the setting + if($fieldName == 'frozen' && $this->Field->getEntity()->frozen) { + $url = [ + 'label' => __d('operation', 'unfreeze'), + 'url' => [ + 'plugin' => null, + 'controller' => \App\Lib\Util\StringUtilities::entityToClassname($this->Field->getEntity()), + 'action' => 'unfreeze', + $this->Field->getEntity()->id + ] + ]; + $arguments = [ + ...$arguments, + 'status' => __d('field', 'frozen'), + 'link' => $url, + ]; + $this->set('vv_field_arguments', $arguments); + } + + // If an attribute is a plugin, return the link to its configuration + if($fieldName == 'plugin' && $vv_action == 'edit') { + $url = [ + 'label' => __d('operation', 'configure.plugin'), + 'url' => [ + 'plugin' => null, + 'controller' => \App\Lib\Util\StringUtilities::entityToClassname($this->Field->getEntity()), + 'action' => 'configure', + $this->Field->getEntity()->id + ] + ]; + $arguments = [ + ...$arguments, + 'status' => $this->Field->getEntity()->$fieldName, + 'link' => $url, + ]; + $this->set('vv_field_arguments', $arguments); + } + +?> + +
  • + element('CoreEnroller.fieldDiv')?> +
  • diff --git a/app/plugins/CoreEnroller/templates/element/mveas/fieldset-field.php b/app/plugins/CoreEnroller/templates/element/mveas/fieldset-field.php index e8e7e53e6..82869edc1 100644 --- a/app/plugins/CoreEnroller/templates/element/mveas/fieldset-field.php +++ b/app/plugins/CoreEnroller/templates/element/mveas/fieldset-field.php @@ -29,6 +29,7 @@ declare(strict_types = 1); use \Cake\Utility\Inflector; +use CoreEnroller\Lib\Util\MagicEnvDefaultUtilities; // $field: string // $attr: object @@ -47,15 +48,29 @@ $isRequiredFromValidationRule = !$modelTable->getValidator()->field($field)->isEmptyAllowed(); } +// Is the field required? +$options['required'] = $isRequiredFromValidationRule; + // Do we have a default value configured? // Either a value or an Environmental Variable, // Each default value is mutually exclusive to the rest. We do not have to worry about a conflict. +// +// NOTE: For Name, default_value_env_name is treated as a base env var name. +// We derive per-component env vars (eg BASE_GIVEN). Missing/empty env vars yield no default. $options['default'] = match(true) { isset($attr->default_value) => $attr->default_value, + // XXX The $attr->default_value_env_name for the name attribute is tricky. Since the name has many values. // Check the EnvSource plugin isset($attr->default_value_env_name) + && $attr->attribute === 'name' + && MagicEnvDefaultUtilities::nameComponentFromEnv((string)$attr->default_value_env_name, (string)$field) !== null + => MagicEnvDefaultUtilities::nameComponentFromEnv((string)$attr->default_value_env_name, (string)$field), + + isset($attr->default_value_env_name) + && $attr->attribute !== 'name' && getenv($attr->default_value_env_name) !== false => getenv($attr->default_value_env_name), + isset($attr->default_value_datetime) => $attr->default_value_datetime, default => '' }; diff --git a/app/plugins/CoreEnroller/templates/element/unorderedList.php b/app/plugins/CoreEnroller/templates/element/unorderedList.php new file mode 100644 index 000000000..d0633c6f1 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/unorderedList.php @@ -0,0 +1,68 @@ + +
      + element('form/submit', ['label' => $vv_submit_button_label]); + } + ?> +
    + + $v) { + print $this->Form->hidden($attr, ['value' => $v]); + } + } \ No newline at end of file diff --git a/app/plugins/CoreJob/config/plugin.json b/app/plugins/CoreJob/config/plugin.json new file mode 100644 index 000000000..8cba2a0dc --- /dev/null +++ b/app/plugins/CoreJob/config/plugin.json @@ -0,0 +1,31 @@ +{ + "types": { + "job": [ + "AdopterJob", + "AssignerJob", + "DeletionJob", + "NesterJob", + "ProvisionerJob", + "SyncJob" + ] + }, + "schema": { + "tables": { + "sync_job_last_runs": { + "comment": "This is a meta-table for SyncJob, and does not have a corresponding MVC", + "columns": { + "id": {}, + "external_identity_source_id": {}, + "job_id": { "type": "integer", "foreignkey": { "table": "jobs", "column": "id" } }, + "start_time": { "type": "datetime" } + }, + "indexes": { + "sync_job_last_runs_i1": { "columns": [ "external_identity_source_id" ] }, + "sync_job_last_runs_i2": { "needed": false, "columns": [ "job_id" ] } + }, + "changelog": false, + "timestamps": false + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreJob/resources/locales/en_US/core_job.po b/app/plugins/CoreJob/resources/locales/en_US/core_job.po index 0598a407b..fe9a7e096 100644 --- a/app/plugins/CoreJob/resources/locales/en_US/core_job.po +++ b/app/plugins/CoreJob/resources/locales/en_US/core_job.po @@ -34,9 +34,24 @@ msgstr "Source Keys to process, comma separated (requires external_identity_sour msgid "opt.assigner.context" msgstr "Identifier Assignment context" +msgid "opt.deletion.target_id" +msgstr "Record ID of Model to delete" + +msgid "opt.deletion.target_model" +msgstr "Model to delete" + msgid "opt.entities" msgstr "Comma separated list of entity IDs to process" +msgid "opt.nester.group_id" +msgstr "Nested Group ID" + +msgid "opt.nester.target_group_id" +msgstr "Target Group ID" + +msgid "opt.nester.negate" +msgstr "If true, Negate Nesting" + msgid "opt.provisioner.model" msgstr "Model to provision" @@ -52,6 +67,9 @@ msgstr "External Identity Source ID (to process a single source)" msgid "opt.sync.force" msgstr "If true, force records to process even if no changes have been detected" +msgid "opt.sync.reference_id" +msgstr "Reference ID to attach to External Identity (requires exactly one value for source_keys)" + msgid "opt.sync.source_keys" msgstr "Source Keys to process, comma separated (requires external_identity_source_id)" @@ -91,6 +109,33 @@ msgstr "Assigned {0}: {1}" msgid "Assigner.start_summary" msgstr "Assigning all Identifiers in context {0} for CO {1} ({2} entities)" +msgid "Deletion.error.co" +msgstr "DeletionJob can only be scheduled from the COmanage CO" + +msgid "Deletion.register_summary" +msgstr "Requested hard delete of {0} {1}" + +msgid "Deletion.start_summary" +msgstr "Beginning hard delete of {0} {1}" + +msgid "Deletion.finish_summary" +msgstr "Deletion completed successfully" + +msgid "Nester.recheck.current" +msgstr "Rechecking existing nested memberships of {0}" + +msgid "Nester.recheck.eligible" +msgstr "Rechecking nesting eligibility of {0} memberships" + +msgid "Nester.register_summary" +msgstr "Requested nesting of Group {0} into target Group {1}" + +msgid "Nester.start_summary" +msgstr "Nesting Group {0} into target Group {1}" + +msgid "Nester.finish_summary" +msgstr "Group Nesting complete" + msgid "Provisioner.cancel_summary" msgstr "Job canceled after provisioning {0} entities ({1} errors)" @@ -115,6 +160,9 @@ msgstr "Post-Run Tasks failed: {0}" msgid "Sync.error.pre_run_checks" msgstr "Pre-Run Checks failed: {0}" +msgid "Sync.error.reference_id" +msgstr "reference_id provided but more than one source key was specified" + msgid "Sync.finish_summary" msgstr "Sync Finished" diff --git a/app/plugins/CoreJob/src/Lib/Jobs/DeletionJob.php b/app/plugins/CoreJob/src/Lib/Jobs/DeletionJob.php new file mode 100644 index 000000000..aac8c216c --- /dev/null +++ b/app/plugins/CoreJob/src/Lib/Jobs/DeletionJob.php @@ -0,0 +1,103 @@ + [ + 'help' => __d('core_job', 'opt.deletion.target_model'), + 'type' => 'select', + 'choices' => ['Cos', 'GroupNestings'], + 'required' => true + ], + 'target_id' => [ + 'help' => __d('core_job', 'opt.deletion.target_id'), + 'type' => 'integer', + 'required' => true + ] + ]; + } + + /** + * Run the requested Job. + * + * @since COmanage Registry v5.2.0 + * @param JobsTable $JobsTable Jobs table, for updating the Job status + * @param JobHistoryRecordsTable $JobHistoryRecordsTable Job History Records table, for recording additional history + * @param Job $job Job entity + * @param array $parameters Parameters for this Job + * @throws InvalidArgumentException + */ + + public function run( + \App\Model\Table\JobsTable $JobsTable, + \App\Model\Table\JobHistoryRecordsTable $JobHistoryRecordsTable, + \App\Model\Entity\Job $job, + array $parameters + ) { + if($parameters['target_model'] == 'Cos') { + // Check that the requesting CO is the COmanage CO. + + $requestingCO = $JobsTable->Cos->get($job->co_id); + + if(!$requestingCO->isCOmanageCO()) { + throw new \InvalidArgumentException(__d('core_job', 'Deletion.error.co')); + } + } + + // Find the table to call delete on, and then pull the entity so we can delete it + $TargetTable = TableRegistry::getTableLocator()->get($parameters['target_model']); + + // get() will throw an exception on an invalid CO ID + $entity = $TargetTable->get($parameters['target_id']); + + $JobsTable->start(job: $job, summary: __d('core_job', 'Deletion.start_summary', [$parameters['target_model'], $parameters['target_id']])); + + // CosTable implements a custom deleteOrFail() that will perform a hard delete, + // any other model will revert to the default Cake call which ChangelogBehavior will intercept. + $TargetTable->deleteOrFail($entity, ['jobId' => $job->id]); + + $JobsTable->finish(job: $job, summary: __d('core_job', 'Deletion.finish_summary')); + } +} \ No newline at end of file diff --git a/app/plugins/CoreJob/src/Lib/Jobs/NesterJob.php b/app/plugins/CoreJob/src/Lib/Jobs/NesterJob.php new file mode 100644 index 000000000..522fdac75 --- /dev/null +++ b/app/plugins/CoreJob/src/Lib/Jobs/NesterJob.php @@ -0,0 +1,100 @@ + [ + 'help' => __d('core_job', 'opt.nester.group_id'), + 'type' => 'integer', + 'required' => true + ], + 'target_group_id' => [ + 'help' => __d('core_job', 'opt.nester.target_group_id'), + 'type' => 'integer', + 'required' => true + ], + 'negate' => [ + 'help' => __d('core_job', 'opt.nester.negate'), + 'type' => 'bool', + 'required' => false + ] + ]; + } + + /** + * Run the requested Job. + * + * @since COmanage Registry v5.2.0 + * @param JobsTable $JobsTable Jobs table, for updating the Job status + * @param JobHistoryRecordsTable $JobHistoryRecordsTable Job History Records table, for recording additional history + * @param Job $job Job entity + * @param array $parameters Parameters for this Job + * @throws InvalidArgumentException + */ + + public function run( + \App\Model\Table\JobsTable $JobsTable, + \App\Model\Table\JobHistoryRecordsTable $JobHistoryRecordsTable, + \App\Model\Entity\Job $job, + array $parameters + ) { + // GMR-2 will prevent nesting Groups across COs, all we need to do is create a + // Group Nesting and save it. + + $GroupNestings = TableRegistry::getTableLocator()->get('GroupNestings'); + + $nesting = $GroupNestings->newEntity([ + 'group_id' => $parameters['group_id'], + 'target_group_id' => $parameters['target_group_id'], + 'negate' => isset($parameters['negate']) && $parameters['negate'] + ]); + + $JobsTable->start(job: $job, summary: __d('core_job', 'Nester.start_summary', [$parameters['group_id'], $parameters['target_group_id']])); + + $GroupNestings->save($nesting, ['job' => $job]); + + $JobsTable->finish(job: $job, summary: __d('core_job', 'Nester.finish_summary')); + } +} \ No newline at end of file diff --git a/app/plugins/CoreJob/src/Lib/Jobs/ProvisionerJob.php b/app/plugins/CoreJob/src/Lib/Jobs/ProvisionerJob.php index d7ff2356d..a9a6393a0 100644 --- a/app/plugins/CoreJob/src/Lib/Jobs/ProvisionerJob.php +++ b/app/plugins/CoreJob/src/Lib/Jobs/ProvisionerJob.php @@ -33,6 +33,7 @@ use Cake\ORM\TableRegistry; use \App\Lib\Enum\JobStatusEnum; use \App\Lib\Enum\ProvisionerModeEnum; +use \App\Lib\Enum\ProvisioningContextEnum; class ProvisionerJob { /** @@ -97,7 +98,12 @@ protected function processEntity( int &$lastPct ): bool { try { - $EntityTable->requestProvisioning($entityId, $model, $target->id); + $EntityTable->requestProvisioning( + id: $entityId, + context: ProvisioningContextEnum::Queue, + provisioningTargetId: $target->id, + job: $job + ); $JobHistoryRecordsTable->record( jobId: $job->id, @@ -186,9 +192,17 @@ public function run( } if(!empty($parameters['entities'])) { - // We have one or more explicitly specified entities to process + // We have one or more explicitly specified entities to process. + // Entities might be an int (single request) or comma separated string + // (because PHP). + + $ids = []; - $ids = explode(',', $parameters['entities']); + if(is_int($parameters['entities'])) { + $ids[] = $parameters['entities']; + } else { + $ids = explode(',', $parameters['entities']); + } $count = count($ids); diff --git a/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php b/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php index c762cdcb0..64a5b8179 100644 --- a/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php +++ b/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php @@ -61,7 +61,11 @@ public function parameterFormat(): array { 'type' => 'bool', 'required' => false ], -// XXX addd reference_id + 'reference_id' => [ + 'help' => __d('core_job', 'opt.sync.reference_id'), + 'type' => 'string', + 'required' => false + ], 'source_keys' => [ 'help' => __d('core_job', 'opt.sync.source_keys'), 'type' => 'string', @@ -280,7 +284,7 @@ protected function getRunContext( $this->runContext->eis = $this->runContext->EISTable->get( $eisId, - ['contain' => 'SqlSources'] + contain: $this->runContext->EISTable->getPluginRelations() ); } @@ -455,6 +459,16 @@ public function run( $keys = explode(',', $parameters['source_keys']); + $referenceId = null; + + if(!empty($parameters['reference_id'])) { + if(count($keys) == 1) { + $referenceId = $parameters['reference_id']; + } else { + throw new \InvalidArgumentException('core_job', 'Sync.error.reference_id'); + } + } + $this->runContext->count = count($keys); $JobsTable->start( @@ -463,7 +477,7 @@ public function run( ); foreach($keys as $key) { - if(!$this->syncRecord($key)) { + if(!$this->syncRecord($key, $referenceId)) { break; } } @@ -501,20 +515,25 @@ public function run( * Sync a single record. * * @since COmanage Registry v5.0.0 - * @param string $key Source Key to process + * @param string $key Source Key to process + * @param string $referenceId Reference ID to link to record, if known * @return bool True if processing should continue, false otherwise */ - protected function syncRecord(string $key): bool { + protected function syncRecord(string $key, string $referenceId=null): bool { // comment and status for HistoryRecords $c = "unknown"; $s = JobStatusEnum::Failed; + // Default result + $result = 'error'; + try { $result = $this->runContext->EISTable->sync( id: (int)$this->runContext->parameters['external_identity_source_id'], sourceKey: $key, - force: $this->runContext->force + force: $this->runContext->force, + referenceId: $referenceId ); switch($result) { diff --git a/app/plugins/CoreJob/src/config/plugin.json b/app/plugins/CoreJob/src/config/plugin.json deleted file mode 100644 index 307e6a147..000000000 --- a/app/plugins/CoreJob/src/config/plugin.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "types": { - "job": [ - "AdopterJob", - "AssignerJob", - "ProvisionerJob", - "SyncJob" - ] - }, - "schema": { - "tables": { - "sync_job_last_runs": { - "comment": "This is a meta-table for SyncJob, and does not have a corresponding MVC", - "columns": { - "id": {}, - "external_identity_source_id": {}, - "job_id": { "type": "integer", "foreignkey": { "table": "jobs", "column": "id" } }, - "start_time": { "type": "datetime" } - }, - "indexes": { - "sync_job_last_runs_i1": { "columns": [ "external_identity_source_id" ] }, - "sync_job_last_runs_i2": { "needed": false, "columns": [ "job_id" ] } - }, - "changelog": false, - "timestamps": false - } - } - } -} \ No newline at end of file diff --git a/app/plugins/CoreServer/config/plugin.json b/app/plugins/CoreServer/config/plugin.json new file mode 100644 index 000000000..9c5fce7c7 --- /dev/null +++ b/app/plugins/CoreServer/config/plugin.json @@ -0,0 +1,107 @@ +{ + "types": { + "server": [ + "HttpServers", + "MatchServers", + "Oauth2Servers", + "SmtpServers", + "SqlServers" + ] + }, + "schema": { + "tables": { + "http_servers": { + "columns": { + "id": {}, + "server_id": {}, + "url": { "type": "url" }, + "username": {}, + "password": {}, + "auth_type": { "type": "string", "size": 2 }, + "skip_ssl_verification": { "type": "boolean" } + }, + "indexes": { + "http_servers_i1": { "columns": [ "server_id" ]} + } + }, + "match_servers": { + "columns": { + "id": {}, + "server_id": {}, + "url": { "type": "url" }, + "username": {}, + "password": {}, + "auth_type": { "type": "string", "size": 2 }, + "skip_ssl_verification": { "type": "boolean" } + }, + "indexes": { + "match_servers_i1": { "columns": [ "server_id" ] } + } + }, + "match_server_attributes": { + "columns": { + "id": {}, + "match_server_id": { "type": "integer", "foreignkey": { "table": "match_servers", "column": "id" }, "notnull": true }, + "attribute": { "type": "string", "size": 80 }, + "type_id": { "notnull": false }, + "required": {} + }, + "indexes": { + "match_server_attributes_i1": { "columns": [ "match_server_id" ]}, + "match_server_attributes_i2": { "needed": false, "columns": [ "type_id" ]} + }, + "clone_relation": true + }, + "oauth2_servers": { + "columns": { + "id": {}, + "server_id": { "type": "integer" }, + "url": { "type": "url" }, + "clientid": { "type": "string", "size": 120 }, + "client_secret": { "type": "string", "size": 80 }, + "access_grant_type": { "type": "string", "size": 2 }, + "scope": { "type": "string", "size": 256 }, + "refresh_token": { "type": "text" }, + "access_token": { "type": "text" }, + "token_response": { "type": "text" }, + "access_token_exp": { "type": "bigint"} + }, + "indexes": { + "oauth2_servers_i1": { "columns": [ "server_id" ] } + } + }, + "smtp_servers": { + "columns": { + "id": {}, + "server_id": {}, + "hostname": { "type": "string", "size": 128 }, + "port": { "type": "integer" }, + "username": {}, + "password": {}, + "use_tls": { "type": "boolean" }, + "default_from": { "type": "string", "size": 256 }, + "default_reply_to": { "type": "string", "size": 256 }, + "override_to": { "type": "string", "size": 256 } + }, + "indexes": { + "smtp_servers_i1": { "columns": [ "server_id" ] } + } + }, + "sql_servers": { + "columns": { + "id": {}, + "server_id": {}, + "type": { "type": "string", "size": 2 }, + "hostname": { "type": "string", "size": 128 }, + "port": { "type": "integer" }, + "databas": { "type": "string", "size": 128 }, + "username": {}, + "password": {} + }, + "indexes": { + "sql_servers_i1": { "columns": [ "server_id" ]} + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreServer/resources/locales/en_US/core_server.po b/app/plugins/CoreServer/resources/locales/en_US/core_server.po index f16da9a10..c6d7ebad1 100644 --- a/app/plugins/CoreServer/resources/locales/en_US/core_server.po +++ b/app/plugins/CoreServer/resources/locales/en_US/core_server.po @@ -25,6 +25,9 @@ msgid "controller.HttpServers" msgstr "{0,plural,=1{HTTP Server} other{HTTP Servers}}" +msgid "controller.MatchServerAttributes" +msgstr "{0,plural,=1{Match Server Attribute} other{Match Server Attributes}}" + msgid "controller.MatchServers" msgstr "{0,plural,=1{Match Server} other{Match Servers}}" @@ -34,6 +37,9 @@ msgstr "{0,plural,=1{SMTP Server} other{SMTP Servers}}" msgid "controller.SqlServers" msgstr "{0,plural,=1{SQL Server} other{SQL Servers}}" +msgid "controller.Oauth2Servers" +msgstr "{0,plural,=1{Oauth2 Server} other{Oauth2 Servers}}" + msgid "enumeration.HttpAuthTypeEnum.BA" msgstr "Basic" @@ -43,6 +49,12 @@ msgstr "Bearer" msgid "enumeration.HttpAuthTypeEnum.X" msgstr "None" +msgid "enumeration.GrantTypesEnum.AC" +msgstr "Authorization Code" + +msgid "enumeration.GrantTypesEnum.CC" +msgstr "Client Credentials" + msgid "enumeration.RdbmsTypeEnum.LT" msgstr "SQLite" @@ -61,14 +73,68 @@ msgstr "Oracle" msgid "enumeration.RdbmsTypeEnum.PG" msgstr "Postgres" +msgid "error.MatchServers.attr.none" +msgstr "No valid attributes assembled for Match request" + +msgid "error.MatchServers.attr.req" +msgstr "Required attribute {0} ({1}) not found in person record" + +msgid "error.MatchServers.response" +msgstr "Match Server responded: {0}" + +msgid "error.Oauth2Servers.callback" +msgstr "Incorrect parameters in callback" + +msgid "error.Oauth2Servers.state" +msgstr "Invalid state received in callback" + +msgid "error.Oauth2Servers.token" +msgstr "Error obtaining access token: {0}" + +msgid "error.SqlServers.oracle.enabled" +msgstr "Oracle support is not enabled" + +msgid "error.SqlServers.oracle.plugin" +msgstr "OracleClient plugin is not loaded" + +msgid "info.Oauth2Servers.token.ok" +msgstr "Access Token Obtained" + +msgid "info.Oauth2Servers.token.obtain" +msgstr "Obtain New Token" + msgid "field.auth_type" msgstr "Authentication Type" msgid "field.skip_ssl_verification" msgstr "Skip SSL Verification" -msgid "field.MatchServers.api_endpoint" -msgstr "API Endpoint" +msgid "field.Oauth2Servers.access_token" +msgstr "Access Token" + +msgid "field.Oauth2Servers.access_token.desc" +msgstr "Save any changes to the configuration before obtaining a new token" + +msgid "field.Oauth2Servers.clientid" +msgstr "Client ID" + +msgid "field.Oauth2Servers.client_secret" +msgstr "Client Secret" + +msgid "field.Oauth2Servers.access_grant_type" +msgstr "Access Token Grant Type" + +msgid "field.Oauth2Servers.url" +msgstr "Server URL" + +msgid "field.Oauth2Servers.redirect_uri" +msgstr "Redirect URI" + +msgid "field.Oauth2Servers.scope" +msgstr "Scopes" + +msgid "info.Oauth2Servers.access_token.ok" +msgstr "Access Token Obtained" msgid "field.SmtpServers.default_from" msgstr "Default From Address" @@ -85,12 +151,6 @@ msgstr "If set, all outgoing email will only be sent to this address" msgid "field.SmtpServers.use_tls" msgstr "Use TLS" -msgid "error.SqlServers.oracle.enabled" -msgstr "Oracle support is not enabled" - -msgid "error.SqlServers.oracle.plugin" -msgstr "OracleClient plugin is not loaded" - msgid "field.SqlServers.databas" msgstr "Database Name" @@ -99,3 +159,6 @@ msgstr "Specify the port only if a non-standard port number is in use" msgid "field.SqlServers.type" msgstr "RDBMS Type" + +msgid "result.MatchServers.match.accepted" +msgstr "Match request requires administrator intervention, Match Request ID: {0}" \ No newline at end of file diff --git a/app/plugins/CoreServer/src/Controller/HttpServersController.php b/app/plugins/CoreServer/src/Controller/HttpServersController.php index 06bbb5fd5..be6405551 100644 --- a/app/plugins/CoreServer/src/Controller/HttpServersController.php +++ b/app/plugins/CoreServer/src/Controller/HttpServersController.php @@ -30,11 +30,33 @@ namespace CoreServer\Controller; use App\Controller\StandardPluginController; +use Cake\Event\EventInterface; class HttpServersController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'HttpServers.url' => 'asc' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->HttpServers->Servers->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->HttpServers->Servers->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->HttpServers->Servers->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } diff --git a/app/plugins/CoreServer/src/Controller/MatchServerAttributesController.php b/app/plugins/CoreServer/src/Controller/MatchServerAttributesController.php new file mode 100644 index 000000000..cd770d8db --- /dev/null +++ b/app/plugins/CoreServer/src/Controller/MatchServerAttributesController.php @@ -0,0 +1,81 @@ + [ + 'MatchServerAttributes.attribute' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.2.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeRender(\Cake\Event\EventInterface $event) + { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->MatchServerAttributes->MatchServers->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->MatchServerAttributes->MatchServers->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->MatchServerAttributes->MatchServers->getPrimaryKey()); + } + + // Build standard server breadcrumbs from *_server_id + $customParents = $this->buildServerParamBreadcrumbs(); + + if (!empty($customParents)) { + $vv_bc_parents = (array)$this->viewBuilder()->getVar('vv_bc_parents'); + $vv_bc_parents = [...$customParents, ...$vv_bc_parents]; + $this->set('vv_bc_parents', $vv_bc_parents); + } + + $title = __d('core_server', 'controller.MatchServerAttributes', [99]); + if(in_array($this->request->getParam('action'), ['add', 'edit'])) { + $title = __d('operation', strtolower($this->request->getParam('action')) . '.a', [$title]); + } + + $this->set('vv_title', $title); + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/CoreServer/src/Controller/MatchServersController.php b/app/plugins/CoreServer/src/Controller/MatchServersController.php index 4b7bc526a..be2e29638 100644 --- a/app/plugins/CoreServer/src/Controller/MatchServersController.php +++ b/app/plugins/CoreServer/src/Controller/MatchServersController.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry-plugins - * @since COmanage Registry v5.2.0 + * @since COmanage Registry v5.1.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ @@ -33,7 +33,7 @@ use Cake\Event\EventInterface; class MatchServersController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'MatchServers.url' => 'asc' ] @@ -45,14 +45,17 @@ class MatchServersController extends StandardPluginController { * @param EventInterface $event Cake Event * * @return Response|void - * @since COmanage Registry v5.2.0 + * @since COmanage Registry v5.0.0 */ public function beforeRender(EventInterface $event) { - // Generate the callback URL + $link = $this->getPrimaryLink(true); -// XXX this needs to be updated for whereever the new API lands - $this->set('vv_api_endpoint', \Cake\Routing\Router::url('/', true) . 'api/co/' . $this->getCOID() . '/core/v1/resolution'); + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->MatchServers->Servers->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->MatchServers->Servers->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->MatchServers->Servers->getPrimaryKey()); + } return parent::beforeRender($event); } diff --git a/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php b/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php new file mode 100644 index 000000000..40c2cbfcb --- /dev/null +++ b/app/plugins/CoreServer/src/Controller/Oauth2ServersController.php @@ -0,0 +1,190 @@ + [ + 'OauthServers.url' => 'asc' + ] + ]; + + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @since COmanage Registry v5.2.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->Oauth2Servers->Servers->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->Oauth2Servers->Servers->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->Oauth2Servers->Servers->getPrimaryKey()); + } + + // Generate the Redirect URI + if ($this->getRequest()->getParam('action') === 'edit') { + $id = $this->getRequest()->getParam('pass')[0] ?? null; // Assuming $id comes from passed arguments + $this->set('vv_redirect_uri', $this->Oauth2Servers->redirectUri($id)); + } + + return parent::beforeRender($event); + } + + /** + * OAuth callback. + * + * @param integer $id Oauth2Server ID + * @since COmanage Registry v5.2.0 + */ + + public function callback($id): void + { + $this->autoRender = false; + try { + $request = $this->getRequest(); + $code = $request->getQuery('code'); + $state = $request->getQuery('state'); + + if (empty($code) || empty($state)) { + throw new \RuntimeException(__d('core_server', 'error.Oauth2Servers.callback')); + } + + // Verify that state is our hashed session ID, as per RFC6749 §10.12 + // recommendations to prevent CSRF. + // https://tools.ietf.org/html/rfc6749#section-10.12 + + // Access session from the request object + $sessionId = $request->getSession()->id(); + + if ($state != hash('sha256', $sessionId)) { + throw new \RuntimeException(__d('core_server', 'error.Oauth2Servers.state')); + } + + $response = $this->Oauth2Servers->exchangeCode( + $id, + $code, + $this->Oauth2Servers->redirectUri((int)$id), + ); + + $this->Flash->success(__d('core_server', 'info.Oauth2Servers.access_token.ok')); + } catch (\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + $this->performRedirect(); + } + + /** + * Obtain an access token for a Oauth2Server. + * + * @since COmanage Registry v5.2.0 + * @param integer $id Oauth2Server ID + */ + + public function token($id): void + { + // Pull our configuration, initially to find out what type of grant type we need + $osrvr = $this->Oauth2Servers->get($id); + + if(!$osrvr) { + $this->Flash->error(__d('error', 'notfound', [__d('core_server', 'controller.Oauth2Servers')])); + $this->performRedirect(); + } + + try { + switch($osrvr->access_grant_type) { + case GrantTypesEnum::AuthorizationCode: + // Issue a redirect to the server + $targetUrl = $osrvr->url + . '/authorize?response_type=code' + . '&client_id=' . $osrvr->clientid + . '&redirect_uri=' . urlencode($this->Oauth2Servers->redirectUri($id)) + . '&state=' . hash('sha256', session_id()); + // Scope is optional + if(!empty($osrvr->scope)) { + $targetUrl .= '&scope='. str_replace(' ', '%20', $osrvr->scope); + } + + $this->redirect($targetUrl); + break; + case GrantTypesEnum::ClientCredentials: + // Make a direct call to the server + $this->Oauth2Servers->obtainToken((int)$id, 'client_credentials'); + $this->Flash->success(__d('core_server', 'info.Oauth2Servers.access_token.ok')); + break; + default: + // No other flows currently supported + throw new \LogicException('Not implemented yet.'); + } + } catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + $this->performRedirect(); + } + + /** + * Perform a redirect back to the controller's default view. + * + * @since COmanage Registry v5.2.0 + */ + + function performRedirect(): void + { + $target = []; + $target['plugin'] = null; + + if (!empty($this->getRequest()->getParam('pass')[0])) { + $target['plugin'] = 'CoreServer'; + $target['controller'] = 'Oauth2Servers'; + $target['action'] = 'edit'; + $target[] = filter_var($this->getRequest()->getParam('pass')[0], FILTER_SANITIZE_SPECIAL_CHARS); + } else { + $target['controller'] = 'Servers'; + $target['action'] = 'index'; + $target['?'] = [ + 'co_id' => $this->getCOID() + ]; + } + + $this->redirect($target); + } +} diff --git a/app/plugins/CoreServer/src/Controller/SmtpServersController.php b/app/plugins/CoreServer/src/Controller/SmtpServersController.php index c472e18df..bca8b47ab 100644 --- a/app/plugins/CoreServer/src/Controller/SmtpServersController.php +++ b/app/plugins/CoreServer/src/Controller/SmtpServersController.php @@ -30,11 +30,33 @@ namespace CoreServer\Controller; use App\Controller\StandardPluginController; +use Cake\Event\EventInterface; class SmtpServersController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'SmtpServers.hostname' => 'asc' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->SmtpServers->Servers->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->SmtpServers->Servers->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->SmtpServers->Servers->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } diff --git a/app/plugins/CoreServer/src/Controller/SqlServersController.php b/app/plugins/CoreServer/src/Controller/SqlServersController.php index f37690a7b..9e840242f 100644 --- a/app/plugins/CoreServer/src/Controller/SqlServersController.php +++ b/app/plugins/CoreServer/src/Controller/SqlServersController.php @@ -30,11 +30,33 @@ namespace CoreServer\Controller; use App\Controller\StandardPluginController; +use Cake\Event\EventInterface; class SqlServersController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'SqlServers.hostname' => 'asc' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->SqlServers->Servers->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->SqlServers->Servers->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->SqlServers->Servers->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } diff --git a/app/plugins/CoreServer/src/Lib/Enum/GrantTypesEnum.php b/app/plugins/CoreServer/src/Lib/Enum/GrantTypesEnum.php new file mode 100644 index 000000000..219c2264f --- /dev/null +++ b/app/plugins/CoreServer/src/Lib/Enum/GrantTypesEnum.php @@ -0,0 +1,39 @@ + */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreServer/src/Model/Entity/MatchServer.php b/app/plugins/CoreServer/src/Model/Entity/MatchServer.php index ceab5bf4f..8b01f27fc 100644 --- a/app/plugins/CoreServer/src/Model/Entity/MatchServer.php +++ b/app/plugins/CoreServer/src/Model/Entity/MatchServer.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry-plugins - * @since COmanage Registry v5.2.0 + * @since COmanage Registry v5.1.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class MatchServer extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class MatchServer extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreServer/src/Model/Entity/MatchServerAttribute.php b/app/plugins/CoreServer/src/Model/Entity/MatchServerAttribute.php new file mode 100644 index 000000000..932135764 --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Entity/MatchServerAttribute.php @@ -0,0 +1,53 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; + + public array $_supportedMetadata = ['clonable-related']; +} diff --git a/app/plugins/CoreServer/src/Model/Entity/Oauth2Server.php b/app/plugins/CoreServer/src/Model/Entity/Oauth2Server.php new file mode 100644 index 000000000..b4d0521ec --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Entity/Oauth2Server.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreServer/src/Model/Entity/SmtpServer.php b/app/plugins/CoreServer/src/Model/Entity/SmtpServer.php index f46d72386..ecb779c3b 100644 --- a/app/plugins/CoreServer/src/Model/Entity/SmtpServer.php +++ b/app/plugins/CoreServer/src/Model/Entity/SmtpServer.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class SmtpServer extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class SmtpServer extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreServer/src/Model/Entity/SqlServer.php b/app/plugins/CoreServer/src/Model/Entity/SqlServer.php index 66ee6b241..8f0c811ca 100644 --- a/app/plugins/CoreServer/src/Model/Entity/SqlServer.php +++ b/app/plugins/CoreServer/src/Model/Entity/SqlServer.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class SqlServer extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class SqlServer extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/CoreServer/src/Model/Table/HttpServersTable.php b/app/plugins/CoreServer/src/Model/Table/HttpServersTable.php index 06802e7d1..6eeff5cd7 100644 --- a/app/plugins/CoreServer/src/Model/Table/HttpServersTable.php +++ b/app/plugins/CoreServer/src/Model/Table/HttpServersTable.php @@ -41,6 +41,7 @@ class HttpServersTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; @@ -112,17 +113,25 @@ public function createHttpClient(int $id): Client { $Client = Client::createFromUrl($httpServer->url); if($httpServer->auth_type == HttpAuthTypeEnum::Basic) { + $authConfig = [ + 'type' => 'Basic' + ]; + if(!empty($httpServer->username)) { - $Client->setConfig('username', $httpServer->username); + $authConfig['username'] = $httpServer->username; } if(!empty($httpServer->password)) { - $Client->setConfig('password', $httpServer->password); + $authConfig['password'] = $httpServer->password; } + + $Client->setConfig('auth', $authConfig); } if(!empty($httpServer->skip_ssl_verification) && $httpServer->skip_ssl_verification) { $Client->setConfig('ssl_verify_peer', false); + $Client->setConfig('ssl_verify_peer_name', false); + $Client->setConfig('ssl_verify_host', false); } return $Client; diff --git a/app/plugins/CoreServer/src/Model/Table/MatchServerAttributesTable.php b/app/plugins/CoreServer/src/Model/Table/MatchServerAttributesTable.php new file mode 100644 index 000000000..8909a309c --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Table/MatchServerAttributesTable.php @@ -0,0 +1,239 @@ +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('CoreServer.MatchServers'); + $this->belongsTo('Types'); + + $this->setDisplayField('attribute'); + + $this->setPrimaryLink('CoreServer.match_server_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal('index'); + + $this->setAutoViewVars([ + 'attributes' => [ + 'type' => 'hash', + 'hash' => Hash::combine($this->supportedAttributes(), '{s}.wire', '{s}.label') + ], + // Because of the way autoviewvars calculates variable names, "requireds" is correct + 'requireds' => [ + 'type' => 'enum', + 'class' => 'RequiredEnum' + ], + 'addressTypes' => [ + 'type' => 'type', + 'attribute' => 'Addresses.type' + ], + 'emailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], + 'nameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ], + 'telephoneNumberTypes' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type' + ], + 'types' => [ + 'type' => 'type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Obtain the set of attributes supported for Match requests. + * + * @since COmanage Registry v5.2.0 + * @return array Array of supported attributes + */ + + public function supportedAttributes(): array { + return [ + // The array key should match the wire name + 'addresses' => [ + 'label' => __d('controller', 'Addresses', [1]), + 'model' => 'Addresses', + // array values are "registry name" => "attribute dictionary name" + 'attributes' => [ + // We'll use the virtual attribute to get a formatted address. + // We could provide individual attributes (similar to Name) but + // we don't have any actual use cases for this yet. + 'formatted_address' => 'formatted' + ], + 'type' => 'Addresses.type', + 'wire' => 'addresses' + ], + 'dateOfBirth' => [ + 'label' => __d('field', 'date_of_birth'), + 'model' => 'ExternalIdentities', + // Note the use of singular "attribute", vs "attributes" for MVPAs + 'attribute' => 'date_of_birth', + 'type' => false, + 'wire' => 'dateOfBirth' + ], + 'emailAddresses' => [ + 'label' => __d('field', 'mail'), + 'model' => 'EmailAddresses', + 'attributes' => [ + 'mail' => 'address' + ], + // type corresponds to Types::$supportedAttributes, and will automatically + // be injected into the wire representation (ie: do not list it under + // "attributes") + 'type' => 'EmailAddresses.type', + 'wire' => 'emailAddresses' + ], + 'identifiers' => [ + 'label' => __d('field', 'identifier'), + 'model' => 'Identifiers', + 'attributes' => [ + 'identifier' => 'identifier' + ], + 'type' => 'Identifiers.type', + 'wire' => 'identifiers' + ], + 'names' => [ + 'label' => __d('field', 'name'), + 'model' => 'Names', + 'attributes' => [ + 'honorific' => 'prefix', + 'given' => 'given', + 'middle' => 'middle', + 'family' => 'family', + 'suffix' => 'suffix' + ], + 'type' => 'Names.type', + 'wire' => 'names' + ], + 'telephoneNumbers' => [ + 'label' => __d('field', 'TelephoneNumbers.number'), + 'model' => 'TelephoneNumbers', + 'attributes' => [ + // We'll use the virtual attribute to get a formatted number + 'formatted_number' => 'number' + ], + 'type' => 'TelephoneNumbers.type', + 'wire' => 'telephoneNumbers' + ] + ]; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('match_server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('match_server_id'); + + $validator->add('attribute', [ + 'content' => ['rule' => ['inList', Hash::extract($this->supportedAttributes(), '{s}.wire')]] + ]); + $validator->notEmptyString('attribute'); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + // Required really depends on attribute + $validator->allowEmptyString('type_id'); + + $validator->add('required', [ + 'content' => ['rule' => ['inList', RequiredEnum::getConstValues()]] + ]); + $validator->notEmptyString('required'); + + return $validator; + } +} diff --git a/app/plugins/CoreServer/src/Model/Table/MatchServersTable.php b/app/plugins/CoreServer/src/Model/Table/MatchServersTable.php index 6b095d270..e9fe6ff8b 100644 --- a/app/plugins/CoreServer/src/Model/Table/MatchServersTable.php +++ b/app/plugins/CoreServer/src/Model/Table/MatchServersTable.php @@ -34,7 +34,11 @@ use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; +use Cake\Utility\Hash; +use Cake\Utility\Inflector; use Cake\Validation\Validator; +use App\Lib\Enum\SuspendableStatusEnum; +use App\Lib\Enum\RequiredEnum; class MatchServersTable extends HttpServersTable { /** @@ -55,7 +59,11 @@ public function initialize(array $config): void { $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); // Define associations - $this->belongsTo('Servers'); + // this is defined in HttpServersTable + // $this->belongsTo('Servers'); + $this->hasMany('CoreServer.MatchServerAttributes') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->setDisplayField('hostname'); @@ -73,10 +81,338 @@ public function initialize(array $config): void { 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], 'index' => ['platformAdmin', 'coAdmin'] + ], + 'related' => [ + 'table' => [ + 'CoreServer.MatchServerAttributes' + ] ] ]); } + /** + * Assemble attributes from a person record into a format suitable for wire + * transfer. + * + * @since COmanage Registry v4.0.0 + * @param array $matchAttributes Match Attribute configuration + * @param array $queryAttributes Identity attributes for querying + * @return array Array of data suitable for conversion to JSON + * @throws InvalidArgumentException + * @throws RuntimeException + */ + + protected function assembleRequestAttributes( + array $matchAttributes, + array $queryAttributes + ): array { + $matchRequest = []; + + $supportedAttrs = $this->MatchServerAttributes->supportedAttributes(); + + foreach($matchAttributes as $mattr) { + if($mattr->required == RequiredEnum::NotPermitted) { + continue; + } + + $found = false; + + // This is the key used by supportedAttributes(), which is also the value + // stored in the database for 'attribute' by the form + $attrKey = $mattr->attribute; + + // $model = (eg) EmailAddresses + $model = $supportedAttrs[$attrKey]['model']; + // $tableName = (eg) email_addresses + $tableName = Inflector::tableize($model); + // $wire = (eg) emailAddresses + $wire = $supportedAttrs[$attrKey]['wire']; + + if(isset($supportedAttrs[$attrKey]['attribute'])) { + // This is a singleton value on OrgIdentity, eg "date_of_birth" + + // XXX date_of_birth is expected to be YYYY-MM-DD but we don't currently try to reformat it... + + if(!empty($queryAttributes[ $supportedAttrs[$attrKey]['attribute'] ])) { + $matchRequest['sorAttributes'][$wire] = $queryAttributes[ $supportedAttrs[$attrKey]['attribute'] ]; + $found = true; + } + } elseif(isset($supportedAttrs[$attrKey]['attributes'])) { + // This is an MVEA, eg "emailAddress" or "telephoneNumber" + + // When assembling attributes from MVEAs, we include all available attributes. + // The Match server can ignore the ones it doesn't care about. + + // We don't try to reformat the attribute (strip spaces, slashes, etc) since + // the match engine should be configured to treat the attribute appropriately + // (eg: alphanumeric). That is, unless a formatting function is specified + // to (eg) assemble a TelephoneNumber from its parts. + + // $type = (eg) official (as configured for this Match Server instance) + $type = $mattr->type->value;; + + if(!empty($queryAttributes[$tableName])) { + $obj = Hash::extract($queryAttributes[$tableName], '{n}[type='.$type.']'); + + if(!empty($obj)) { + foreach($obj as $o) { + // Assemble the record + $attrs = ['type' => $type]; + + if(!empty($supportedAttrs[$attrKey]['attributes'])) { + foreach($supportedAttrs[$attrKey]['attributes'] as $ra => $ad) { + // $ra = Registry Attribute, $ad = Attribute Dictionary attribute + // We use isset() rather than !empty() to avoid issues with + // "blank" values, including 0 + if(isset($o[$ra])) { + $attrs[$ad] = $o[$ra]; + } + } + } + /* So far we don't need a function in PE since we use virtual attributes + on the entity instead + else { + // Call the function + $fn = $supportedAttrs[$attrKey]['function']; + + $attrs[ $supportedAttrs[$attrKey]['wireField'] ] = $fn($o); + } */ + + // Make sure we have something other than type to work with + if(count(array_keys($attrs)) > 1) { + $matchRequest['sorAttributes'][$wire][] = $attrs; + $found = true; + } + } + } + } + } else { + throw new \LogicException('NOT IMPLEMENTED: ' . $attrKey); + } + + if(!$found && $mattr->required == RequiredEnum::Required) { + throw new \InvalidArgumentException(__d('core_server', 'error.MatchServers.attr.req', [$attrKey, $mattr->id])); + } + } + + if(empty($matchRequest)) { + // We didn't find any attributes, so throw an error + + throw new \RuntimeException(__d('core_server', 'error.MatchServers.attr.none')); + } + + return $matchRequest; + } + + /** + * Perform an ID Match Reference Identifier or Update Attributes Request. + * + * @since COmanage Registry v5.2.0 + * @param int $serverId Server ID (NOT Match Server ID) + * @param string $sorLabel System of Record Label + * @param string $sorId System of Record ID (Source Key) + * @param array $attributes Available attributes used to perform match request with + * @param string $referenceId Reference ID, for forced reconciliation request + * @param string $action Requested action: "request" (Reference ID) or "update" (Match Attributes) + * @return string|array|bool Reference ID or (on 300 response) array of choices, or true (Update request) + * @throws InvalidArgumentException + * @throws UnexpectedValueException + */ + + protected function doRequest( + int $serverId, + string $sorLabel, + string $sorId, + array $attributes, + ?string $referenceId=null, + string $action='request' + ): string|array|bool { + // Pull the Match Server configuration + + $matchServer = $this->Servers->get( + $serverId, + contain: ['MatchServers' => ['MatchServerAttributes' => 'Types']], + ); + + if($matchServer->status != SuspendableStatusEnum::Active) { + throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Servers', [1]), $serverId])); + } + + // Assemble the match request attributes using the provided entity attributes + // Let any exceptions bubble up + $matchRequest = $this->assembleRequestAttributes( + $matchServer->match_server->match_server_attributes, + $attributes + ); + + if($referenceId) { + // Insert the requested Reference ID into the message body + + $matchRequest['referenceId'] = $referenceId; + } + + $MatchClient = $this->createHttpClient($matchServer->match_server->id); + + $url = "/people/" . urlencode($sorLabel) . "/" . urlencode($sorId); + $options = [ + 'headers' =>['Content-Type' => 'application/json'] + ]; + + if($action == 'request') { + // Before we submit the PUT, we do a GET (Request Current Values) to see + // if there is already a reference ID available. This is primarily to + // handle a potential match (202) situation (ie: we previously tried to + // get a reference ID but got a 202 instead). Deployers could enable the + // Match Callback API, in which case this wouldn't be necessary. + + // An alternate approach here would be to store the Match Request ID + // returned as part of the 202 response, however that ID is optional + // (maybe it should be required?) and we would need to track it somewhere + // (in the OIS Record?). It would be nice to link to the pending request + // though... + + $response = $MatchClient->get($url); + + if($response->getStatusCode() == 200) { + $body = $response->getJson(); + + if(!empty($body['meta']['referenceId'])) { + $this->llog('trace', "Received existing Reference ID " . $body['meta']['referenceId'] . " for $sorLabel / $sorId from Match server " . $matchServer->match_server->id); + + // The pending match has been resolved + return $body['meta']['referenceId']; + } + } + } + + $response = $MatchClient->put($url, json_encode($matchRequest), $options); + + $body = $response->getJson(); + + $this->llog('trace', "Match server " . $matchServer->match_server->id + . " returned " . $response->getStatusCode() + . " for $sorLabel / $sorId"); + + // If we get anything other than a 200/201 back, throw an error. This includes + // 202, which we handle by simply generating a slightly different error. + + if($response->getStatusCode() == 202) { + $requestId = "?"; + + if(!empty($body['matchRequest'])) { + // Match Request is an optional part of the response + $requestId = $body['matchRequest']; + } + + throw new \UnexpectedValueException(__d('core_server', 'result.MatchServers.match.accepted', $requestId)); + } + + if($response->getStatusCode() == 300) { + $candidates = []; + + // Inject the "new" candidate to make it easier for the calling code + $candidates[] = [ + 'referenceId' => 'new', + 'sorRecords' => [ + [ + 'meta' => [ + 'referenceId' => 'new' + ], + 'sorAttributes' => $matchRequest['sorAttributes'] + ] + ] + ]; + + $candidates = array_merge($candidates, $body['candidates']); + return $candidates; + } + + if($response->getStatusCode() != 200 && $response->getStatusCode() != 201) { + $error = $response->getReasonPhrase(); + + // If an error was provided in the response, use that instead + if(!empty($body['error'])) { + $error = $body['error']; + } + + $this->llog('error', "Match Server " . $matchServer->match_server->id . " returned error $error"); + + throw new \RuntimeException(__d('core_server', 'error.MatchServers.response', [$error])); + } + + if($action == 'request') { + // We expect a reference ID + $this->llog('trace', "Received Reference ID " . $body['referenceId'] . " for $sorLabel / $sorId from Match server " . $matchServer->match_server->id); + + return $body['referenceId']; + } + + // There is no Reference ID returned for an Update Match Attributes request + return true; + } + + /** + * Perform an ID Match Reference Identifier Request. + * + * @since COmanage Registry v5.2.0 + * @param integer $serverId Server ID + * @param string $sorLabel System of Record Label + * @param string $sorId System of Record ID (Source Key) + * @param array $attributes Available attributes used to perform match request with + * @param string $referenceId Reference ID, for forced reconciliation request + * @return string|array Reference ID or (on 300 response) array of choices + * @throws InvalidArgumentException + * @throws UnexpectedValueException + */ + + public function requestReferenceIdentifier( + int $serverId, + string $sorLabel, + string $sorId, + array $attributes, + ?string $referenceId=null + ): string|array { + return $this->doRequest( + serverId: $serverId, + sorLabel: $sorLabel, + sorId: $sorId, + attributes: $attributes, + referenceId: $referenceId, + action: 'request' + ); + } + + /** + * Perform an ID Match Update Match Attributes Request. + * + * @since COmanage Registry v5.2.0 + * @param int $serverId Server ID (NOT Match Server ID) + * @param string $sorLabel System of Record Label + * @param string $sorId System of Record ID (Source Key) + * @param array $attributes Available attributes used to perform match request with + * @return boolean true on success + * @throws InvalidArgumentException + */ + + public function updateMatchAttributes( + int $serverId, + string $sorLabel, + string $sorId, + array $attributes + ): bool { + // This is basically the same request as requestReferenceIdentifier(). + // If we don't throw an exception then we successfully processed the request. + + return $this->doRequest( + serverId: $serverId, + sorLabel: $sorLabel, + sorId: $sorId, + attributes: $attributes, + action: 'update' + ); + } + + /** * Set validation rules. * diff --git a/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php b/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php new file mode 100644 index 000000000..f31ce438c --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Table/Oauth2ServersTable.php @@ -0,0 +1,287 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + // this is defined in HttpServersTable + // $this->belongsTo('Servers'); + + $this->setDisplayField('hostname'); + + $this->setPrimaryLink('server_id'); + $this->setAllowLookupPrimaryLink(['token', 'callback']); + + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'types' => [ + 'type' => 'enum', + 'class' => 'CoreServer.GrantTypesEnum' + ] + ]); + + $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'], + 'token' => ['platformAdmin', 'coAdmin'], + 'callback' => true, + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + ] + ]); + } + + /** + * Exchange an authorization code for an access and refresh token. + * + * @param int|string $id Oauth2Server ID + * @param string $code Access code returned by call to /oauth/authorize + * @param string $redirectUri Callback URL used for initial request + * @return mixed Object of data as returned by server, including access and refresh token + * @throws RuntimeException + *@since COmanage Registry v5.2.0 + */ + + public function exchangeCode(int|string $id, string $code, string $redirectUri, $store=true): mixed + { + return $this->obtainToken((int)$id, 'authorization_code', $code, $redirectUri, $store); + } + + /** + * Obtain an OAuth token. + * + * @param Integer $id Oauth2Server ID + * @param String $grantType OAuth grant type + * @param String|null $code Access code returned by call to /oauth/authorize, for authorization_code grant + * @param String|null $redirectUri Callback URL used for initial request, for authorization_code grant + * @param Boolean $store If true, store the retrieved tokens in the Oauth2Server configuration + * @return mixed Object of data as returned by server, including access and refresh token + * @throws RuntimeException + *@since COmanage Registry v5.2.0 + */ + + public function obtainToken(int $id, string $grantType, string $code=null, string $redirectUri=null, bool $store=true): mixed + { + // Pull our configuration + $srvr = $this->get($id); + + $httpClient = $this->createHttpClient($id); + + $postData = [ + 'client_id' => $srvr->clientid, + 'client_secret' => $srvr->client_secret, + 'grant_type' => $grantType + ]; + + if($grantType == 'refresh_token') { + $postData['refresh_token'] = $srvr->refresh_token; + $postData['format'] = 'json'; + } elseif($grantType == 'authorization_code' && $code) { + $postData['code'] = $code; + $postData['redirect_uri'] = $redirectUri; + } else { + $postData['scope'] = str_replace(' ', '%20', $srvr->scope); + } + + $postUrl = $srvr->url . "/token"; + + $results = $httpClient->post($postUrl, $postData); + + $json = json_decode($results->getStringBody()); + + if($results->getStatusCode() != 200) { + // There should be an error in the response + throw new \RuntimeException(__d('core_server', 'error.Oauth2Servers.token', [$json->error . ": " . $json->error_description])); + } + + if($store) { + // Save the fields we want to keep + $data = [ + 'id' => $id, + 'access_token' => $json->access_token, + // Store the raw result in case the server has added some custom attributes + 'token_response' => json_encode($json) + ]; + + // We shouldn't have a new refresh token on a refresh_token grant + // (which just gets us a new access token). Additionally, section + // 4.4.3 of RFC 6749 explains that the server should NOT return + // a refresh token for a client credentials grant. + if($grantType != 'refresh_token' && property_exists($json, 'refresh_token')) { + $data['refresh_token'] = $json->refresh_token; + } + + // If the Oauth2 server returned `expires_in` use it to set the + // access token expiration time. See section 5.1 of RFC 6749. + if(property_exists($json, 'expires_in')) { + $data['access_token_exp'] = time() + $json->expires_in; + } + + // Update the dataset + $srvr = $this->patchEntity($srvr, $data); + if (!$this->save($srvr)) { + throw new \RuntimeException(__d('error', 'save' [__d('core_server', 'field.Oauth2Servers.access_token')])); + } + } + + return $json; + } + + + /** + * Generate a redirect URI for the given server ID. + * + * @param int|string $id The unique identifier of the OAuth2 server + * @return string The full URL of the redirect URI + */ + public function redirectUri(int|string $id): string + { + $callback = [ + 'plugin' => 'CoreServer', + 'controller' => 'Oauth2Servers', + 'action' => 'callback', + $id + ]; + + return Router::url($callback, true); + } + + + /** + * Refresh the OAuth access token using the stored refresh token. + * + * @param int|string $id The unique identifier of the OAuth2 server + * @return string The new access token + * @throws RuntimeException + * @since COmanage Registry v5.2.0 + */ + public function refreshToken(int|string $id):string + { + $json = $this->obtainToken((int)$id, 'refresh_token'); + + return $json->access_token; + } + + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.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('access_grant_type', [ + 'content' => ['rule' => ['inList', GrantTypesEnum::getConstValues()]] + ]); + $validator->notEmptyString('access_grant_type'); + + $validator->add('url', ['content' => ['rule' => 'url']]); + $validator->notEmptyString('url'); + + $validator->notEmptyString('clientid'); + $validator->notEmptyString('client_secret'); + + $validator->add('scope', [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + $validator->notEmptyString('scope'); + + $validator->add('refresh_token', [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + $validator->allowEmptyString('refresh_token'); + + $validator->add('access_token', [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + $validator->allowEmptyString('access_token'); + + $validator->add('token_response', [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + $validator->allowEmptyString('token_response'); + + $validator->integer('access_token_exp') + ->allowEmptyString('access_token_exp'); + + return $validator; + } +} diff --git a/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php b/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php index 8030d5d4c..7e14e836b 100644 --- a/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php +++ b/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php @@ -36,6 +36,7 @@ use Cake\ORM\Table; use Cake\Validation\Validator; use CoreServer\Lib\Enum\RdbmsTypeEnum; +use \App\Lib\Enum\SuspendableStatusEnum; class SqlServersTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; @@ -127,8 +128,12 @@ public function connect(int $serverId, string $name): bool { // which is basically what the SQL Provisioner does. // Pull our configuration via the parent Server object. - $server = $this->Servers->get($serverId, ['contain' => ['SqlServers']]); + $server = $this->Servers->get($serverId, contain: ['SqlServers']); + if($server->status != SuspendableStatusEnum::Active) { + throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Servers', [1]), $serverId])); + } + $dbmap = [ RdbmsTypeEnum::MariaDB => 'Mysql', RdbmsTypeEnum::MySQL => 'Mysql', @@ -168,15 +173,17 @@ public function connect(int $serverId, string $name): bool { // Use 'CakeDC\\OracleDriver\\Database\\Driver\\OraclePDO' for PDO_OCI, but CakeDC // recommends OCI8 - // The plugin documentation says certain features are enabled at v12, so we hard - // code that version to simplify configuration. As of this writing, Oracle 11g - // is the oldest supported version, but 12c dates back to July 2013, so it seems - // reasonable to require v12 (at least for now). Note Oracle changed their release - // numbers to be based on calendar years, retroactively assigning 18c (12.2.0.2) - // and 19c (12.2.0.3), so this approach should work at least for those versions. - // Since semantic versioning is not being used, it's unclear when backwards - // incompatible changes might be introduced, or if the CakeDC plugin even cares. - $dbconfig['server_version'] = 12; + // The plugin documentation says certain features are enabled at v12, and more + // specifically we require support for long aliases (Oracle only supported 30 + // characters until 12.2, which allows 128). See eg this commit + // https://github.com/CakeDC/cakephp-oracle-driver/pull/57/commits/1461451ce896aa55a14b08fddc0b28266a3391df + // Oracle 19c (aka 19.1.0 aka 12.2.0.3) appears to be the current oldest release + // (as of this writing), and based on testing from SMU setting this value to "19" + // correctly enables the long alias support, to we hard code that version to simplify + // configuration. Note Oracle changed their release numbers to be based on calendar years, + // retroactively assigning 18c (12.2.0.2) and 19c (12.2.0.3), so this approach should + // work at least for those versions. + $dbconfig['server_version'] = 19; } } diff --git a/app/plugins/CoreServer/src/config/plugin.json b/app/plugins/CoreServer/src/config/plugin.json deleted file mode 100644 index a1901134c..000000000 --- a/app/plugins/CoreServer/src/config/plugin.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "types": { - "server": [ - "HttpServers", - "MatchServers", - "SmtpServers", - "SqlServers" - ] - }, - "schema": { - "tables": { - "http_servers": { - "columns": { - "id": {}, - "server_id": {}, - "url": { "type": "url" }, - "username": {}, - "password": {}, - "auth_type": { "type": "string", "size": 2 }, - "skip_ssl_verification": { "type": "boolean" } - }, - "indexes": { - "http_servers_i1": { "columns": [ "server_id" ]} - } - }, - "match_servers": { - "columns": { - "id": {}, - "server_id": {}, - "url": { "type": "url" }, - "username": {}, - "password": {}, - "auth_type": { "type": "string", "size": 2 }, - "skip_ssl_verification": { "type": "boolean" } - }, - "indexes": { - "match_servers_i1": { "columns": [ "server_id" ] } - } - }, - "smtp_servers": { - "columns": { - "id": {}, - "server_id": {}, - "hostname": { "type": "string", "size": 128 }, - "port": { "type": "integer" }, - "username": {}, - "password": {}, - "use_tls": { "type": "boolean" }, - "default_from": { "type": "string", "size": 256 }, - "default_reply_to": { "type": "string", "size": 256 }, - "override_to": { "type": "string", "size": 256 } - }, - "indexes": { - "smtp_servers_i1": { "columns": [ "server_id" ] } - } - }, - "sql_servers": { - "columns": { - "id": {}, - "server_id": {}, - "type": { "type": "string", "size": 2 }, - "hostname": { "type": "string", "size": 128 }, - "port": { "type": "integer" }, - "databas": { "type": "string", "size": 128 }, - "username": {}, - "password": {} - }, - "indexes": { - "sql_servers_i1": { "columns": [ "server_id" ]} - } - } - } - } -} \ No newline at end of file diff --git a/app/plugins/CoreServer/templates/HttpServers/fields.inc b/app/plugins/CoreServer/templates/HttpServers/fields.inc index d643222b2..6926478fc 100644 --- a/app/plugins/CoreServer/templates/HttpServers/fields.inc +++ b/app/plugins/CoreServer/templates/HttpServers/fields.inc @@ -25,18 +25,18 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only, and add is not used -if($vv_action == 'edit') { - foreach ([ - 'url', - 'username', - 'password', - 'auth_type', - 'skip_ssl_verification' - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field - ]]); - } -} +$fields = [ + 'url', + 'username', + 'password', + 'auth_type', + 'skip_ssl_verification' +]; + +$subnav = [ + 'tabs' => ['Servers', 'CoreServer.HttpServers'], + 'action' => [ + 'Servers' => ['edit'], + 'CoreServer.HttpServers' => ['edit'] + ] +]; diff --git a/app/plugins/CoreServer/templates/MatchServerAttributes/columns.inc b/app/plugins/CoreServer/templates/MatchServerAttributes/columns.inc new file mode 100644 index 000000000..2e2d71ad4 --- /dev/null +++ b/app/plugins/CoreServer/templates/MatchServerAttributes/columns.inc @@ -0,0 +1,52 @@ + [ + 'type' => 'link' + ], + 'type_id' => [ + 'type' => 'fk' + ], + 'required' => [ + 'type' => 'enum', + 'class' => 'RequiredEnum' + ] +]; + +$subnav = [ + 'tabs' => ['Servers', 'CoreServer.MatchServers', 'CoreServer.MatchServerAttributes'], + 'action' => [ + 'Servers' => ['edit'], + 'CoreServer.MatchServers' => ['edit'], + 'CoreServer.MatchServerAttributes' => ['index'] + ] +]; diff --git a/app/plugins/CoreServer/templates/MatchServerAttributes/fields.inc b/app/plugins/CoreServer/templates/MatchServerAttributes/fields.inc new file mode 100644 index 000000000..d674bc5e8 --- /dev/null +++ b/app/plugins/CoreServer/templates/MatchServerAttributes/fields.inc @@ -0,0 +1,150 @@ + [ // type_id is a hidden field used to persist the type + 'type' => 'integer' + ] +]; + +// These are display widgets that are filtered to the correct available types +foreach ([ + 'address_type_id', + 'email_address_type_id', + 'identifier_type_id', + 'name_type_id', + 'telephone_number_type_id' +] as $field) { + $fields[$field] = [ + 'fieldLabel' => __d('field', 'type') + ]; +} + +$fields[] = 'required'; + +$subnav = [ + 'tabs' => ['Servers', 'CoreServer.MatchServers', 'CoreServer.MatchServerAttributes'], + 'action' => [ + 'Servers' => ['edit'], + 'CoreServer.MatchServers' => ['edit'], + 'CoreServer.MatchServerAttributes' => ['index'] + ] +]; +?> + + diff --git a/app/plugins/CoreServer/templates/MatchServers/fields.inc b/app/plugins/CoreServer/templates/MatchServers/fields.inc index 550143398..8fb6ac14c 100644 --- a/app/plugins/CoreServer/templates/MatchServers/fields.inc +++ b/app/plugins/CoreServer/templates/MatchServers/fields.inc @@ -28,13 +28,29 @@ // At least initially we use only the HttpServer fields include ROOT . DS . "plugins" . DS . "CoreServer" . DS . "templates" . DS . "HttpServers" . DS . "fields.inc"; -// Render the callback API endpoint +// If the Match Server URL is empty, then we're in the initial configuration, so we hide the +// Match Server Attributes link so the admin doesn't try to visit that link without finishing +// the main configuration +if(!empty($vv_obj->url)) { + $topLinks[] = [ + 'icon' => 'list', + 'order' => 'Default', + 'label' => __d('core_server', 'controller.MatchServerAttributes', [99]), + 'link' => [ + 'plugin' => 'CoreServer', + 'controller' => 'match_server_attributes', + 'action' => 'index', + 'match_server_id' => $vv_obj->id + ], + 'class' => '' + ]; +} -print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'api_endpoint', - 'fieldOptions' => [ - 'readOnly' => true, - 'default' => $vv_api_endpoint - ] - ]]); \ No newline at end of file +$subnav = [ + 'tabs' => ['Servers', 'CoreServer.MatchServers', 'CoreServer.MatchServerAttributes'], + 'action' => [ + 'Servers' => ['edit'], + 'CoreServer.MatchServers' => ['edit'], + 'CoreServer.MatchServerAttributes' => ['index'] + ] +]; diff --git a/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc b/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc new file mode 100644 index 000000000..aa342d45b --- /dev/null +++ b/app/plugins/CoreServer/templates/Oauth2Servers/fields.inc @@ -0,0 +1,74 @@ + [ + 'readonly' => true, + 'default' => $vv_redirect_uri + ], + 'url', + 'access_grant_type' => [ + 'options' => $types, + 'type' => 'select', + 'empty' => false + ], + 'clientid', + 'client_secret', + 'scope' => [ + 'placeholder' => '/authenticate', + ] +]; + +$generateLink = []; +if(!empty($vv_obj->id)) { + $generateLink = [ + 'url' => [ + 'plugin' => 'CoreServer', + 'controller' => 'Oauth2Servers', + 'action' => 'token', + $vv_obj->id + ], + 'label' => __d('core_server', 'info.Oauth2Servers.token.obtain'), + 'class' => 'provisionbutton nospin btn btn-primary btn-sm', + ]; +} + +$fields['access_token'] = [ + 'status' => !empty($vv_obj->access_token) ? __d('enumeration', 'SetBooleanEnum.1') : __d('enumeration', 'SetBooleanEnum.0'), + 'link' => $generateLink, + 'labelIsTextOnly' => true +]; + +$subnav = [ + 'tabs' => ['Servers', 'CoreServer.Oauth2Servers'], + 'action' => [ + 'Servers' => ['edit'], + 'CoreServer.Oauth2Servers' => ['edit'] + ] +]; + \ No newline at end of file diff --git a/app/plugins/CoreServer/templates/SmtpServers/fields.inc b/app/plugins/CoreServer/templates/SmtpServers/fields.inc index bac623cdc..882000297 100644 --- a/app/plugins/CoreServer/templates/SmtpServers/fields.inc +++ b/app/plugins/CoreServer/templates/SmtpServers/fields.inc @@ -25,50 +25,26 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only, and add is not used -if($vv_action == 'edit') { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'hostname', - ] - ]); +$fields = [ + 'hostname', + 'port' => [ + 'default' => 587 + ], + 'username', + 'password', + 'use_tls' => [ + 'default' => true + ], + 'default_from', + 'default_reply_to', + 'override_to' +]; - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'port', - 'fieldOptions' => [ - 'default' => 587 - ] - ]]); - - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'username', - ] - ]); - - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'password', - ] - ]); - - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'use_tls', - 'fieldOptions' => [ - 'default' => true - ] - ]]); - - foreach ([ - 'default_from', - 'default_reply_to', - 'override_to', - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field - ]]); - } -} +$subnav = [ + 'tabs' => ['Servers', 'CoreServer.SmtpServers'], + 'action' => [ + 'Servers' => ['edit'], + 'CoreServer.SmtpServers' => ['edit'] + ] +]; + \ No newline at end of file diff --git a/app/plugins/CoreServer/templates/SqlServers/fields.inc b/app/plugins/CoreServer/templates/SqlServers/fields.inc index a15d291f9..7b9482170 100644 --- a/app/plugins/CoreServer/templates/SqlServers/fields.inc +++ b/app/plugins/CoreServer/templates/SqlServers/fields.inc @@ -25,20 +25,19 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view does currently not support read-only, and add is not used -if($vv_action == 'edit') { - foreach([ - 'type', - 'hostname', - 'port', - 'databas', - 'username', - 'password', - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field, - ] - ]); - } -} +$fields = [ + 'type', + 'hostname', + 'port', + 'databas', + 'username', + 'password' +]; + +$subnav = [ + 'tabs' => ['Servers', 'CoreServer.SqlServers'], + 'action' => [ + 'Servers' => ['edit'], + 'CoreServer.SqlServers' => ['edit'] + ] +]; diff --git a/app/plugins/EnvSource/config/plugin.json b/app/plugins/EnvSource/config/plugin.json new file mode 100644 index 000000000..91680f5ea --- /dev/null +++ b/app/plugins/EnvSource/config/plugin.json @@ -0,0 +1,106 @@ +{ + "types": { + "enrollment_flow_step": [ + "EnvSourceCollectors" + ], + "external_identity_source": [ + "EnvSources" + ], + "traffic_detour": [ + "EnvSourceDetours" + ] + }, + "schema": { + "tables": { + "env_source_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "external_identity_source_id": {}, + "enable_confirmation_page": { "type": "boolean" } + }, + "indexes": { + "env_source_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "env_source_collectors_i2": { "needed": false, "columns": [ "external_identity_source_id" ] } + } + }, + "env_sources": { + "columns": { + "id": {}, + "external_identity_source_id": {}, + "redirect_on_duplicate": { "type": "url" }, + "mva_delimiter": { "type": "string", "size": "1" }, + "sync_on_login": { "type": "boolean"}, + "default_affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "env_address_street": { "type": "string", "size": "80" }, + "env_address_locality": { "type": "string", "size": "80" }, + "env_address_state": { "type": "string", "size": "80" }, + "env_address_postalcode": { "type": "string", "size": "80" }, + "env_address_country": { "type": "string", "size": "80" }, + "env_affiliation": { "type": "string", "size": "80" }, + "env_department": { "type": "string", "size": "80" }, + "env_identifier_eppn": { "type": "string", "size": "80" }, + "env_identifier_eptid": { "type": "string", "size": "80" }, + "env_identifier_epuid": { "type": "string", "size": "80" }, + "env_identifier_network": { "type": "string", "size": "80" }, + "env_identifier_oidcsub": { "type": "string", "size": "80" }, + "env_identifier_samlpairwiseid": { "type": "string", "size": "80" }, + "env_identifier_samlsubjectid": { "type": "string", "size": "80" }, + "env_identifier_sourcekey": { "type": "string", "size": "80" }, + "env_mail": { "type": "string", "size": "80" }, + "env_name_honorific": { "type": "string", "size": "80" }, + "env_name_given": { "type": "string", "size": "80" }, + "env_name_middle": { "type": "string", "size": "80" }, + "env_name_family": { "type": "string", "size": "80" }, + "env_name_suffix": { "type": "string", "size": "80" }, + "env_organization": { "type": "string", "size": "80" }, + "env_telephone_number": { "type": "string", "size": "80" }, + "env_title": { "type": "string", "size": "80" }, + "lookaside_file": { "type": "path" } + }, + "indexes": { + "env_sources_i1": { "columns": [ "external_identity_source_id" ] } + } + }, + "env_source_identities": { + "columns": { + "id": {}, + "env_source_id": { "type": "integer", "foreignkey": { "table": "env_sources", "column": "id" }}, + "source_key": { "type": "string", "size": 1024 }, + "env_attributes": { "type": "text" } + }, + "indexes": { + "env_source_identities_i1": { "columns": [ "source_key" ] }, + "env_source_identities_i2": { "needed": false, "columns": [ "env_source_id" ] } + }, + "clone_relation": true + }, + "env_source_detours": { + "columns": { + "id": {}, + "traffic_detour_id": {} + }, + "indexes": { + "env_source_detours_i1": { "needed": false, "columns": [ "traffic_detour_id" ] } + } + }, + "petition_env_identities": { + "columns": { + "id": {}, + "petition_id": {}, + "env_source_collector_id": { "type": "integer", "foreignkey": { "table": "env_source_collectors", "column": "id" }}, + "env_source_identity_id": { "type": "integer", "foreignkey": { "table": "env_source_identities", "column": "id" }} + }, + "indexes": { + "petition_env_identities_i1": { "columns": [ "petition_id" ] }, + "petition_env_identities_i2": { "needed": false, "columns": [ "env_source_collector_id" ] }, + "petition_env_identities_i3": { "needed": false, "columns": [ "env_source_identity_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/EnvSource/resources/locales/en_US/env_source.po b/app/plugins/EnvSource/resources/locales/en_US/env_source.po index 2cf309708..d0c39ad61 100644 --- a/app/plugins/EnvSource/resources/locales/en_US/env_source.po +++ b/app/plugins/EnvSource/resources/locales/en_US/env_source.po @@ -31,6 +31,9 @@ msgstr "{0,plural,=1{Env Source} other{Env Sources}}" msgid "controller.PetitionEnvIdentities" msgstr "{0,plural,=1{Petition Env Identity} other{Petition Env Identities}}" +msgid "display.EnvSource" +msgstr "{0} Source" + msgid "enumeration.EnvSourceSpModeEnum.O" msgstr "Other" @@ -49,9 +52,18 @@ msgstr "Source Key (env_identifier_sourcekey) not found in attributes" msgid "error.source_key.duplicate" msgstr "Source Key {0} is already attached to External Identity {1}" +msgid "field.EnvSourceCollectors.enable_confirmation_page" +msgstr "Enable Confirmation Page" + +msgid "field.EnvSourceCollectors.enable_confirmation_page.desc" +msgstr "If enabled, Enrollees will be presented with the values asserted by their IdP to review before continuing" + msgid "field.EnvSources.address_type_id" msgstr "Address Type" +msgid "field.EnvSources.mva_delimiter_common" +msgstr "Common Delimiters" + msgid "field.EnvSources.default_affiliation_type_id" msgstr "Default Affiliation Type" @@ -142,8 +154,8 @@ msgstr "Path to lookaside file, intended for testing only" msgid "field.EnvSources.redirect_on_duplicate" msgstr "Redirect on Duplicate" -msgid "field.EnvSources.sp_mode" -msgstr "Web Server Service Provider" +msgid "field.EnvSources.mva_delimiter" +msgstr "Service Provider Multi Value Delimiter" msgid "field.EnvSources.sync_on_login" msgstr "Sync on Login" @@ -170,4 +182,4 @@ msgid "result.env.saved.login" msgstr "Env Attributes updated at login" msgid "result.pipeline.status" -msgstr "Pipeline completed with status {0}" \ No newline at end of file +msgstr "EnvSource Pipeline completed with status {0}" \ No newline at end of file diff --git a/app/plugins/EnvSource/src/Controller/EnvSourceCollectorsController.php b/app/plugins/EnvSource/src/Controller/EnvSourceCollectorsController.php index 167b8ef22..dcba7ae83 100644 --- a/app/plugins/EnvSource/src/Controller/EnvSourceCollectorsController.php +++ b/app/plugins/EnvSource/src/Controller/EnvSourceCollectorsController.php @@ -36,7 +36,7 @@ use Cake\ORM\TableRegistry; class EnvSourceCollectorsController extends StandardEnrollerController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'EnvSourceCollectors.id' => 'asc' ] @@ -78,14 +78,15 @@ public function dispatch(string $id) { // Pull our configuration - $envSource = $this->EnvSourceCollectors->get((int)$id, ['contain' => ['ExternalIdentitySources' => 'EnvSources']]); + $envSource = $this->EnvSourceCollectors->get((int)$id, contain: ['ExternalIdentitySources' => 'EnvSources']); try { $vars = $this->EnvSourceCollectors->parse($envSource->external_identity_source->env_source); $this->set('vv_env_source_vars', $vars); - if($this->request->is(['post', 'put'])) { + if($this->request->is(['post', 'put']) + || ($this->request->is(['get']) && !$envSource->enable_confirmation_page)) { // We'll upsert the collected attributes. Generally this should always be an insert, // but we could imagine a scenario where an admin reruns the step to change the // collected identity. Or maybe if the enrollee just hits the back button. diff --git a/app/plugins/EnvSource/src/Controller/EnvSourceDetoursController.php b/app/plugins/EnvSource/src/Controller/EnvSourceDetoursController.php index dbd5ffa7b..55d90b3f6 100644 --- a/app/plugins/EnvSource/src/Controller/EnvSourceDetoursController.php +++ b/app/plugins/EnvSource/src/Controller/EnvSourceDetoursController.php @@ -35,7 +35,7 @@ use \App\Lib\Events\CoIdEventListener; class EnvSourceDetoursController extends StandardDetourController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'EnvSourceDetours.id' => 'asc' ] diff --git a/app/plugins/EnvSource/src/Controller/EnvSourcesController.php b/app/plugins/EnvSource/src/Controller/EnvSourcesController.php index 81d6dd222..40cd534aa 100644 --- a/app/plugins/EnvSource/src/Controller/EnvSourcesController.php +++ b/app/plugins/EnvSource/src/Controller/EnvSourcesController.php @@ -34,7 +34,7 @@ use Cake\Http\Response; class EnvSourcesController extends StandardPluginController { - public $paginate = [ + protected array $paginate = [ 'order' => [ 'EnvSources.id' => 'asc' ] diff --git a/app/plugins/EnvSource/src/Model/Entity/EnvSource.php b/app/plugins/EnvSource/src/Model/Entity/EnvSource.php index a739b2d30..49d121a1d 100644 --- a/app/plugins/EnvSource/src/Model/Entity/EnvSource.php +++ b/app/plugins/EnvSource/src/Model/Entity/EnvSource.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class EnvSource extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class EnvSource extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/EnvSource/src/Model/Entity/EnvSourceCollector.php b/app/plugins/EnvSource/src/Model/Entity/EnvSourceCollector.php index 91989da2c..3b8099365 100644 --- a/app/plugins/EnvSource/src/Model/Entity/EnvSourceCollector.php +++ b/app/plugins/EnvSource/src/Model/Entity/EnvSourceCollector.php @@ -43,7 +43,7 @@ class EnvSourceCollector extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/EnvSource/src/Model/Entity/EnvSourceDetour.php b/app/plugins/EnvSource/src/Model/Entity/EnvSourceDetour.php index 5b8717dc7..a73a1ebbd 100644 --- a/app/plugins/EnvSource/src/Model/Entity/EnvSourceDetour.php +++ b/app/plugins/EnvSource/src/Model/Entity/EnvSourceDetour.php @@ -43,7 +43,7 @@ class EnvSourceDetour extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/EnvSource/src/Model/Entity/EnvSourceIdentity.php b/app/plugins/EnvSource/src/Model/Entity/EnvSourceIdentity.php index 2873ca37c..bbab90dd5 100644 --- a/app/plugins/EnvSource/src/Model/Entity/EnvSourceIdentity.php +++ b/app/plugins/EnvSource/src/Model/Entity/EnvSourceIdentity.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class EnvSourceIdentity extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class EnvSourceIdentity extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/EnvSource/src/Model/Entity/PetitionEnvIdentity.php b/app/plugins/EnvSource/src/Model/Entity/PetitionEnvIdentity.php index beb919bfc..684db2846 100644 --- a/app/plugins/EnvSource/src/Model/Entity/PetitionEnvIdentity.php +++ b/app/plugins/EnvSource/src/Model/Entity/PetitionEnvIdentity.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class PetitionEnvIdentity extends Entity { + use \App\Lib\Traits\EntityMetaTrait; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -41,7 +43,7 @@ class PetitionEnvIdentity extends Entity { * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ '*' => true, 'id' => false, 'slug' => false, diff --git a/app/plugins/EnvSource/src/Model/Table/EnvSourceCollectorsTable.php b/app/plugins/EnvSource/src/Model/Table/EnvSourceCollectorsTable.php index 5a865dbfe..f6870577f 100644 --- a/app/plugins/EnvSource/src/Model/Table/EnvSourceCollectorsTable.php +++ b/app/plugins/EnvSource/src/Model/Table/EnvSourceCollectorsTable.php @@ -31,6 +31,7 @@ use Cake\Datasource\ConnectionManager; use Cake\Datasource\EntityInterface; +use Cake\Datasource\Exception\RecordNotFoundException; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; @@ -47,7 +48,6 @@ class EnvSourceCollectorsTable extends Table { use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\TabTrait; /** * Perform Cake Model initialization. @@ -80,27 +80,10 @@ public function initialize(array $config): void { $this->setRequiresCO(true); $this->setAllowLookupPrimaryLink(['dispatch', 'display']); - // All the tabs share the same configuration in the ModelTable file - $this->setTabsConfig( - [ - // Ordered list of Tabs - 'tabs' => ['EnrollmentFlowSteps', 'EnvSource.EnvSourceCollectors'], - // What actions will include the subnavigation header - 'action' => [ - // If a model renders in a subnavigation mode in edit/view mode, it cannot - // render in index mode for the same use case/context - // XXX edit should go first. - 'EnrollmentFlowSteps' => ['edit', 'view'], - 'EnvSource.EnvSourceCollectors' => ['edit'], - ] - ] - ); - $this->setAutoViewVars([ 'externalIdentitySources' => [ - 'type' => 'select', - 'model' => 'ExternalIdentitySources', - 'where' => ['plugin' => 'EnvSource.EnvSources'] + 'type' => 'plugin', + 'model' => 'EnvSource.EnvSources' ] ]); @@ -150,6 +133,71 @@ protected function checkDuplicate(int $eisId, string $sourceKey): bool { return true; } + /** + * Obtain a name (as a string) for the Enrollee associated with the specified Petition. + * + * @since COmanage Registry v5.2.0 + * @param EntityInterface $config Configuration entity for this plugin + * @param int $petitionId Petition ID + * @return string Name, or null if thre is no name data + */ + + public function enrolleeName( + EntityInterface $config, + int $petitionId + ): ?string { + $ret = null; + + // To find an Enrolle Name we have to piece together a number of different queries. + // First, look up our configuration to find the EnvSource configuration in use. + + $EnvSources = TableRegistry::getTableLocator()->get('EnvSource.EnvSources'); + + $envcfg = $EnvSources->find() + ->where([ + 'external_identity_source_id' => $config->external_identity_source_id + ]) + ->firstOrFail(); + + // Now pull the EnvSourceIdentity. From here on out any failure just means we don't + // return a value (instead of throwing an exception). + + $pei = $this->PetitionEnvIdentities + ->find() + ->where([ + 'petition_id' => $petitionId, + 'env_source_collector_id' => $config->id + ]) + ->contain(['EnvSourceIdentities']) + ->first(); + + if(!empty($pei->env_source_identity->env_attributes)) { + // Create an array of env source columns to stored values + $envattrs = json_decode(json: $pei->env_source_identity->env_attributes, associative: true); + + // Build a Name entity with the mapped values. As a basic test, we'll require + // given name, otherwise we'll assume we don't have a valid name. Note we're not + // planning on saving the Name entity, we just create it via the table to ensure + // it is properly initialized. + + if(!empty($envattrs['env_name_given'])) { + $Names = TableRegistry::getTableLocator()->get('Names'); + + $name = $Names->newEntity([ + 'honorific' => $envattrs['env_name_honorific'] ?? null, + 'given' => $envattrs['env_name_given'] ?? null, + 'middle' => $envattrs['env_name_middle'] ?? null, + 'family' => $envattrs['env_name_family'] ?? null, + 'suffix' => $envattrs['env_name_suffix'] ?? null, + ]); + + return $name->full_name; + } + } + + return $ret; + } + /** * Perform steps necessary to hydrate the Person record as part of Petition finalization. * @@ -206,6 +254,55 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition) { action: PetitionActionEnum::Finalized, comment: __d('env_source', 'result.pipeline.status', [$status]) ); + + // Because PetitionsTable::hydrate() creates a skeletal Person record without + // a Name, PipelinesTable::createPersonFromEIS() won't be called from sync(), + // so the Pipeline won't create a Primary Name if there isn't one already. + // As such, we need to check here if there is a Primary Name (highly dependent + // on the Flow configuration), and if there isn't one we'll create one (even + // though a subsequent step such as an Attribute Collector might create a new + // one later). + + $Names = TableRegistry::getTableLocator()->get('Names'); + + try { + $Names->primaryName($petition->enrollee_person_id); + } + catch(RecordNotFoundException $e) { + // No Primary Name found, create one. Note we need to honor + // AR-Pipeline-1 If a Pipeline creates a new Person, the first Name + // returned by the External Identity Source backend will be used as + // the initial Primary Name for the new Person. + // so we call retrieve() for consistency (though note EnvSource only + // supports 1 name currently). + + $EnvSources = TableRegistry::getTableLocator()->get('EnvSource.EnvSources'); + + $eis = $ExtIdentitySources->get( + $cfg->external_identity_source_id, + contain: 'EnvSources' + ); + + $eisrecord = $EnvSources->retrieve($eis, $pei->env_source_identity->source_key); + + if(!empty($eisrecord['entity_data']['names'][0])) { + $name = $eisrecord['entity_data']['names'][0]; + + // Add the additional attributes and convert the type back to a type_id + $name['person_id'] = $petition->enrollee_person_id; + $name['primary_name'] = true; + $name['type_id'] = $eis->env_source->name_type_id; + unset($name['type']); + + $Names->saveOrFail($Names->newEntity($name)); + + $this->llog('trace', 'EnvSource created Primary Name for Petition ' . $petition->id); + } else { + // This isn't necessarily an error since a subsequent step could create + // a Primary Name + $this->llog('trace', 'EnvSource could not find a Name for Petition ' . $petition->id); + } + } } catch(\Exception $e) { // We allow an error in the sync process (probably a duplicate record) to interrupt @@ -234,6 +331,41 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition) { return true; } + /** + * Load environment variables from a lookaside file based on the given configuration. + * + * @since COmanage Registry v5.1.0 + * @param string $filename Path to the lookaside file + * @param \EnvSource\Model\Entity\EnvSource $envSource EnvSource configuration entity + * @return array Array of environment variables and their parsed values + * @throws InvalidArgumentException + */ + + public function loadFromLookasideFile(string $filename, \EnvSource\Model\Entity\EnvSource $envSource): array { + $src = parse_ini_file($filename); + $ret = []; + + if(!$src) { + throw new \InvalidArgumentException(__d('env_source', 'error.lookaside_file', [$filename])); + } + + // We walk through our configuration and only copy the variables that were configured + foreach($envSource->getVisible() as $field) { + // We only want the fields starting env_ (except env_source_id, which is changelog metadata) + + if(strncmp($field, "env_", 4)==0 && $field != "env_source_id" + && !empty($envSource->$field) // This field is configured with an env var name + && isset($src[$envSource->$field]) // This env var is populated + ) { + // Note we're using the EnvSource field name (eg: env_name_given) as the key + // and not the configured variable name (which might be something like SHIB_FIRST_NAME) + $ret[$field] = $src[$envSource->$field]; + } + } + + return $ret; + } + /** * Parse the environment values as per the configuration. * @@ -273,40 +405,6 @@ public function parse(\EnvSource\Model\Entity\EnvSource $envSource): array { return $ret; } - /** - * Load environment variables from a lookaside file based on the given configuration. - * - * @param string $filename Path to the lookaside file - * @param \EnvSource\Model\Entity\EnvSource $envSource EnvSource configuration entity - * @return array Array of environment variables and their parsed values - * @throws InvalidArgumentException - *@since COmanage Registry v5.1.0 - */ - public function loadFromLookasideFile(string $filename, \EnvSource\Model\Entity\EnvSource $envSource): array { - $src = parse_ini_file($filename); - $ret = []; - - if(!$src) { - throw new \InvalidArgumentException(__d('env_source', 'error.lookaside_file', [$filename])); - } - - // We walk through our configuration and only copy the variables that were configured - foreach($envSource->getVisible() as $field) { - // We only want the fields starting env_ (except env_source_id, which is changelog metadata) - - if(strncmp($field, "env_", 4)==0 && $field != "env_source_id" - && !empty($envSource->$field) // This field is configured with an env var name - && isset($src[$envSource->$field]) // This env var is populated - ) { - // Note we're using the EnvSource field name (eg: env_name_given) as the key - // and not the configured variable name (which might be something like SHIB_FIRST_NAME) - $ret[$field] = $src[$envSource->$field]; - } - } - - return $ret; - } - /** * Insert or update a Petition Env Identity. * @@ -436,6 +534,11 @@ public function validationDefault(Validator $validator): Validator { 'content' => ['rule' => 'isInteger'] ]); $validator->notEmptyString('external_identity_source_id'); + + $validator->add('enable_confirmation_page', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('enable_confirmation_page'); return $validator; } @@ -464,7 +567,7 @@ public function verifiableEmailAddresses( $eis = $this->ExternalIdentitySources->get( $config->external_identity_source_id, - ['contain' => 'Pipelines'] + contain: 'Pipelines' ); $defaultVerified = isset($eis->pipeline->sync_verify_email_addresses) diff --git a/app/plugins/EnvSource/src/Model/Table/EnvSourcesTable.php b/app/plugins/EnvSource/src/Model/Table/EnvSourcesTable.php index 48e62f736..89b077107 100644 --- a/app/plugins/EnvSource/src/Model/Table/EnvSourcesTable.php +++ b/app/plugins/EnvSource/src/Model/Table/EnvSourcesTable.php @@ -40,7 +40,7 @@ class EnvSourcesTable extends Table { use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; - use \App\Lib\Traits\TabTrait; + use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -97,22 +97,13 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_source_id']); $this->setRequiresCO(true); - // All the tabs share the same configuration in the ModelTable file - $this->setTabsConfig( - [ - // Ordered list of Tabs - 'tabs' => ['ExternalIdentitySources', 'EnvSource.EnvSources', 'ExternalIdentitySources@action.search'], - // What actions will include the subnavigation header - 'action' => [ - // If a model renders in a subnavigation mode in edit/view mode, it cannot - // render in index mode for the same use case/context - // XXX edit should go first. - 'ExternalIdentitySources' => ['edit', 'view', 'search'], - 'EnvSource.EnvSources' => ['edit'], - 'ExternalIdentitySources@action.search' => [], - ], - ] - ); + $this->setEditContains([ + 'ExternalIdentitySources', + ]); + + $this->setViewContains([ + 'ExternalIdentitySources', + ]); $this->setAutoViewVars([ 'addressTypes' => [ @@ -156,6 +147,17 @@ public function initialize(array $config): void { ]); } + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.2.0 + * @param \EnvSource\Model\Entity\EnvSource $entity Entity to generate display field for + * @return string Display field + */ + public function generateDisplayField(\EnvSource\Model\Entity\EnvSource $entity): string { + return __d('env_source', 'display.EnvSource', [$entity->external_identity_source->description]); + } + /** * Obtain the set of changed records from the source database. * @@ -278,21 +280,15 @@ protected function resultToEntityData( // Email Address if(!empty($result['env_mail'])) { - $mails = []; - // We accept multiple values if supported by the configured SP software. - - switch($EnvSource->sp_mode) { - case EnvSourceSpModeEnum::Shibboleth: - $mails = explode(";", $result['env_mail']); - break; - case EnvSourceSpModeEnum::SimpleSamlPhp: - $mails = explode(",", $result['env_mail']); - break; - default: - // We dont' try to tokenize the string - $mails = [ $result['env_mail' ]]; - break; + $delimiter = $EnvSource->mva_delimiter; + $stringValue = $result['env_mail']; + + // Check if the delimiter is provided and not an empty string + if (!empty($delimiter)) { + $mails = explode($delimiter, $stringValue); + } else { + $mails = [$stringValue]; } foreach($mails as $m) { @@ -315,8 +311,8 @@ protected function resultToEntityData( 'env_identifier_epuid' => 'epuid', 'env_identifier_network' => 'network', 'env_identifier_oidcsub' => 'oidcsub', - 'env_identifier_samlpairwiseid' => 'samlpairwiseid', - 'env_identifier_samlsubjectid' => 'samlsubjectid' + 'env_identifier_samlpairwiseid' => 'pairwiseid', + 'env_identifier_samlsubjectid' => 'subjectid' // We don't include source_key (sorid) because the Pipeline will automatically insert it ] as $v => $t) { // Because we're in an External Identity context, we don't need to map the @@ -441,10 +437,7 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('redirect_on_duplicate'); - $validator->add('sp_mode', [ - 'content' => ['rule' => ['inList', EnvSourceSpModeEnum::getConstValues()]] - ]); - $validator->notEmptyString('sp_mode'); + $this->registerStringValidation($validator, $schema, 'mva_delimiter', true); $validator->add('sync_on_login', [ 'content' => ['rule' => 'boolean'] diff --git a/app/plugins/EnvSource/src/View/Cell/EnvSourceCollectorsCell.php b/app/plugins/EnvSource/src/View/Cell/EnvSourceCollectorsCell.php index 207b3c094..2e4e472e7 100644 --- a/app/plugins/EnvSource/src/View/Cell/EnvSourceCollectorsCell.php +++ b/app/plugins/EnvSource/src/View/Cell/EnvSourceCollectorsCell.php @@ -40,13 +40,28 @@ */ class EnvSourceCollectorsCell extends Cell { + /** + * @var mixed + */ + public $vv_obj; + + /** + * @var mixed + */ + public $vv_step; + + /** + * @var mixed + */ + public $viewVars; + /** * List of valid options that can be passed into this * cell's constructor. * * @var array */ - protected $_validCellOptions = [ + protected array $_validCellOptions = [ 'vv_obj', 'vv_step', 'viewVars', diff --git a/app/plugins/EnvSource/src/config/plugin.json b/app/plugins/EnvSource/src/config/plugin.json deleted file mode 100644 index 0b8fd35bf..000000000 --- a/app/plugins/EnvSource/src/config/plugin.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "types": { - "enroller": [ - "EnvSourceCollectors" - ], - "source": [ - "EnvSources" - ], - "traffic": [ - "EnvSourceDetours" - ] - }, - "schema": { - "tables": { - "env_source_collectors": { - "columns": { - "id": {}, - "enrollment_flow_step_id": {}, - "external_identity_source_id": {} - }, - "indexes": { - "env_source_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, - "env_source_collectors_i2": { "needed": false, "columns": [ "external_identity_source_id" ] } - } - }, - "env_sources": { - "columns": { - "id": {}, - "external_identity_source_id": {}, - "redirect_on_duplicate": { "type": "url" }, - "sp_mode": { "type": "enum" }, - "sync_on_login": { "type": "boolean"}, - "default_affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "env_address_street": { "type": "string", "size": "80" }, - "env_address_locality": { "type": "string", "size": "80" }, - "env_address_state": { "type": "string", "size": "80" }, - "env_address_postalcode": { "type": "string", "size": "80" }, - "env_address_country": { "type": "string", "size": "80" }, - "env_affiliation": { "type": "string", "size": "80" }, - "env_department": { "type": "string", "size": "80" }, - "env_identifier_eppn": { "type": "string", "size": "80" }, - "env_identifier_eptid": { "type": "string", "size": "80" }, - "env_identifier_epuid": { "type": "string", "size": "80" }, - "env_identifier_network": { "type": "string", "size": "80" }, - "env_identifier_oidcsub": { "type": "string", "size": "80" }, - "env_identifier_samlpairwiseid": { "type": "string", "size": "80" }, - "env_identifier_samlsubjectid": { "type": "string", "size": "80" }, - "env_identifier_sourcekey": { "type": "string", "size": "80" }, - "env_mail": { "type": "string", "size": "80" }, - "env_name_honorific": { "type": "string", "size": "80" }, - "env_name_given": { "type": "string", "size": "80" }, - "env_name_middle": { "type": "string", "size": "80" }, - "env_name_family": { "type": "string", "size": "80" }, - "env_name_suffix": { "type": "string", "size": "80" }, - "env_organization": { "type": "string", "size": "80" }, - "env_telephone_number": { "type": "string", "size": "80" }, - "env_title": { "type": "string", "size": "80" }, - "lookaside_file": { "type": "path" } - }, - "indexes": { - "env_sources_i1": { "columns": [ "external_identity_source_id" ] } - } - }, - "env_source_identities": { - "columns": { - "id": {}, - "env_source_id": { "type": "integer", "foreignkey": { "table": "env_sources", "column": "id" }}, - "source_key": { "type": "string", "size": 1024 }, - "env_attributes": { "type": "text" } - }, - "indexes": { - "env_source_identities_i1": { "columns": [ "source_key" ] }, - "env_source_identities_i2": { "needed": false, "columns": [ "env_source_id" ] } - } - }, - "env_source_detours": { - "columns": { - "id": {}, - "traffic_detour_id": {} - }, - "indexes": { - "env_source_detours_i1": { "needed": false, "columns": [ "traffic_detour_id" ] } - } - }, - "petition_env_identities": { - "columns": { - "id": {}, - "petition_id": {}, - "env_source_collector_id": { "type": "integer", "foreignkey": { "table": "env_source_collectors", "column": "id" }}, - "env_source_identity_id": { "type": "integer", "foreignkey": { "table": "env_source_identities", "column": "id" }} - }, - "indexes": { - "petition_env_identities_i1": { "columns": [ "petition_id" ] }, - "petition_env_identities_i2": { "needed": false, "columns": [ "env_source_collector_id" ] }, - "petition_env_identities_i3": { "needed": false, "columns": [ "env_source_identity_id" ] } - } - } - } - } -} \ No newline at end of file diff --git a/app/plugins/EnvSource/templates/EnvSourceCollectors/dispatch.inc b/app/plugins/EnvSource/templates/EnvSourceCollectors/dispatch.inc index ef474132b..9c0faa879 100644 --- a/app/plugins/EnvSource/templates/EnvSourceCollectors/dispatch.inc +++ b/app/plugins/EnvSource/templates/EnvSourceCollectors/dispatch.inc @@ -29,7 +29,7 @@ declare(strict_types = 1); // This view is intended to work with dispatch if($vv_action == 'dispatch') { - // Make the Form fields editable + // This form isn't editable, but we need the continue button to render $this->Field->enableFormEditMode(); ksort($vv_env_source_vars); $previousKey = ''; diff --git a/app/plugins/EnvSource/templates/EnvSourceCollectors/fields.inc b/app/plugins/EnvSource/templates/EnvSourceCollectors/fields.inc index 713b393ba..e302021a4 100644 --- a/app/plugins/EnvSource/templates/EnvSourceCollectors/fields.inc +++ b/app/plugins/EnvSource/templates/EnvSourceCollectors/fields.inc @@ -25,12 +25,17 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// This view only supports edit -if($vv_action == 'edit') { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'external_identity_source_id', - 'fieldLabel' => __d('env_source', 'controller.EnvSources', [1]) - ] - ]); -} +$fields = [ + 'external_identity_source_id' => [ + 'fieldLabel' => __d('env_source', 'controller.EnvSources', [1]) + ], + 'enable_confirmation_page' +]; + +$subnav = [ + 'tabs' => ['EnrollmentFlowSteps', 'EnvSource.EnvSourceCollectors'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'EnvSource.EnvSourceCollectors' => ['edit'] + ], +]; diff --git a/app/plugins/EnvSource/templates/EnvSources/fields-nav.inc b/app/plugins/EnvSource/templates/EnvSources/fields-nav.inc deleted file mode 100644 index e174c7b99..000000000 --- a/app/plugins/EnvSource/templates/EnvSources/fields-nav.inc +++ /dev/null @@ -1,31 +0,0 @@ - 'plugin', - 'active' => 'plugin' - ]; \ No newline at end of file diff --git a/app/plugins/EnvSource/templates/EnvSources/fields.inc b/app/plugins/EnvSource/templates/EnvSources/fields.inc index 54fb0f882..5bcbf00bc 100644 --- a/app/plugins/EnvSource/templates/EnvSources/fields.inc +++ b/app/plugins/EnvSource/templates/EnvSources/fields.inc @@ -24,92 +24,150 @@ * @since COmanage Registry v5.1.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -?> -element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field - ] - ]); +use EnvSource\Lib\Enum\EnvSourceSpModeEnum; + +// Build the delimiter map dynamically from your $spModes array +$phpDelimiterMap = []; +$selectedValue = ''; +foreach ($spModes as $key => $label) { + if ($key === EnvSourceSpModeEnum::Shibboleth) { + $phpDelimiterMap[$key] = ';'; + if ($vv_obj?->mva_delimiter == ';') { + $selectedValue = EnvSourceSpModeEnum::Shibboleth; + } + } elseif ($key === EnvSourceSpModeEnum::SimpleSamlPhp) { + $phpDelimiterMap[$key] = ','; + if ($vv_obj?->mva_delimiter == ',') { + $selectedValue = EnvSourceSpModeEnum::SimpleSamlPhp; + } + } elseif ($key === EnvSourceSpModeEnum::Other) { + $phpDelimiterMap[$key] = ''; + if (!in_array($vv_obj?->mva_delimiter, ['', ',', ';'])) { + $phpDelimiterMap[$key] = $vv_obj?->mva_delimiter; + $selectedValue = EnvSourceSpModeEnum::Other; + } } +} - print "

    " . __d('env_source', 'information.header.map') . "

    \n"; +// Encode the array to JSON before inserting it into the Javascript block +$jsonDelimiterMap = json_encode($phpDelimiterMap); - $defaultNames = [ - 'env_identifier_sourcekey' => 'ENV_SOURCE_KEY', - 'env_address_street' => 'ENV_STREET', - 'env_address_locality' => 'ENV_LOCALITY', - 'env_address_state' => 'ENV_STATE', - 'env_address_postalcode' => 'ENV_POSTALCODE', - 'env_address_country' => 'ENV_COUNTRY', - 'env_affiliation' => 'ENV_AFFILIATION', - 'env_department' => 'ENV_DEPARTMENT', - 'env_identifier_eppn' => 'ENV_EPPN', - 'env_identifier_eptid' => 'ENV_EPTID', - 'env_identifier_epuid' => 'ENV_EPUID', - 'env_identifier_network' => 'ENV_NETWORK', - 'env_identifier_oidcsub' => 'ENV_OIDCSUB', - 'env_identifier_samlpairwiseid' => 'ENV_SAMLPAIRWISE', - 'env_identifier_samlsubjectid' => 'ENV_SAMLSUBJECT', - 'env_mail' => 'ENV_MAIL', - 'env_name_honorific' => 'ENV_HONORIFIC', - 'env_name_given' => 'ENV_GIVEN', - 'env_name_middle' => 'ENV_MIDDLE', - 'env_name_family' => 'ENV_FAMILY', - 'env_name_suffix' => 'ENV_SUFFIX', - 'env_organization' => 'ENV_ORGANIZATION', - 'env_telephone_number' => 'ENV_TELEPHONE', - 'env_title' => 'ENV_TITLE' - ]; - foreach([ - 'env_identifier_sourcekey', - 'env_address_street', - 'env_address_locality', - 'env_address_state', - 'env_address_postalcode', - 'env_address_country', - 'env_affiliation', - 'env_department', - 'env_identifier_eppn', - 'env_identifier_eptid', - 'env_identifier_epuid', - 'env_identifier_network', - 'env_identifier_oidcsub', - 'env_identifier_samlpairwiseid', - 'env_identifier_samlsubjectid', - 'env_mail', - 'env_name_honorific', - 'env_name_given', - 'env_name_middle', - 'env_name_family', - 'env_name_suffix', - 'env_organization', - 'env_telephone_number', - 'env_title' - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => $field, - 'fieldOptions' => [ - 'default' => $defaultNames[$field] - ] - ] - ]); - } +$afterField = << +document.addEventListener('DOMContentLoaded', function() { + var select = document.getElementById('mva_delimiter_default'); + var textbox = document.getElementById('mva_delimiter'); + + // Map the dropdown values to actual delimiter characters + var delimiterMap = $jsonDelimiterMap; + + select.addEventListener('change', function() { + var selectedValue = select.value; + + // Update the textbox with the corresponding delimiter + if (delimiterMap[selectedValue] !== undefined) { + textbox.value = delimiterMap[selectedValue]; + } else { + // Fallback in case of unexpected values + textbox.value = selectedValue; + } + + // Focus the textbox if "Other" is selected so we can immediately type + if (selectedValue === 'O') { + textbox.focus(); + } + }); + + if (!textbox.value && select.value) { + textbox.value = delimiterMap[select.value] || ''; + } +}); + +JS; + + +$fields = [ + // 'duplicate_mode', + 'mva_delimiter' => [ + 'fieldLabel' => __d('env_source','field.EnvSources.mva_delimiter'), + 'groupedControls' => [ + 'mva_delimiter' => [ + 'type' => 'text', + 'id' => 'mva_delimiter', + 'label' => false, + 'required' => true, + 'class' => 'form-control mb-1', + 'singleRowItem' => true, + ], + 'mva_delimiter_default' => [ + 'id' => 'mva_delimiter_default', + 'label' => [ + 'text' => __d('env_source','field.EnvSources.mva_delimiter_common'), + 'class' => 'mb-2' + ], + 'name' => 'mva_delimiter_default', + 'required' => false, + 'empty' => false, + 'type' => 'select', + 'value' => $selectedValue, + 'options' => $spModes, + ], + ], + 'afterField' => $afterField + ], + 'sync_on_login', + 'address_type_id', + 'default_affiliation_type_id', + 'email_address_type_id', + 'name_type_id', + 'telephone_number_type_id', + 'redirect_on_duplicate', + 'lookaside_file', + 'SUBTITLE' => [ + 'subtitle' => __d('env_source', 'information.header.map') + ] +]; + +$defaultNames = [ + 'env_identifier_sourcekey' => 'ENV_SOURCE_KEY', + 'env_address_street' => 'ENV_STREET', + 'env_address_locality' => 'ENV_LOCALITY', + 'env_address_state' => 'ENV_STATE', + 'env_address_postalcode' => 'ENV_POSTALCODE', + 'env_address_country' => 'ENV_COUNTRY', + 'env_affiliation' => 'ENV_AFFILIATION', + 'env_department' => 'ENV_DEPARTMENT', + 'env_identifier_eppn' => 'ENV_EPPN', + 'env_identifier_eptid' => 'ENV_EPTID', + 'env_identifier_epuid' => 'ENV_EPUID', + 'env_identifier_network' => 'ENV_NETWORK', + 'env_identifier_oidcsub' => 'ENV_OIDCSUB', + 'env_identifier_samlpairwiseid' => 'ENV_SAMLPAIRWISE', + 'env_identifier_samlsubjectid' => 'ENV_SAMLSUBJECT', + 'env_mail' => 'ENV_MAIL', + 'env_name_honorific' => 'ENV_HONORIFIC', + 'env_name_given' => 'ENV_GIVEN', + 'env_name_middle' => 'ENV_MIDDLE', + 'env_name_family' => 'ENV_FAMILY', + 'env_name_suffix' => 'ENV_SUFFIX', + 'env_organization' => 'ENV_ORGANIZATION', + 'env_telephone_number' => 'ENV_TELEPHONE', + 'env_title' => 'ENV_TITLE' +]; + +foreach($defaultNames as $field => $envName) { + $fields[$field] = [ + 'default' => $envName + ]; } + +$subnav = [ + 'tabs' => ['ExternalIdentitySources', 'EnvSource.EnvSources', 'ExternalIdentitySources@action.search'], + 'action' => [ + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'EnvSource.EnvSources' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], +]; diff --git a/app/plugins/OrcidSource/.gitignore b/app/plugins/OrcidSource/.gitignore new file mode 100644 index 000000000..244d127b1 --- /dev/null +++ b/app/plugins/OrcidSource/.gitignore @@ -0,0 +1,8 @@ +/composer.lock +/composer.phar +/phpunit.xml +/.phpunit.result.cache +/phpunit.phar +/config/Migrations/schema-dump-default.lock +/vendor/ +/.idea/ diff --git a/app/plugins/OrcidSource/README.md b/app/plugins/OrcidSource/README.md new file mode 100644 index 000000000..14821f1da --- /dev/null +++ b/app/plugins/OrcidSource/README.md @@ -0,0 +1,11 @@ +# OrcidSource 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/orcid-source +``` diff --git a/app/plugins/OrcidSource/composer.json b/app/plugins/OrcidSource/composer.json new file mode 100644 index 000000000..83e4a9222 --- /dev/null +++ b/app/plugins/OrcidSource/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/orcid-source", + "description": "OrcidSource plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.6.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "OrcidSource\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "OrcidSource\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/OrcidSource/config/plugin.json b/app/plugins/OrcidSource/config/plugin.json new file mode 100644 index 000000000..e3113e8b1 --- /dev/null +++ b/app/plugins/OrcidSource/config/plugin.json @@ -0,0 +1,73 @@ +{ + "types": { + "enrollment_flow_step": [ + "OrcidSourceCollectors" + ], + "external_identity_source": [ + "OrcidSources" + ] + }, + "schema": { + "tables": { + "orcid_source_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "external_identity_source_id": {} + }, + "indexes": { + "orcid_source_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "orcid_source_collectors_i2": { "needed": false, "columns": [ "external_identity_source_id" ] } + } + }, + "orcid_sources": { + "columns": { + "id": {}, + "external_identity_source_id": {}, + "server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" }, "notnull": false }, + "scope_inherit": { "type": "boolean" }, + "api_tier": { "type": "string", "size": "3" }, + "api_type": { "type": "string", "size": "3" }, + "default_affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } } + }, + "indexes": { + "orcid_sources_i1": { "columns": [ "external_identity_source_id" ] } + } + }, + "orcid_tokens": { + "columns": { + "id": {}, + "orcid_source_id": { "type": "integer", "foreignkey": { "table": "orcid_sources", "column": "id" }, "notnull": true }, + "orcid_identifier": { "type": "string", "size": "128" }, + "access_token": { "type": "text" }, + "id_token": { "type": "text" }, + "refresh_token": { "type": "text" } + }, + "indexes": { + "orcid_source_tokens_i1": { + "columns": [ "orcid_source_id", "orcid_identifier"] + }, + "orcid_source_tokens_i2": { "columns": [ "orcid_identifier" ] }, + "orcid_source_tokens_i3": { "needed": false, "columns": [ "orcid_source_id" ] } + } + }, + "petition_orcids": { + "columns": { + "id": {}, + "petition_id": {}, + "orcid_source_collector_id": { "type": "integer", "foreignkey": { "table": "orcid_source_collectors", "column": "id" }, "notnull": true }, + "orcid_identifier": { "type": "string", "size": "128" }, + "orcid_token": { "type": "text" } + }, + "indexes": { + "petition_orcids_i1": { "columns": [ "petition_id" ] }, + "petition_orcids_i2": { "columns": [ "orcid_source_collector_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/OrcidSource/config/routes.php b/app/plugins/OrcidSource/config/routes.php new file mode 100644 index 000000000..0fe23ea2c --- /dev/null +++ b/app/plugins/OrcidSource/config/routes.php @@ -0,0 +1,58 @@ +scope('/api/orcidsource', function (RouteBuilder $builder) { + // Register scoped middleware for in scopes. +// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients +// $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware(['httponly' => true])); + // BodyParserMiddleware will automatically parse JSON bodies, but we only + // want that for API transactions, so we only apply it to the /api scope. + $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); + /* + * Apply a middleware to the current route scope. + * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()` + */ +// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients +// $builder->applyMiddleware('csrf'); + $builder->setExtensions(['json']); + $builder->applyMiddleware('bodyparser'); + $builder->get( + '/v2/token/{orcid}/co/{coId}', + ['plugin' => 'OrcidSource', 'controller' => 'ApiV2', 'action' => 'get'] + ) + ->setPass(['orcid', 'coId']) + ->setPatterns([ + 'orcid' => '([0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4})', + 'coId' => '[0-9]+', + ]); +}); \ No newline at end of file diff --git a/app/plugins/OrcidSource/phpunit.xml.dist b/app/plugins/OrcidSource/phpunit.xml.dist new file mode 100644 index 000000000..f828b8533 --- /dev/null +++ b/app/plugins/OrcidSource/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po b/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po new file mode 100644 index 000000000..decc5298a --- /dev/null +++ b/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po @@ -0,0 +1,113 @@ +# COmanage Registry Localizations (orcid_source domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.2.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.OrcidSourceCollectors" +msgstr "{0,plural,=1{Orcid Source Collector} other{Orcid Source Collectors}}" + +msgid "controller.OrcidSources" +msgstr "{0,plural,=1{Orcid Source} other{Orcid Sources}}" + +msgid "controller.PetitionOrcidIdentities" +msgstr "{0,plural,=1{Petition Orcid Identity} other{Petition Orcid Identities}}" + +msgid "enumeration.OrcidSourceTierEnum.PRO" +msgstr "Production" + +msgid "enumeration.OrcidSourceTierEnum.SBX" +msgstr "Sandbox" + +msgid "enumeration.OrcidSourceApiEnum.AUT" +msgstr "Authorize" + +msgid "enumeration.OrcidSourceApiEnum.MEM" +msgstr "Members" + +msgid "enumeration.OrcidSourceApiEnum.PUB" +msgstr "Public" + +msgid "error.search" +msgstr "Search request returned {0}" + +msgid "error.token.none" +msgstr "Access token not configured (try resaving configuration)" + +msgid "error.param.notfound" +msgstr "{0} was not found" + +msgid "error.response.no_orcid" +msgstr "ORCID identifier missing from response." + +msgid "error.exists" +msgstr "Orcid Token already exists with this Identifier" + +msgid "field.OrcidSources.api_type" +msgstr "API Type" + +msgid "field.OrcidSources.redirect_uri" +msgstr "Additional ORCID Redirect URI" + +msgid "field.OrcidSources.scope_inherit" +msgstr "Inherit Scope" + +msgid "field.OrcidSources.api_tier" +msgstr "API Tier" + +msgid "field.OrcidSources.name_type_id" +msgstr "Name Type" + +msgid "field.OrcidSources.telephone_number_type_id" +msgstr "Telephone Number Type" + +msgid "field.OrcidSources.address_type_id" +msgstr "Address Type" + +msgid "field.OrcidSources.default_affiliation_type_id" +msgstr "Default Affiliation Type" + +msgid "field.OrcidSources.email_address_type_id" +msgstr "Email Address Type" + +msgid "information.OrcidSources.linked" +msgstr "Obtained ORCID {0} via authenticated OAuth flow" + +msgid "information.orcid_source.identifier" +msgstr "ORCID Identifier" + +msgid "information.OrcidSourceCollectors.authenticate" +msgstr "Authenticate with ORCID" + +msgid "information.OrcidSourceCollectors.sign_in" +msgstr "Sign in with your ORCID account to securely verify your ORCID iD." + +msgid "information.OrcidSources.default.types" +msgstr "Select Default Types for ORCID Fields" + +msgid "result.OrcidSourceCollector.collected" +msgstr "Obtained ORCID Identifier {0}" + +msgid "result.orcid.saved" +msgstr "ORCID Token recorded" + +msgid "result.pipeline.status" +msgstr "OrcidSource Pipeline completed with status {0}" diff --git a/app/plugins/OrcidSource/src/Controller/ApiV2Controller.php b/app/plugins/OrcidSource/src/Controller/ApiV2Controller.php new file mode 100644 index 000000000..22ff3307c --- /dev/null +++ b/app/plugins/OrcidSource/src/Controller/ApiV2Controller.php @@ -0,0 +1,207 @@ +request->getParam('coId') ?? null; + } + + + public function initialize(): void { + parent::initialize(); + $this->OrcidSources = TableRegistry::getTableLocator()->get('OrcidSource.OrcidSources'); + $this->OrcidTokens = TableRegistry::getTableLocator()->get('OrcidSource.OrcidTokens'); + } + + /** + * Calculate authorization for the current request. + * + * @since COmanage Registry v5.2.0 + * @return bool True if the current request is permitted, false otherwise + */ + + public function calculatePermission(): bool { + $authUser = $this->RegistryAuth->getAuthenticatedUser(); + $coid = $this->calculateRequestedCOID(); + + return $authUser !== null + && $this->RegistryAuth->isApiUser() + && ($this->RegistryAuth->isPlatformAdmin() || $this->RegistryAuth->isCoAdmin($coid)); + } + + /** + * Handle a Get SOR Person Role request. + * + * @param string $orcid + * @param int $coId + * @since COmanage Registry v5.2.0 + */ + + public function get(string $orcid, int $coId) { + try { + $orcidSourcesRecords = $this->OrcidSources + ->find() + ->contain([ + 'Servers', + 'ExternalIdentitySources', + ]) + ->innerJoinWith('Servers') + ->innerJoinWith('ExternalIdentitySources') + ->where([ + 'Servers.plugin' => 'CoreServer.Oauth2Servers', + 'ExternalIdentitySources.co_id' => $coId, + 'ExternalIdentitySources.plugin' => 'OrcidSource.OrcidSources' + ]) + ->disableHydration() + ->all() + ->toArray(); + + // Extract OrcidSource IDs + $orcid_source_ids = Hash::extract($orcidSourcesRecords, '{n}.id'); + + // Find token records from the database + $tokens = $this->OrcidTokens->find() + ->where([ + 'OrcidTokens.orcid_identifier' => $orcid, + 'OrcidTokens.orcid_source_id IN' => $orcid_source_ids + ]) + ->disableHydration() + ->all() + ->toArray(); + + $columnsToDecrypt = [ + 'access_token', + 'id_token', + 'refresh_token' + ]; + + if (count($tokens) === 0) { + throw new RecordNotFoundException(__d('orcid_source', 'error.param.notfound', [__d('orcid_source', 'information.orcid_source.identifier')])); + } + + foreach ($tokens as $idx => $token) { + $orcidSourceIndex = array_search($token['orcid_source_id'], $orcid_source_ids); + $tokens[$idx]['scopes'] = $this->getOauth2ServerScopes( + $orcidSourcesRecords[$orcidSourceIndex]['server'], + $orcidSourcesRecords[$orcidSourceIndex] + ); + foreach ($columnsToDecrypt as $column) { + $value = $token[$column] ?? null; + $tokens[$idx][$column] = !empty($value) ? $this->OrcidTokens->getUnencrypted($value) : ''; + } + } + + // Return data in structured format + $this->set('orcid_tokens', $tokens); + $this->set('vv_model_name', 'OrcidTokens'); + $this->set('vv_table_name', 'orcid_tokens'); + } + catch(RecordNotFoundException $e) { + // Return 404 + $this->response = $this->response->withStatus( + HttpStatusCodesEnum::HTTP_NOT_FOUND, + $e->getMessage() + ); + $this->autoRender = false; + return; + } + catch(\Exception $e) { + // Return 400 + $this->response = $this->response->withStatus( + HttpStatusCodesEnum::HTTP_BAD_REQUEST, + $e->getMessage() + ); + $this->autoRender = false; + return; + } + + // Let the view render + $this->render('/Standard/api/v2/json/index'); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.2.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + // We always take over authz + return 'authz'; + } + + /** + * Get the scopes + * + * @param array $server Server Record + * @param array $orcidSource OrcidSource record + * + * @return string List of scopes + * @since COmanage Registry v5.2.0 + */ + + public function getOauth2ServerScopes(array $server, array $orcidSource): string + { + if(is_bool($orcidSource['scope_inherit']) && $orcidSource['scope_inherit']) { + $Oauth2ServersTable = TableRegistry::getTableLocator()->get('Oauth2Servers'); + $oauth2Server = $Oauth2ServersTable->find() + ->select(['scope']) + ->where(['server_id' => $server['id']]) + ->first(); + + if ($oauth2Server && !empty($oauth2Server->scope)) { + return $oauth2Server->scope; + } + } + + return OrcidSourceScopeEnum::DEFAULT_SCOPE; + } +} diff --git a/app/plugins/OrcidSource/src/Controller/AppController.php b/app/plugins/OrcidSource/src/Controller/AppController.php new file mode 100644 index 000000000..9e22929f7 --- /dev/null +++ b/app/plugins/OrcidSource/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'OrcidSourceCollectors.id' => 'asc' + ] + ]; + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.2.0 + * @param EventInterface $event Cake Event + */ + public function beforeFilter(\Cake\Event\EventInterface $event) + { + $this->OrcidSources = TableRegistry::getTableLocator()->get('OrcidSource.OrcidSources'); + $this->PetitionOrcids = TableRegistry::getTableLocator()->get('OrcidSource.PetitionOrcids'); + + parent::beforeFilter($event); + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.2.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->OrcidSourceCollectors->EnrollmentFlowSteps->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->OrcidSourceCollectors->EnrollmentFlowSteps->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->OrcidSourceCollectors->EnrollmentFlowSteps->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.2.0 + * @param string $id Env Source Collector ID + */ + + public function dispatch(string $id) { + $request = $this->getRequest(); + $session = $request->getSession(); + + $op = $this->requestParam('op'); + $code = $this->getRequest()->getQuery('code') ?? null; + $petition = $this->getPetition(); + + $this->set('vv_op', $op); + + $oricdSourceEntity = $this->OrcidSourceCollectors->get( + (int)$id, + [ + 'contain' => [ + 'ExternalIdentitySources' => ['OrcidSources' => ['Servers']] + ]] + ); + + $ServerModel = $oricdSourceEntity->external_identity_source->orcid_source->server->plugin; + $PluginServersTable = TableRegistry::getTableLocator()->get($ServerModel); + $serverId = $oricdSourceEntity->external_identity_source->orcid_source->server->id; + $PluginServerEntity = $PluginServersTable ->find() + ->where(['server_id' => $serverId]) + ->first(); + + + $this->set('vv_config', $oricdSourceEntity); + $this->set('vv_config_server', $PluginServerEntity); + $this->set('controller', $this); + + try { + // Let's authenticate first + if ($op == 'authenticate') { + $this->authenticate($id, $PluginServerEntity); + } else if (!empty($code) && $op !== 'savetoken') { + $response = $PluginServersTable->exchangeCode( + $id, + $code, + $this->OrcidSources->redirectUri( + [ + $id, + '?' => ['petition_id' => $petition->id], + ] + ), + false + ); + + // Use the response and save the data to petitions table + if(empty($response->orcid)) { + throw new \RuntimeException(__d('orcid_source', 'error.response.no_orcid')); + } + $this->set('vv_orcid', $response->orcid); + $this->set('vv_token', $response); + } if (!empty($code) && $op === 'savetoken') { + $orcid_token = $this->requestParam('orcid_token'); + $this->PetitionOrcids->record( + petitionId: $petition->id, + enrollmentFlowStepId: $oricdSourceEntity->enrollment_flow_step_id, + orcidToken: $orcid_token, + orcidSourceCollectorId: (int)$id, + ); + // On success, indicate the step is completed and generate a redirect + // to the next step + + return $this->finishStep( + enrollmentFlowStepId: $oricdSourceEntity->enrollment_flow_step_id, + petitionId: $petition->id, + comment: __d('orcid_source', 'result.orcid.saved') + ); + } else { + // Fall Through. Let the view render + } + + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + // Fall through and let the form render + + $this->render('/Standard/dispatch'); + } + + + /** + * Authenticate the user with ORCID OAuth2 server + * + * @param string|int $id ID of the collector + * @param EntityInterface $serverCfg ORCID Server configuration + * @return void + * @since COmanage Registry v5.2.0 + */ + protected function authenticate(string|int $id, EntityInterface $serverCfg): void + { + $petition = $this->getPetition(); + $callback = $this->OrcidSources->redirectUri([ + $id, + '?' => ['petition_id' => $petition->id], + ]); + // Build the redirect URI + $redirectUri = Router::url($callback, true); + + $scope = OrcidSourceScopeEnum::DEFAULT_SCOPE; + if (!empty($serverCfg->scope_inherit)) { + $scope = $serverCfg->scope_inherit; + } + + $url = $serverCfg->url . '/authorize?'; + $url .= 'client_id=' . $serverCfg->clientid; + $url .= '&response_type=code'; + $url .= '&scope=' . str_replace(' ', '%20', $scope); + $url .= '&redirect_uri=' . urlencode($redirectUri); + + $this->redirect($url); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.2.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + if($action == 'dispatch') { + // We need to perform special logic (vs StandardEnrollerController) + // to ensure that web server authentication is triggered. + // (This is the same logic as IdentifierCollectorsController.) +// XXX We could maybe move this into StandardEnrollerController with a flag like +// $this->alwaysAuthDispatch(true); + + // To start, we trigger the parent logic. This will return + // notauth: Some error occurred, we don't want to override this + // authz: No token in use + // yes: Token validated + + $auth = parent::willHandleAuth($event); + + // The only status we need to override is 'yes', since we always want authentication + // to run in order to be able to grab $REMOTE_USER. + + return ($auth == 'yes' ? 'authz' : $auth); + } + + return parent::willHandleAuth($event); + } +} diff --git a/app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php b/app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php new file mode 100644 index 000000000..61a7cabcc --- /dev/null +++ b/app/plugins/OrcidSource/src/Controller/OrcidSourcesController.php @@ -0,0 +1,64 @@ + [ + 'EnvSources.id' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.2.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->OrcidSources->ExternalIdentitySources->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->OrcidSources->ExternalIdentitySources->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->OrcidSources->ExternalIdentitySources->getPrimaryKey()); + } + $this->set('vv_redirect_uri', $this->OrcidSources->redirectUri()); + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php b/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php new file mode 100644 index 000000000..ab60d37f1 --- /dev/null +++ b/app/plugins/OrcidSource/src/Controller/OrcidTokensController.php @@ -0,0 +1,36 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/OrcidSource/src/Model/Entity/OrcidSourceCollector.php b/app/plugins/OrcidSource/src/Model/Entity/OrcidSourceCollector.php new file mode 100644 index 000000000..dfc309d06 --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Entity/OrcidSourceCollector.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/OrcidSource/src/Model/Entity/OrcidToken.php b/app/plugins/OrcidSource/src/Model/Entity/OrcidToken.php new file mode 100644 index 000000000..5ca584d67 --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Entity/OrcidToken.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/OrcidSource/src/Model/Entity/PetitionOrcid.php b/app/plugins/OrcidSource/src/Model/Entity/PetitionOrcid.php new file mode 100644 index 000000000..fdca95859 --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Entity/PetitionOrcid.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php new file mode 100644 index 000000000..809a89471 --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidSourceCollectorsTable.php @@ -0,0 +1,199 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('ExternalIdentitySources'); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setAutoViewVars([ + 'externalIdentitySources' => [ + 'type' => 'plugin', + 'model' => 'OrcidSource.OrcidSources', + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform steps necessary to hydrate the Person record as part of Petition finalization. + * + * @param int $id Env Source Collector ID + * @param Petition $petition Petition + * @return bool true on success + * @since COmanage Registry v5.2.0 + */ + + public function hydrate(int $id, \App\Model\Entity\Petition $petition) { + $orcidSourceCollectorsEntity = $this->get($id); + + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + $PetitionOrcids = TableRegistry::getTableLocator()->get('OrcidSource.PetitionOrcids'); + $OrcidSources = TableRegistry::getTableLocator()->get('OrcidSource.OrcidSources'); + $OrcidTokens = TableRegistry::getTableLocator()->get('OrcidSource.OrcidTokens'); + $ExtIdentitySources = TableRegistry::getTableLocator()->get('ExternalIdentitySources'); + + + $orcid_source = $OrcidSources->find() + ->where(['external_identity_source_id' => $orcidSourceCollectorsEntity->external_identity_source_id]) + ->first(); + + $pOricd = $PetitionOrcids + ->find() + ->where( + ['petition_id' => $petition->id, 'orcid_source_collector_id' => $id]) + ->first(); + + if(!empty($pOricd->orcid_token)) { + // Copy the Identifier to the Person record in accordance with the configuration + $token = unserialize($pOricd->orcid_token); + $data = [ + 'orcid_identifier' => $token->orcid, + 'access_token' => $token->access_token, + 'refresh_token' => $token->refresh_token ?? '', + 'id_token' => $token->id_token ?? '', + 'orcid_source_id' => $orcid_source->id + ]; + + $OrcidTokens->upsertOrFail( + data: $data, + whereClause: [ + 'orcid_source_id' => $orcid_source->id, + 'orcid_identifier' => $token->orcid + ], + ); + + // Continue on to process the sync + // Trigger the ExternalIdentitySource sync and push the data to the pipeline + $status = $ExtIdentitySources->sync( + id: $orcidSourceCollectorsEntity->external_identity_source_id, + sourceKey: $token->orcid, + personId: $petition->enrollee_person_id, + syncOnly: true + ); + + // Record Petition History - Token Save + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $orcidSourceCollectorsEntity->enrollment_flow_step_id, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('orcid_source', 'result.orcid.saved') + ); + + // Record Petition History - Pipeline save + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $orcidSourceCollectorsEntity->enrollment_flow_step_id, + action: PetitionActionEnum::Finalized, + comment: __d('orcid_source', 'result.pipeline.status', [$status]) + ); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('external_identity_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('external_identity_source_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php new file mode 100644 index 000000000..60745cddd --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidSourcesTable.php @@ -0,0 +1,587 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ExternalIdentitySources'); + $this->belongsTo('Servers'); + $this->belongsTo('AddressTypes') + ->setClassName('Types') + ->setForeignKey('address_type_id') + ->setProperty('address_type'); + $this->belongsTo('DefaultAffiliationTypes') + ->setClassName('Types') + ->setForeignKey('default_affiliation_type_id') + ->setProperty('default_affiliation_type'); + $this->belongsTo('EmailAddressTypes') + ->setClassName('Types') + ->setForeignKey('email_address_type_id') + ->setProperty('email_address_type'); + $this->belongsTo('NameTypes') + ->setClassName('Types') + ->setForeignKey('name_type_id') + ->setProperty('name_type'); + $this->belongsTo('TelephoneNumberTypes') + ->setClassName('Types') + ->setForeignKey('telephone_number_type_id') + ->setProperty('telephone_number_type'); + + $this->hasMany('OrcidSource.OrcidTokens') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink(['external_identity_source_id']); + $this->setRequiresCO(true); + + $this->setEditContains([ + 'Servers' => ['Oauth2Servers'], + 'ExternalIdentitySources', + ]); + + $this->setViewContains([ + 'Servers' => ['Oauth2Servers'], + 'ExternalIdentitySources', + ]); + + $this->setAutoViewVars([ + 'servers' => [ + 'type' => 'plugin', + 'model' => 'CoreServer.Oauth2Servers' + ], + 'api_tiers' => [ + 'type' => 'enum', + 'class' => 'OrcidSource.OrcidSourceTierEnum' + ], + 'api_types' => [ + 'type' => 'enum', + 'class' => 'OrcidSource.OrcidSourceApiEnum' + ], + 'addressTypes' => [ + 'type' => 'type', + 'attribute' => 'Addresses.type' + ], + 'defaultAffiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ], + 'emailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'nameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ], + 'telephoneNumberTypes' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + + $this->orcidTokensTable = TableRegistry::getTableLocator()->get('OrcidSource.OrcidTokens'); + $this->oauth2ServersTable = TableRegistry::getTableLocator()->get('CoreServer.Oauth2Servers'); + } + + + /** + * Get the OAuth2 redirect URI for ORCID callbacks + * + * @param array $extra Additional URL parameters to include in redirect + * @return string Full URL for OAuth2 redirect + * @since COmanage Registry v5.2.0 + */ + public function redirectUri(array $extra = []): string + { + $callback = [ + 'plugin' => 'OrcidSource', + 'controller' => 'OrcidSourceCollectors', + 'action' => 'dispatch', + ]; + + if (!empty($extra)) { + $callback = array_merge($callback, $extra); + } + + return Router::url($callback, true); + } + + /** + * Obtain the set of changed records from the source database. + * + * @since COmanage Registry v5.2.0 + * @param ExternalIdentitySource $source External Identity Source + * @param int $lastStart Timestamp of last run + * @param int $curStart Timestamp of current run + * @return array|bool An array of changed source keys, or false + */ + + public function getChangeList( + \App\Model\Entity\ExternalIdentitySource $source, + int $lastStart, // timestamp of last run + int $curStart // timestamp of current run + ): array|bool { + return false; + } + + /** + * Obtain the full set of records from the source database. + * + * @since COmanage Registry v5.2.0 + * @param ExternalIdentitySource $source External Identity Source + * @return array An array of source keys + */ + + public function inventory( + \App\Model\Entity\ExternalIdentitySource $source + ): array { + return false; + } + + /** + * Convert a record from the OrcidSource data to a record suitable for + * construction of an Entity. This call is for use with Relational Mode. + * + * @since COmanage Registry v5.2.0 + * @param OrcidSource $OrcidSource OrcidSource configuration entity + * @param array $result Array of Orcid attributes + * @return array Entity record (in array format) + */ + + protected function resultToEntityData( + OrcidSource $OrcidSource, + array $result + ): array { + // Build the External Identity as an array + $eidata = []; + + // We don't currently have a field to record DoB, so we need to null it + $eidata['date_of_birth'] = null; + + // Single value fields that map to the External Identity Role + $role = [ + // We only support one role per record + 'role_key' => '1', + 'affiliation' => $this->DefaultAffiliationTypes->getTypeLabel($OrcidSource->default_affiliation_type_id) + ]; + + $eidata['external_identity_roles'][] = $role; + + $name = [ + 'type' => $this->NameTypes->getTypeLabel($OrcidSource->name_type_id), + 'given' => $result['name']['given-names']['value'], + 'family' => $result['name']['family-name']['value'] + ]; + + $eidata['names'][] = $name; + + foreach($result['emails']['email'] as $m) { + $eidata['email_addresses'][] = [ + 'mail' => $m['email'], + 'type' => $this->EmailAddressTypes->getTypeLabel($OrcidSource->email_address_type_id), + 'verified' => $m['verified'] + ]; + } + + if (!empty($result['addresses']['address'])) { + $address = []; + $address['type'] = $this->AddressTypes->getTypeLabel($OrcidSource->address_type_id); + foreach($result['addresses']['address'] as $ad) { + $address['country'] = $ad['country']['value']; + } + $eidata['addresses'][] = $address; + } + + $eidata['identifiers'][] = [ + 'identifier' => $result['name']['path'], + 'type' => 'orcid' + ]; + + return $eidata; + } + + /** + * Retrieve a record from the External Identity Source. + * + * @since COmanage Registry v5.2.0 + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param string $source_key Backend source key for requested record + * @return array Array of source_key, source_record, and entity_data + * @throws InvalidArgumentException + */ + + public function retrieve( + \App\Model\Entity\ExternalIdentitySource $source, + string $source_key + ): array { + try { + $this->httpClient = $this->orcidConnect($source, $source_key); + + $orcidbio = $this->orcidRequest('/v3.0/' . $source_key . '/person'); +// $orcidActivities = $this->orcidRequest('/v3.0/' . $source_key . '/activities'); + } + catch(InvalidArgumentException $e) { + throw new \InvalidArgumentException(__d('error', 'unknown.identifier', [$source_key])); + } + + return [ + 'source_key' => $source_key, + 'source_record' => json_encode($orcidbio), + 'entity_data' => $this->resultToEntityData($source->orcid_source, $orcidbio) + ]; + } + + /** + * Search the External Identity Source. + * The ORCID search will be triggered by the CO/Platform Admin. As a result, we want to use a privileged access key + * i.e. the one the CO Admin got in Oauth2Server setup page + * + * refrence: https://info.orcid.org/documentation/api-tutorials/api-tutorial-searching-the-orcid-registry/ + * + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param array $searchAttrs Array of search attributes and values, as configured by searchAttributes() + * @return array Array of matching records + * @since COmanage Registry v5.2.0 + */ + + public function search( + \App\Model\Entity\ExternalIdentitySource $source, + array $searchAttrs + ): array { + $ret = []; + + if(!isset($searchAttrs['q'])) { + // For now, we only support free form search (though ORCID does support + // search by eg email). + + return []; + } + + // Turn the query string into an associative array + $queryString = $searchAttrs['q']; + if (!str_starts_with($searchAttrs['q'], 'q=')) { + $queryString = 'q=' . $searchAttrs['q']; + } + parse_str($queryString, $queryParts); + $searchAttrs = $queryParts; + + // We just let search exceptions pop up the stack + + $this->httpClient = $this->orcidConnect($source); + + $records = $this->orcidRequest('/v3.0/search/', $searchAttrs); + + if(isset($records['num-found']) && $records['num-found'] > 0) { + foreach($records['result'] as $rec) { + if(!empty($rec['orcid-identifier']['path'])) { + $orcid = $rec['orcid-identifier']['path']; + + $orcidbio = $this->orcidRequest('/v3.0/' . $orcid . '/person'); + + if(!empty($orcidbio)) { + $ret[ $orcid ] = $this->resultToEntityData($source->orcid_source, $orcidbio); + } + } + } + } + + return $ret; + } + + /** + * Get the set of searchable attributes for this backend. + * + * @since COmanage Registry v5.2.0 + * @return array Array of searchable attributes and localized descriptions + */ + + public function searchableAttributes(): array { + return [ + 'q' => __d('operation', 'search') + ]; + } + + + /** + * Make an HTTP request to the ORCID API + * + * @param string $urlPath The API endpoint path to request + * @param array $data Request parameters or body data + * @param string $action HTTP method to use (get, post, etc) + * @return array Response data decoded from JSON + * @throws InvalidArgumentException If the ORCID identifier is invalid + * @throws RuntimeException If the API request fails + * @since COmanage Registry v5.2.0 + */ + public function orcidRequest(string $urlPath, array $data=[], string $action="get"): array + { + // Get the user access_token. If none is provided, then throw an exception + $accessToken = match(true) { + $this->orcidToken?->access_token !== null => $this->orcidTokensTable->getUnencrypted($this->orcidToken->access_token), + $this->orcidSource?->server?->oauth2_server?->access_token !== null => $this->orcidSource->server->oauth2_server->access_token, + default => throw new \InvalidArgumentException(__d('orcid_source', 'error.token.none')) + }; + + $options = [ + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/orcid+json' + ] + ]; + + + // We do not need a token for public api and + if($this->orcidSource->api_type == OrcidSourceApiEnum::PUBLIC + && ( + $urlPath == '/v3.0/search/' + || preg_match('#v3\.0/([0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4})/person#', $urlPath, $matches) + )) { + // No authentication is required for the public tier. Limited to 1000 requests per day. + unset($options['headers']['Authorization']); + } + + $orcidUrlBase = $this->orcidUrl($this->orcidSource->api_type, $this->orcidSource->api_tier); + $fullUrl = $orcidUrlBase . $urlPath; + $response = $this->httpClient->$action( + url: $fullUrl, + data: ($action == 'get' ? $data : json_encode($data)), + options: $options + ); + + if($response->getStatusCode() == HttpStatusCodesEnum::HTTP_BAD_REQUEST) { + // Most likely retrieving an invalid ORCID + throw new \InvalidArgumentException(__d('orcid_source', 'error.search', [$response->getStatusCode()])); + } + + if($response->getStatusCode() != HttpStatusCodesEnum::HTTP_OK) { + // This is probably an RDF blob, which is slightly annoying to parse. + // Rather than do it properly since we don't parse RDF anywhere else, + // we return a generic error. + throw new \RuntimeException(__d('orcid_source', 'error.search', [$response->getStatusCode()])); + } + + return $response->getJson(); + } + + /** + * Get the root URL for the ORCID API. + * + * @param string $api API type: auth, public, or member + * @param string $tier API tier: prod or sandbox + * @return string URL prefix + * @since COmanage Registry v5.2.0 + */ + + public function orcidUrl(string $api=OrcidSourceApiEnum::PUBLIC, string $tier=OrcidSourceTierEnum::PROD): string + { + $orcidUrls = [ + OrcidSourceApiEnum::AUTH => [ + OrcidSourceTierEnum::PROD => 'https://orcid.org', + OrcidSourceTierEnum::SANDBOX => 'https://sandbox.orcid.org' + ], + OrcidSourceApiEnum::MEMBERS => [ + OrcidSourceTierEnum::PROD => 'https://api.orcid.org', + OrcidSourceTierEnum::SANDBOX => 'https://api.sandbox.orcid.org' + ], + OrcidSourceApiEnum::PUBLIC => [ + OrcidSourceTierEnum::PROD => 'https://pub.orcid.org', + OrcidSourceTierEnum::SANDBOX => 'https://pub.sandbox.orcid.org' + ] + ]; + + return $orcidUrls[$api][$tier]; + } + + + /** + * Establish connection to ORCID API by configuring the HTTP client with appropriate credentials. + * + * @param ExternalIdentitySource $exterrnalIdentitySource + * @param string|null $orcidIdentifier The ORCID identifier to use for authentication + * @return Client Configured HTTP client for ORCID API requests + * @since COmanage Registry v5.2.0 + */ + protected function orcidConnect( + \App\Model\Entity\ExternalIdentitySource $exterrnalIdentitySource, + ?string $orcidIdentifier = null + ): \Cake\Http\Client { + $this->orcidSource = $this->find() + ->contain([ + 'Servers' => ['Oauth2Servers'], + 'ExternalIdentitySources', + ]) + ->innerJoinWith('Servers.Oauth2Servers') + ->innerJoinWith('ExternalIdentitySources') + ->where([ + 'Servers.plugin' => 'CoreServer.Oauth2Servers', + 'ExternalIdentitySources.id' => $exterrnalIdentitySource->id, + 'ExternalIdentitySources.plugin' => 'OrcidSource.OrcidSources', + ]) + ->first(); + + // Set the CO ID + $this->setCurCoId($this->orcidSource->server->co_id); + + if (empty($this->orcidSource->id)) { + throw new \InvalidArgumentException(__d('error', 'notfound', [__d('core_server', 'controller.Oauth2Servers')])); + } + + // Since this is null, we will use the master access token stored in Oauth2Server Configuration + if ($this->orcidSource->api_type !== OrcidSourceApiEnum::PUBLIC) { + $this->orcidToken = $this->orcidTokensTable + ->find() + ->where([ + 'orcid_source_id' => $this->orcidSource->id, + 'orcid_identifier' => $orcidIdentifier, + ]) + ->first(); + + if (empty($this->orcidToken->access_token)) { + throw new \InvalidArgumentException(__d('orcid_source', 'error.token.none')); + } + } + + return $this->oauth2ServersTable->createHttpClient($this->orcidSource->server->oauth2_server->id); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + foreach([ + 'external_source_identity_id', + 'default_affiliation_type_id', + 'address_type_id', + 'email_address_type_id', + 'name_type_id', + 'telephone_number_type_id' + ] as $field) { + $validator->add($field, [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString($field); + } + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('server_id'); + + $validator->add('api_tier', [ + 'content' => ['rule' => ['inList', OrcidSourceTierEnum::getConstValues()]] + ]); + $validator->allowEmptyString('api_tier'); + + $validator->add('api_type', [ + 'content' => ['rule' => ['inList', OrcidSourceApiEnum::getConstValues()]] + ]); + $validator->allowEmptyString('api_type'); + + $validator->add('scope_inherit', [ + 'content' => ['rule' => 'boolean'] + ]); + $validator->allowEmptyString('scope_inherit'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/plugins/OrcidSource/src/Model/Table/OrcidTokensTable.php b/app/plugins/OrcidSource/src/Model/Table/OrcidTokensTable.php new file mode 100644 index 000000000..6db305dc1 --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/OrcidTokensTable.php @@ -0,0 +1,192 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + // Define associations + $this->belongsTo('OrcidSource.OrcidSources'); + $this->setDisplayField('orcid_identifier'); + $this->setPrimaryLink('orcid_source_id'); + + $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'] + ] + ]); + } + + /** + * Perform actions while marshaling data, before validation. + * + * @param EventInterface $event Event + * @param \ArrayObject $data Object data, in array format + * @param \ArrayObject $options Entity save options + * @since COmanage Registry v5.2.0 + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + // Encryption logic + $key = Security::getSalt(); + + foreach (['id_token', 'access_token', 'refresh_token'] as $column) { + if (!empty($data[$column])) { + // Security::encrypt expects string, $key must be correct length for the cipher! + $payload = base64_encode(Security::encrypt($data[$column], $key)); + + // If updating, try to fetch existing stored value to compare + $stored_key = ''; + if (!empty($data['id'])) { + $entity = $this->find()->select([$column])->where(['id' => $data['id']])->first(); + if ($entity) { + $stored_key = $entity->{$column}; + } + } + + if ($stored_key !== $payload) { + $data[$column] = $payload; + } + } + } + + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.2.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + $rules->add( + $rules->isUnique( + ["orcid_source_id", "orcid_identifier"]), + __d('orcid_source', 'error.exists') + ); + + return $rules; + } + + /** + * Unencrypt a value previously encrypted using salt + * + * @param string $value + * + * @return false|string + * @since COmanage Registry v5.2.0 + */ + + public function getUnencrypted(string $value): string|false + { + if(empty($value)) { + return ''; + } + return Security::decrypt(base64_decode($value), Security::getSalt()); + } + + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('orcid_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('orcid_source_id'); + + foreach(['orcid_identifier', 'access_token', 'id_token', 'refresh_token'] as $column) { + $validator->add($column, [ + 'content' => [ + 'rule' => 'validateNotBlank', + 'provider' => 'table' + ] + ]); + } + $validator->notEmptyString('orcid_identifier'); + $validator->notEmptyString('access_token'); + $validator->allowEmptyString('id_token'); + $validator->allowEmptyString('refresh_token'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/plugins/OrcidSource/src/Model/Table/PetitionOrcidsTable.php b/app/plugins/OrcidSource/src/Model/Table/PetitionOrcidsTable.php new file mode 100644 index 000000000..5d61ab95b --- /dev/null +++ b/app/plugins/OrcidSource/src/Model/Table/PetitionOrcidsTable.php @@ -0,0 +1,156 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + $this->belongsTo('OrcidSource.OrcidSourceCollectors'); + + $this->setDisplayField('orcid_token'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false + ] + ]); + } + + /** + * Record an ORCID Token. + * + * @param int $petitionId Petition ID + * @param int $enrollmentFlowStepId Enrollment Flow Step ID + * @param int $orcidSourceCollectorId ORCID Source Collector ID + * @param string $orcid_token ORCID Token Response serialized + * @return void + * @since COmanage Registry v5.2.0 + */ + + public function record( + int $petitionId, + int $enrollmentFlowStepId, + int $orcidSourceCollectorId, + string $orcidToken, + ): void { + // Record the Identifier. We use upsert since at least initially we only support + // one Identifier per Petition. + + $orcid = unserialize($orcidToken); + + $orcidData = [ + 'petition_id' => $petitionId, + 'orcid_token' => $orcidToken, + 'orcid_identifier' => $orcid->orcid, + 'orcid_source_collector_id' => $orcidSourceCollectorId, + ]; + + $this->upsertOrFail( + data: $orcidData, + whereClause: ['petition_id' => $petitionId, 'orcid_identifier' => $orcid->orcid, 'orcid_source_collector_id' => $orcidSourceCollectorId], + ); + + // Record PetitionHistory + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + $PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $enrollmentFlowStepId, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('orcid_source', 'result.OrcidSourceCollector.collected', [$orcid->orcid]) +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('orcid_source_collector_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('orcid_source_collector_id'); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $this->registerStringValidation($validator, $schema, 'orcid_token', true); + + return $validator; + } +} diff --git a/app/plugins/OrcidSource/src/OrcidSourcePlugin.php b/app/plugins/OrcidSource/src/OrcidSourcePlugin.php new file mode 100644 index 000000000..f6fcc78d5 --- /dev/null +++ b/app/plugins/OrcidSource/src/OrcidSourcePlugin.php @@ -0,0 +1,93 @@ +plugin( + 'OrcidSource', + ['path' => '/orcid-source'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/plugins/OrcidSource/src/View/Cell/OrcidSourceCollectorsCell.php b/app/plugins/OrcidSource/src/View/Cell/OrcidSourceCollectorsCell.php new file mode 100644 index 000000000..788b3c14d --- /dev/null +++ b/app/plugins/OrcidSource/src/View/Cell/OrcidSourceCollectorsCell.php @@ -0,0 +1,98 @@ + + */ + protected array $_validCellOptions = [ + 'vv_obj', + 'vv_step', + 'viewVars', + ]; + + /** + * Initialization logic run at the end of object construction. + * + * @return void + */ + public function initialize(): void + { + } + + /** + * Default display method. + * + * @param int $petitionId + * @return void + * @since COmanage Registry v5.2.0 + */ + public function display(int $petitionId): void + { + $vv_oi = $this->fetchTable('CoreSource.PetitionOrcids') + ->find() + ->where(['petition_id' => $this->vv_obj->id]) + ->first(); + + $this->set('vv_orcid', $vv_oi);; + + $this->set('vv_step', $this->vv_step); + $this->set('vv_obj', $this->vv_obj); + } +} diff --git a/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc new file mode 100644 index 000000000..c941ef0dd --- /dev/null +++ b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/dispatch.inc @@ -0,0 +1,47 @@ +Html->css('OrcidSource/orcid-source', ['block' => true]); + +// Authenticate and fetch the token +if (empty($vv_orcid) || empty($vv_token)) { + print $this->element('OrcidSource.authenticate'); + return; +} + +// Make the Form fields editable and the form submittable +$this->Field->enableFormEditMode(); +print $this->element('OrcidSource.preview'); + diff --git a/app/plugins/OrcidSource/templates/OrcidSourceCollectors/fields.inc b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/fields.inc new file mode 100644 index 000000000..b7f871854 --- /dev/null +++ b/app/plugins/OrcidSource/templates/OrcidSourceCollectors/fields.inc @@ -0,0 +1,40 @@ + [ + 'fieldLabel' => __d('orcid_source', 'controller.OrcidSources', [1]) + ] +]; + +$subnav = [ + 'tabs' => ['EnrollmentFlowSteps', 'OrcidSource.OrcidSourceCollectors'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'OrcidSource.OrcidSourceCollectors' => ['edit'] + ], +]; diff --git a/app/plugins/OrcidSource/templates/OrcidSources/fields.inc b/app/plugins/OrcidSource/templates/OrcidSources/fields.inc new file mode 100644 index 000000000..f1ff2572e --- /dev/null +++ b/app/plugins/OrcidSource/templates/OrcidSources/fields.inc @@ -0,0 +1,78 @@ +scope_inherit, FILTER_VALIDATE_BOOLEAN)) { + $vv_inherited_scopes = $vv_obj->server?->oauth2_server?->scope ?? OrcidSourceScopeEnum::DEFAULT_SCOPE; +} + +$fields = [ + // Render the Redirect URI + 'redirect_uri' => [ + 'readonly' => true, + 'default' => $vv_redirect_uri + ], + 'server_id' => [ + 'empty' => false, + 'required' => true + ], + 'api_type' => [ + 'options' => $api_types, + 'type' => 'select', + 'empty' => false, + 'required' => true + ], + 'api_tier' => [ + 'options' => $api_tiers, + 'type' => 'select', + 'empty' => false, + 'required' => true + ], + 'scope_inherit', + // Render active scopes + 'Scope' => [ + 'readonly' => true, + ], + 'SUBTITLE' => [ + 'subtitle' => __d('orcid_source', 'information.OrcidSources.default.types') + ], + 'address_type_id', + 'default_affiliation_type_id', + 'email_address_type_id', + 'name_type_id' +]; + +$subnav = [ + 'tabs' => ['ExternalIdentitySources', 'OrcidSource.OrcidSources', 'ExternalIdentitySources@action.search'], + 'action' => [ + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'OrcidSource.OrcidSources' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], +]; diff --git a/app/plugins/OrcidSource/templates/cell/OrcidSourceCollectors/display.php b/app/plugins/OrcidSource/templates/cell/OrcidSourceCollectors/display.php new file mode 100644 index 000000000..6347ade5d --- /dev/null +++ b/app/plugins/OrcidSource/templates/cell/OrcidSourceCollectors/display.php @@ -0,0 +1,38 @@ + + +orcid_identifier)): ?> +
      +
    • + orcid_identifier]) ?> +
    • +
    + diff --git a/app/plugins/OrcidSource/templates/element/authenticate.php b/app/plugins/OrcidSource/templates/element/authenticate.php new file mode 100644 index 000000000..6d58c1dec --- /dev/null +++ b/app/plugins/OrcidSource/templates/element/authenticate.php @@ -0,0 +1,51 @@ +login' + . __d('orcid_source', 'information.OrcidSourceCollectors.authenticate'); + +print $this->Form->hidden('op', ['value' => 'authenticate']); + +?> + +
    + Html->image('OrcidSource.orcid_128x128.png', ['alt' => 'Logo', 'class' => 'mb-3']) ?> +

    +

    + Form->button( + $btnAuthenticateLabel, + [ + 'id' => 'orcid-auth-btn', + 'escapeTitle' => false, + 'type' => 'submit', + 'class' => 'spin submit-button btn btn-primary d-flex mx-auto', + ] + ) + ?> +
    diff --git a/app/plugins/OrcidSource/templates/element/preview.php b/app/plugins/OrcidSource/templates/element/preview.php new file mode 100644 index 000000000..c5ad8ab0b --- /dev/null +++ b/app/plugins/OrcidSource/templates/element/preview.php @@ -0,0 +1,50 @@ +Form->hidden('op', ['value' => 'savetoken']); +print $this->Form->hidden('orcid_token', ['value' => serialize($vv_token)]); + +?> + + + + + + + + + + + + + + + +
    + diff --git a/app/plugins/OrcidSource/tests/bootstrap.php b/app/plugins/OrcidSource/tests/bootstrap.php new file mode 100644 index 000000000..a3cd830d9 --- /dev/null +++ b/app/plugins/OrcidSource/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/OrcidSource/tests/schema.sql b/app/plugins/OrcidSource/tests/schema.sql new file mode 100644 index 000000000..d28524851 --- /dev/null +++ b/app/plugins/OrcidSource/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for OrcidSource diff --git a/app/plugins/OrcidSource/webroot/.gitkeep b/app/plugins/OrcidSource/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/plugins/OrcidSource/webroot/css/orcid-source.css b/app/plugins/OrcidSource/webroot/css/orcid-source.css new file mode 100644 index 000000000..4b8fc336c --- /dev/null +++ b/app/plugins/OrcidSource/webroot/css/orcid-source.css @@ -0,0 +1,18 @@ +.text-center img { + width: 72px; +} + +.text-center h2 { + color: #68b245; +} + +#orcid-auth-btn { + color: white; + font-weight: bolder; +} + +.material-symbols-outlined { + font-size: 22px; + vertical-align: middle; +} + diff --git a/app/plugins/OrcidSource/webroot/img/orcid_128x128.png b/app/plugins/OrcidSource/webroot/img/orcid_128x128.png new file mode 100644 index 000000000..484207317 Binary files /dev/null and b/app/plugins/OrcidSource/webroot/img/orcid_128x128.png differ diff --git a/app/plugins/SshKeyAuthenticator/README.md b/app/plugins/SshKeyAuthenticator/README.md new file mode 100644 index 000000000..f73123136 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/README.md @@ -0,0 +1,11 @@ +# SshKeyAuthenticator 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/ssh-key-authenticator +``` diff --git a/app/plugins/SshKeyAuthenticator/composer.json b/app/plugins/SshKeyAuthenticator/composer.json new file mode 100644 index 000000000..c8e124adb --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/ssh-key-authenticator", + "description": "SshKeyAuthenticator plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.6.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "SshKeyAuthenticator\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SshKeyAuthenticator\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/SshKeyAuthenticator/config/plugin.json b/app/plugins/SshKeyAuthenticator/config/plugin.json new file mode 100644 index 000000000..80f8350de --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/config/plugin.json @@ -0,0 +1,34 @@ +{ + "types": { + "authenticator": [ + "SshKeyAuthenticators" + ] + }, + "schema": { + "tables": { + "ssh_key_authenticators": { + "columns": { + "id": {}, + "authenticator_id": {} + }, + "indexes": { + "ssh_key_authenticators_i1": { "columns": [ "authenticator_id" ]} + } + }, + "ssh_keys": { + "columns": { + "id": {}, + "ssh_key_authenticator_id": { "type": "integer", "foreignkey": { "table": "ssh_key_authenticators", "column": "id" }, "notnull": true }, + "person_id": {}, + "skey": { "type": "text" }, + "comment": {}, + "type": { "type": "string", "size": 32 } + }, + "indexes": { + "ssh_keys_i1": { "columns": [ "ssh_key_authenticator_id" ]}, + "ssh_keys_i2": { "columns": [ "person_id" ]} + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/SshKeyAuthenticator/phpunit.xml.dist b/app/plugins/SshKeyAuthenticator/phpunit.xml.dist new file mode 100644 index 000000000..3f44f9113 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/SshKeyAuthenticator/resources/locales/en_US/ssh_key_authenticator.po b/app/plugins/SshKeyAuthenticator/resources/locales/en_US/ssh_key_authenticator.po new file mode 100644 index 000000000..3881d4f02 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/resources/locales/en_US/ssh_key_authenticator.po @@ -0,0 +1,77 @@ +# COmanage Registry Localizations (ssh_key_authenticator 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.2.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.SshKeyAuthenticators" +msgstr "{0,plural,=1{SSH Key Authenticator} other{SSH Key Authenticators}}" + +msgid "controller.SshKeys" +msgstr "{0,plural,=1{SSH Key} other{SSH Keys}}" + +msgid "enumeration.SshKeyActionEnum.SSHD" +msgstr "SSH Key Deleted" + +msgid "enumeration.SshKeyActionEnum.SSHU" +msgstr "SSH Key Uploaded" + +msgid "enumeration.SshKeyTypeEnum.ecdsa-sha2-nistp256" +msgstr "ECDSA" + +msgid "enumeration.SshKeyTypeEnum.ecdsa-sha2-nistp384" +msgstr "ECDSA384" + +msgid "enumeration.SshKeyTypeEnum.ecdsa-sha2-nistp521" +msgstr "ECDSA521" + +msgid "enumeration.SshKeyTypeEnum.ssh-dss" +msgstr "DSA" + +msgid "enumeration.SshKeyTypeEnum.ssh-ed25519" +msgstr "ED25519" + +msgid "enumeration.SshKeyTypeEnum.ssh-rsa" +msgstr "RSA" + +msgid "enumeration.SshKeyTypeEnum.ssh-rsa" +msgstr "RSA1" + +msgid "error.SshKeys.empty" +msgstr "SSH Key file was empty" + +msgid "error.SshKeys.format" +msgstr "File does not appear to be a valid ssh public key" + +msgid "error.SshKeys.private" +msgstr "Uploaded file appears to be a private key" + +msgid "field.keyFile" +msgstr "Select an SSH Public Key file to upload" + +msgid "operation.upload" +msgstr "Upload a New SSH Key" + +msgid "result.registered" +msgstr "{0} {1} registered" + +msgid "result.uploaded" +msgstr "SSH Key {0} uploaded" diff --git a/app/plugins/SshKeyAuthenticator/src/Controller/AppController.php b/app/plugins/SshKeyAuthenticator/src/Controller/AppController.php new file mode 100644 index 000000000..97b0e1aa5 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'SshKeyAuthenticators.id' => 'asc' + ] + ]; + + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->SshKeyAuthenticators->Authenticators->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->SshKeyAuthenticators->Authenticators->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->SshKeyAuthenticators->Authenticators->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + +} diff --git a/app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php b/app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php new file mode 100644 index 000000000..b9200e223 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php @@ -0,0 +1,94 @@ + [ + 'SshKeys.comment' => 'asc' + ] + ]; + + /** + * Handle an add action for an SSH Key. + * + * @since COmanage Registry v5.2.0 + */ + + public function add() { + $obj = $this->SshKeys->newEmptyEntity(); + + if(empty($this->requestParam('person_id'))) { + throw new \InvalidArgumentException(__d('error', 'notprov', [__d('controller', 'People', [1])])); + } + + if($this->request->is('post')) { + try { + $upload = $this->getRequest()->getData('keyFile')->getStream()->getContents(); + + $obj = $this->SshKeys->addFromKeyFile( + sshKeyAuthenticatorId: (int)$this->requestParam('ssh_key_authenticator_id'), + personId: (int)$this->requestParam('person_id'), + contents: $upload + ); + + return $this->generateRedirect($obj); + } + catch(\Exception $e) { + // This throws \Cake\ORM\Exception\RolledbackTransactionException if + // aborted in afterSave + + $this->Flash->error($e->getMessage()); + } + } + + // Pass $obj as context so the view can render validation errors + $this->set('vv_obj', $obj); + + // Default title is add new object + [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle( + $obj, + 'SshKeys', + 'add', + 'ssh_key_authenticator' + ); + $this->set('vv_title', $title); + $this->set('vv_supertitle', $supertitle); + $this->set('vv_subtitle', $subtitle); + + // Let the view render + $this->render('/Standard/add-edit-view'); + } +} diff --git a/app/plugins/SshKeyAuthenticator/src/Lib/Enum/SshKeyActionEnum.php b/app/plugins/SshKeyAuthenticator/src/Lib/Enum/SshKeyActionEnum.php new file mode 100644 index 000000000..20d4fad4f --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/src/Lib/Enum/SshKeyActionEnum.php @@ -0,0 +1,40 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity record can be deleted. + * + * @since COmanage Registry v5.2.0 + * @return bool True if the record can be deleted, false otherwise + */ + + public function canDelete(): bool { + return true; + } + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.2.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // SSH Keys can't be altered once created (though they can be deleted) + + return true; + } +} diff --git a/app/plugins/SshKeyAuthenticator/src/Model/Entity/SshKeyAuthenticator.php b/app/plugins/SshKeyAuthenticator/src/Model/Entity/SshKeyAuthenticator.php new file mode 100644 index 000000000..b89c2ce77 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/src/Model/Entity/SshKeyAuthenticator.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php b/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php new file mode 100644 index 000000000..9ac019b9b --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php @@ -0,0 +1,157 @@ +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('Authenticators'); + + $this->hasMany('SshKeyAuthenticator.SshKeys') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('authenticator_id'); + $this->setRequiresCO(true); + + $this->setEditContains([ + 'Authenticators', + ]); + + $this->setViewContains([ + 'Authenticators', + ]); + + $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'] + ] + ]); + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.2.0 + * @param \SshKeyAuthenticator\Model\Entity\SshKeyAuthenticator $entity Entity to generate display field for + * @return string Display field + */ + public function generateDisplayField(\SshKeyAuthenticator\Model\Entity\SshKeyAuthenticator $entity): string { + return $entity->authenticator->description; + } + + /** + * Assemble Authenticator data for provisioning. + * + * @since COmanage Registry v5.2.0 + * @param Authenticator $cfg Authenticator Configuration + * @param int $personId Person ID + * @return array Array of SshKey entities + */ + + public function marshalProvisioningData( + \App\Model\Entity\Authenticator $cfg, + int $personId + ): array { + // Retrieve any Passwords associated with this Person and the requested configuration. + // We'll include all available Password types (encodings) since we don't know which types + // any specific Provisioner will be interested in. + + $sshKeys = $this->SshKeys->find() + ->where([ + 'SshKeys.person_id' => $personId, + 'SshKeys.ssh_key_authenticator_id' => $cfg->ssh_key_authenticator->id + ]) + ->all(); + + return $sshKeys->toArray(); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('authenticator_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('authenticator_id'); + + return $validator; + } +} diff --git a/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeysTable.php b/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeysTable.php new file mode 100644 index 000000000..4a3877abc --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeysTable.php @@ -0,0 +1,329 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('SshKeyAuthenticator.SshKeyAuthenticators'); + $this->belongsTo('People'); + + $this->setDisplayField('comment'); + + $this->setPrimaryLink('SshKeyAuthenticator.ssh_key_authenticator_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal('index'); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + // Following the v4 pattern, SSH Keys cannot be edited + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + // SSH Entities are readOnly but permit delete, so we need to re-enable the action + 'readOnly' => ['delete'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Add an SSH Key from a string parsed from a file. + * + * @since COmanage Registry v5.2.0 + * @param int $sshKeyAuthenticatorId SSH Key Authenticator ID + * @param int $personId Person ID + * @param string $contents Contents of SSH key file (not parsed) + */ + + public function addFromKeyFile( + int $sshKeyAuthenticatorId, + int $personId, + string $contents + ): SshKey { + // GMR-2 will handle checking that $personId and $sshKeyAuthenticatorId are in the + // same CO, so we don't have to. + + // Process the key file + $keyFileString = rtrim($contents); + + if(empty($keyFileString) || ctype_space($keyFileString)) { + throw new \InvalidArgumentException(__d('ssh_key_authenticator', 'error.SshKeys.empty')); + } + + if(preg_match("/-----BEGIN.*PRIVATE.*/", $keyFileString) == 1) { + // This is the private key, not the public key + throw new \InvalidArgumentException(__d('ssh_key_authenticator', 'error.SshKeys.private')); + } + + // We currently only support OpenSSH format, which is a triple of type/key/comment, + // and RFC 4716 Secure Shell (SSH) Public Key File format. + + $newEntityData = [ + 'ssh_key_authenticator_id' => $sshKeyAuthenticatorId, + 'person_id' => $personId + ]; + + // Currently, we only support one key per file regardless of format. + + if(preg_match("/---- BEGIN SSH2 PUBLIC KEY ----.*/", $keyFileString) == 1) { + // RFC4716 format + $newEntityData = array_merge($newEntityData, $this->parseRfc4716($keyFileString)); + } else { + // OpenSSH format + + $sshKeyLine = explode("\n", $keyFileString); + $bits = explode(' ', $sshKeyLine[0], 3); + + $newEntityData['type'] = $bits[0]; + $newEntityData['skey'] = $bits[1]; + $newEntityData['comment'] = $bits[2]; + } + + if(empty($newEntityData['skey'])) { + throw new \InvalidArgumentException(__d('ssh_key_authenticator', 'error.SshKeys.private')); + } + + $sshkey = $this->newEntity($newEntityData); + + $this->saveOrFail($sshkey); + + // Record History and trigger provisioning + + $this->People->recordHistory( + $sshkey, + SshKeyActionEnum::SshKeyUploaded, + __d('ssh_key_authenticator', 'result.uploaded', [$sshkey->comment]) + ); + + $this->People->requestProvisioning($personId, ProvisioningContextEnum::Automatic); + + return $sshkey; + } + + /** + * Parse an RFC 4716 SSH Public Key File. + * + * @since COmanage Registry v5.2.0 + * @param string $keyFileString SSH Key File contents + * @return array Array of 'type', 'skey', and 'comment' + */ + + public function parseRfc4716(string $keyFileString) { + // RFC 4716 format is line based. + $lines = explode("\n", $keyFileString); + + $firstLineFound = false; + $keyFileHeaders = []; + $lineContinuationInProgress = false; + $base64EncodedBody = ""; + + foreach ($lines as $line) { + // A Conforming key file would begin immediately with the begin marker + // but try to be liberal in what we accept so skip any initial lines. + if(!$firstLineFound) { + if(preg_match("/^---- BEGIN SSH2 PUBLIC KEY ----.*/", $line) === 1) { + $firstLineFound = true; + } + continue; + } + + // Parse key file headers with possible continuation lines + // until the base64-encoded body begins. + if(empty($base64EncodedBody)) { + if(!$lineContinuationInProgress) { + $headerLineParts = preg_split("/:/u", $line, 2); + if(count($headerLineParts) == 1) { + $base64EncodedBody .= $line; + } elseif(count($headerLineParts) == 2) { + $headerTag = $headerLineParts[0]; + if(substr($headerLineParts[1], -1) == '\\') { + $lineContinuationInProgress = true; + $headerValue = substr($headerLineParts[1], 0, -1); + } else { + $headerValue = $headerLineParts[1]; + $keyFileHeaders[$headerTag] = $headerValue; + } + continue; + } + } else { + if(substr($line, -1) == '\\') { + $headerValue = $headerValue . substr($line, 0, -1); + } else { + $headerValue = $headerValue . $line; + $lineContinuationInProgress = false; + $keyFileHeaders[$headerTag] = $headerValue; + } + continue; + } + } else { + // Stop parsing when we find the end marker and so ignore any + // non-conforming end material. + if(preg_match("/^---- END SSH2 PUBLIC KEY ----.*/", $line) === 1) { + break; + } + $base64EncodedBody .= $line; + continue; + } + } + + // Base-64 decode the body. The resulting binary string has the format + // 3 null bytes, key type string, 3 null bytes, public key. The key type + // string needs to further be trimmed to remove non-ascii characters. + $bodyBinaryString = base64_decode($base64EncodedBody); + $keyTypeString = trim(explode("\x00\x00\x00", $bodyBinaryString)[1], "\x00..\x1F"); + + // An empty comment is allowed. + $comment = ""; + + if(array_key_exists("Comment", $keyFileHeaders)) { + $comment = trim($keyFileHeaders["Comment"]); + } elseif (array_key_exists("Subject", $keyFileHeaders)) { + $comment = trim($keyFileHeaders["Subject"]); + } + + return [ + 'type' => $keyTypeString, + 'skey' => $base64EncodedBody, + 'comment' => $comment + ]; + } + + /** + * Obtain the current Authenticator status for a Person. + * + * @since COmanage Registry v5.2.0 + * @param Authenticator $cfg Authenticator Configuration + * @param int $personId Person ID + * @return array Array with values + * status: AuthenticatorStatusEnum + * comment: Human readable string, visible to the CO Person + */ + + public function status(\App\Model\Entity\Authenticator $cfg, int $personId): array { + // Are there any SSH Keys for this person? + + $count = $this->find() + ->where([ + 'ssh_key_authenticator_id' => $cfg->ssh_key_authenticator->id, + 'person_id' => $personId + ]) + ->count(); + + if($count > 0) { + return [ + 'status' => AuthenticatorStatusEnum::Active, + 'comment' => __d('ssh_key_authenticator', 'result.registered', [$count, __d('ssh_key_authenticator', 'controller.SshKeys', [$count])]) + ]; + } + + return [ + 'status' => AuthenticatorStatusEnum::NotSet, + 'comment' => __d('result', 'set.not') + ]; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('ssh_key_authenticator_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ssh_keyauthenticator_id'); + + $validator->add('person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('person_id'); + + $validator->add('skey', [ + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->notEmptyString('skey'); + + $this->registerStringValidation($validator, $schema, 'comment', false); + + $validator->add('type', [ + 'content' => ['rule' => ['inList', SshKeyTypeEnum::getConstValues()]] + ]); + $validator->notEmptyString('type'); + + return $validator; + } +} diff --git a/app/plugins/SshKeyAuthenticator/src/SshKeyAuthenticatorPlugin.php b/app/plugins/SshKeyAuthenticator/src/SshKeyAuthenticatorPlugin.php new file mode 100644 index 000000000..d5e6fe584 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/src/SshKeyAuthenticatorPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'SshKeyAuthenticator', + ['path' => '/ssh-key-authenticator'], + 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/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc b/app/plugins/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc new file mode 100644 index 000000000..e22c09e37 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc @@ -0,0 +1,46 @@ +Field->disableFormEditMode(); + +$subnav = [ + 'tabs' => ['Authenticators', 'SshKeyAuthenticator.SshKeyAuthenticators'], + 'action' => [ + 'Authenticators' => ['edit'], + 'SshKeyAuthenticator.SshKeyAuthenticators' => ['edit'] + ] +]; + +// There are currently no configurable options for the SSH Key Authenticator +$alerts = [ + [ + 'type' => 'information', + 'message' => __d('information', 'plugin.config.none') + ] +]; \ No newline at end of file diff --git a/app/plugins/SshKeyAuthenticator/templates/SshKeys/columns.inc b/app/plugins/SshKeyAuthenticator/templates/SshKeys/columns.inc new file mode 100644 index 000000000..4f428592f --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/templates/SshKeys/columns.inc @@ -0,0 +1,56 @@ + [ + 'type' => 'link' + ], + 'type' => [ + 'type' => 'enum', + 'plugin' => 'SshKeyAuthenticator', + 'class' => 'SshKeyTypeEnum' + ] +]; + +// $topLinks appear as an upper right menu. +// We use $topLinks to rebuild the add link because we need additional parameters. +$suppressAddLink = true; + +$topLinks = [ + [ + 'icon' => 'upload', + 'order' => 'Default', + 'label' => __d('ssh_key_authenticator', 'operation.upload'), + 'link' => [ + 'action' => 'add', + '?' => [ + 'person_id' => $vv_person_id + ] + ], + 'class' => '' + ] +]; diff --git a/app/plugins/SshKeyAuthenticator/templates/SshKeys/fields.inc b/app/plugins/SshKeyAuthenticator/templates/SshKeys/fields.inc new file mode 100644 index 000000000..674c57e26 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/templates/SshKeys/fields.inc @@ -0,0 +1,55 @@ + $vv_primary_link_obj->id, + 'person_id' => $vv_person_id + ]; + + // As of v3.2.0, we only allow uploading of SSH Keys, not manually adding or editing + $fields = [ + 'keyFile' => [ + 'type' => 'file' + ] + ]; +} elseif($vv_action == 'view') { + $fields = [ + 'type', + 'comment', + 'skey' + ]; +} diff --git a/app/plugins/SshKeyAuthenticator/tests/bootstrap.php b/app/plugins/SshKeyAuthenticator/tests/bootstrap.php new file mode 100644 index 000000000..033ab6831 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/SshKeyAuthenticator/tests/schema.sql b/app/plugins/SshKeyAuthenticator/tests/schema.sql new file mode 100644 index 000000000..024cfa376 --- /dev/null +++ b/app/plugins/SshKeyAuthenticator/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for SshKeyAuthenticator diff --git a/app/plugins/SshKeyAuthenticator/webroot/.gitkeep b/app/plugins/SshKeyAuthenticator/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/plugins/TermsAgreer/config/plugin.json b/app/plugins/TermsAgreer/config/plugin.json new file mode 100644 index 000000000..90cac19ad --- /dev/null +++ b/app/plugins/TermsAgreer/config/plugin.json @@ -0,0 +1,35 @@ +{ + "types": { + "enrollment_flow_step": [ + "AgreementCollectors" + ] + }, + "schema": { + "tables": { + "agreement_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "t_and_c_mode": { "type": "string", "size": 2 } + }, + "indexes": { + "agreement_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] } + } + }, + "petition_agreements": { + "columns": { + "id": {}, + "petition_id": {}, + "agreement_collector_id": { "type": "integer", "foreignkey": { "table": "agreement_collectors", "column": "id" } }, + "terms_and_conditions_id": { "type": "integer", "foreignkey": { "table": "terms_and_conditions", "column": "id" } }, + "identifier": { "type": "string", "size": 512 }, + "agreement_time": { "type": "datetime" } + }, + "indexes": { + "petition_agreements_i1": { "columns": [ "petition_id" ] }, + "petition_agreements_i2": { "needed": false, "columns": [ "terms_and_conditions_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/TermsAgreer/resources/locales/en_US/terms_agreer.po b/app/plugins/TermsAgreer/resources/locales/en_US/terms_agreer.po new file mode 100644 index 000000000..b2c8b8cda --- /dev/null +++ b/app/plugins/TermsAgreer/resources/locales/en_US/terms_agreer.po @@ -0,0 +1,62 @@ +# COmanage Registry Localizations (terms_agreer 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.2.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.AgreementCollectors" +msgstr "{0,plural,=1{T&C Agreement Collector} other{T&C Agreement Collectors}}" + +msgid "controller.PetitionAgreements" +msgstr "{0,plural,=1{Petition T&C Agreement} other{Petition T&C Agreements}}" + +msgid "enumeration.TAndCEnrollmentModeEnum.EC" +msgstr "Explicit Consent" + +msgid "enumeration.TAndCEnrollmentModeEnum.X" +msgstr "Ignore" + +msgid "enumeration.TAndCEnrollmentModeEnum.IC" +msgstr "Implied Consent" + +msgid "error.TAndCAgreement.missing" +msgstr "Did not receive agreement for \"{0}\" (T&C {1})" + +msgid "field.AgreementCollectors.t_and_c_mode" +msgstr "Terms and Conditions Mode" + +msgid "information.AgreementCollectors.external" +msgstr "These Terms and Conditions will be loaded in an external browser window. After review, you must return to this window and click \"Agree\" to continue." + +msgid "information.AgreementCollectors.review" +msgstr "You must review and agree to these Terms and Conditions before continuing." + +msgid "information.AgreementCollectors.review.tc" +msgstr "Review Terms & Conditions" + +msgid "result.AgreementCollectors.ignored" +msgstr "Terms and Conditions collection disabled" + +msgid "result.AgreementCollectors.recorded" +msgstr "Agreed to Terms and Conditions {0} ({1}, {2})" + +msgid "result.AgreementCollectors.recorded.summary" +msgstr "Agreed to {0} Terms and Conditions" diff --git a/app/plugins/TermsAgreer/src/Controller/AgreementCollectorsController.php b/app/plugins/TermsAgreer/src/Controller/AgreementCollectorsController.php new file mode 100644 index 000000000..80f4cd977 --- /dev/null +++ b/app/plugins/TermsAgreer/src/Controller/AgreementCollectorsController.php @@ -0,0 +1,163 @@ + [ + 'AgreementCollectors.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.2.0 + * @param string $id Approval Collector ID + */ + + public function dispatch(string $id) { + $request = $this->getRequest(); + $petition = $this->getPetition(); + $coId = $this->getCOID(); + + // Pull the set of T&C as well as the plugin step configuration. + // Explicit vs Implicit is handled by the frontend. + + $cfg = $this->AgreementCollectors->get($id); + + if($cfg->t_and_c_mode == TAndCEnrollmentModeEnum::Ignore) { + // If the Plugin is set to Ignore, we simply skip this step and move on. + + return $this->finishStep( + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + petitionId: $petition->id, + comment: __d('terms_agreer', 'result.AgreementCollectors.ignored') + ); + } + + // We pull Active T&C only, We pull all T&C that are not COU specific, + // and if there is a COU associated with this Petition then we also pull T&C + // for that COU. + + $TermsAndConditions = TableRegistry::getTableLocator()->get('TermsAndConditions'); + + $whereClause = [ + 'TermsAndConditions.co_id' => $coId, + 'TermsAndConditions.status' => SuspendableStatusEnum::Active + ]; + + if(!empty($petition->cou_id)) { + $whereClause['OR'] = [ + 'cou_id IS NULL', + 'cou_id' => $petition->cou_id + ]; + } else { + $whereClause[] = 'cou_id IS NULL'; + } + + $tandc = $TermsAndConditions->find() + ->contain(['MostlyStaticPages']) + ->where($whereClause) + ->order('ordr ASC') + ->all(); + + // Calculating status of current Agreements is a bit tricky, because we need to look + // in both the Petition (PetitionAgreements) and if this is a Petition for a Person + // that already exists in TAndCAgreements. As a first pass, we simply ignore any + // existing Agreements (eg if this is an Additional Role Enrollment) and we always + // require this Step to be completed. It's valid to have multiple T&C Agreements, + // and even desirable if they expired. + + if($request->is('post')) { + // Walk the set of $tandc and look for an agreement in the POST data. + // We shouldn't really get here without all $tandc agreed to since the + // frontend shouldn't allow the enrollee to get here otherwise. + + $data = $request->getData(); + + $ok = true; + + foreach($tandc as $tc) { + // The post data is keyed on the string "tc" appended to the T&C id, + // and the expected value is "1". + + $key = "tc".$tc->id; + + if(!isset($data[$key]) || $data[$key] != "1") { + $ok = false; + + $this->Flash->error(__d('terms_agreer','error.TAndCAgreement.missing', [$tc->description, $tc->id])); + } + } + + if($ok) { + // Record the agreements and update the Petition + + // Similar to v4, we use the Petition Token (formerly the Enrollee Token) + // for unauthenticated Enrollments. + + $authIdentifier = $request->getSession()->read('Auth.external.user'); + + if(empty($authIdentifier)) { + $authIdentifier = $petition->token; + } + + $this->AgreementCollectors->record( + petitionId: $petition->id, + agreementCollectorId: $cfg->id, + identifier: $authIdentifier, + tAndC: $tandc + ); + + // Redirect to the next step + + return $this->finishStep( + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + petitionId: $petition->id, + comment: __d('terms_agreer', 'result.AgreementCollectors.recorded.summary', $tandc->count()) + ); + } + } + + // If there are no pending T&C, redirect to the next step. + + // Otherwise let the view render. + $this->set('vv_tandc_mode', $cfg->t_and_c_mode); + $this->set('vv_tandc', $tandc); + + $this->render('/Standard/dispatch'); + } +} \ No newline at end of file diff --git a/app/plugins/TermsAgreer/src/Controller/AppController.php b/app/plugins/TermsAgreer/src/Controller/AppController.php new file mode 100644 index 000000000..deeefd08f --- /dev/null +++ b/app/plugins/TermsAgreer/src/Controller/AppController.php @@ -0,0 +1,10 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/TermsAgreer/src/Model/Entity/PetitionAgreement.php b/app/plugins/TermsAgreer/src/Model/Entity/PetitionAgreement.php new file mode 100644 index 000000000..6bdb3dbf7 --- /dev/null +++ b/app/plugins/TermsAgreer/src/Model/Entity/PetitionAgreement.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/TermsAgreer/src/Model/Table/AgreementCollectorsTable.php b/app/plugins/TermsAgreer/src/Model/Table/AgreementCollectorsTable.php new file mode 100644 index 000000000..41dc1699b --- /dev/null +++ b/app/plugins/TermsAgreer/src/Model/Table/AgreementCollectorsTable.php @@ -0,0 +1,237 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + + $this->hasMany('TermsAgreer.PetitionAgreements') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setAutoViewVars([ + 'tAndCModes' => [ + 'type' => 'enum', + 'class' => 'TermsAgreer.TAndCEnrollmentModeEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform steps necessary to hydrate the Person record as part of Petition finalization. + * + * @since COmanage Registry v5.2.0 + * @param int $id Approval Collector ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function hydrate(int $id, \App\Model\Entity\Petition $petition) { + // We don't currently need the configuration for anything + // $cfg = $this->get($id); + + if(empty($petition->enrollee_person_id)) { + throw new \InvalidArgumentException(__d('error', 'Petitions.enrollee.notfound', [$petition->id])); + } + + $TAndCAgreements = TableRegistry::getTableLocator()->get('TAndCAgreements'); + + // Retrieve the Petition Agreements and convert each one to a T&C Agreement. + // Fon certain types of Enrollments, it's possible that the Enrollee has already + // agreed to one or more T&C, but that's OK, we can simply add a new Agreement + // that will supercede the previous one. + + $agreements = $this->PetitionAgreements + ->find() + ->where([ + 'petition_id' => $petition->id, + // Strictly speaking since T&C are an all-or-nothing thing in the + // current implementation, we don't really care about the + // agreement_collector_id, but we'll filter for it anyway to + // maintain consistency + 'agreement_collector_id' => $id + ]) + ->all(); + + foreach($agreements as $pa) { + $TAndCAgreements->record( + termsAndConditionsId: $pa->terms_and_conditions_id, + personId: $petition->enrollee_person_id, + actorPersonId: $petition->enrollee_person_id, + identifier: $pa->identifier, + agreementTime: $pa->agreement_time->getTimestamp(), + petitionId: $petition->id + ); + } + + // We recorded Petition History when the T&C were processed, and TAndCAgreements + // will record History above, so we don't really need to record any more here. + + return true; + } + + /** + * Record T&C Agreements. + * + * @since COmanage Registry v5.2.0 + * @param int $petitionId Petition ID + * @param int $agreementCollectorId Agreement Collector ID + * @param string $identifier Authenticated Identifier of Agreer + * @param ResultSet $tAndC Set of TermsAndConditions that were agreed to + * @throws \InvalidArgumentException + */ + + public function record( + int $petitionId, + int $agreementCollectorId, + string $identifier, + ResultSet $tAndC + ) { + $cfg = $this->get($agreementCollectorId); + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition = $Petitions->get($petitionId); + + // Walk the set of $tAndC, upserting PetitionAgreements for each + + foreach($tAndC as $tc) { + // Store the PetitionAgreement + + $this->PetitionAgreements->upsertOrFail( + data: [ + 'petition_id' => $petitionId, + 'agreement_collector_id' => $agreementCollectorId, + 'terms_and_conditions_id' => $tc->id, + 'identifier' => $identifier, + // We could probably use changelog timestamps, but it's clearer to record + // an explicit agreement_time. (We'd have to use modified instead of created + // in case we upsert, but then other changes could theoretically update the + // modified timestamp). + 'agreement_time' => date('Y-m-d H:i:s', time()) + ], + whereClause: [ + 'petition_id' => $petitionId, + 'agreement_collector_id' => $agreementCollectorId, + 'terms_and_conditions_id' => $tc->id + ] + ); + + // Record PetitionHistory + + $Petitions->PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + action: $cfg->t_and_c_mode == TAndCEnrollmentModeEnum::ExplicitConsent ? PetitionActionEnum::TCExplicitAgreement : PetitionActionEnum::TCImpliedAgreement, + comment: __d('terms_agreer', 'result.AgreementCollectors.recorded', [$tc->description, $tc->id, $cfg->t_and_c_mode]) + ); + } + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('t_and_c_mode', [ + 'content' => ['rule' => ['inList', TAndCEnrollmentModeEnum::getConstValues()]] + ]); + $validator->notEmptyString('t_and_c_mode'); + + return $validator; + } +} diff --git a/app/plugins/TermsAgreer/src/Model/Table/PetitionAgreementsTable.php b/app/plugins/TermsAgreer/src/Model/Table/PetitionAgreementsTable.php new file mode 100644 index 000000000..2599286b0 --- /dev/null +++ b/app/plugins/TermsAgreer/src/Model/Table/PetitionAgreementsTable.php @@ -0,0 +1,133 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('TermsAgreer.AgreementCollectors'); + $this->belongsTo('Petitions'); + $this->belongsTo('TermsAndConditions') + // It's unclear why, but Cake isn't inflecting the property key correctly here + // even though it does elsewhere (maybe something related to this being a plugin?) + ->setProperty('terms_and_conditions') + ->setForeignKey('terms_and_conditions_id'); + + $this->setDisplayField('agreement_time'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + // Strictly speaking we don't require agreement_collector_id because we always + // collect all T&C, at least in the current implementation. We use it partly + // for consistency with the the CoreEnroller plugins, and partly for future + // proofing (in case we eg support collecting different T&C at different times). + $validator->add('agreement_collector_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('agreement_collector_id'); + + $validator->add('terms_and_conditions_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('terms_and_conditions_id'); + + $this->registerStringValidation($validator, $schema, 'identifier', true); + + $validator->add('agreement_time', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->notEmptyString('agreement_time'); + + return $validator; + } +} diff --git a/app/plugins/TermsAgreer/src/TermsAgreerPlugin.php b/app/plugins/TermsAgreer/src/TermsAgreerPlugin.php new file mode 100644 index 000000000..6d971b990 --- /dev/null +++ b/app/plugins/TermsAgreer/src/TermsAgreerPlugin.php @@ -0,0 +1,98 @@ +plugin( + 'TermsAgreer', + ['path' => '/terms-agreer'], + 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 + // remove this method hook if you don't need it + + 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 + // remove this method hook if you don't need it + + $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/5/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + // remove this method hook if you don't need it + } +} diff --git a/app/plugins/TermsAgreer/src/View/Cell/AgreementCollectorsCell.php b/app/plugins/TermsAgreer/src/View/Cell/AgreementCollectorsCell.php new file mode 100644 index 000000000..8e9d1a52e --- /dev/null +++ b/app/plugins/TermsAgreer/src/View/Cell/AgreementCollectorsCell.php @@ -0,0 +1,96 @@ + + */ + protected array $_validCellOptions = [ + 'vv_obj', + 'vv_step', + 'viewVars', + ]; + + /** + * Initialization logic run at the end of object construction. + * + * @return void + */ + public function initialize(): void + { + } + + /** + * Default display method. + * + * @since COmanage Registry v5.2.0 + * @param int $petitionId + * @return void + */ + + public function display(int $petitionId): void { + $vv_pa = $this->fetchTable('TermsAgreer.PetitionAgreements') + ->find() + ->where([ + 'agreement_collector_id' => $this->vv_step->agreement_collector->id, + 'petition_id' => $this->vv_obj->id + ]) + ->contain('TermsAndConditions') + ->all(); + + $this->set('vv_pa', $vv_pa); + } +} diff --git a/app/plugins/TermsAgreer/templates/AgreementCollectors/dispatch.inc b/app/plugins/TermsAgreer/templates/AgreementCollectors/dispatch.inc new file mode 100644 index 000000000..159fe00ee --- /dev/null +++ b/app/plugins/TermsAgreer/templates/AgreementCollectors/dispatch.inc @@ -0,0 +1,193 @@ +element('flash', []); + +// Make the Form fields editable +$this->Field->enableFormEditMode(); +?> + +

    + +Form->create(null, [ + 'id' => 'agreement-form', + 'type' => 'post' +]); + + +?> + + + + + + + + + + + + + + + + +
    + + + + url)): // We have a URL based T&C ?> + + + element('TermsAgreer.agreeDialog', ['vv_tc' => $tc]); ?> + + +
    + Form->checkbox( + 'tc'.$tc['id'], + ['id' => 'tc'.$tc['id'], 'class' => 'form-check-input tc-agree-checkbox'] + ) . + $this->Form->label( + 'tc'.$tc['id'], + __d('operation','agree'), + ['class' => 'form-check-label'] + ) + ?> +
    +
    + + + \ No newline at end of file diff --git a/app/plugins/TermsAgreer/templates/AgreementCollectors/fields.inc b/app/plugins/TermsAgreer/templates/AgreementCollectors/fields.inc new file mode 100644 index 000000000..ae160b7cb --- /dev/null +++ b/app/plugins/TermsAgreer/templates/AgreementCollectors/fields.inc @@ -0,0 +1,38 @@ + ['EnrollmentFlowSteps', 'TermsAgreer.AgreementCollectors'], + 'action' => [ + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'TermsAgreer.AgreementCollectors' => ['edit'] + ], +]; diff --git a/app/plugins/TermsAgreer/templates/cell/AgreementCollectors/display.php b/app/plugins/TermsAgreer/templates/cell/AgreementCollectors/display.php new file mode 100644 index 000000000..8d8ecefeb --- /dev/null +++ b/app/plugins/TermsAgreer/templates/cell/AgreementCollectors/display.php @@ -0,0 +1,45 @@ + + +
      + +
    • + terms_and_conditions->description, + $pa->terms_and_conditions->id, + $pa->modified + ]); ?> +
    • + +
    + \ No newline at end of file diff --git a/app/plugins/TermsAgreer/templates/element/agreeDialog.php b/app/plugins/TermsAgreer/templates/element/agreeDialog.php new file mode 100644 index 000000000..0d710126d --- /dev/null +++ b/app/plugins/TermsAgreer/templates/element/agreeDialog.php @@ -0,0 +1,76 @@ + and + +
    +
    + sec +
    +
    + +
    +
    + +
    + +
    +
    + + + diff --git a/app/templates/element/pagination.php b/app/templates/element/pagination.php index edc3cda49..5fa1be347 100644 --- a/app/templates/element/pagination.php +++ b/app/templates/element/pagination.php @@ -36,94 +36,100 @@ $appStateId = $this->ApplicationState->getId(ApplicationStateEnum::PaginationLimit); ?> \ No newline at end of file diff --git a/app/templates/element/peopleAutocomplete.php b/app/templates/element/peopleAutocomplete.php index c545e7b1f..7df52c7f4 100644 --- a/app/templates/element/peopleAutocomplete.php +++ b/app/templates/element/peopleAutocomplete.php @@ -30,129 +30,166 @@ // - 'search', used when we find a person and display the fullname along with the ID // - 'field', used for model records. It has a postfix with a link to the person canvas $type = $type ?? 'stand-alone'; - // In the context of a type=field we will pass vv_field_arguments - // In the context of a stand-alone field we will have vv_autocomplete_arguments - $vv_field_arguments = $vv_field_arguments ?? $vv_autocomplete_arguments ?? []; - $label = $label ?? $vv_field_arguments["fieldLabel"] ?? __d('operation','autocomplete.people.label'); - $fieldName = $fieldName ?? 'person_id'; - // Used by the SearchFilter Configuration - $personType = $personType ?? 'person'; - $htmlId = $htmlId ?? 'cmPersonPickerId'; - // Does it have a value already. Default or stored - // CAKEPHP automatically generates a select element if the value is an integer. This is not helpful here. - $inputValue = $inputValue ?? $vv_field_arguments["fieldOptions"]["default"] ?? $vv_field_arguments["fieldOptions"]["value"] ?? ''; - // Mainly required for the Group Members people picker since this is placed as an action url - $actionUrl = $actionUrl ?? []; // the url of the page to launch on select for a stand-alone picker - $viewConfigParameters = $viewConfigParameters ?? []; - $containerClasses = $containerClasses ?? 'cm-autocomplete-container'; + // For a frozen field, print the referenced person (if one exists) and link to the person canvas. + if($type == 'field' && isset($vv_obj) && $vv_obj['frozen']) { + $personId = $vv_obj[$vv_field_arguments['fieldName']]; + if(!empty($personId)) { + $personRecord = $this->Petition->getRecordForId( + 'person_id', $personId, ['PrimaryName', 'EmailAddresses'] + ); + print $this->Html->link( + $personRecord['primary_name']['full_name'], + ['controller' => 'people', 'action' => 'edit', $personId] + ); + } + // Otherwise, build the people picker. + } else { + // In the context of a type=field we will pass vv_field_arguments + // In the context of a stand-alone field we will have vv_autocomplete_arguments + $vv_field_arguments = $vv_field_arguments ?? $vv_autocomplete_arguments ?? []; + $label = $label ?? $vv_field_arguments["fieldLabel"] ?? __d('operation', 'autocomplete.people.label'); + $fieldName = $fieldName ?? 'person_id'; + // Used by the SearchFilter Configuration + $personType = $personType ?? 'person'; + $htmlId = $htmlId ?? 'person-id-picker'; + // Does it have a value already. Default or stored + // CAKEPHP automatically generates a select element if the value is an integer. This is not helpful here. + $inputValue = $inputValue ?? $vv_field_arguments["fieldOptions"]["default"] ?? $vv_field_arguments["fieldOptions"]["value"] ?? ''; - // Load my helper functions - $vueHelper = $this->loadHelper('Vue'); - - // If we have the $actionUrl array, construct the URL - $constructedActionUrl = ''; - if(!empty($actionUrl)) { - $constructedActionUrl = $this->Url->build($actionUrl); - } + // Mainly required for the Group Members people picker since this is placed as an action url + $actionUrl = $actionUrl ?? []; // the url of the page to launch on select for a stand-alone picker + $viewConfigParameters = $viewConfigParameters ?? []; + $containerClasses = $containerClasses ?? 'cm-autocomplete-container'; - // This is the peopleAutocomplete element. If we have the id we need to self construct the - // - the person canvas link - // - Get the person record for view or edit - if (!empty($inputValue)) { - $personRecord = $this->Petition->getRecordForId('person_id', $inputValue, ['PrimaryName', 'EmailAddresses']); - $canvasUrl = $this->Url->build(['controller' => 'people', 'action' => 'edit', $inputValue]); - } -?> + // Load my helper functions + $vueHelper = $this->loadHelper('Vue'); - + // Mount the component and provide a global reference for this app instance. + window. = app.mount("#-container"); + -
    +
    + + diff --git a/app/templates/element/searchGlobal.php b/app/templates/element/searchGlobal.php index 4ff0ed1f4..e6fb5b364 100644 --- a/app/templates/element/searchGlobal.php +++ b/app/templates/element/searchGlobal.php @@ -42,7 +42,7 @@ - - - - element('flash', $vv_subnavigation_flashArgs ?? []) ?> -