From f88d8a65faef68e056453f90686663bae601c880 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Sun, 31 Dec 2023 10:12:54 -0500 Subject: [PATCH] Initial implementation of ApiSource (CFM-115) --- app/availableplugins/ApiConnector/README.md | 11 + .../ApiConnector/composer.json | 24 + .../ApiConnector/config/routes.php | 77 +++ .../ApiConnector/phpunit.xml.dist | 30 ++ .../resources/locales/en_US/api_connector.po | 32 ++ .../resources/schemas/apisource-message.json | 312 ++++++++++++ .../ApiConnector/src/ApiConnectorPlugin.php | 94 ++++ .../src/Controller/ApiSourcesController.php | 66 +++ .../src/Controller/ApiV2Controller.php | 209 ++++++++ .../src/Controller/AppController.php | 10 + .../src/Model/Entity/ApiSource.php | 49 ++ .../src/Model/Entity/ApiSourceRecord.php | 49 ++ .../src/Model/Table/ApiSourceRecordsTable.php | 111 ++++ .../src/Model/Table/ApiSourcesTable.php | 473 ++++++++++++++++++ .../ApiConnector/src/config/plugin.json | 34 ++ .../templates/ApiSources/fields.inc | 36 ++ .../templates/ApiV2/json/delete.php | 30 ++ .../ApiConnector/templates/ApiV2/json/get.php | 32 ++ .../templates/ApiV2/json/upsert.php | 30 ++ .../ApiConnector/tests/bootstrap.php | 55 ++ .../ApiConnector/tests/schema.sql | 1 + .../ApiConnector/webroot/.gitkeep | 0 .../src/Model/Table/FileProvisionersTable.php | 2 +- .../src/Model/Table/FileSourcesTable.php | 7 +- .../src/Model/Table/SqlProvisionersTable.php | 2 +- app/composer.json | 10 +- app/config/schema/schema.json | 1 + app/resources/locales/en_US/defaultType.po | 2 +- app/src/Controller/PagesController.php | 2 +- app/src/Controller/StandardApiController.php | 65 +++ app/src/Model/Table/IdentifiersTable.php | 23 + app/src/Model/Table/PipelinesTable.php | 84 +++- .../ExternalIdentitySources/search.php | 6 +- app/vendor/composer/autoload_psr4.php | 2 + app/vendor/composer/autoload_static.php | 10 + 35 files changed, 1964 insertions(+), 17 deletions(-) create mode 100644 app/availableplugins/ApiConnector/README.md create mode 100644 app/availableplugins/ApiConnector/composer.json create mode 100644 app/availableplugins/ApiConnector/config/routes.php create mode 100644 app/availableplugins/ApiConnector/phpunit.xml.dist create mode 100644 app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po create mode 100644 app/availableplugins/ApiConnector/resources/schemas/apisource-message.json create mode 100644 app/availableplugins/ApiConnector/src/ApiConnectorPlugin.php create mode 100644 app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php create mode 100644 app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php create mode 100644 app/availableplugins/ApiConnector/src/Controller/AppController.php create mode 100644 app/availableplugins/ApiConnector/src/Model/Entity/ApiSource.php create mode 100644 app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php create mode 100644 app/availableplugins/ApiConnector/src/Model/Table/ApiSourceRecordsTable.php create mode 100644 app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php create mode 100644 app/availableplugins/ApiConnector/src/config/plugin.json create mode 100644 app/availableplugins/ApiConnector/templates/ApiSources/fields.inc create mode 100644 app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php create mode 100644 app/availableplugins/ApiConnector/templates/ApiV2/json/get.php create mode 100644 app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php create mode 100644 app/availableplugins/ApiConnector/tests/bootstrap.php create mode 100644 app/availableplugins/ApiConnector/tests/schema.sql create mode 100644 app/availableplugins/ApiConnector/webroot/.gitkeep create mode 100644 app/src/Controller/StandardApiController.php diff --git a/app/availableplugins/ApiConnector/README.md b/app/availableplugins/ApiConnector/README.md new file mode 100644 index 000000000..5c68d2808 --- /dev/null +++ b/app/availableplugins/ApiConnector/README.md @@ -0,0 +1,11 @@ +# ApiSource 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/api-source +``` diff --git a/app/availableplugins/ApiConnector/composer.json b/app/availableplugins/ApiConnector/composer.json new file mode 100644 index 000000000..98e70b8c4 --- /dev/null +++ b/app/availableplugins/ApiConnector/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/api-connector", + "description": "ApiConnector plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "ApiConnector\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ApiConnector\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/availableplugins/ApiConnector/config/routes.php b/app/availableplugins/ApiConnector/config/routes.php new file mode 100644 index 000000000..2f470cb73 --- /dev/null +++ b/app/availableplugins/ApiConnector/config/routes.php @@ -0,0 +1,77 @@ +scope('/api/apisource', 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->delete( + '/{id}/v2/sorPeople/{sorlabel}/{sorid}', + ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'delete'] + ) + ->setPass(['id', 'sorlabel', 'sorid']) + ->setPatterns(['id' => '[0-9]+']); + + $builder->get( + '/{id}/v2/sorPeople/{sorlabel}/{sorid}', + ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'get'] + ) + ->setPass(['id', 'sorlabel', 'sorid']) + ->setPatterns(['id' => '[0-9]+']); + + $builder->put( + '/{id}/v2/sorPeople/{sorlabel}/{sorid}', + ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'upsert'] + ) + ->setPass(['id', 'sorlabel', 'sorid']) + ->setPatterns(['id' => '[0-9]+']); +}); \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/phpunit.xml.dist b/app/availableplugins/ApiConnector/phpunit.xml.dist new file mode 100644 index 000000000..487e87af0 --- /dev/null +++ b/app/availableplugins/ApiConnector/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po b/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po new file mode 100644 index 000000000..eea0637bd --- /dev/null +++ b/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po @@ -0,0 +1,32 @@ +# COmanage Registry Localizations (api_connector domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.ApiSources" +#msgstr "{0,plural,=1{API Source} other{API Sources}}" + +msgid "field.ApiSources.push_mode" +msgstr "Push Mode" + +msgid "information.endpoint.push" +msgstr "The API endpoint for using this plugin in Push Mode is {0}" \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/resources/schemas/apisource-message.json b/app/availableplugins/ApiConnector/resources/schemas/apisource-message.json new file mode 100644 index 000000000..0089ef082 --- /dev/null +++ b/app/availableplugins/ApiConnector/resources/schemas/apisource-message.json @@ -0,0 +1,312 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.internet2.edu/COmanage/registry/tree/5.0.0/app/resources/schema/apisource-message.json", + "title": "COmanage Registry API Source Message Format", + "description": "COmanage Registry API Source Message Format", + + "$defs": { + "addresses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "country": { + "description": "The country for this address", + "type": "string" + }, + "language": { + "description": "The language encoding for this address", + "type": "string" + }, + "locality": { + "description": "The city or locality for this address", + "type": "string" + }, + "postalCode": { + "description": "The postal code for this address", + "type": "string" + }, + "region": { + "description": "The state, province, or region for this address", + "type": "string" + }, + "room": { + "description": "The room number for this address", + "type": "string" + }, + "streetAddress": { + "description": "The street for this address", + "type": "string" + }, + "type": { + "description": "The type of address", + "type": "string" + } + } + } + }, + "adhoc": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag": { + "description": "Ad Hoc attribute tag/label", + "type": "string" + }, + "value": { + "description": "Ad Hoc attribute value", + "type": "string" + } + } + }, + "required": [ "tag" ] + }, + "dateOfBirth": { + "description": "Date of Birth for the person associated with this identity", + "type": "string", + "format": "date" + }, + "emailAddresses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "mail": { + "description": "An email address for this identity", + "type": "string", + "format": "email" + }, + "type": { + "description": "The type of email address", + "type": "string" + }, + "verified": { + "description": "Whether this email address has been verified", + "type": "boolean" + } + } + }, + "required": [ "mail" ] + }, + "identifiers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "identifier": { + "description": "A identifier for the person", + "type": "string" + }, + "type": { + "description": "The type of identifier", + "type": "string" + } + }, + "required": [ "identifier" ] + } + }, + "names": { + "type": "array", + "items": { + "type": "object", + "properties": { + "family": { + "description": "The person's family or surname", + "type": "string" + }, + "given": { + "description": "The person's given or first name", + "type": "string" + }, + "language": { + "description": "The language encoding for this name", + "type": "string" + }, + "middle": { + "description": "The person's middle name", + "type": "string" + }, + "prefix": { + "description": "The honorific or prefix for the person's name", + "type": "string" + }, + "suffix": { + "description": "The suffix for this person's name", + "type": "string" + }, + "type": { + "description": "The type of name", + "type": "string" + } + }, + "required": [ "given" ] + } + }, + "telephoneNumbers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "description": "A telephone number for the person", + "type": "string", + "format": "itu-e164" + }, + "type": { + "description": "The type of telephone number", + "type": "string" + } + }, + "required": [ "number" ] + } + }, + "urls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { + "description": "A URL for the person", + "type": "string", + "format": "uri" + }, + "type": { + "description": "The type of URL", + "type": "string" + } + }, + "required": [ "url" ] + } + }, + "meta": { + "type": "object", + "properties": { + "id": { + "description": "COmanage identifier for this object", + "type": "string" + } + }, + "required": [ "id" ] + } + }, + + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "version": { + "const": "1.0.0" + }, + "objectType": { + "const": "externalIdentity" + } + }, + "required": [ "version", "objectType" ] + }, + "returnUrl": { + "description": "Petition Specific Redirect Target", + "type": "string", + "format": "uri" + }, + "sorAttributes": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/$defs/meta" + }, + "addresses": { + "$ref": "#/$defs/addresses" + }, + "adhoc": { + "$ref": "#/$defs/adhoc" + }, + "dateOfBirth": { + "$ref": "#/$defs/dateOfBirth" + }, + "emailAddresses": { + "$ref": "#/$defs/emailAddresses" + }, + "identifiers": { + "$ref": "#/$defs/identifiers" + }, + "names": { + "$ref": "#/$defs/names" + }, + "telephoneNumbers": { + "$ref": "#/$defs/telephoneNumbers" + }, + "urls": { + "$ref": "#/$defs/urls" + }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "addresses": { + "$ref": "#/$defs/addresses" + }, + "adhoc": { + "$ref": "#/$defs/adhoc" + }, + "affiliation": { + "description": "The affiliation for this role", + "type": "string" + }, + "department": { + "description": "The department for this role", + "type": "string" + }, + "managerIdentifier": { + "description": "An identifier for the Manager for this role", + "type": "string" + }, + "organization": { + "description": "The organization for this role", + "type": "string" + }, + "rank": { + "description": "The rank or order for this role, among all roles (lower number indicate higher priorities)", + "type": "integer" + }, + "roleIdentifier": { + "description": "A unique identifier for this role", + "type": "string" + }, + "sponsorIdentifier": { + "description": "An identifier for the Sponsor for this role", + "type": "string" + }, + "status": { + "description": "The status for this role", + "type": "string", + "enum": [ "A", "D", "D2", "GP", "S" ] + }, + "telephoneNumbers": { + "$ref": "#/$defs/telephoneNumbers" + }, + "title": { + "description": "The title for this role", + "type": "string" + }, + "validFrom": { + "description": "The time from which this role is valid", + "type": "string", + "format": "date-time" + }, + "validThrough": { + "description": "The time through which this role is valid", + "type": "string", + "format": "date-time" + } + } + }, + "required": [ "affiliation", "roleIdentifier", "status" ] + } + } + } + }, + "required": [ "sorAttributes" ] +} diff --git a/app/availableplugins/ApiConnector/src/ApiConnectorPlugin.php b/app/availableplugins/ApiConnector/src/ApiConnectorPlugin.php new file mode 100644 index 000000000..3f3ec29b8 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/ApiConnectorPlugin.php @@ -0,0 +1,94 @@ +plugin( + 'ApiConnector', + ['path' => '/api-connector'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php b/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php new file mode 100644 index 000000000..f003372ed --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php @@ -0,0 +1,66 @@ + [ + '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/ApiV2Controller.php new file mode 100644 index 000000000..d865f96c5 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php @@ -0,0 +1,209 @@ +request->getParam('id'); + + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ExternalIdentitySources']); + + return $cfg->external_identity_source->co_id ?? null; + } + + /** + * Calculate authorization for the current request. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the current request is permitted, false otherwise + */ + + public function calculatePermission(): bool { + $request = $this->getRequest(); + $action = $request->getParam('action'); + $authUser = $this->RegistryAuth->getAuthenticatedUser(); + + $authorized = false; + + // Our authorization is pretty straightforward, the configured API User + // is permitted to perform all actions. + + // This should be set or the route won't match + $apiSourceId = $this->request->getParam('id'); + + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ApiUsers']); + + if(!empty($cfg->api_user->username) + && !empty($authUser) + && $authUser == $cfg->api_user->username) { + $authorized = true; + } + + return $authorized; + } + + /** + * Handle an SOR Person Role Deleted request. + * + * @since COmanage Registry v5.0.0 + * @param string $id ApiSource ID + * @param string $sorlabel System of Record Label from request URL + * @param string $sorid System of Record ID from request URL + */ + + public function delete(string $id, string $sorlabel, string $sorid) { + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + $resultCode = 500; + $results = []; + + try { + $ApiSource->remove((int)$id, $sorlabel, $sorid); + + $resultCode = 200; + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + $resultCode = 404; + $results['error'] = $e->getMessage(); + } + catch(\Exception $e) { + $this->llog('debug', $e->getMessage()); + $results['error'] = $e->getMessage(); + + $resultCode = 500; + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } + + /** + * Handle a Get SOR Person Role request. + * + * @since COmanage Registry v5.0.0 + * @param string $id ApiSource ID + * @param string $sorlabel System of Record Label from request URL + * @param string $sorid System of Record ID from request URL + */ + + public function get(string $id, string $sorlabel, string $sorid) { + // We basically just pull the currently cached source record and return it. + + $ApiSourceRecord = TableRegistry::getTableLocator()->get('ApiConnector.ApiSourceRecords'); + + $results = []; + $resultCode = 500; + + try { + $record = $ApiSourceRecord->find() + ->where(['api_source_id' => $id, 'source_key' => $sorid]) + ->firstOrFail(); + + $resultCode = 200; + $results = json_decode($record->source_record); + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + $resultCode = 404; + $results['error'] = $e->getMessage(); + } + catch(\Exception $e) { + $results['error'] = $e->getMessage(); + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } + + /** + * Handle an SOR Person Role Added or Updated request. + * + * @since COmanage Registry v5.0.0 + * @param string $id ApiSource ID + * @param string $sorlabel System of Record Label from request URL + * @param string $sorid System of Record ID from request URL + */ + + public function upsert(string $id, string $sorlabel, string $sorid) { + // Pass the requested data to the Backend and return a response. +// XXX todo: add support for returnUrl back in + + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + $resultCode = 400; + $results = []; + + try { + $result = $ApiSource->upsert((int)$id, $sorlabel, $sorid, $this->request->getData()); + + if(isset($result['new']) && $result['new']) { + $resultCode = 201; + } else { + $resultCode = 200; + } + } + catch(\Exception $e) { + $this->llog('debug', $e->getMessage()); + $results['error'] = $e->getMessage(); + + $resultCode = 400; + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + // We always take over authz + return 'authz'; + } +} diff --git a/app/availableplugins/ApiConnector/src/Controller/AppController.php b/app/availableplugins/ApiConnector/src/Controller/AppController.php new file mode 100644 index 000000000..c11c4469c --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Controller/AppController.php @@ -0,0 +1,10 @@ + + */ + 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 new file mode 100644 index 000000000..763b797cc --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourceRecordsTable.php b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourceRecordsTable.php new file mode 100644 index 000000000..9013bb2ce --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourceRecordsTable.php @@ -0,0 +1,111 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('ApiConnector.ApiSources'); + //$this->belongsTo('ApiUsers'); + + $this->setDisplayField('source_key'); + + $this->setPrimaryLink(['ApiConnector.api_source_id']); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['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_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('api_source_id'); + + $this->registerStringValidation($validator, $schema, 'source_key', true); + + // We don't require any particular validation of source_record we because + // we don't want to accidentally throw errors + $validator->allowEmptyString('source_record'); + + 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 new file mode 100644 index 000000000..fd4b255e4 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php @@ -0,0 +1,473 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ExternalIdentitySources'); + $this->belongsTo('ApiUsers'); + + $this->hasMany('ApiConnector.ApiSourceRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $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) + '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'] + ] + ]); + } + + /** + * Map API field names to Registry data model names. + * + * @since COmanage Registry v5.0.0 + * @param string $model Model name (in API format) + * @param array $attributes Model attributes + * @return array Mapped model attributes + */ + + protected function mapApiToRegistry(string $model, array $attributes): array { + // All API attributes must be defined in the fieldMap, even if both names are the same + $fieldMap = [ + // sorAttributes = top level External Identity + 'sorAttributes' => [ + // Dates should correctly marshal to a DateTime object without us doing anything + 'dateOfBirth' => 'date_of_birth' + ], + // roles = External Identity Role + 'roles' => [ + 'roleIdentifier' => 'role_key', + 'affiliation' => 'affiliation', + 'department' => 'department', + 'managerIdentifier' => 'manager_identifier', + 'rank' => 'ordr', + 'organization' => 'organization', + 'sponsorIdentifier' => 'sponsor_identifier', + 'status' => 'status', + 'title' => 'title', + 'validFrom' => 'valid_from', + 'validThrough' => 'valid_through' + ], + // MVEAs + 'addresses' => [ + 'country' => 'country', + 'language' => 'language', + 'locality' => 'locality', + 'postalCode' => 'postal_code', + 'region' => 'state', + 'room' => 'room', + 'streetAddress' => 'street', + 'type' => 'type' + ], + 'adhoc' => [ + 'tag' => 'tag', + 'value' => 'value' + ], + 'emailAddresses' => [ + 'address' => 'mail', + 'type' => 'type', + 'verified' => 'verified' + ], + 'identifiers' => [ + 'identifier' => 'identifier', + 'type' => 'type' + ], + 'names' => [ + 'family' => 'family', + 'given' => 'given', + 'language' => 'language', + 'middle' => 'middle', + 'prefix' => 'honorific', + 'suffix' => 'suffix', + 'type' => 'type' + ], + 'telephoneNumbers' => [ + 'number' => 'number', + 'type' => 'type' + ], + 'urls' => [ + 'type' => 'type', + 'url' => 'url' + ] + ]; + + $ret = []; + + foreach($attributes as $attr => $value) { + if(isset($fieldMap[$model][$attr])) { + $ret[ $fieldMap[$model][$attr] ] = $value; + } + } + + return $ret; + } + + /** + * Remove a record from the External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param string $sorId API System of Record ID + * @return bool True on success + * @throws RecordNotFoundException + */ + + public function remove( + \App\Model\Entity\ExternalIdentitySource $source, + string $sorId + ): array { + // We call this remove() so as not to interfere with the default table::delete(). + + // Remove the ApiSourceRecord for this $source_key from the cache + + $apiSourceRecord = $this->ApiSourceRecords->find() + ->where([ + 'api_source_id' => $source->api_source->id, + 'source_key' => $sorId + ]) + ->firstOrFail(); + + $this->ApiSourceRecords->delete($apiSourceRecord); + + // Run sync +// XXX do we need some sort of return value to pass back in the API response? + $this->ExternalIdentitySources->sync($source->id, $sorId); + + return true; + } + + + /** + * Convert a record from the ApiSource message to a record suitable for + * construction of an Entity. + * + * @since COmanage Registry v5.0.0 + * @param array $result ApiSource message + * @return array Entity record (in array format) + */ + + protected function resultToEntityData(array $result): array { + // Convert the inbound message format to the Entity record array format. + // They're actually very similar, but we need to do some work, in particular + // around mapping field names. + + $eidata = []; + + // Start with single-value EI attributes + + $eidata = $this->mapApiToRegistry('sorAttributes', $result['sorAttributes']); + + // EI MVEAs, which can generally just be copied in place + + foreach([ + 'addresses' => 'addresses', + 'adhoc' => 'ad_hoc_attributes', + 'emailAddresses' => 'email_addresses', + 'identifiers' => 'identifiers', + 'names' => 'names', + 'telephoneNumbers' => 'telephone_numbers', + 'urls' => 'urls' + ] as $apiModel => $registryModel) { + if(!empty($result['sorAttributes'][$apiModel])) { + foreach($result['sorAttributes'][$apiModel] as $m) { + $eidata[$registryModel][] = $this->mapApiToRegistry($apiModel, $m); + } + } + } + + // EI Roles + + if(!empty($result['sorAttributes']['roles'])) { + foreach($result['sorAttributes']['roles'] as $roleData) { + if(!empty($roleData['roleIdentifier'])) { + // The top level role data + $eirdata = $this->mapApiToRegistry('roles', $roleData); + + // EIR MVEAs + + foreach([ + 'addresses' => 'addresses', + 'adhoc' => 'ad_hoc_attributes', + 'telephoneNumbers' => 'telephone_numbers', + 'urls' => 'urls' + ] as $apiModel => $registryModel) { + if(!empty($roleData[$apiModel])) { + foreach($roleData[$apiModel] as $m) { + $eirdata[$registryModel][] = $this->mapApiToRegistry($apiModel, $m); + } + } + } + + $eidata['external_identity_roles'][] = $eirdata; + } + } + } + + return $eidata; + } + + /** + * Retrieve a record from the External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param string $source_key Backend source key for requested record + * @return array Array of source_key, source_record, and entity_data + * @throws RecordNotFoundException + */ + + public function retrieve( + \App\Model\Entity\ExternalIdentitySource $source, + string $source_key + ): array { + $ret = [ + 'source_key' => $source_key + ]; + + // Pull the ApiSourceRecord for this $source_key from the cache + + $apiSourceRecord = $this->ApiSourceRecords->find() + ->where([ + 'api_source_id' => $source->api_source->id, + 'source_key' => $source_key + ]) + ->firstOrFail(); + + $ret['source_record'] = $apiSourceRecord->source_record; + $ret['entity_data'] = $this->resultToEntityData( + json_decode(json: $apiSourceRecord->source_record, associative: true) + ); + + return $ret; + } + + /** + * Search the External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param array $searchAttrs Array of search attributes and values, as configured by searchAttributes() + * @return array Array of matching records + * @throws InvalidArgumentException + */ + + public function search( + \App\Model\Entity\ExternalIdentitySource $source, + array $searchAttrs + ): array { + $ret = []; + + // We search the cache of existing records (push), but not (currently) a + // remote URL (pull). For now we only search on SORID, which matches v4 behavior. + + $records = $this->ApiSourceRecords->find() + ->where([ + 'api_source_id' => $source->api_source->id, + 'source_key' => $searchAttrs['q'] + ]) + ->all(); + + if(!empty($records)) { + foreach($records as $rec) { + $ret[ $rec->source_key ] = $this->resultToEntityData(json_decode($rec->source_record, true)); + } + } + + return $ret; + } + + /** + * Obtain the set of searchable attributes for this backend. + * + * @since COmanage Registry v5.0.0 + * @return array Array of searchable attributes and localized descriptions + */ + + public function searchableAttributes(): array { + // In v4 we aonly accepted SORID. For now, that's all we implement in search(), + // but we could enhance this to search the text of source_record as well. + + return [ + 'q' => __d('field', 'search.placeholder') + ]; + } + + /** + * Insert or update an ApiSource record and associated External Identity. + * + * @since COmanage Registry v5.0.0 + * @param int $id ApiSource ID + * @param string $sorLabel System of Record Label + * @param string $sorId System of Record ID + * @param array $attributes Attributes from message body + * @return array bool 'new': true if a new External Identity was created + * @throws InvalidArgumentException + */ + + public function upsert( + int $id, + string $sorLabel, + string $sorId, + array $attributes + ): array { + $ret = []; + + // Pull our configuration + $apiSource = $this->get($id, ['contain' => ['ExternalIdentitySources']]); + + // Strictly speaking we don't need $sorid since we know which configuration + // to use from the ApiSource ID, and $sorlabel might not be unique across COs + // in a multi-tenant environment. Eventually we could support multiple + // Systems of Record within the same ApiSource configuration, but for now + // we just make sure $sorLabel matches the configuration and throw an error + // if it doesn't. + + if(empty($apiSource->external_identity_source->sor_label) + || $apiSource->external_identity_source->sor_label != $sorLabel) { + throw new \InvalidArgumentException("Requested SOR Label $sorLabel does not match configuration"); + } + + // Create or Update the API Source Record + + // For consistency, we'll always make the source_record pretty (which + // should also make it slightly easier for an admin to look at it. + $sourceRecord = json_encode($attributes, JSON_PRETTY_PRINT); + + // Note we transition from "SOR ID" (TAP API terminology) to "Source Key" + // (Registry terminology) here + $apiSourceRecord = $this->ApiSourceRecords->find() + ->where([ + 'api_source_id' => $id, + 'source_key' => $sorId + ]) + ->first(); + + if(!empty($apiSourceRecord)) { + // Update + + $apiSourceRecord->source_record = $sourceRecord; + } else { + // Insert + + $apiSourceRecord = $this->ApiSourceRecords->newEntity([ + 'api_source_id' => $id, + 'source_key' => $sorId, + 'source_record' => $sourceRecord + ]); + + $ret['new'] = true; + } + + $this->ApiSourceRecords->saveOrFail($apiSourceRecord); + + // Note update of ApiSourceRecord doesn't necessarily imply update of + // an associated External Identity - it could be an insert. Regardless, + // ExternalIdentitySources::sync (really Pipelines::execute) will deal with it. + + $this->ExternalIdentitySources->sync($apiSource->external_identity_source_id, $sorId); + + return $ret; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('external_source_identity_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('external_source_identity_id'); + + $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 new file mode 100644 index 000000000..be96e7f42 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/config/plugin.json @@ -0,0 +1,34 @@ +{ + "types": { + "source": [ + "ApiSources" + ] + }, + "schema": { + "tables": { + "api_sources": { + "columns": { + "id": {}, + "external_identity_source_id": {}, + "api_user_id": {} + }, + "indexes": { + "api_sources_i1": { "columns": [ "external_identity_source_id" ] } + } + }, + "api_source_records": { + "columns": { + "id": {}, + "api_source_id": { "type": "integer", "foreignkey": { "table": "api_sources", "column": "id" } }, + "source_key": { "type": "string", "size": 1024 }, + "source_record": { "type": "text" } + }, + "indexes": { + "api_source_records_i1": { "columns": [ "api_source_id" ] }, + "api_source_records_i2": { "columns": [ "api_source_id", "source_key" ] } + }, + "changelog": false + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc b/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc new file mode 100644 index 000000000..26e766501 --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc @@ -0,0 +1,36 @@ + for section headers... + print "" . __d('api_connector', 'field.ApiSources.push_mode') . ""; + + print $this->Field->banner(__d('api_connector', 'information.endpoint.push', [$vv_push_endpoint])); + + print $this->Field->control('api_user_id'); +} diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php b/app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php new file mode 100644 index 000000000..ec7400ae0 --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php @@ -0,0 +1,30 @@ + $vv_results]); +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/get.php b/app/availableplugins/ApiConnector/templates/ApiV2/json/get.php new file mode 100644 index 000000000..e584fac54 --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiV2/json/get.php @@ -0,0 +1,32 @@ +error)) { + print json_encode(["results" => $vv_results]); +} else { + print json_encode($vv_results); +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php b/app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php new file mode 100644 index 000000000..143ca1280 --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php @@ -0,0 +1,30 @@ + $vv_results]); +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/tests/bootstrap.php b/app/availableplugins/ApiConnector/tests/bootstrap.php new file mode 100644 index 000000000..92fce1162 --- /dev/null +++ b/app/availableplugins/ApiConnector/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/availableplugins/ApiConnector/tests/schema.sql b/app/availableplugins/ApiConnector/tests/schema.sql new file mode 100644 index 000000000..d01d6f95e --- /dev/null +++ b/app/availableplugins/ApiConnector/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for ApiSource diff --git a/app/availableplugins/ApiConnector/webroot/.gitkeep b/app/availableplugins/ApiConnector/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php index d9ccb0bdc..5817a6d31 100644 --- a/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php +++ b/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php @@ -71,7 +71,7 @@ public function initialize(array $config): void { $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ - 'delete' => ['platformAdmin', 'coAdmin'], + 'delete' => false, // Delete the pluggable object instead 'edit' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php index c50aa1136..c170e3ebb 100644 --- a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php +++ b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php @@ -81,7 +81,7 @@ public function initialize(array $config): void { $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ - 'delete' => ['platformAdmin', 'coAdmin'], + 'delete' => false, // Delete the pluggable object instead 'edit' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], @@ -205,9 +205,8 @@ protected function readFieldConfig( */ protected function resultToEntityData(array $result): array { - // Build the External Identity as an array, then convert it to an entity. - // Unlike v4, backends need to insert the SORID (for consistency with the role ID) - $eidata = [ 'source_key' => $result[ $this->fieldCfg['SORID'] ] ]; + // Build the External Identity as an array + $eidata = []; // We copy whatever attributes the inbound file asserts for a given model, // leaving it to the validation rules to worry about correctness. diff --git a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php index 50f7b493f..fe01da0fb 100644 --- a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php +++ b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php @@ -263,7 +263,7 @@ public function initialize(array $config): void { $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ - 'delete' => ['platformAdmin', 'coAdmin'], + 'delete' => false, // Delete the pluggable object instead 'edit' => ['platformAdmin', 'coAdmin'], 'reapply' => ['platformAdmin', 'coAdmin'], 'resync' => ['platformAdmin', 'coAdmin'], diff --git a/app/composer.json b/app/composer.json index 913c590aa..29cb6562f 100644 --- a/app/composer.json +++ b/app/composer.json @@ -30,20 +30,22 @@ "autoload": { "psr-4": { "App\\": "src/", + "ApiConnector\\": "availableplugins/ApiConnector/src/", + "CoreAssigner\\": "plugins/CoreAssigner/src/", "CoreServer\\": "plugins/CoreServer/src/", "FileConnector\\": "availableplugins/FileConnector/src/", - "SqlConnector\\": "availableplugins/SqlConnector/src/", - "CoreAssigner\\": "plugins/CoreAssigner/src/" + "SqlConnector\\": "availableplugins/SqlConnector/src/" } }, "autoload-dev": { "psr-4": { "App\\Test\\": "tests/", + "ApiConnector\\Test\\": "availableplugins/ApiConnector/tests/", "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", + "CoreAssigner\\Test\\": "plugins/CoreAssigner/tests/", "CoreServer\\Test\\": "plugins/CoreServer/tests/", "FileConnector\\Test\\": "availableplugins/FileConnector/tests/", - "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/", - "CoreAssigner\\Test\\": "plugins/CoreAssigner/tests/" + "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/" } }, "scripts": { diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index bae9ccfdc..d3c5511af 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -9,6 +9,7 @@ "comment": "Columns with names matching those defined here will by default inherit these properties", "columns": { + "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 }, "context": { "type": "string", "size": 2 }, diff --git a/app/resources/locales/en_US/defaultType.po b/app/resources/locales/en_US/defaultType.po index 3cd270731..ee6498f0f 100644 --- a/app/resources/locales/en_US/defaultType.po +++ b/app/resources/locales/en_US/defaultType.po @@ -151,7 +151,7 @@ msgstr "Staff" msgid "PersonRoles.affiliation_type.student" msgstr "Student" -msgid "Pronouns.default" +msgid "Pronouns.type.default" msgstr "Default" msgid "TelephoneNumbers.type.campus" diff --git a/app/src/Controller/PagesController.php b/app/src/Controller/PagesController.php index 3f0d510cc..709348c60 100644 --- a/app/src/Controller/PagesController.php +++ b/app/src/Controller/PagesController.php @@ -96,7 +96,7 @@ public function display(...$path): ?Response * @return string "no", "open", "authz", or "yes" */ - function willHandleAuth(\Cake\Event\EventInterface $event): string { + public function willHandleAuth(\Cake\Event\EventInterface $event): string { return "open"; } } diff --git a/app/src/Controller/StandardApiController.php b/app/src/Controller/StandardApiController.php new file mode 100644 index 000000000..c3d039079 --- /dev/null +++ b/app/src/Controller/StandardApiController.php @@ -0,0 +1,65 @@ +get($m); + + $Table->setCurCoId($this->getCOID()); + } + + // We want API auth, not Web Auth + $this->RegistryAuth->setConfig('apiUser', true); + } +} \ No newline at end of file diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 56655a11b..169047526 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -178,6 +178,29 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } + /** + * Look up a Person ID from an identifier and identifier type ID. + * + * @since COmanage Registry v5.0.0 + * @param int $typeId Identifier Type ID + * @param string $identifier Identifier + * @return int Person ID + * @throws Cake\Datasource\Exception\RecordNotFoundException + */ + + public function lookupPerson(int $typeId, string $identifier): int { + $id = $this->find() + ->where([ + 'identifier' => $identifier, + 'type_id' => $typeId, + 'status' => SuspendableStatusEnum::Active, + 'person_id IS NOT NULL' + ]) + ->firstOrFail(); + + return $id->person_id; + } + /** * Perform a keyword search. * diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php index 0239fd466..8fa5e19ed 100644 --- a/app/src/Model/Table/PipelinesTable.php +++ b/app/src/Model/Table/PipelinesTable.php @@ -784,6 +784,26 @@ protected function mapAttributesToCO( return $ret; } + /** + * Map an Identifier of the configured type to a Person ID. + * + * @since COmanage Registry v5.0.0 + * @param int $typeId Identifier Type ID + * @param string $identifier Identifier + * @return int Person ID + */ + + protected function mapIdentifier(int $typeId, string $identifier): ?int { + try { + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + return $Identifiers->lookupPerson($typeId, $identifier); + } + catch(\Exception $e) { + return null; + } + } + /** * Pipeline step to obtain a Person associated with the $eisRecord, possibly * by executing the Match Strategy. @@ -873,9 +893,41 @@ protected function syncExternalIdentity( // We also need to add the Person ID $mapped['person_id'] = $person->id; - $entity = $this->Cos->People->ExternalIdentities->newEntity($mapped); + $entity = $this->Cos->People->ExternalIdentities->newEntity( + $mapped, + ['associated' => [ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls', + 'ExternalIdentityRoles', + 'ExternalIdentityRoles.AdHocAttributes', + 'ExternalIdentityRoles.Addresses', + 'ExternalIdentityRoles.TelephoneNumbers' + ]] + ); - $this->Cos->People->ExternalIdentities->saveOrFail($entity); + $this->Cos->People->ExternalIdentities->saveOrFail( + $entity, + ['associated' => [ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls', + 'ExternalIdentityRoles', + 'ExternalIdentityRoles.AdHocAttributes', + 'ExternalIdentityRoles.Addresses', + 'ExternalIdentityRoles.TelephoneNumbers' + ]] + ); // Update $eisRecord with the new external_entity_id $eisRecord->external_identity_id = $entity->id; @@ -1367,6 +1419,34 @@ protected function syncPerson( $newdata['cou_id'] = $pipeline->sync_cou_id; } + // Map Manager and Sponsor identifiers, if set, to corresponding People. + // If not found, we'll log a warning but otherwise proceed. + // Also, we need a configured Identifier type. + + foreach(['manager', 'sponsor'] as $f) { + $eirField = $f . "_identifier"; + $prField = $f . "_person_id"; + + // Populate a null value by default, in case an existing foreign key + // is removed + $newdata[$prField] = null; + + if(!empty($eirentity->$eirField)) { + if(!empty($pipeline->sync_identifier_type_id)) { + $newdata[$prField] = $this->mapIdentifier( + $pipeline->sync_identifier_type_id, + $eirentity->$eirField + ); + + if(empty($newdata[$prField])) { + $this->llog('trace', "Unable to map $eirField for External Identity Role " . $eirentity->id . " because no Person with the specified identifier was found"); + } + } else { + $this->llog('trace', "Unable to map $eirField for External Identity Role " . $eirentity->id . " because there is no Sync Identifier Type configured for Pipeline " . $pipeline->id); + } + } + } + // duplicateFilterEntityData() will remove status, but we need to // set it back (if asserted) or set a default (if not). if(!empty($eirentity->status)) { diff --git a/app/templates/ExternalIdentitySources/search.php b/app/templates/ExternalIdentitySources/search.php index 1dc20feee..835f2a9e8 100644 --- a/app/templates/ExternalIdentitySources/search.php +++ b/app/templates/ExternalIdentitySources/search.php @@ -115,15 +115,15 @@ - + $r): ?> Html->link( - $r['source_key'], + $source_key, [ 'action' => 'retrieve', $this->request->getParam('pass')[0], - '?' => ['source_key' => $r['source_key']] + '?' => ['source_key' => $source_key] ] ); ?> diff --git a/app/vendor/composer/autoload_psr4.php b/app/vendor/composer/autoload_psr4.php index 10ec936a9..bf98ef054 100644 --- a/app/vendor/composer/autoload_psr4.php +++ b/app/vendor/composer/autoload_psr4.php @@ -85,4 +85,6 @@ 'Bake\\' => array($vendorDir . '/cakephp/bake/src'), 'App\\Test\\' => array($baseDir . '/tests'), 'App\\' => array($baseDir . '/src'), + 'ApiConnector\\Test\\' => array($baseDir . '/availableplugins/ApiConnector/tests'), + 'ApiConnector\\' => array($baseDir . '/availableplugins/ApiConnector/src'), ); diff --git a/app/vendor/composer/autoload_static.php b/app/vendor/composer/autoload_static.php index b4fa06fab..c91e0a626 100644 --- a/app/vendor/composer/autoload_static.php +++ b/app/vendor/composer/autoload_static.php @@ -160,6 +160,8 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e array ( 'App\\Test\\' => 9, 'App\\' => 4, + 'ApiConnector\\Test\\' => 18, + 'ApiConnector\\' => 13, ), ); @@ -482,6 +484,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e array ( 0 => __DIR__ . '/../..' . '/src', ), + 'ApiConnector\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/ApiConnector/tests', + ), + 'ApiConnector\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/ApiConnector/src', + ), ); public static $prefixesPsr0 = array (