From 2299e552d96264868cc6fe4e1578f4d49b08ba66 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Sun, 18 May 2025 15:24:18 -0400 Subject: [PATCH] Pipeline External Match Strategy (CFM-375) and related functionality --- .../ApiConnector/config/routes.php | 6 +- .../resources/locales/en_US/api_connector.po | 4 + .../ApiSourceEndpointsController.php | 70 ++++ .../src/Controller/ApiSourcesController.php | 25 -- ...2Controller.php => SorApiV2Controller.php} | 61 +--- .../src/Model/Entity/ApiSource.php | 2 +- .../src/Model/Entity/ApiSourceEndpoint.php | 49 +++ .../src/Model/Entity/ApiSourceRecord.php | 2 +- .../Model/Table/ApiSourceEndpointsTable.php | 118 ++++++ .../src/Model/Table/ApiSourcesTable.php | 15 +- .../ApiConnector/src/config/plugin.json | 17 +- .../templates/ApiSourceEndpoints/fields.inc | 39 ++ .../templates/ApiSources/fields.inc | 13 +- .../{ApiV2 => SorApiV2}/json/delete.php | 0 .../{ApiV2 => SorApiV2}/json/get.php | 0 .../{ApiV2 => SorApiV2}/json/upsert.php | 0 app/composer.json | 10 +- app/config/schema/schema.json | 17 + app/plugins/CoreApi/README.md | 11 + app/plugins/CoreApi/composer.json | 24 ++ app/plugins/CoreApi/config/routes.php | 60 ++++ app/plugins/CoreApi/phpunit.xml.dist | 30 ++ .../resources/locales/en_US/core_api.po | 38 ++ .../CoreApi/src/Controller/AppController.php | 10 + .../MatchCallbackApiV1Controller.php | 126 +++++++ .../Controller/MatchCallbacksController.php | 58 +++ app/plugins/CoreApi/src/CoreApiPlugin.php | 93 +++++ .../src/Model/Entity/MatchCallback.php | 49 +++ .../src/Model/Table/MatchCallbacksTable.php | 120 +++++++ app/plugins/CoreApi/src/config/plugin.json | 25 ++ .../MatchCallbackApiV1/json/resolve_match.php | 30 ++ .../templates/MatchCallbacks/fields.inc | 41 +++ app/plugins/CoreApi/tests/bootstrap.php | 55 +++ app/plugins/CoreApi/tests/schema.sql | 1 + app/plugins/CoreApi/webroot/.gitkeep | 0 .../resources/locales/en_US/core_job.po | 6 + app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php | 29 +- .../resources/locales/en_US/core_server.po | 30 +- .../MatchServerAttributesController.php | 41 +++ .../src/Controller/MatchServersController.php | 21 +- .../src/Model/Entity/MatchServer.php | 2 +- .../src/Model/Entity/MatchServerAttribute.php | 49 +++ .../src/Model/Table/HttpServersTable.php | 13 +- .../Table/MatchServerAttributesTable.php | 239 +++++++++++++ .../src/Model/Table/MatchServersTable.php | 337 ++++++++++++++++++ app/plugins/CoreServer/src/config/plugin.json | 16 +- .../MatchServerAttributes/columns.inc | 43 +++ .../MatchServerAttributes/fields.inc | 133 +++++++ .../templates/MatchServers/fields-nav.inc | 39 ++ .../templates/MatchServers/fields.inc | 11 - app/resources/locales/en_US/controller.po | 3 + app/resources/locales/en_US/error.po | 9 + app/resources/locales/en_US/field.po | 9 + app/resources/locales/en_US/result.po | 12 +- app/src/Controller/ApisController.php | 41 +++ .../AuthenticationEventsController.php | 21 +- .../Component/RegistryAuthComponent.php | 37 +- app/src/Controller/DashboardsController.php | 20 +- app/src/Controller/StandardApiController.php | 74 ++++ app/src/Lib/Enum/ActionEnum.php | 2 + app/src/Lib/Traits/AutoViewVarsTrait.php | 68 +++- app/src/Lib/Traits/TableMetaTrait.php | 8 +- app/src/Lib/Util/TableUtilities.php | 51 ++- app/src/Model/Entity/Address.php | 28 ++ app/src/Model/Entity/Api.php | 42 +++ app/src/Model/Entity/TelephoneNumber.php | 2 +- app/src/Model/Table/AddressesTable.php | 2 +- app/src/Model/Table/ApiUsersTable.php | 13 +- app/src/Model/Table/ApisTable.php | 138 +++++++ .../Model/Table/AuthenticationEventsTable.php | 20 +- app/src/Model/Table/CoSettingsTable.php | 6 + .../Table/ExternalIdentitySourcesTable.php | 20 +- app/src/Model/Table/JobsTable.php | 10 +- app/src/Model/Table/NamesTable.php | 2 +- app/src/Model/Table/PipelinesTable.php | 300 +++++++++++++--- app/src/Model/Table/TelephoneNumbersTable.php | 2 +- app/templates/Apis/columns.inc | 63 ++++ app/templates/Apis/fields.inc | 51 +++ app/templates/CoSettings/fields.inc | 5 + app/templates/Dashboards/configuration.php | 13 +- app/templates/Identifiers/columns.inc | 2 +- app/templates/Identifiers/fields-nav.inc | 45 +++ app/templates/Pipelines/fields.inc | 23 +- app/templates/Standard/index.php | 12 +- app/vendor/cakephp-plugins.php | 1 + app/vendor/composer/autoload_psr4.php | 2 + app/vendor/composer/autoload_static.php | 10 + 87 files changed, 3086 insertions(+), 309 deletions(-) create mode 100644 app/availableplugins/ApiConnector/src/Controller/ApiSourceEndpointsController.php rename app/availableplugins/ApiConnector/src/Controller/{ApiV2Controller.php => SorApiV2Controller.php} (75%) create mode 100644 app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceEndpoint.php create mode 100644 app/availableplugins/ApiConnector/src/Model/Table/ApiSourceEndpointsTable.php create mode 100644 app/availableplugins/ApiConnector/templates/ApiSourceEndpoints/fields.inc rename app/availableplugins/ApiConnector/templates/{ApiV2 => SorApiV2}/json/delete.php (100%) rename app/availableplugins/ApiConnector/templates/{ApiV2 => SorApiV2}/json/get.php (100%) rename app/availableplugins/ApiConnector/templates/{ApiV2 => SorApiV2}/json/upsert.php (100%) create mode 100644 app/plugins/CoreApi/README.md create mode 100644 app/plugins/CoreApi/composer.json create mode 100644 app/plugins/CoreApi/config/routes.php create mode 100644 app/plugins/CoreApi/phpunit.xml.dist create mode 100644 app/plugins/CoreApi/resources/locales/en_US/core_api.po create mode 100644 app/plugins/CoreApi/src/Controller/AppController.php create mode 100644 app/plugins/CoreApi/src/Controller/MatchCallbackApiV1Controller.php create mode 100644 app/plugins/CoreApi/src/Controller/MatchCallbacksController.php create mode 100644 app/plugins/CoreApi/src/CoreApiPlugin.php create mode 100644 app/plugins/CoreApi/src/Model/Entity/MatchCallback.php create mode 100644 app/plugins/CoreApi/src/Model/Table/MatchCallbacksTable.php create mode 100644 app/plugins/CoreApi/src/config/plugin.json create mode 100644 app/plugins/CoreApi/templates/MatchCallbackApiV1/json/resolve_match.php create mode 100644 app/plugins/CoreApi/templates/MatchCallbacks/fields.inc create mode 100644 app/plugins/CoreApi/tests/bootstrap.php create mode 100644 app/plugins/CoreApi/tests/schema.sql create mode 100644 app/plugins/CoreApi/webroot/.gitkeep create mode 100644 app/plugins/CoreServer/src/Controller/MatchServerAttributesController.php create mode 100644 app/plugins/CoreServer/src/Model/Entity/MatchServerAttribute.php create mode 100644 app/plugins/CoreServer/src/Model/Table/MatchServerAttributesTable.php create mode 100644 app/plugins/CoreServer/templates/MatchServerAttributes/columns.inc create mode 100644 app/plugins/CoreServer/templates/MatchServerAttributes/fields.inc create mode 100644 app/plugins/CoreServer/templates/MatchServers/fields-nav.inc create mode 100644 app/src/Controller/ApisController.php create mode 100644 app/src/Model/Entity/Api.php create mode 100644 app/src/Model/Table/ApisTable.php create mode 100644 app/templates/Apis/columns.inc create mode 100644 app/templates/Apis/fields.inc create mode 100644 app/templates/Identifiers/fields-nav.inc 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..cf1b26a17 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,10 @@ 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.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..86ad2e659 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Controller/ApiSourceEndpointsController.php @@ -0,0 +1,70 @@ + [ + '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) { + $vv_obj = $this->viewBuilder()->getVar('vv_obj'); + + if(!empty($vv_obj)) { + $apiSource = $this->ApiSourceEndpoints->ApiSources->get( + $vv_obj->api_source_id, + ['contain' => 'ExternalIdentitySources'] + ); + + $this->set( + 'vv_push_endpoint', + Router::url( + url: '/api/apisource/' . $apiSource->id . '/v2/sorPeople/' . $apiSource->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..19b998274 100644 --- a/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php +++ b/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php @@ -38,29 +38,4 @@ class ApiSourcesController extends StandardPluginController { 'ApiSources.id' => 'asc' ] ]; - - /** - * Callback run prior to the request render. - * - * @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 - ) - ); - - return parent::beforeRender($event); - } } diff --git a/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php b/app/availableplugins/ApiConnector/src/Controller/SorApiV2Controller.php similarity index 75% rename from app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php rename to app/availableplugins/ApiConnector/src/Controller/SorApiV2Controller.php index d865f96c5..bb7b622b1 100644 --- a/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php +++ b/app/availableplugins/ApiConnector/src/Controller/SorApiV2Controller.php @@ -1,6 +1,6 @@ 'ApiSourceEndpoints', + 'get' => 'ApiSourceEndpoints', + 'upsert' => 'ApiSourceEndpoints' + ]; /** * Calculate the CO ID associated with the request. @@ -46,42 +53,11 @@ public function calculateRequestedCOID(): ?int { $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); - $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ExternalIdentitySources']); + return $ApiSource->findCoForRecord((int)$apiSourceId); - 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 - */ + // $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ExternalIdentitySources']); - 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; + // return $cfg->external_identity_source->co_id ?? null; } /** @@ -193,17 +169,4 @@ public function upsert(string $id, string $sorlabel, string $sorid) { $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/Model/Entity/ApiSource.php b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSource.php index 7403f3759..87b958bfc 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 = [ + '*' => 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..815a62655 100644 --- a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php +++ b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php @@ -1,6 +1,6 @@ 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('ApiConnector.ApiSources'); + // $this->belongsTo('ApiUsers'); + $this->belongsTo('Apis'); + + $this->setDisplayField('api_id'); + + $this->setPrimaryLink(['api_id']); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + + $this->setAutoViewVars([ + 'apiSources' => [ + '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('api_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('api_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..99b6a8878 100644 --- a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php +++ b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php @@ -62,6 +62,9 @@ public function initialize(array $config): void { $this->belongsTo('ExternalIdentitySources'); $this->belongsTo('ApiUsers'); + $this->hasMany('ApiConnector.ApiSourceEntities') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('ApiConnector.ApiSourceRecords') ->setDependent(true) ->setCascadeCallbacks(true); @@ -70,13 +73,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) @@ -491,11 +487,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 index be96e7f42..69a8f6010 100644 --- a/app/availableplugins/ApiConnector/src/config/plugin.json +++ b/app/availableplugins/ApiConnector/src/config/plugin.json @@ -1,5 +1,8 @@ { "types": { + "api": [ + "ApiSourceEndpoints" + ], "source": [ "ApiSources" ] @@ -9,13 +12,23 @@ "api_sources": { "columns": { "id": {}, - "external_identity_source_id": {}, - "api_user_id": {} + "external_identity_source_id": {} }, "indexes": { "api_sources_i1": { "columns": [ "external_identity_source_id" ] } } }, + "api_source_endpoints": { + "columns": { + "id": {}, + "api_id": {}, + "api_source_id": { "type": "integer", "foreignkey": { "table": "api_sources", "column": "id" } } + }, + "indexes": { + "api_source_endpoints_i1": { "columns": [ "api_id" ] }, + "api_source_endpoints_i2": { "columns": [ "api_source_id" ] } + } + }, "api_source_records": { "columns": { "id": {}, diff --git a/app/availableplugins/ApiConnector/templates/ApiSourceEndpoints/fields.inc b/app/availableplugins/ApiConnector/templates/ApiSourceEndpoints/fields.inc new file mode 100644 index 000000000..b5afc856c --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiSourceEndpoints/fields.inc @@ -0,0 +1,39 @@ +element('notify/banner', [ + 'info' => __d('api_connector', 'information.endpoint.push', [$vv_push_endpoint]) + ]); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'api_source_id', + ] + ]); +} diff --git a/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc b/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc index c469f6a67..ef5dd13b5 100644 --- a/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc +++ b/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc @@ -26,19 +26,10 @@ */ // This view does currently not support read-only -if($vv_action == 'add' || $vv_action == 'edit') { - +if($vv_action == 'edit') { print '
  • ' . __d('api_connector', 'field.ApiSources.push_mode') . '

  • '; print $this->element('notify/banner', [ - 'info' => __d('api_connector', 'information.endpoint.push', [$vv_push_endpoint]) + 'info' => __d('information', 'plugin.config.none',) ]); - - - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'api_user_id', - ] - ]); - } 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/composer.json b/app/composer.json index 527fe89b2..907a8bfba 100644 --- a/app/composer.json +++ b/app/composer.json @@ -32,15 +32,16 @@ "psr-4": { "App\\": "src/", "ApiConnector\\": "availableplugins/ApiConnector/src/", + "CoreApi\\": "plugins/CoreApi/src/", "CoreAssigner\\": "plugins/CoreAssigner/src/", "CoreEnroller\\": "plugins/CoreEnroller/src/", "CoreServer\\": "plugins/CoreServer/src/", "EnvSource\\": "plugins/EnvSource/src/", "FileConnector\\": "availableplugins/FileConnector/src/", + "OrcidSource\\": "plugins/OrcidSource/src/", "PipelineToolkit\\": "availableplugins/PipelineToolkit/src/", "SqlConnector\\": "availableplugins/SqlConnector/src/", - "CoreJob\\": "plugins/CoreJob/src/", - "OrcidSource\\": "plugins/OrcidSource/src/" + "CoreJob\\": "plugins/CoreJob/src/" } }, "autoload-dev": { @@ -48,15 +49,16 @@ "App\\Test\\": "tests/", "ApiConnector\\Test\\": "availableplugins/ApiConnector/tests/", "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", + "CoreApi\\Test\\": "plugins/CoreApi/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/", + "OrcidSource\\Test\\": "plugins/OrcidSource/tests/", "PipelineToolkit\\Test\\": "availableplugins/PipelineToolkit/tests/", "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/", - "CoreJob\\Test\\": "plugins/CoreJob/tests/", - "OrcidSource\\Test\\": "plugins/OrcidSource/tests/" + "CoreJob\\Test\\": "plugins/CoreJob/tests/" } }, "scripts": { diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index dc3080a8a..7cc67284f 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -10,6 +10,7 @@ "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" } }, "co_id": { "type": "integer", "foreignkey": { "table": "cos", "column": "id" }, "notnull": true }, "comment": { "type": "string", "size": 256 }, @@ -130,6 +131,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 }, @@ -899,6 +901,21 @@ } }, + "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" ]} + } + }, + "traffic_detours": { "columns": { "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/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..aef070cf4 --- /dev/null +++ b/app/plugins/CoreApi/src/Controller/MatchCallbacksController.php @@ -0,0 +1,58 @@ + [ + '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) { + // 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..a96b57d5a --- /dev/null +++ b/app/plugins/CoreApi/src/Model/Entity/MatchCallback.php @@ -0,0 +1,49 @@ + + */ + protected $_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..f69c0f495 --- /dev/null +++ b/app/plugins/CoreApi/src/Model/Table/MatchCallbacksTable.php @@ -0,0 +1,120 @@ +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' => 'select', + 'model' => 'Servers', + 'where' => ['plugin' => '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/src/config/plugin.json b/app/plugins/CoreApi/src/config/plugin.json new file mode 100644 index 000000000..ccd52e420 --- /dev/null +++ b/app/plugins/CoreApi/src/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/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..57c744426 --- /dev/null +++ b/app/plugins/CoreApi/templates/MatchCallbacks/fields.inc @@ -0,0 +1,41 @@ +element('notify/banner', [ + 'info' => __d('core_api', 'information.endpoint.match.callback', [$vv_api_endpoint]) + ]); + } + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'server_id', + ] + ]); +} \ No newline at end of file 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/plugins/CoreApi/webroot/.gitkeep b/app/plugins/CoreApi/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb 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..2386045ac 100644 --- a/app/plugins/CoreJob/resources/locales/en_US/core_job.po +++ b/app/plugins/CoreJob/resources/locales/en_US/core_job.po @@ -52,6 +52,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)" @@ -115,6 +118,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/SyncJob.php b/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php index c762cdcb0..24770f5d1 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', @@ -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/CoreServer/resources/locales/en_US/core_server.po b/app/plugins/CoreServer/resources/locales/en_US/core_server.po index 6fdd6cca9..94e0a81bf 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}}" @@ -70,6 +73,15 @@ 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" @@ -79,6 +91,12 @@ 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" @@ -91,9 +109,6 @@ 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" @@ -136,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" @@ -150,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/MatchServerAttributesController.php b/app/plugins/CoreServer/src/Controller/MatchServerAttributesController.php new file mode 100644 index 000000000..cef8f47a8 --- /dev/null +++ b/app/plugins/CoreServer/src/Controller/MatchServerAttributesController.php @@ -0,0 +1,41 @@ + [ + 'MatchServerAttributes.attribute' => 'asc' + ] + ]; +} diff --git a/app/plugins/CoreServer/src/Controller/MatchServersController.php b/app/plugins/CoreServer/src/Controller/MatchServersController.php index 4b7bc526a..4b715ca3a 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) */ @@ -30,7 +30,6 @@ namespace CoreServer\Controller; use App\Controller\StandardPluginController; -use Cake\Event\EventInterface; class MatchServersController extends StandardPluginController { public $paginate = [ @@ -38,22 +37,4 @@ class MatchServersController extends StandardPluginController { 'MatchServers.url' => '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) { - // Generate the callback URL - -// 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'); - - return parent::beforeRender($event); - } } diff --git a/app/plugins/CoreServer/src/Model/Entity/MatchServer.php b/app/plugins/CoreServer/src/Model/Entity/MatchServer.php index ceab5bf4f..688c77f6b 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) */ 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..84be4ce58 --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Entity/MatchServerAttribute.php @@ -0,0 +1,49 @@ + + */ + protected $_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..f11adea97 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 { /** @@ -56,6 +60,9 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Servers'); + $this->hasMany('CoreServer.MatchServerAttributes') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->setDisplayField('hostname'); @@ -73,10 +80,340 @@ 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->reasonPhrase; + + // 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/config/plugin.json b/app/plugins/CoreServer/src/config/plugin.json index f64e58cbd..7bf87729f 100644 --- a/app/plugins/CoreServer/src/config/plugin.json +++ b/app/plugins/CoreServer/src/config/plugin.json @@ -3,9 +3,9 @@ "server": [ "HttpServers", "MatchServers", + "Oauth2Servers", "SmtpServers", - "SqlServers", - "Oauth2Servers" + "SqlServers" ] }, "schema": { @@ -38,6 +38,18 @@ "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" ]} + } + }, "oauth2_servers": { "columns": { "id": {}, diff --git a/app/plugins/CoreServer/templates/MatchServerAttributes/columns.inc b/app/plugins/CoreServer/templates/MatchServerAttributes/columns.inc new file mode 100644 index 000000000..588605a76 --- /dev/null +++ b/app/plugins/CoreServer/templates/MatchServerAttributes/columns.inc @@ -0,0 +1,43 @@ + [ + 'type' => 'link' + ], + 'type_id' => [ + 'type' => 'fk' + ], + 'required' => [ + 'type' => 'enum', + 'class' => 'RequiredEnum' + ] +]; diff --git a/app/plugins/CoreServer/templates/MatchServerAttributes/fields.inc b/app/plugins/CoreServer/templates/MatchServerAttributes/fields.inc new file mode 100644 index 000000000..333c89e45 --- /dev/null +++ b/app/plugins/CoreServer/templates/MatchServerAttributes/fields.inc @@ -0,0 +1,133 @@ + + +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'attribute', + 'fieldOptions' => [ + 'onChange' => 'resetType()' + ] + ]]); + + // type_id is a hidden field used to persist the type + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'type_id', + 'fieldOptions' => [ + '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) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field, + 'fieldLabel' => __d('field', 'type'), + 'fieldOptions' => [ + 'onChange' => 'copyType("' . $field . '")' + ] + ] + ]); + } + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'required' + ]]); +} \ No newline at end of file diff --git a/app/plugins/CoreServer/templates/MatchServers/fields-nav.inc b/app/plugins/CoreServer/templates/MatchServers/fields-nav.inc new file mode 100644 index 000000000..a5f996e57 --- /dev/null +++ b/app/plugins/CoreServer/templates/MatchServers/fields-nav.inc @@ -0,0 +1,39 @@ + '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' => '' +]; diff --git a/app/plugins/CoreServer/templates/MatchServers/fields.inc b/app/plugins/CoreServer/templates/MatchServers/fields.inc index 550143398..b5aae1e1d 100644 --- a/app/plugins/CoreServer/templates/MatchServers/fields.inc +++ b/app/plugins/CoreServer/templates/MatchServers/fields.inc @@ -27,14 +27,3 @@ // 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 - -print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'api_endpoint', - 'fieldOptions' => [ - 'readOnly' => true, - 'default' => $vv_api_endpoint - ] - ]]); \ No newline at end of file diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index f63a9ef83..2005d0e44 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -33,6 +33,9 @@ msgstr "{0,plural,=1{Ad Hoc Attribute} other{Ad Hoc Attributes}}" msgid "ApiUsers" msgstr "{0,plural,=1{API User} other{API Users}}" +msgid "Apis" +msgstr "{0,plural,=1{API} other{APIs}}" + msgid "AuthenticationEvents" msgstr "{0,plural,=1{Authentication Event} other{Authentication Events}}" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index f86d6a80c..b9040adb6 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -340,6 +340,15 @@ msgstr "Petition {0} is not in Finalizing status" msgid "Pipelines.eis.record.adopted" msgstr "Source Key {0} has been adopted by Person ID {1} and is no longer eligible for syncing" +msgid "Pipelines.match.external.empty" +msgstr "Received unexpected empty Reference Identifier from server" + +msgid "Pipelines.match.external.response" +msgstr "Received unexpected multiple choice response from server" + +msgid "Pipelines.match.multiple" +msgstr "Match Strategy found more than one matching Person for Reference ID {0}" + msgid "Pipelines.plugin.notimpl" msgstr "Pipeline plugin does not implement {0}" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 7ecb1e294..8350a7254 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -375,6 +375,12 @@ msgstr "Authentication Event" msgid "Cos.member.not" msgstr "{0} (Not a Member)" +msgid "CoSettings.authn_events_api_disable" +msgstr "Disable API User Authentication Events" + +msgid "CoSettings.authn_events_api_disable.desc" +msgstr "If checked, Authentication Events will not be recorded for API User logins" + msgid "CoSettings.default_address_type_id" msgstr "Default Address Type" @@ -749,6 +755,9 @@ msgstr "Email Address Type" msgid "Pipelines.match_identifier_type_id" msgstr "Identifier Type" +msgid "Pipelines.match_server_id" +msgstr "Match Server" + msgid "Pipelines.match_strategy" msgstr "Match Strategy" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index 783986b96..848744e1e 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -141,9 +141,6 @@ msgstr "Identifiers Assigned ({0})" msgid "IdentifierAssignments.queued.ok" msgstr "Identifiers Assignment for context {0} queued for CO {1}" -msgid "Names.primary_name" -msgstr "Primary Name Updated" - msgid "Jobs.canceled" msgstr "Job {0} canceled" @@ -153,6 +150,9 @@ msgstr "Job canceled by {0}" msgid "Jobs.registered" msgstr "Started via JobCommand by {0} (uid {1})" +msgid "Names.primary_name" +msgstr "Primary Name Updated" + # These are for generating history records msgid "Notifications.A" msgstr "Notification acknowledged: {0}" @@ -208,12 +208,18 @@ msgstr "Created new External Identity via Pipeline {0} ({1}) using Source {2} ({ msgid "Pipelines.matched" msgstr "Pipeline {0} ({1}) matched EIS {2} ({3}) source key {4} to Person using Match Strategy {5}" +msgid "Pipelines.matched.external" +msgstr "Reference Identifier {0} obtained from match server" + msgid "Pipelines.requested" msgstr "Pipeline {0} ({1}) linked to requested Person {2} for EIS {3} ({4}) source key {5}" msgid "Pipelines.started" msgstr "Pipeline {0} ({1}) started for EIS {2} ({3}) source key {4}" +msgid "Pipelines.updated.external" +msgstr "Sent Update Match Attributes Request" + msgid "ProvisioningTargets.queued.ok" msgstr "Reprovisioning for {0} queued for {1} ({2})" diff --git a/app/src/Controller/ApisController.php b/app/src/Controller/ApisController.php new file mode 100644 index 000000000..c9a29ab85 --- /dev/null +++ b/app/src/Controller/ApisController.php @@ -0,0 +1,41 @@ + [ + 'Apis.description' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/AuthenticationEventsController.php b/app/src/Controller/AuthenticationEventsController.php index 2c835c02a..c991d03ba 100644 --- a/app/src/Controller/AuthenticationEventsController.php +++ b/app/src/Controller/AuthenticationEventsController.php @@ -33,7 +33,7 @@ use Cake\Log\Log; use Cake\ORM\TableRegistry; -class AuthenticationEventsController extends MVEAController { +class AuthenticationEventsController extends StandardController { public $paginate = [ 'order' => [ 'AuthenticationEvents.id' => 'desc' @@ -42,23 +42,4 @@ class AuthenticationEventsController extends MVEAController { // Cached permissions protected ?array $permCache = null; - - /** - * Callback run prior to the request action. - * - * @since COmanage Registry v5.0.0 - * @param EventInterface $event Cake Event - */ - - public function beforeFilter(\Cake\Event\EventInterface $event) { - // If an identifier was passed in, use that to filter the index query. - // (Authz is handled in the closure passed to setIndexFilter by AuthenticationEventsTable.) - $targetIdentifier = $this->getRequest()->getQuery('authenticated_identifier'); - - if($targetIdentifier) { - $this->AuthenticationEvents->setIndexFilter(['authenticated_identifier' => \App\Lib\Util\StringUtilities::urlbase64decode($targetIdentifier)]); - } - - return parent::beforeFilter($event); - } } \ No newline at end of file diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index 1054d3c13..7ae9ac416 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -94,13 +94,13 @@ protected function authenticateApiUser(): bool { try { // validateKey takes care of all validity logic, as well as rehashing (if needed) - if($ApiUsers->validateKey($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'], $_SERVER['REMOTE_ADDR'])) { - $this->authenticatedUser = $_SERVER['PHP_AUTH_USER']; - $this->authenticatedApiUser = true; - $this->llog('debug', "Authenticated API User \"" . $this->authenticatedUser . "\""); + $this->cache['api_user']['co_id'] = $ApiUsers->validateKey($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'], $_SERVER['REMOTE_ADDR']); + + $this->authenticatedUser = $_SERVER['PHP_AUTH_USER']; + $this->authenticatedApiUser = true; + $this->llog('debug', "Authenticated API User \"" . $this->authenticatedUser . "\""); - return true; - } + return true; } catch(\Exception $e) { $this->llog('debug', "User authentication failed: " . $e->getMessage()); @@ -205,11 +205,28 @@ public function beforeFilter(EventInterface $event) { } if($authok) { - $AuthenticationEvents = TableRegistry::getTableLocator()->get('AuthenticationEvents'); + // Record an Authentication Event, unless disabled for the current CO, + // which should have been returned during authenticateApiUsre(). + + $skipRecording = false; - $AuthenticationEvents->record(identifier: $this->authenticatedUser, - eventType: AuthenticationEventEnum::ApiLogin, - remoteIp: $_SERVER['REMOTE_ADDR']); + if(!empty($this->cache['api_user']['co_id'])) { + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find() + ->where(['co_id' => $this->cache['api_user']['co_id']]) + ->firstOrFail(); + + $skipRecording = ($settings->authn_events_api_disable === true); + } + + if(!$skipRecording) { + $AuthenticationEvents = TableRegistry::getTableLocator()->get('AuthenticationEvents'); + + $AuthenticationEvents->record(identifier: $this->authenticatedUser, + eventType: AuthenticationEventEnum::ApiLogin, + remoteIp: $_SERVER['REMOTE_ADDR']); + } return true; } diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index e28248549..f3021cadc 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -87,6 +87,11 @@ public function configuration() { 'controller' => 'api_users', 'action' => 'index' ], + __d('controller', 'Apis', [99]) => [ + 'icon' => 'api', + 'controller' => 'apis', + 'action' => 'index' + ], __d('controller', 'Cous', [99]) => [ 'icon' => 'people_outline', 'controller' => 'cous', @@ -179,7 +184,7 @@ public function configuration() { 'controller' => 'plugins', 'action' => 'index' ], - __d('controller', "TrafficDetours", [99]) => [ + __d('controller', 'TrafficDetours', [99]) => [ 'icon' => 'fork_right', 'controller' => 'traffic_detours', 'action' => 'index' @@ -235,6 +240,19 @@ public function configuration() { ] ]; + if($co->isCOmanageCO()) { + $artifactMenuItems[__d('controller', 'AuthenticationEvents', [99])] = [ + // Authentication Events are only visible to the Platform Administrators + // since they are tied to Identifiers, not CO-specific information. + 'icon' => 'lock', + 'iconClass' => 'material-symbols-outlined', + 'controller' => 'authentication_events', + 'action' => 'index', + // Suppress injection of CO ID + 'platform' => true + ]; + } + ksort($artifactMenuItems); $this->set('vv_artifacts_menu_items', $artifactMenuItems); diff --git a/app/src/Controller/StandardApiController.php b/app/src/Controller/StandardApiController.php index c3947b67d..49dd9fb10 100644 --- a/app/src/Controller/StandardApiController.php +++ b/app/src/Controller/StandardApiController.php @@ -31,6 +31,7 @@ use Cake\ORM\TableRegistry; use Cake\Utility\Inflector; +use App\Lib\Enum\SuspendableStatusEnum; class StandardApiController extends AppController { /** @@ -46,4 +47,77 @@ public function initialize(): void { // We want API auth, not Web Auth $this->RegistryAuth->setConfig('apiUser', true); } + + /** + * 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 { + $request = $this->getRequest(); + $action = $request->getParam('action'); + $authuser = null; + + // We let RegistryAuth handle the authentication + if(!$this->RegistryAuth->isApiUser()) { + // We don't localize exception in this function or call llog directly because + // any exception we throw will be caught by RegistryAuthComponent and logged there + throw new \InvalidArgumentException("RegistryAuth did not provide API User in calculatePermission"); + } + + $authUser = $this->RegistryAuth->getAuthenticatedUser(); + + $authorized = false; + + // For authorization, we need to find the corresponding entry in Apis. + // First we need the API User ID. + + $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers'); + + // RegistryAuthComponent took care of all the validation, we just need the ID + $apiuser = $ApiUsers->find() + ->where(['username' => $authUser]) + ->firstOrFail(); + + // Next we need the Plugin's Entry Point Map to tell us what Entry Point Model the + // current controller points to. + + if(!isset($this->entryPointMap[$action])) { + throw new \RuntimeException("Plugin did not provide Entry Point Map value for $action"); + } + + // Find the associated API configuration + + $api = $ApiUsers->Apis->find() + ->where([ + 'api_user_id' => $apiuser->id, + 'plugin' => $this->getPlugin() . "." . $this->entryPointMap[$action] + ]) + ->firstOrFail(); + + // We manually check status (as opposed to updating the find) to faciliate logging + + if($api->status != SuspendableStatusEnum::Active) { + throw new \InvalidArgumentException("API " . $api->id . " is not active"); + } + + // If we get here the API User is authorized for the requested plugin configuration + + return true; + } + + /** + * 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'; + } } \ No newline at end of file diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php index 9b5012df4..01bd08bfb 100644 --- a/app/src/Lib/Enum/ActionEnum.php +++ b/app/src/Lib/Enum/ActionEnum.php @@ -48,6 +48,7 @@ class ActionEnum extends StandardEnum { const GroupOwnerAdded = 'ACGO'; const GroupOwnerDeleted = 'DCGO'; const IdentifierAutoAssigned = 'AIDA'; + const MatchAttributesUpdated = 'UMAT'; const MVEAAdded = 'AMVE'; const MVEADeleted = 'DMVE'; const MVEAEdited = 'EMVE'; @@ -63,4 +64,5 @@ class ActionEnum extends StandardEnum { const PersonPipelineStarted = 'SCPL'; const PersonRoleRelinked = 'LCPR'; const PersonStatusRecalculated = 'RCPS'; + const ReferenceIdentifierObtained = 'OIDR'; } \ No newline at end of file diff --git a/app/src/Lib/Traits/AutoViewVarsTrait.php b/app/src/Lib/Traits/AutoViewVarsTrait.php index 853fbb1e4..49ecbf123 100644 --- a/app/src/Lib/Traits/AutoViewVarsTrait.php +++ b/app/src/Lib/Traits/AutoViewVarsTrait.php @@ -31,7 +31,9 @@ use App\Lib\Enum\SuspendableStatusEnum; use App\Lib\Util\FunctionUtilities; -use \Cake\ORM\TableRegistry; +use App\Lib\Util\StringUtilities; +use Cake\ORM\TableRegistry; +use Cake\Utility\Hash; trait AutoViewVarsTrait { // Array (and configuration) of view variables to automatically populate @@ -105,17 +107,16 @@ public function calculateAutoViewVars(int|null $coId, Object $obj = null): \Gene // Inject configuration. Since we're only ever looking at the types // table, inject the current CO along with the requested attribute $avv['model'] = 'Types'; - if(\is_array($avv['attribute'])) { - $avv['where'] = [ - 'attribute IN' => $avv['attribute'], - 'status' => SuspendableStatusEnum::Active - ]; - } else { - $avv['where'] = [ - 'attribute' => $avv['attribute'], - 'status' => SuspendableStatusEnum::Active - ]; + $avv['where']['status'] = SuspendableStatusEnum::Active; + if(!empty($avv['attribute'])) { + if(\is_array($avv['attribute'])) { + $avv['where']['attribute IN'] = $avv['attribute']; + } else { + $avv['where']['attribute'] = $avv['attribute']; + } } + // else we allow for 'attribute' to be omitted for cases where type_id + // can be populated by multiple types (eg Match Server Attributes) // fall through case 'auxiliary': // XXX add list as in match? @@ -202,8 +203,49 @@ public function calculateAutoViewVars(int|null $coId, Object $obj = null): \Gene $generatedValue = $table->getParents($coId); break; case 'plugin': - $PluginTable = TableRegistry::getTableLocator()->get('Plugins'); - $generatedValue = $PluginTable->getActivePluginModels($avv['pluginType']); + if(!empty($avv['pluginType'])) { + // Return the list of Entry Point Models implementing the specified Plugin Type + // (eg: "source") + $PluginTable = TableRegistry::getTableLocator()->get('Plugins'); + $generatedValue = $PluginTable->getActivePluginModels($avv['pluginType']); + } elseif(!empty($avv['model'])) { + // Return the list of instantiations of the specified Entry Point Model + // (eg: "ApiConnector.ApiSources") + $ModelTable = TableRegistry::getTableLocator()->get($avv['model']); + + // We need to find the parent ("pluggable") model, but there actually isn't a way + // for a Plugin to explicitly say what it's pluggable model parent is. (The reverse + // is possible via the PluggableModel Trait.) Instead, we walk the model's primary + // links until we find one that implements the Pluggable functions, at which point + // it's safe to assume that's the correct parent model since Entry Point Models + // can't implement two pluggable interfaces. + $primaryLinks = $ModelTable->getPrimaryLinks(); + + foreach($primaryLinks as $l) { + $PluggableTable = TableRegistry::getTableLocator()->get(StringUtilities::foreignKeyToClassName($l)); + + if(method_exists($PluggableTable, "getPluggableModelType")) { + // This is the correct primary link. Note we don't necessarily know how to + // filter inactive records since the only column PluggableTrait requires is + // "plugin". + + // The entity field holding the related model + $modelKey = StringUtilities::pluginToEntityField($avv['model']); + + // For now we don't filter on status because not all Pluggable models + // use it consistency. (Specifically, EISs use SyncModeEnum instead.) + $generatedValue = $PluggableTable->find('list', [ + 'keyField' => $modelKey.'.id', + 'valueField' => 'description' + ]) + ->where(['plugin' => $avv['model']]) + ->contain(StringUtilities::pluginModel($avv['model'])) + ->all(); + + break; + } + } + } break; default: // XXX I18n? and in match? diff --git a/app/src/Lib/Traits/TableMetaTrait.php b/app/src/Lib/Traits/TableMetaTrait.php index 671503ea1..a91aa4eac 100644 --- a/app/src/Lib/Traits/TableMetaTrait.php +++ b/app/src/Lib/Traits/TableMetaTrait.php @@ -124,12 +124,12 @@ protected function filterMetadataForCopy( // HasMany foreach($entity->$m as $s) { - $ret[$m][] = $this->filterMetadataForDuplicate($t, $s); + $ret[$m][] = $this->filterMetadataForCopy($t, $s); } } elseif(!empty($entity->$m1)) { // HasOne - $ret[$m1] = $this->filterMetadataForDuplicate($t, $entity->$m1); + $ret[$m1] = $this->filterMetadataForCopy($t, $entity->$m1); } } elseif(is_array($v)) { // $k is the model name (EnrollmentFlowSteps) and $v is an array of related models @@ -149,12 +149,12 @@ protected function filterMetadataForCopy( // HasMany foreach($entity->$m as $s) { - $ret[$m][] = $this->filterMetadataForDuplicate($t, $s, $v); + $ret[$m][] = $this->filterMetadataForCopy($t, $s, $v); } } elseif(!empty($entity->$m1)) { // HasOne - $ret[$m1] = $this->filterMetadataForDuplicate($t, $entity->$m1, $v); + $ret[$m1] = $this->filterMetadataForCopy($t, $entity->$m1, $v); } } } diff --git a/app/src/Lib/Util/TableUtilities.php b/app/src/Lib/Util/TableUtilities.php index c97680cc6..599e8e274 100644 --- a/app/src/Lib/Util/TableUtilities.php +++ b/app/src/Lib/Util/TableUtilities.php @@ -99,26 +99,43 @@ public static function treeTraversalFromPrimaryLink( // Get a table reference $ModelTable = TableRegistry::getTableLocator()->get($primaryLinkModelName); - // Get the Record from the database - $resp = $ModelTable->find() - ->where(['id' => $primaryLinkValue]) - ->first() - ->toArray(); - - // Find all the foreign keys and fetch the rest of the tree - foreach($resp as $col => $val) { - if ( - $val !== null - && $col !== $primaryLinkKey - && str_ends_with($col, '_id') - ) { - $fkModel = StringUtilities::foreignKeyToClassName(($col)); - $fk_table = Inflector::underscore($fkModel); - if (\in_array($fk_table, $listOfTables, true)) { - self::treeTraversalFromPrimaryLink($col, $val, $results); + + try { + // Get the Record from the database + $resp = $ModelTable->find() + ->where(['id' => $primaryLinkValue]) + ->firstOrFail() + ->toArray(); + + // Find all the foreign keys and fetch the rest of the tree + foreach($resp as $col => $val) { + if ( + $val !== null + && $col !== $primaryLinkKey + && str_ends_with($col, '_id') + ) { + $fkModel = StringUtilities::foreignKeyToClassName(($col)); + $fk_table = Inflector::underscore($fkModel); + if (\in_array($fk_table, $listOfTables, true)) { + self::treeTraversalFromPrimaryLink($col, $val, $results); + } } } } + catch(\Exception $e) { + // Because this code is trying to get the parent table from the foreign key name, + // it doesn't work for foreign keys to plugin provided tables. (For example, when + // trying to view an External Identity Source connected to a Pipeline that uses an + // External Match Strategy configured for a Match Server), the edit view of the EIS + // no longer renders because at some point we run into a foreign key of match_server_id + // and there's no way to resolve that to CoreServer::MatchServersTable since the + // database doesn't know what plugin provides a table. + + // Note that $ModelTable _is_ created in this case, because Cake by default creates + // a stub model if it can't find the corresponding model definition, so what actually + // fails is the call to first(), and then the chain to toArray(). As a workaround, + // we use firstOrFail() instead to cause an exception to be thrown, and then we ignore it. + } } /** diff --git a/app/src/Model/Entity/Address.php b/app/src/Model/Entity/Address.php index f58bc0d80..387ffca52 100644 --- a/app/src/Model/Entity/Address.php +++ b/app/src/Model/Entity/Address.php @@ -41,4 +41,32 @@ class Address extends Entity { 'id' => false, 'slug' => false, ]; + + /** + * Generate a formatted address. + * + * @since COmanage Registry v5.2.0 + * @return string Formatted address + * @todo This is an incredibly simplistic initial implementation that is not locale aware + */ + + protected function _getFormattedAddress(): string { + $a = ""; + + foreach([ + 'street', + 'room', + 'locality', + 'state', + 'postal_code', + 'country' + ] as $f) { + if(!empty($this->$f)) { + if($a != "") { $a .= ", "; } + $a .= $this->$f; + } + } + + return $a; + } } \ No newline at end of file diff --git a/app/src/Model/Entity/Api.php b/app/src/Model/Entity/Api.php new file mode 100644 index 000000000..f453372b2 --- /dev/null +++ b/app/src/Model/Entity/Api.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/TelephoneNumber.php b/app/src/Model/Entity/TelephoneNumber.php index 305cf34b6..0b068c11b 100644 --- a/app/src/Model/Entity/TelephoneNumber.php +++ b/app/src/Model/Entity/TelephoneNumber.php @@ -49,7 +49,7 @@ class TelephoneNumber extends Entity { * @return string Formatted telephone number */ - protected function _getFormattedNumber() { + protected function _getFormattedNumber(): string { // Start with number since it's always required, then prepend and/or append $n = $this->number; diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index 72cde2371..d9341a2cf 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -97,7 +97,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); $this->setRequiresCO(true); - // Models that AcceptCoId should be expicitly added to StandardApiController::initialize() + // Models that AcceptCoId should be explicitly added to AppController::beforeFilter() $this->setAcceptsCoId(true); $this->setRedirectGoal('self'); $this->setRedirectGoal(action: 'delete', goal: 'deleted'); diff --git a/app/src/Model/Table/ApiUsersTable.php b/app/src/Model/Table/ApiUsersTable.php index 169a5b17b..34840f236 100644 --- a/app/src/Model/Table/ApiUsersTable.php +++ b/app/src/Model/Table/ApiUsersTable.php @@ -66,6 +66,8 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); + + $this->hasMany('Apis'); $this->setDisplayField('username'); @@ -92,6 +94,11 @@ public function initialize(array $config): void { 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], 'index' => ['platformAdmin', 'coAdmin'] + ], + 'related' => [ + 'table' => [ + 'AuthenticationEvents' + ] ] ]); } @@ -193,11 +200,11 @@ public function getUserPrivilege(string $username) { * @param string $username API Username * @param string $apiKey API Key to validate * @param string $remoteIp IP Address of request - * @return boolean true if the API Key validates + * @return int The CO ID for the API User (on success) * @throws InvalidArgumentException */ - public function validateKey(string $username, string $apiKey, string $remoteIp) { + public function validateKey(string $username, string $apiKey, string $remoteIp): int { // First pull the ApiUser record for $username. Note we don't know which // CO we're querying for, so $username requires the CO name as a prefix // (except for legacy usernames, which are assumed to be part of the @@ -261,7 +268,7 @@ public function validateKey(string $username, string $apiKey, string $remoteIp) throw new \InvalidArgumentException(__d('error', 'auth.api.ip', [$remoteIp, $username])); } - return true; + return $apiUser->co_id; } /** diff --git a/app/src/Model/Table/ApisTable.php b/app/src/Model/Table/ApisTable.php new file mode 100644 index 000000000..740b9d258 --- /dev/null +++ b/app/src/Model/Table/ApisTable.php @@ -0,0 +1,138 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ApiUsers'); + $this->belongsTo('Cos'); + + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'apiUsers' => [ + 'type' => 'select', + 'model' => 'ApiUsers' + ], + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'api' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * 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('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', true); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + $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/src/Model/Table/AuthenticationEventsTable.php b/app/src/Model/Table/AuthenticationEventsTable.php index f74a64cc6..8b1e945fc 100644 --- a/app/src/Model/Table/AuthenticationEventsTable.php +++ b/app/src/Model/Table/AuthenticationEventsTable.php @@ -73,7 +73,7 @@ public function initialize(array $config): void { $this->setRequiresCO(false); $this->setAutoViewVars([ - 'authentication_events' => [ + 'authenticationEvents' => [ 'type' => 'enum', 'class' => 'AuthenticationEventEnum' ] @@ -95,8 +95,8 @@ public function initialize(array $config): void { // We set $manages = true here because we need to return index // permission for related models calculation in identifiers?person_id=X // (so the link to Authentication Events renders). However, in - // setIndexFilter below we will reject requests without a $targetIdentifier - // which effectively denies such requests. + // getIndexFilter below we will reject requests without a $targetIdentifier + // for non-platform admins. $manages = true; } @@ -113,20 +113,6 @@ public function initialize(array $config): void { ] ]; }); - - $this->setIndexFilter(function (\Cake\Http\ServerRequest $r): array { - // This will be checked for authz in RegistryAuthComponent - $targetIdentifier = $r->getQuery('authenticated_identifier'); - - // Note that in setPermissions above we permit index operations when no - // targetIdentifier is specified. We reject that here though since index - // views require a targetIdentifier. - if(!$targetIdentifier) { - throw new \InvalidArgumentException(__d('error', 'input.notprov', 'authenticated_identifier')); - } - - return ['authenticated_identifier' => StringUtilities::urlbase64decode($targetIdentifier)]; - }); } /** diff --git a/app/src/Model/Table/CoSettingsTable.php b/app/src/Model/Table/CoSettingsTable.php index 33884a2f5..875da642e 100644 --- a/app/src/Model/Table/CoSettingsTable.php +++ b/app/src/Model/Table/CoSettingsTable.php @@ -230,6 +230,7 @@ public function addDefaults(int $coId): int { 'default_pronoun_type_id' => null, 'default_telephone_number_type_id' => null, 'default_url_type_id' => null, + 'authn_events_api_disable' => false, 'email_smtp_server_id' => null, 'email_delivery_address_type_id' => null, 'permitted_fields_name' => PermittedNameFieldsEnum::HGMFS, @@ -391,6 +392,11 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('default_url_type_id'); + $validator->add('authn_events_api_disable', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('authn_events_api_disable'); + $validator->add('email_delivery_address_type_id', [ 'content' => ['rule' => 'isInteger'] ]); diff --git a/app/src/Model/Table/ExternalIdentitySourcesTable.php b/app/src/Model/Table/ExternalIdentitySourcesTable.php index 8eef5d03b..06acd84bf 100644 --- a/app/src/Model/Table/ExternalIdentitySourcesTable.php +++ b/app/src/Model/Table/ExternalIdentitySourcesTable.php @@ -343,12 +343,13 @@ public function searchableAttributes(int $id) { * Sync an External Identity from a Source to a Person via a Pipeline. * * @since COmanage Registry v5.0.0 - * @param int $id External Identity Source ID - * @param string $sourceKey EIS Backend Source Key - * @param bool $force Whether to force the full Pipeline to run even if the backend record didn't change - * @param bool $syncOnly Whether to skip identifier assignment and provisioning - * @param int $personId If set, request the Pipeline to link to this Person (for create operations only) - * @return string Record status (new, unchanged, unknown, updated) + * @param int $id External Identity Source ID + * @param string $sourceKey EIS Backend Source Key + * @param bool $force Whether to force the full Pipeline to run even if the backend record didn't change + * @param bool $syncOnly Whether to skip identifier assignment and provisioning + * @param int $personId If set, request the Pipeline to link to this Person (for create operations only) + * @param string $referenceId Reference ID to assign to the provided EIS record + * @return string Record status (new, unchanged, unknown, updated) */ public function sync( @@ -356,7 +357,8 @@ public function sync( string $sourceKey, bool $force=true, bool $syncOnly=false, - ?int $personId=null + ?int $personId=null, + ?string $referenceId=null ): string { // All work is actually handled by the Pipeline, but we need our configuration // to know which Pipeline. @@ -373,7 +375,9 @@ public function sync( force: $force, personId: $personId, // Do we want the Pipeline to assign identifiers and provision? - syncOnly: $syncOnly + syncOnly: $syncOnly, + // Reference ID should only be provided when handling Match Callback requests + referenceId: $referenceId ); } diff --git a/app/src/Model/Table/JobsTable.php b/app/src/Model/Table/JobsTable.php index 5a74f03fd..7e23e3cb2 100644 --- a/app/src/Model/Table/JobsTable.php +++ b/app/src/Model/Table/JobsTable.php @@ -634,8 +634,14 @@ protected function validateJobParameters(string $plugin, int $coId, array $param // or it doesn't. $className = StringUtilities::foreignKeyToClassName($p); $Table = TableRegistry::getTableLocator()->get($className); - - $vals = explode(',', $val); + + $vals = []; + + if(is_int($val)) { + $vals = [$val]; + } else { + $vals = explode(',', $val); + } foreach($vals as $v) { $entity = $Table->get($val); diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index 5f2b22acb..7779d2165 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -101,7 +101,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'person_id']); $this->setAllowLookupPrimaryLink(['primary', 'unfreeze']); $this->setRequiresCO(true); - // Models that AcceptCoId should be explicitly added to StandardApiController::initialize() + // Models that AcceptCoId should be explicitly added to AppController::beforeFilter() $this->setAcceptsCoId(true); $this->setRedirectGoal('self'); $this->setRedirectGoal(action: 'delete', goal: 'deleted'); diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php index e844a5648..7a95f9839 100644 --- a/app/src/Model/Table/PipelinesTable.php +++ b/app/src/Model/Table/PipelinesTable.php @@ -125,6 +125,11 @@ public function initialize(array $config): void { 'type' => 'type', 'attribute' => 'Identifiers.type' ], + 'matchServers' => [ + 'type' => 'select', + 'model' => 'Servers', + 'where' => ['plugin' => 'CoreServer.MatchServers'] + ], 'matchStrategies' => [ 'type' => 'enum', 'class' => 'MatchStrategyEnum' @@ -522,27 +527,28 @@ protected function duplicateFilterEntityData($entity): array { return array_filter($newdata, 'is_scalar'); } - /** - * Execute the specified Pipeline on the provided EIS data. - * - * @param int $id Pipeline ID - * @param int $eisId Exxternal Identity Source ID - * @param array $eisBackendRecord Record returned by EIS Backend - * @param bool $force Force the Pipeline to run all steps, even if no changes were detected - * @param int|null $personId If set, for create operations only use this as the target Person ID - * @param bool $syncOnly If true, do not run Finalize steps - * @return string Record status (new, unchanged, unknown, updated) - * @throws \Exception - * @since COmanage Registry v5.0.0 - */ + /** + * Execute the specified Pipeline on the provided EIS data. + * + * @since COmanage Registry v5.0.0 + * @param int $id Pipeline ID + * @param int $eisId Exxternal Identity Source ID + * @param array $eisBackendRecord Record returned by EIS Backend + * @param bool $force Force the Pipeline to run all steps, even if no changes were detected + * @param int $personId If set, for create operations only use this as the target Person ID + * @param bool $syncOnly If true, do not run Finalize steps + * @param string $referenceId Reference ID to assign to the provided EIS record + * @return string Record status (new, unchanged, unknown, updated) + */ public function execute( - int $id, - int $eisId, - array $eisBackendRecord, - bool $force=false, - ?int $personId=null, - bool $syncOnly=false + int $id, + int $eisId, + array $eisBackendRecord, + bool $force=false, + ?int $personId=null, + bool $syncOnly=false, + ?string $referenceId=null ): string { // We broadly split the Pipeline into two parts, "Sync" and "Finalize". // This is to support being called from within an Enrollment Flow to connect @@ -589,12 +595,17 @@ public function execute( // (2) Match against an existing Person or create a new Person, in // accordance with the Pipeline's Match Strategy + + // Do we already have a Reference ID for this EIS Record? + $origReferenceId = $eisRecord['record']->reference_identifier; + $personInfo = $this->obtainPerson( $pipeline, $eis, $eisRecord['record'], $eisBackendRecord['entity_data'], - $personId + $personId, + $referenceId ); $person = $personInfo['person']; @@ -618,6 +629,36 @@ public function execute( $eisBackendRecord['entity_data'] ); + $newReferenceId = $eisRecord['record']->reference_identifier; + + // Perform some record keeping if a new Reference Identifier was assigned + + if(!empty($newReferenceId) && ($newReferenceId !== $origReferenceId)) { + // Attach the Reference Identifier to the Person. Where we're creating a + // new Person, there won't be much else on the Person record at this point, + // but that will change quickly at step (4). + + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + $rid = $Identifiers->newEntity([ + 'identifier' => $newReferenceId, + 'person_id' => $person->id, + 'type_id' => $Identifiers->Types->getTypeId($eis->co_id, 'Identifiers.type', 'reference'), + 'login' => false, + 'status' => SuspendableStatusEnum::Active + ]); + + $Identifiers->saveOrFail($rid); + + // We can now also record Reference ID history + + $this->Cos->People->ExternalIdentities->recordHistory( + entity: $externalIdentity, + action: ActionEnum::ReferenceIdentifierObtained, + comment: __d('result', 'Pipelines.matched.external', [$newReferenceId]) + ); + } + // If the Person record was matched or requested (meaning it isn't new) create a // History Record here, now that we have an External Identity @@ -705,17 +746,17 @@ public function execute( } } - /** - * Pipeline step to create or update the External Identity Source Record. - * - * @param Pipeline $pipeline Pipeline - * @param ExternalIdentitySource $eis External Identity Source - * @param string $sourceKey Source Key - * @param ?string $sourceRecord Source Record - * @param ?int $personId Person ID - * @return array ExtIdentitySourceRecord and change status - * @since COmanage Registry v5.0.0 - */ + /** + * Pipeline step to create or update the External Identity Source Record. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentitySource $eis External Identity Source + * @param string $sourceKey Source Key + * @param string $sourceRecord Source Record + * @param int $personId Person ID (XXX ???) + * @return array ExtIdentitySourceRecord and change status + */ protected function manageEISRecord( Pipeline $pipeline, @@ -1016,8 +1057,9 @@ protected function mapIdentifier(int $typeId, string $identifier): ?int { * @param Pipeline $pipeline Pipeline * @param ExternalIdentitySource $eis External Identity Source * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record - * @param array|null $eisAttributes Attributes provided by EIS Backend - * @param int|null $personId For create operations, use this as the target Person ID, if set + * @param array $eisAttributes Attributes provided by EIS Backend + * @param int $personId For create operations, use this as the target Person ID, if set + * @param string $referenceId For create operations, Reference ID to assign * @return array 'person': Person object * 'status': 'linked', 'created', 'matched', 'requested' * 'strategy': If status = 'matched', the MatchStrategy @@ -1026,9 +1068,11 @@ protected function mapIdentifier(int $typeId, string $identifier): ?int { protected function obtainPerson( Pipeline $pipeline, ExternalIdentitySource $eis, - ExtIdentitySourceRecord $eisRecord, - ?array $eisAttributes = null, - ?int $personId = null + // We pass by reference so searchByApi can update reference_identifier + ExtIdentitySourceRecord &$eisRecord, + ?array $eisAttributes, + ?int $personId=null, + ?string $referenceId=null ): array { // Shorthand... $sourceKey = $eisRecord->source_key; @@ -1038,6 +1082,41 @@ protected function obtainPerson( if(!empty($eisRecord->external_identity_id)) { $this->llog('trace', "Using previously linked Person " . $eisRecord->external_identity->person->id . " for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + + if($pipeline->match_strategy == MatchStrategyEnum::External + && !empty($eisRecord->reference_identifier) + && !empty($eisAttributes)) { + // If we have a Person already and we are using an External match strategy, + // and there is a Reference Identifier in the EIS record, issue an update + // match attributes request. Note we might not actually be updaing any relevant + // attribtues, but for most use cases this should be an inexpensive enough + // operation that we don't need to optimize it just yet. + + // We check for a Reference Identifier because if we don't have one we don't + // know enough about the state of the Match server, and it's not clear that + // we should send the Update Match Attributes Request (which might be + // interpreted as a New Match Request instead). We don't actually need to + // use it in the request, though, since the Match Server uses SOR Label + + // SOR ID (Source Key) to identify our request. + + // Hand off to MatchServer to process the request + + $MatchServers = TableRegistry::getTableLocator()->get('CoreServer.MatchServers'); + + $MatchServers->updateMatchAttributes( + serverId: $pipeline->match_server_id, + sorLabel: $eis->sor_label, + sorId: $eisRecord->source_key, + attributes: $eisAttributes + ); + + $this->Cos->People->ExternalIdentities->recordHistory( + entity: $eisRecord->external_identity, + action: ActionEnum::MatchAttributesUpdated, + comment: __d('result', 'Pipelines.updated.external') + ); + } + return [ 'person' => $eisRecord->external_identity->person, 'status' => 'linked' @@ -1047,7 +1126,7 @@ protected function obtainPerson( if(empty($eisAttributes)) { // We shouldn't get here since attributes should only be null on a delete, // which should only happen for previously processed records. - throw new \RuntimeException('$eisAttributes unexpectedly empty in Pipeline::obtainPerson'); + throw new \RuntimeException('$eisAttributes unexpectedly empty in Pipeline::obtainPerson (invalid source key?)'); } // If there was a Person ID provided in the function call, use that @@ -1085,8 +1164,36 @@ protected function obtainPerson( ); break; case MatchStrategyEnum::External: -// XXX If we get a reference ID, attach it to the $eisRecord here CFM-33 - throw new \RuntimeException('NOT IMPLEMENTED'); + if($referenceId) { + // The reference ID was provided by the Match Callback API, and we are + // reprocessing the record. Use the asserted ID instead of calling the API again + // (though calling the API again should result in the same reference ID). + $this->llog('trace', "Linking provided Reference ID " . $referenceId . " for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + + // We need to look up the Reference ID type ID to pass to searchByAttribute + // so it can convert it back to a label. Not the most efficient method, but + // this should be a relatively infrequent operation. + $Types = TableRegistry::getTableLocator()->get('Types'); + + $person = $this->searchByAttribute( + $eis, + $eisRecord, + MatchStrategyEnum::Identifier, + $Types->getTypeId( + coId: $eis->co_id, + attribute: 'Identifiers.type', + value: 'reference' + ), + ['identifiers' => [0 => ['type' => 'reference', 'identifier' => $referenceId]]] + ); + } else { + $person = $this->searchByApi( + $eis, + $eisRecord, + $pipeline->match_server_id, + $eisAttributes + ); + } break; case MatchStrategyEnum::NoMatching: // No matching configured, so just fall through and create a new Person @@ -1249,6 +1356,110 @@ public function relink( } } + /** + * Search for an existing Person using the ID Match API. + * + * @since COmanage Registry v5.2.0 + * @param ExternalIdentitySource $eis External Identity Source + * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record + * @param int $serverId Server ID (NOT Match Server ID) + * @param array $attributes Attributes to use for searching + * @return Person Person if found, null otherwise + * @throws InvalidArgumentException + */ + + protected function searchByApi( + ExternalIdentitySource $eis, + ExtIdentitySourceRecord &$eisRecord, + int $serverId, + array $attributes + ): ?Person { + // By the time the Pipeline is called, $attributes (while an array) should be + // normalized to the Registry data model (though we haven't yet called + // mapAttributesToCO). + + // Hand off to MatchServer to process the request + + $MatchServers = TableRegistry::getTableLocator()->get('CoreServer.MatchServers'); + + $referenceId = $MatchServers->requestReferenceIdentifier( + serverId: $serverId, + sorLabel: $eis->sor_label, + sorId: $eisRecord->source_key, + attributes: $attributes + ); + + // On error, including 202, an exception is thrown and we don't continue. + // If we get a Reference ID back, look for an existing CO Person with it. + + if(is_array($referenceId)) { + // We received a 300 response, which is not supported in a Pipeline context + // and probably means the admin misconfigured the Match Server. + + throw new \RuntimeException('error', 'Pipelines.match.external.response'); + } + + if(empty($referenceId)) { + // We shouldn't get here with an empty Reference ID, but check just in case + + throw new \RuntimeException('error', 'Pipelines.match.external.empty'); + } + + // Note we can't record history here because we don't necessarily have an + // External Identity yet (let alone a Person). (We _could_ record history if + // we find an existing Person with the Reference Identifier attached.) + + // Attach the Reference ID to the EIS Record. Note because we're working with + // a passed by reference entity, the calling function should be able to see + // it without having to pull an updated record from the database. + + $EISRecords = TableRegistry::getTableLocator()->get('ExtIdentitySourceRecords'); + + $eisRecord->reference_identifier = $referenceId; + + $EISRecords->save($eisRecord); + + // Look for Identifiers associated with People in the current CO. + // We do _not_ filter on Person status -- if a Person is Suspended but has + // the current Reference Identifier we still want to link to that Person. + // We _do_ filter on Identifier status -- if an Identifier is no longer + // Active it may be on the record for historical reasons. + + $Identifiers = TableRegistry::getTableLocator()->get("Identifiers"); + + $matches = $Identifiers->find() + // Select DISTINCT to avoid issues with the same Identifier + // being attached to the same Person multiple times due to + // coming from multiple SORs + ->distinct('Identifiers.person_id') + ->where([ + 'Identifiers.identifier' => $referenceId, + 'Identifiers.status' => SuspendableStatusEnum::Active, + 'Identifiers.person_id IS NOT NULL', + 'People.co_id' => $eis->co_id + ]) + ->contain(['People' => 'PrimaryName']) // XXX do we need PrimaryName? + ->all(); + + // We find all(), but really we should get back 0 or 1 records. + + if($matches->count() == 1) { + $person = $matches->first(); + + $this->llog('trace', "Mapped Reference ID $referenceId to Person " . $person->id); + + return $person; + } elseif($matches->count() == 0) { + $this->llog('trace', "No existing Person record found for Reference ID $referenceId"); + // No match + } else { + // This is an error, we shouldn't have more than 1 matching Person + throw new \InvalidArgumentException('Pipelines.match.multiple', [$referenceId]); + } + + return null; + } + /** * Search for an existing Person using an attribute provided in the EIS Record. * @@ -1762,7 +1973,16 @@ protected function syncPerson( // a Person object here (it would have been created by obtainPerson if there // wasn't one at the start of the process). - // Start with the directly related models + // First handle attributes stored directly on the Person, which currently consists + // solely of date_of_birth. + + if(!empty($externalIdentity->date_of_birth) || !empty($person->date_of_birth)) { + $person->date_of_birth = $externalIdentity->date_of_birth; + + $this->Cos->People->saveOrFail($person, ['associated' => false]); + } + + // Next proceed with the directly related models foreach([ 'Addresses', diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index fb73d950c..63ce7f92d 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -96,7 +96,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); $this->setRequiresCO(true); - // Models that AcceptCoId should be expicitly added to StandardApiController::initialize() + // Models that AcceptCoId should be explicitly added to AppController::beforeFilter() $this->setAcceptsCoId(true); $this->setRedirectGoal('self'); $this->setRedirectGoal(action: 'delete', goal: 'deleted'); diff --git a/app/templates/Apis/columns.inc b/app/templates/Apis/columns.inc new file mode 100644 index 000000000..2909122e0 --- /dev/null +++ b/app/templates/Apis/columns.inc @@ -0,0 +1,63 @@ + [ + 'type' => 'link', + 'sortable' => true + ], + 'plugin' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'api_user_id' => [ + 'type' => 'fk', + 'sortable' => true + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum', + 'sortable' => true + ] +]; + +// $rowActions appear as row-level menu items in the index view gear icon +$rowActions = [ + [ + 'action' => 'configure', + 'label' => __d('operation', 'configure.plugin'), + 'icon' => 'electrical_services' + ] +]; + +/* +// When the $bulkActions variable exists in a columns.inc config, the "Bulk edit" switch will appear in the index. +$bulkActions = [ + // TODO: develop bulk actions. For now, use a placeholder. + 'delete' => true +]; +*/ \ No newline at end of file diff --git a/app/templates/Apis/fields.inc b/app/templates/Apis/fields.inc new file mode 100644 index 000000000..5fa07ba6c --- /dev/null +++ b/app/templates/Apis/fields.inc @@ -0,0 +1,51 @@ + +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field + ]]); + } + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'plugin', + 'labelIsTextOnly' => true + ] + ]); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'api_user_id' + ]]); +} diff --git a/app/templates/CoSettings/fields.inc b/app/templates/CoSettings/fields.inc index 562030eec..09b7b7d14 100644 --- a/app/templates/CoSettings/fields.inc +++ b/app/templates/CoSettings/fields.inc @@ -133,4 +133,9 @@ if($vv_action == 'edit') { 'arguments' => [ 'fieldName' => 'email_delivery_address_type_id' ]]); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'authn_events_api_disable' + ]]); } diff --git a/app/templates/Dashboards/configuration.php b/app/templates/Dashboards/configuration.php index 368b41b47..adfbef5a9 100644 --- a/app/templates/Dashboards/configuration.php +++ b/app/templates/Dashboards/configuration.php @@ -90,12 +90,17 @@ $iconClass = !empty($cfg['iconClass']) ? $cfg['iconClass'] : 'material-symbols'; $linkContent = '' . '' . $label . ''; + $linkUrl = [ + 'plugin' => null, + 'controller' => $cfg['controller'], + 'action' => $cfg['action'] + ]; + if(!isset($cfg['platform']) || !$cfg['platform']) { + $linkUrl['?']['co_id'] = $vv_cur_co->id; + } print $this->Html->link( $linkContent, - ['plugin' => null, - 'controller' => $cfg['controller'], - 'action' => $cfg['action'], - '?' => ['co_id' => $vv_cur_co->id]], + $linkUrl, ['escape' => false] ); ?> diff --git a/app/templates/Identifiers/columns.inc b/app/templates/Identifiers/columns.inc index 770654fb4..2bacb34bc 100644 --- a/app/templates/Identifiers/columns.inc +++ b/app/templates/Identifiers/columns.inc @@ -34,7 +34,7 @@ $indexColumns = [ ] ]; -$indexActions = [ +$rowActions = [ [ 'controller' => 'authentication_events', 'action' => 'index', diff --git a/app/templates/Identifiers/fields-nav.inc b/app/templates/Identifiers/fields-nav.inc new file mode 100644 index 000000000..59f806711 --- /dev/null +++ b/app/templates/Identifiers/fields-nav.inc @@ -0,0 +1,45 @@ +identifier) && $vv_obj->isLogin()) { + $topLinks = [ + [ + 'icon' => 'lock', + 'order' => 'Default', + 'label' => __d('controller', 'AuthenticationEvents', [99]), + 'link' => [ + 'controller' => 'authentication_events', + 'action' => 'index', + '?' => [ + 'authenticated_identifier' => + \App\Lib\Util\StringUtilities::urlbase64encode($vv_obj->identifier) + ] + ], + 'class' => '' + ] + ]; +} \ No newline at end of file diff --git a/app/templates/Pipelines/fields.inc b/app/templates/Pipelines/fields.inc index d4f1821ab..349767fb4 100644 --- a/app/templates/Pipelines/fields.inc +++ b/app/templates/Pipelines/fields.inc @@ -33,13 +33,29 @@ var strategy = document.getElementById('match-strategy').value; if(strategy == '') { - hideFields(['match-identifier-type-id'], isPageLoad); + hideFields([ + 'match-identifier-type-id', + 'match-server-id' + ], isPageLoad); showFields(['match-email-address-type-id'], isPageLoad); + } else if(strategy == '') { + hideFields([ + 'match-email-address-type-id', + 'match-identifier-type-id' + ], isPageLoad); + showFields(['match-server-id'], isPageLoad); } else if(strategy == '') { - hideFields(['match-email-address-type-id'], isPageLoad); + hideFields([ + 'match-email-address-type-id', + 'match-server-id' + ], isPageLoad); showFields(['match-identifier-type-id'], isPageLoad); } else { - hideFields(['match-email-address-type-id', 'match-identifier-type-id'], isPageLoad); + hideFields([ + 'match-email-address-type-id', + 'match-identifier-type-id', + 'match-server-id' + ], isPageLoad); } } @@ -75,6 +91,7 @@ if($vv_action == 'add' || $vv_action == 'edit') { foreach([ 'match_email_address_type_id', 'match_identifier_type_id', + 'match_server_id', // 'sync_on_update', // 'sync_on_delete', 'sync_status_on_delete', diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index 4819012d7..419d99486 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -317,7 +317,17 @@ '?' => [$tableFK => $entity->id] ]; } - + + if(!empty($a['query'])) { + $fn = $a['query']; + + if(!empty($actionUrl['?'])) { + $actionUrl['?'] = array_merge($actionUrl['?'], $fn($entity)); + } else { + $actionUrl['?'] = $fn($entity); + } + } + // Generate the link text and urls: if (!empty($a['confirm'])) { diff --git a/app/vendor/cakephp-plugins.php b/app/vendor/cakephp-plugins.php index 79406d9c3..2a207981b 100644 --- a/app/vendor/cakephp-plugins.php +++ b/app/vendor/cakephp-plugins.php @@ -5,6 +5,7 @@ 'plugins' => [ 'Bake' => $baseDir . '/vendor/cakephp/bake/', 'Cake/TwigView' => $baseDir . '/vendor/cakephp/twig-view/', + 'CoreApi' => $baseDir . '/plugins/CoreApi/', 'CoreAssigner' => $baseDir . '/plugins/CoreAssigner/', 'CoreEnroller' => $baseDir . '/plugins/CoreEnroller/', 'CoreJob' => $baseDir . '/plugins/CoreJob/', diff --git a/app/vendor/composer/autoload_psr4.php b/app/vendor/composer/autoload_psr4.php index 72b5df30c..2dbb2f4f2 100644 --- a/app/vendor/composer/autoload_psr4.php +++ b/app/vendor/composer/autoload_psr4.php @@ -77,6 +77,8 @@ 'CoreEnroller\\' => array($baseDir . '/plugins/CoreEnroller/src'), 'CoreAssigner\\Test\\' => array($baseDir . '/plugins/CoreAssigner/tests'), 'CoreAssigner\\' => array($baseDir . '/plugins/CoreAssigner/src'), + 'CoreApi\\Test\\' => array($baseDir . '/plugins/CoreApi/tests'), + 'CoreApi\\' => array($baseDir . '/plugins/CoreApi/src'), 'Composer\\XdebugHandler\\' => array($vendorDir . '/composer/xdebug-handler/src'), 'Composer\\Spdx\\' => array($vendorDir . '/composer/spdx-licenses/src'), 'Composer\\Semver\\' => array($vendorDir . '/composer/semver/src'), diff --git a/app/vendor/composer/autoload_static.php b/app/vendor/composer/autoload_static.php index 32a07e523..d5738fcc7 100644 --- a/app/vendor/composer/autoload_static.php +++ b/app/vendor/composer/autoload_static.php @@ -157,6 +157,8 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'CoreEnroller\\' => 13, 'CoreAssigner\\Test\\' => 18, 'CoreAssigner\\' => 13, + 'CoreApi\\Test\\' => 13, + 'CoreApi\\' => 8, 'Composer\\XdebugHandler\\' => 23, 'Composer\\Spdx\\' => 14, 'Composer\\Semver\\' => 16, @@ -474,6 +476,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e array ( 0 => __DIR__ . '/../..' . '/plugins/CoreAssigner/src', ), + 'CoreApi\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreApi/tests', + ), + 'CoreApi\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreApi/src', + ), 'Composer\\XdebugHandler\\' => array ( 0 => __DIR__ . '/..' . '/composer/xdebug-handler/src',