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): ?>
|
= $this->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 (