diff --git a/app/availableplugins/ApiConnector/config/routes.php b/app/availableplugins/ApiConnector/config/routes.php
index a7af3b212..6b0020f74 100644
--- a/app/availableplugins/ApiConnector/config/routes.php
+++ b/app/availableplugins/ApiConnector/config/routes.php
@@ -55,21 +55,21 @@
$builder->delete(
'/{id}/v2/sorPeople/{sorlabel}/{sorid}',
- ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'delete']
+ ['plugin' => 'ApiConnector', 'controller' => 'SorApiV2', 'action' => 'delete']
)
->setPass(['id', 'sorlabel', 'sorid'])
->setPatterns(['id' => '[0-9]+']);
$builder->get(
'/{id}/v2/sorPeople/{sorlabel}/{sorid}',
- ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'get']
+ ['plugin' => 'ApiConnector', 'controller' => 'SorApiV2', 'action' => 'get']
)
->setPass(['id', 'sorlabel', 'sorid'])
->setPatterns(['id' => '[0-9]+']);
$builder->put(
'/{id}/v2/sorPeople/{sorlabel}/{sorid}',
- ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'upsert']
+ ['plugin' => 'ApiConnector', 'controller' => 'SorApiV2', 'action' => 'upsert']
)
->setPass(['id', 'sorlabel', 'sorid'])
->setPatterns(['id' => '[0-9]+']);
diff --git a/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po b/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po
index eea0637bd..cf1b26a17 100644
--- a/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po
+++ b/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po
@@ -25,6 +25,10 @@
msgid "controller.ApiSources"
#msgstr "{0,plural,=1{API Source} other{API Sources}}"
+# XXX this should autodetect and use the controller key?
+msgid "field.api_source_id"
+msgstr "API Source"
+
msgid "field.ApiSources.push_mode"
msgstr "Push Mode"
diff --git a/app/availableplugins/ApiConnector/src/Controller/ApiSourceEndpointsController.php b/app/availableplugins/ApiConnector/src/Controller/ApiSourceEndpointsController.php
new file mode 100644
index 000000000..86ad2e659
--- /dev/null
+++ b/app/availableplugins/ApiConnector/src/Controller/ApiSourceEndpointsController.php
@@ -0,0 +1,70 @@
+ [
+ 'ApiSourceEndpoints.id' => 'asc'
+ ]
+ ];
+
+ /**
+ * Callback run prior to the request render.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param EventInterface $event Cake Event
+ * @return \Cake\Http\Response HTTP Response
+ */
+
+ public function beforeRender(\Cake\Event\EventInterface $event) {
+ $vv_obj = $this->viewBuilder()->getVar('vv_obj');
+
+ if(!empty($vv_obj)) {
+ $apiSource = $this->ApiSourceEndpoints->ApiSources->get(
+ $vv_obj->api_source_id,
+ ['contain' => 'ExternalIdentitySources']
+ );
+
+ $this->set(
+ 'vv_push_endpoint',
+ Router::url(
+ url: '/api/apisource/' . $apiSource->id . '/v2/sorPeople/' . $apiSource->external_identity_source->sor_label,
+ full: true
+ )
+ );
+ }
+
+ return parent::beforeRender($event);
+ }
+}
diff --git a/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php b/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php
index f003372ed..19b998274 100644
--- a/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php
+++ b/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php
@@ -38,29 +38,4 @@ class ApiSourcesController extends StandardPluginController {
'ApiSources.id' => 'asc'
]
];
-
- /**
- * Callback run prior to the request render.
- *
- * @since COmanage Registry v5.0.0
- * @param EventInterface $event Cake Event
- * @return \Cake\Http\Response HTTP Response
- */
-
- public function beforeRender(\Cake\Event\EventInterface $event) {
- $apiSource = $this->ApiSources->get(
- $this->request->getParam('pass.0'),
- ['contain' => 'ExternalIdentitySources']
- );
-
- $this->set(
- 'vv_push_endpoint',
- Router::url(
- url: '/api/apisource/' . $apiSource->id . '/v2/sorPeople/' . $apiSource->external_identity_source->sor_label,
- full: true
- )
- );
-
- return parent::beforeRender($event);
- }
}
diff --git a/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php b/app/availableplugins/ApiConnector/src/Controller/SorApiV2Controller.php
similarity index 75%
rename from app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php
rename to app/availableplugins/ApiConnector/src/Controller/SorApiV2Controller.php
index d865f96c5..bb7b622b1 100644
--- a/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php
+++ b/app/availableplugins/ApiConnector/src/Controller/SorApiV2Controller.php
@@ -1,6 +1,6 @@
'ApiSourceEndpoints',
+ 'get' => 'ApiSourceEndpoints',
+ 'upsert' => 'ApiSourceEndpoints'
+ ];
/**
* Calculate the CO ID associated with the request.
@@ -46,42 +53,11 @@ public function calculateRequestedCOID(): ?int {
$ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources');
- $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ExternalIdentitySources']);
+ return $ApiSource->findCoForRecord((int)$apiSourceId);
- return $cfg->external_identity_source->co_id ?? null;
- }
-
- /**
- * Calculate authorization for the current request.
- *
- * @since COmanage Registry v5.0.0
- * @return bool True if the current request is permitted, false otherwise
- */
+ // $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ExternalIdentitySources']);
- public function calculatePermission(): bool {
- $request = $this->getRequest();
- $action = $request->getParam('action');
- $authUser = $this->RegistryAuth->getAuthenticatedUser();
-
- $authorized = false;
-
- // Our authorization is pretty straightforward, the configured API User
- // is permitted to perform all actions.
-
- // This should be set or the route won't match
- $apiSourceId = $this->request->getParam('id');
-
- $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources');
-
- $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ApiUsers']);
-
- if(!empty($cfg->api_user->username)
- && !empty($authUser)
- && $authUser == $cfg->api_user->username) {
- $authorized = true;
- }
-
- return $authorized;
+ // return $cfg->external_identity_source->co_id ?? null;
}
/**
@@ -193,17 +169,4 @@ public function upsert(string $id, string $sorlabel, string $sorid) {
$this->response = $this->response->withStatus($resultCode);
$this->set('vv_results', $results);
}
-
- /**
- * Indicate whether this Controller will handle some or all authnz.
- *
- * @since COmanage Registry v5.0.0
- * @param EventInterface $event Cake event, ie: from beforeFilter
- * @return string "no", "open", "authz", or "yes"
- */
-
- public function willHandleAuth(\Cake\Event\EventInterface $event): string {
- // We always take over authz
- return 'authz';
- }
}
diff --git a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSource.php b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSource.php
index 7403f3759..87b958bfc 100644
--- a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSource.php
+++ b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSource.php
@@ -1,6 +1,6 @@
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php
index 763b797cc..815a62655 100644
--- a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php
+++ b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php
@@ -1,6 +1,6 @@
addBehavior('Changelog');
+ $this->addBehavior('Log');
+ // Timestamp behavior handles created/modified updates
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('ApiConnector.ApiSources');
+ // $this->belongsTo('ApiUsers');
+ $this->belongsTo('Apis');
+
+ $this->setDisplayField('api_id');
+
+ $this->setPrimaryLink(['api_id']);
+ $this->setRequiresCO(true);
+ $this->setRedirectGoal('self');
+
+ $this->setAutoViewVars([
+ 'apiSources' => [
+ 'type' => 'plugin',
+ 'model' => 'ApiConnector.ApiSources'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => false, // Delete the pluggable object instead
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => false,
+ 'index' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ * @throws InvalidArgumentException
+ * @throws RecordNotFoundException
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('api_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('api_id');
+
+ $validator->add('api_source_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('api_source_id');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php
index 1712c158f..99b6a8878 100644
--- a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php
+++ b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php
@@ -62,6 +62,9 @@ public function initialize(array $config): void {
$this->belongsTo('ExternalIdentitySources');
$this->belongsTo('ApiUsers');
+ $this->hasMany('ApiConnector.ApiSourceEntities')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
$this->hasMany('ApiConnector.ApiSourceRecords')
->setDependent(true)
->setCascadeCallbacks(true);
@@ -70,13 +73,6 @@ public function initialize(array $config): void {
$this->setPrimaryLink(['external_identity_source_id']);
$this->setRequiresCO(true);
-
- $this->setAutoViewVars([
- 'apiUsers' => [
- 'type' => 'select',
- 'model' => 'ApiUsers',
- ]
- ]);
$this->setPermissions([
// Actions that operate over an entity (ie: require an $id)
@@ -491,11 +487,6 @@ public function validationDefault(Validator $validator): Validator {
]);
$validator->notEmptyString('external_source_identity_id');
- $validator->add('api_user_id', [
- 'content' => ['rule' => 'isInteger']
- ]);
- $validator->notEmptyString('api_user_id');
-
return $validator;
}
}
\ No newline at end of file
diff --git a/app/availableplugins/ApiConnector/src/config/plugin.json b/app/availableplugins/ApiConnector/src/config/plugin.json
index be96e7f42..69a8f6010 100644
--- a/app/availableplugins/ApiConnector/src/config/plugin.json
+++ b/app/availableplugins/ApiConnector/src/config/plugin.json
@@ -1,5 +1,8 @@
{
"types": {
+ "api": [
+ "ApiSourceEndpoints"
+ ],
"source": [
"ApiSources"
]
@@ -9,13 +12,23 @@
"api_sources": {
"columns": {
"id": {},
- "external_identity_source_id": {},
- "api_user_id": {}
+ "external_identity_source_id": {}
},
"indexes": {
"api_sources_i1": { "columns": [ "external_identity_source_id" ] }
}
},
+ "api_source_endpoints": {
+ "columns": {
+ "id": {},
+ "api_id": {},
+ "api_source_id": { "type": "integer", "foreignkey": { "table": "api_sources", "column": "id" } }
+ },
+ "indexes": {
+ "api_source_endpoints_i1": { "columns": [ "api_id" ] },
+ "api_source_endpoints_i2": { "columns": [ "api_source_id" ] }
+ }
+ },
"api_source_records": {
"columns": {
"id": {},
diff --git a/app/availableplugins/ApiConnector/templates/ApiSourceEndpoints/fields.inc b/app/availableplugins/ApiConnector/templates/ApiSourceEndpoints/fields.inc
new file mode 100644
index 000000000..b5afc856c
--- /dev/null
+++ b/app/availableplugins/ApiConnector/templates/ApiSourceEndpoints/fields.inc
@@ -0,0 +1,39 @@
+element('notify/banner', [
+ 'info' => __d('api_connector', 'information.endpoint.push', [$vv_push_endpoint])
+ ]);
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'api_source_id',
+ ]
+ ]);
+}
diff --git a/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc b/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc
index c469f6a67..ef5dd13b5 100644
--- a/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc
+++ b/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc
@@ -26,19 +26,10 @@
*/
// This view does currently not support read-only
-if($vv_action == 'add' || $vv_action == 'edit') {
-
+if($vv_action == 'edit') {
print '
' . __d('api_connector', 'field.ApiSources.push_mode') . '
';
print $this->element('notify/banner', [
- 'info' => __d('api_connector', 'information.endpoint.push', [$vv_push_endpoint])
+ 'info' => __d('information', 'plugin.config.none',)
]);
-
-
- print $this->element('form/listItem', [
- 'arguments' => [
- 'fieldName' => 'api_user_id',
- ]
- ]);
-
}
diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php b/app/availableplugins/ApiConnector/templates/SorApiV2/json/delete.php
similarity index 100%
rename from app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php
rename to app/availableplugins/ApiConnector/templates/SorApiV2/json/delete.php
diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/get.php b/app/availableplugins/ApiConnector/templates/SorApiV2/json/get.php
similarity index 100%
rename from app/availableplugins/ApiConnector/templates/ApiV2/json/get.php
rename to app/availableplugins/ApiConnector/templates/SorApiV2/json/get.php
diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php b/app/availableplugins/ApiConnector/templates/SorApiV2/json/upsert.php
similarity index 100%
rename from app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php
rename to app/availableplugins/ApiConnector/templates/SorApiV2/json/upsert.php
diff --git a/app/composer.json b/app/composer.json
index 527fe89b2..907a8bfba 100644
--- a/app/composer.json
+++ b/app/composer.json
@@ -32,15 +32,16 @@
"psr-4": {
"App\\": "src/",
"ApiConnector\\": "availableplugins/ApiConnector/src/",
+ "CoreApi\\": "plugins/CoreApi/src/",
"CoreAssigner\\": "plugins/CoreAssigner/src/",
"CoreEnroller\\": "plugins/CoreEnroller/src/",
"CoreServer\\": "plugins/CoreServer/src/",
"EnvSource\\": "plugins/EnvSource/src/",
"FileConnector\\": "availableplugins/FileConnector/src/",
+ "OrcidSource\\": "plugins/OrcidSource/src/",
"PipelineToolkit\\": "availableplugins/PipelineToolkit/src/",
"SqlConnector\\": "availableplugins/SqlConnector/src/",
- "CoreJob\\": "plugins/CoreJob/src/",
- "OrcidSource\\": "plugins/OrcidSource/src/"
+ "CoreJob\\": "plugins/CoreJob/src/"
}
},
"autoload-dev": {
@@ -48,15 +49,16 @@
"App\\Test\\": "tests/",
"ApiConnector\\Test\\": "availableplugins/ApiConnector/tests/",
"Cake\\Test\\": "vendor/cakephp/cakephp/tests/",
+ "CoreApi\\Test\\": "plugins/CoreApi/tests/",
"CoreAssigner\\Test\\": "plugins/CoreAssigner/tests/",
"CoreEnroller\\Test\\": "plugins/CoreEnroller/tests/",
"CoreServer\\Test\\": "plugins/CoreServer/tests/",
"EnvSource\\Test\\": "plugins/EnvSource/tests/",
"FileConnector\\Test\\": "availableplugins/FileConnector/tests/",
+ "OrcidSource\\Test\\": "plugins/OrcidSource/tests/",
"PipelineToolkit\\Test\\": "availableplugins/PipelineToolkit/tests/",
"SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/",
- "CoreJob\\Test\\": "plugins/CoreJob/tests/",
- "OrcidSource\\Test\\": "plugins/OrcidSource/tests/"
+ "CoreJob\\Test\\": "plugins/CoreJob/tests/"
}
},
"scripts": {
diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json
index dc3080a8a..7cc67284f 100644
--- a/app/config/schema/schema.json
+++ b/app/config/schema/schema.json
@@ -10,6 +10,7 @@
"columns": {
"action": { "type": "string", "size": 4 },
+ "api_id": { "type": "integer", "foreignkey": { "table": "apis", "column": "id" } },
"api_user_id": { "type": "integer", "foreignkey": { "table": "api_users", "column": "id" } },
"co_id": { "type": "integer", "foreignkey": { "table": "cos", "column": "id" }, "notnull": true },
"comment": { "type": "string", "size": 256 },
@@ -130,6 +131,7 @@
"default_pronoun_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } },
"default_telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } },
"default_url_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } },
+ "authn_events_api_disable": { "type": "boolean" },
"email_delivery_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } },
"email_smtp_server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" } },
"permitted_fields_name": { "type": "string", "size": 160 },
@@ -899,6 +901,21 @@
}
},
+ "apis": {
+ "columns": {
+ "id": {},
+ "co_id": {},
+ "description": {},
+ "plugin": {},
+ "status": {},
+ "api_user_id": {}
+ },
+ "indexes": {
+ "apis_i1": { "columns": [ "co_id" ] },
+ "apis_i2": { "needed": false, "columns": [ "api_user_id" ]}
+ }
+ },
+
"traffic_detours": {
"columns": {
"id": {},
diff --git a/app/plugins/CoreApi/README.md b/app/plugins/CoreApi/README.md
new file mode 100644
index 000000000..50517a3e4
--- /dev/null
+++ b/app/plugins/CoreApi/README.md
@@ -0,0 +1,11 @@
+# CoreApi plugin for CakePHP
+
+## Installation
+
+You can install this plugin into your CakePHP application using [composer](https://getcomposer.org).
+
+The recommended way to install composer packages is:
+
+```
+composer require your-name-here/core-api
+```
diff --git a/app/plugins/CoreApi/composer.json b/app/plugins/CoreApi/composer.json
new file mode 100644
index 000000000..d3b2e6d93
--- /dev/null
+++ b/app/plugins/CoreApi/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "your-name-here/core-api",
+ "description": "CoreApi plugin for CakePHP",
+ "type": "cakephp-plugin",
+ "license": "MIT",
+ "require": {
+ "php": ">=7.2",
+ "cakephp/cakephp": "4.6.*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5 || ^9.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "CoreApi\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "CoreApi\\Test\\": "tests/",
+ "Cake\\Test\\": "vendor/cakephp/cakephp/tests/"
+ }
+ }
+}
diff --git a/app/plugins/CoreApi/config/routes.php b/app/plugins/CoreApi/config/routes.php
new file mode 100644
index 000000000..dcb615142
--- /dev/null
+++ b/app/plugins/CoreApi/config/routes.php
@@ -0,0 +1,60 @@
+scope('/api/match', function (RouteBuilder $builder) {
+ // Register scoped middleware for in scopes.
+// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients
+// $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware(['httponly' => true]));
+ // BodyParserMiddleware will automatically parse JSON bodies, but we only
+ // want that for API transactions, so we only apply it to the /api scope.
+ $builder->registerMiddleware('bodyparser', new BodyParserMiddleware());
+ /*
+ * Apply a middleware to the current route scope.
+ * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()`
+ */
+// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients
+// $builder->applyMiddleware('csrf');
+ $builder->setExtensions(['json']);
+ $builder->applyMiddleware('bodyparser');
+
+ $builder->post(
+ '/{coid}/v1/resolution',
+ ['plugin' => 'CoreApi', 'controller' => 'MatchCallbackApiV1', 'action' => 'resolveMatch']
+ )
+ ->setPass(['coid'])
+ ->setPatterns(['coid' => '[0-9]+']);
+});
\ No newline at end of file
diff --git a/app/plugins/CoreApi/phpunit.xml.dist b/app/plugins/CoreApi/phpunit.xml.dist
new file mode 100644
index 000000000..a1b1a0f7a
--- /dev/null
+++ b/app/plugins/CoreApi/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+ tests/TestCase/
+
+
+
+
+
+
+
+
+
+
+ src/
+
+
+
diff --git a/app/plugins/CoreApi/resources/locales/en_US/core_api.po b/app/plugins/CoreApi/resources/locales/en_US/core_api.po
new file mode 100644
index 000000000..fb902f2b1
--- /dev/null
+++ b/app/plugins/CoreApi/resources/locales/en_US/core_api.po
@@ -0,0 +1,38 @@
+# COmanage Registry Localizations (core_api domain)
+#
+# Portions licensed to the University Corporation for Advanced Internet
+# Development, Inc. ("UCAID") under one or more contributor license agreements.
+# See the NOTICE file distributed with this work for additional information
+# regarding copyright ownership.
+#
+# UCAID licenses this file to you under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with the
+# License. You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# @link https://www.internet2.edu/comanage COmanage Project
+# @package registry-plugins
+# @since COmanage Registry v5.2.0
+# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+
+msgid "controller.MatchCallbacks"
+msgstr "{0,plural,=1{Match Callback} other{Match Callbacks}}"
+
+msgid "error.MatchCallbacks.json.invalid"
+msgstr "JSON document missing required fields"
+
+msgid "error.MatchCallbacks.match.resolved"
+msgstr "Match resolution notification received"
+
+msgid "error.MatchCallbacks.sor.notfound"
+msgstr "Requested SOR Label not found"
+
+msgid "information.endpoint.match.callback"
+msgstr "The Match Resolution Notification (Callback) Endpoint for using this API is {0}"
diff --git a/app/plugins/CoreApi/src/Controller/AppController.php b/app/plugins/CoreApi/src/Controller/AppController.php
new file mode 100644
index 000000000..867598718
--- /dev/null
+++ b/app/plugins/CoreApi/src/Controller/AppController.php
@@ -0,0 +1,10 @@
+ 'MatchCallbacks'
+ ];
+
+ /**
+ * Handle a Match Resolution Callback Notification
+ *
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function resolveMatch() {
+ $payload = $this->request->getData();
+
+ if(empty($payload['sor'])
+ || empty($payload['sorid'])
+ || empty($payload['referenceId'])) {
+ $this->response = $this->response->withStatus(400);
+ $this->set('vv_results', ['error' => __d('core_api', 'error.MatchCallbacks.json.invalid')]);
+ return;
+ }
+
+ // Find the EIS associated with this sor label. There should be exactly one.
+
+ $EISTable = TableRegistry::getTableLocator()->get('ExternalIdentitySources');
+
+ $eis = $EISTable->find()
+ ->where([
+ 'co_id' => $this->request->getParam('coid'),
+ 'sor_label' => $payload['sor'],
+ 'status <>' => SyncModeEnum::Disabled
+ ])
+ ->first();
+
+ // We check for the EIS rather than let find throw an exception so we can return
+ // a 400 (client error) instead of 500 (server error).
+
+ if(empty($eis)) {
+ $this->response = $this->response->withStatus(400);
+ $this->set('vv_results', ['error' => __d('core_api', 'error.MatchCallbacks.sor.notfound')]);
+ return;
+ }
+
+ // We register a Job rather than process the record directly for a few of reasons.
+ // (1) A record could take "too long" to process (usually due to slow provisioning),
+ // resulting in a web server timeout for the request from Match.
+ // (2) There's fairly complicated logic in SyncJob to process the record, and there's
+ // not a compelling reason to (partially) duplicate that here.
+ // (3) By processing via the Job infrastructure, the artificats from processing via
+ // this callback will be available alongside where the rest of the artifacts are.
+ // The primary downside is that processing isn't immediate, but this can be managed
+ // by setting the queue runner to run at a reasonable frequency.
+
+ // If an EIS sync fails because of multiple choices returned by the Match Server,
+ // there will be an EIS Record, but no indication of the multiple choice status is
+ // retained (and no External Identity has yet been created). This implies we don't
+ // have a reliable way to validate the SORID (source key) provided in this request
+ // but the Job can do that. (We could in theory record the Match Reference ID and
+ // link on that, but SOR Label + Source Key is good enough.) We also don't try to
+ // map the Reference ID here, since that could theoretically change before the Job
+ // actually runs (but probably it won't).
+
+ try {
+ $JobTable = TableRegistry::getTableLocator()->get("Jobs");
+
+ $JobTable->register(
+ coId: (int)$this->request->getParam('coid'),
+ plugin: 'CoreJob.SyncJob',
+ parameters: [
+ 'external_identity_source_id' => $eis->id,
+ 'source_keys' => $payload['sorid'],
+ 'reference_id' => $payload['referenceId']
+ ],
+ registerSummary: __d('core_api', 'error.MatchCallbacks.match.resolved')
+ );
+
+ $this->response = $this->response->withStatus(202);
+ }
+ catch(\Exception $e) {
+ // We catch and rethrow any errors to make sure the formatting is compatible with
+ // the API
+ $this->response = $this->response->withStatus(500);
+ $this->set('vv_results', ['error' => $e->getMessage()]);
+ return;
+ }
+
+ // Note there's nothing to attach a history record to yet, so we don't
+ }
+}
diff --git a/app/plugins/CoreApi/src/Controller/MatchCallbacksController.php b/app/plugins/CoreApi/src/Controller/MatchCallbacksController.php
new file mode 100644
index 000000000..aef070cf4
--- /dev/null
+++ b/app/plugins/CoreApi/src/Controller/MatchCallbacksController.php
@@ -0,0 +1,58 @@
+ [
+ 'MatchCallbacks.server_id' => 'asc'
+ ]
+ ];
+
+ /**
+ * Callback run prior to the request render.
+ *
+ * @param EventInterface $event Cake Event
+ *
+ * @return Response|void
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function beforeRender(EventInterface $event) {
+ // Generate the callback URL
+
+ $this->set('vv_api_endpoint', \Cake\Routing\Router::url('/', true) . 'api/match/' . $this->getCOID() . '/v1/resolution');
+
+ return parent::beforeRender($event);
+ }
+}
diff --git a/app/plugins/CoreApi/src/CoreApiPlugin.php b/app/plugins/CoreApi/src/CoreApiPlugin.php
new file mode 100644
index 000000000..b42497b8d
--- /dev/null
+++ b/app/plugins/CoreApi/src/CoreApiPlugin.php
@@ -0,0 +1,93 @@
+plugin(
+ 'CoreApi',
+ ['path' => '/core-api'],
+ function (RouteBuilder $builder) {
+ // Add custom routes here
+
+ $builder->fallbacks();
+ }
+ );
+ parent::routes($routes);
+ }
+
+ /**
+ * Add middleware for the plugin.
+ *
+ * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update.
+ * @return \Cake\Http\MiddlewareQueue
+ */
+ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
+ {
+ // Add your middlewares here
+
+ return $middlewareQueue;
+ }
+
+ /**
+ * Add commands for the plugin.
+ *
+ * @param \Cake\Console\CommandCollection $commands The command collection to update.
+ * @return \Cake\Console\CommandCollection
+ */
+ public function console(CommandCollection $commands): CommandCollection
+ {
+ // Add your commands here
+
+ $commands = parent::console($commands);
+
+ return $commands;
+ }
+
+ /**
+ * Register application container services.
+ *
+ * @param \Cake\Core\ContainerInterface $container The Container to update.
+ * @return void
+ * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection
+ */
+ public function services(ContainerInterface $container): void
+ {
+ // Add your services here
+ }
+}
diff --git a/app/plugins/CoreApi/src/Model/Entity/MatchCallback.php b/app/plugins/CoreApi/src/Model/Entity/MatchCallback.php
new file mode 100644
index 000000000..a96b57d5a
--- /dev/null
+++ b/app/plugins/CoreApi/src/Model/Entity/MatchCallback.php
@@ -0,0 +1,49 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/CoreApi/src/Model/Table/MatchCallbacksTable.php b/app/plugins/CoreApi/src/Model/Table/MatchCallbacksTable.php
new file mode 100644
index 000000000..f69c0f495
--- /dev/null
+++ b/app/plugins/CoreApi/src/Model/Table/MatchCallbacksTable.php
@@ -0,0 +1,120 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ // Timestamp behavior handles created/modified updates
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('Apis');
+ $this->belongsTo('Servers');
+
+ $this->setDisplayField('api_id');
+
+ $this->setPrimaryLink('api_id');
+ $this->setRequiresCO(true);
+ $this->setRedirectGoal('self');
+
+ $this->setAutoViewVars([
+ 'servers' => [
+ 'type' => 'select',
+ 'model' => 'Servers',
+ 'where' => ['plugin' => 'CoreServer.MatchServers']
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => false, // Delete the pluggable object instead
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => false,
+ 'index' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('api_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('api_id');
+
+ $validator->add('server_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('server_id');
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/CoreApi/src/config/plugin.json b/app/plugins/CoreApi/src/config/plugin.json
new file mode 100644
index 000000000..ccd52e420
--- /dev/null
+++ b/app/plugins/CoreApi/src/config/plugin.json
@@ -0,0 +1,25 @@
+{
+ "types": {
+ "api": [
+ "MatchCallbacks"
+ ]
+ },
+ "schema": {
+ "tables": {
+ "match_callbacks": {
+ "columns": {
+ "id": {},
+ "api_id": {},
+ "server_id": {
+ "notnull": false,
+ "comment": "server_id isn't available on the initial row insert by StandardPluggableController::instantiatePlugin"
+ }
+ },
+ "indexes": {
+ "match_callbacks_i1": { "columns": [ "api_id" ]},
+ "match_callbacks_i2": { "needed": false, "columns": [ "server_id" ]}
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/plugins/CoreApi/templates/MatchCallbackApiV1/json/resolve_match.php b/app/plugins/CoreApi/templates/MatchCallbackApiV1/json/resolve_match.php
new file mode 100644
index 000000000..3d6605db5
--- /dev/null
+++ b/app/plugins/CoreApi/templates/MatchCallbackApiV1/json/resolve_match.php
@@ -0,0 +1,30 @@
+ $vv_results]);
+}
\ No newline at end of file
diff --git a/app/plugins/CoreApi/templates/MatchCallbacks/fields.inc b/app/plugins/CoreApi/templates/MatchCallbacks/fields.inc
new file mode 100644
index 000000000..57c744426
--- /dev/null
+++ b/app/plugins/CoreApi/templates/MatchCallbacks/fields.inc
@@ -0,0 +1,41 @@
+element('notify/banner', [
+ 'info' => __d('core_api', 'information.endpoint.match.callback', [$vv_api_endpoint])
+ ]);
+ }
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'server_id',
+ ]
+ ]);
+}
\ No newline at end of file
diff --git a/app/plugins/CoreApi/tests/bootstrap.php b/app/plugins/CoreApi/tests/bootstrap.php
new file mode 100644
index 000000000..e8f54348d
--- /dev/null
+++ b/app/plugins/CoreApi/tests/bootstrap.php
@@ -0,0 +1,55 @@
+loadSqlFiles('tests/schema.sql', 'test');
diff --git a/app/plugins/CoreApi/tests/schema.sql b/app/plugins/CoreApi/tests/schema.sql
new file mode 100644
index 000000000..1d268db90
--- /dev/null
+++ b/app/plugins/CoreApi/tests/schema.sql
@@ -0,0 +1 @@
+-- Test database schema for CoreApi
diff --git a/app/plugins/CoreApi/webroot/.gitkeep b/app/plugins/CoreApi/webroot/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/plugins/CoreJob/resources/locales/en_US/core_job.po b/app/plugins/CoreJob/resources/locales/en_US/core_job.po
index 0598a407b..2386045ac 100644
--- a/app/plugins/CoreJob/resources/locales/en_US/core_job.po
+++ b/app/plugins/CoreJob/resources/locales/en_US/core_job.po
@@ -52,6 +52,9 @@ msgstr "External Identity Source ID (to process a single source)"
msgid "opt.sync.force"
msgstr "If true, force records to process even if no changes have been detected"
+msgid "opt.sync.reference_id"
+msgstr "Reference ID to attach to External Identity (requires exactly one value for source_keys)"
+
msgid "opt.sync.source_keys"
msgstr "Source Keys to process, comma separated (requires external_identity_source_id)"
@@ -115,6 +118,9 @@ msgstr "Post-Run Tasks failed: {0}"
msgid "Sync.error.pre_run_checks"
msgstr "Pre-Run Checks failed: {0}"
+msgid "Sync.error.reference_id"
+msgstr "reference_id provided but more than one source key was specified"
+
msgid "Sync.finish_summary"
msgstr "Sync Finished"
diff --git a/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php b/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php
index c762cdcb0..24770f5d1 100644
--- a/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php
+++ b/app/plugins/CoreJob/src/Lib/Jobs/SyncJob.php
@@ -61,7 +61,11 @@ public function parameterFormat(): array {
'type' => 'bool',
'required' => false
],
-// XXX addd reference_id
+ 'reference_id' => [
+ 'help' => __d('core_job', 'opt.sync.reference_id'),
+ 'type' => 'string',
+ 'required' => false
+ ],
'source_keys' => [
'help' => __d('core_job', 'opt.sync.source_keys'),
'type' => 'string',
@@ -455,6 +459,16 @@ public function run(
$keys = explode(',', $parameters['source_keys']);
+ $referenceId = null;
+
+ if(!empty($parameters['reference_id'])) {
+ if(count($keys) == 1) {
+ $referenceId = $parameters['reference_id'];
+ } else {
+ throw new \InvalidArgumentException('core_job', 'Sync.error.reference_id');
+ }
+ }
+
$this->runContext->count = count($keys);
$JobsTable->start(
@@ -463,7 +477,7 @@ public function run(
);
foreach($keys as $key) {
- if(!$this->syncRecord($key)) {
+ if(!$this->syncRecord($key, $referenceId)) {
break;
}
}
@@ -501,20 +515,25 @@ public function run(
* Sync a single record.
*
* @since COmanage Registry v5.0.0
- * @param string $key Source Key to process
+ * @param string $key Source Key to process
+ * @param string $referenceId Reference ID to link to record, if known
* @return bool True if processing should continue, false otherwise
*/
- protected function syncRecord(string $key): bool {
+ protected function syncRecord(string $key, string $referenceId=null): bool {
// comment and status for HistoryRecords
$c = "unknown";
$s = JobStatusEnum::Failed;
+ // Default result
+ $result = 'error';
+
try {
$result = $this->runContext->EISTable->sync(
id: (int)$this->runContext->parameters['external_identity_source_id'],
sourceKey: $key,
- force: $this->runContext->force
+ force: $this->runContext->force,
+ referenceId: $referenceId
);
switch($result) {
diff --git a/app/plugins/CoreServer/resources/locales/en_US/core_server.po b/app/plugins/CoreServer/resources/locales/en_US/core_server.po
index 6fdd6cca9..94e0a81bf 100644
--- a/app/plugins/CoreServer/resources/locales/en_US/core_server.po
+++ b/app/plugins/CoreServer/resources/locales/en_US/core_server.po
@@ -25,6 +25,9 @@
msgid "controller.HttpServers"
msgstr "{0,plural,=1{HTTP Server} other{HTTP Servers}}"
+msgid "controller.MatchServerAttributes"
+msgstr "{0,plural,=1{Match Server Attribute} other{Match Server Attributes}}"
+
msgid "controller.MatchServers"
msgstr "{0,plural,=1{Match Server} other{Match Servers}}"
@@ -70,6 +73,15 @@ msgstr "Oracle"
msgid "enumeration.RdbmsTypeEnum.PG"
msgstr "Postgres"
+msgid "error.MatchServers.attr.none"
+msgstr "No valid attributes assembled for Match request"
+
+msgid "error.MatchServers.attr.req"
+msgstr "Required attribute {0} ({1}) not found in person record"
+
+msgid "error.MatchServers.response"
+msgstr "Match Server responded: {0}"
+
msgid "error.Oauth2Servers.callback"
msgstr "Incorrect parameters in callback"
@@ -79,6 +91,12 @@ msgstr "Invalid state received in callback"
msgid "error.Oauth2Servers.token"
msgstr "Error obtaining access token: {0}"
+msgid "error.SqlServers.oracle.enabled"
+msgstr "Oracle support is not enabled"
+
+msgid "error.SqlServers.oracle.plugin"
+msgstr "OracleClient plugin is not loaded"
+
msgid "info.Oauth2Servers.token.ok"
msgstr "Access Token Obtained"
@@ -91,9 +109,6 @@ msgstr "Authentication Type"
msgid "field.skip_ssl_verification"
msgstr "Skip SSL Verification"
-msgid "field.MatchServers.api_endpoint"
-msgstr "API Endpoint"
-
msgid "field.Oauth2Servers.access_token"
msgstr "Access Token"
@@ -136,12 +151,6 @@ msgstr "If set, all outgoing email will only be sent to this address"
msgid "field.SmtpServers.use_tls"
msgstr "Use TLS"
-msgid "error.SqlServers.oracle.enabled"
-msgstr "Oracle support is not enabled"
-
-msgid "error.SqlServers.oracle.plugin"
-msgstr "OracleClient plugin is not loaded"
-
msgid "field.SqlServers.databas"
msgstr "Database Name"
@@ -150,3 +159,6 @@ msgstr "Specify the port only if a non-standard port number is in use"
msgid "field.SqlServers.type"
msgstr "RDBMS Type"
+
+msgid "result.MatchServers.match.accepted"
+msgstr "Match request requires administrator intervention, Match Request ID: {0}"
\ No newline at end of file
diff --git a/app/plugins/CoreServer/src/Controller/MatchServerAttributesController.php b/app/plugins/CoreServer/src/Controller/MatchServerAttributesController.php
new file mode 100644
index 000000000..cef8f47a8
--- /dev/null
+++ b/app/plugins/CoreServer/src/Controller/MatchServerAttributesController.php
@@ -0,0 +1,41 @@
+ [
+ 'MatchServerAttributes.attribute' => 'asc'
+ ]
+ ];
+}
diff --git a/app/plugins/CoreServer/src/Controller/MatchServersController.php b/app/plugins/CoreServer/src/Controller/MatchServersController.php
index 4b7bc526a..4b715ca3a 100644
--- a/app/plugins/CoreServer/src/Controller/MatchServersController.php
+++ b/app/plugins/CoreServer/src/Controller/MatchServersController.php
@@ -21,7 +21,7 @@
*
* @link https://www.internet2.edu/comanage COmanage Project
* @package registry-plugins
- * @since COmanage Registry v5.2.0
+ * @since COmanage Registry v5.1.0
* @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
*/
@@ -30,7 +30,6 @@
namespace CoreServer\Controller;
use App\Controller\StandardPluginController;
-use Cake\Event\EventInterface;
class MatchServersController extends StandardPluginController {
public $paginate = [
@@ -38,22 +37,4 @@ class MatchServersController extends StandardPluginController {
'MatchServers.url' => 'asc'
]
];
-
- /**
- * Callback run prior to the request render.
- *
- * @param EventInterface $event Cake Event
- *
- * @return Response|void
- * @since COmanage Registry v5.2.0
- */
-
- public function beforeRender(EventInterface $event) {
- // Generate the callback URL
-
-// XXX this needs to be updated for whereever the new API lands
- $this->set('vv_api_endpoint', \Cake\Routing\Router::url('/', true) . 'api/co/' . $this->getCOID() . '/core/v1/resolution');
-
- return parent::beforeRender($event);
- }
}
diff --git a/app/plugins/CoreServer/src/Model/Entity/MatchServer.php b/app/plugins/CoreServer/src/Model/Entity/MatchServer.php
index ceab5bf4f..688c77f6b 100644
--- a/app/plugins/CoreServer/src/Model/Entity/MatchServer.php
+++ b/app/plugins/CoreServer/src/Model/Entity/MatchServer.php
@@ -21,7 +21,7 @@
*
* @link https://www.internet2.edu/comanage COmanage Project
* @package registry-plugins
- * @since COmanage Registry v5.2.0
+ * @since COmanage Registry v5.1.0
* @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
*/
diff --git a/app/plugins/CoreServer/src/Model/Entity/MatchServerAttribute.php b/app/plugins/CoreServer/src/Model/Entity/MatchServerAttribute.php
new file mode 100644
index 000000000..84be4ce58
--- /dev/null
+++ b/app/plugins/CoreServer/src/Model/Entity/MatchServerAttribute.php
@@ -0,0 +1,49 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/CoreServer/src/Model/Table/HttpServersTable.php b/app/plugins/CoreServer/src/Model/Table/HttpServersTable.php
index 06802e7d1..6eeff5cd7 100644
--- a/app/plugins/CoreServer/src/Model/Table/HttpServersTable.php
+++ b/app/plugins/CoreServer/src/Model/Table/HttpServersTable.php
@@ -41,6 +41,7 @@
class HttpServersTable extends Table {
use \App\Lib\Traits\AutoViewVarsTrait;
use \App\Lib\Traits\CoLinkTrait;
+ use \App\Lib\Traits\LabeledLogTrait;
use \App\Lib\Traits\PermissionsTrait;
use \App\Lib\Traits\PrimaryLinkTrait;
use \App\Lib\Traits\TableMetaTrait;
@@ -112,17 +113,25 @@ public function createHttpClient(int $id): Client {
$Client = Client::createFromUrl($httpServer->url);
if($httpServer->auth_type == HttpAuthTypeEnum::Basic) {
+ $authConfig = [
+ 'type' => 'Basic'
+ ];
+
if(!empty($httpServer->username)) {
- $Client->setConfig('username', $httpServer->username);
+ $authConfig['username'] = $httpServer->username;
}
if(!empty($httpServer->password)) {
- $Client->setConfig('password', $httpServer->password);
+ $authConfig['password'] = $httpServer->password;
}
+
+ $Client->setConfig('auth', $authConfig);
}
if(!empty($httpServer->skip_ssl_verification) && $httpServer->skip_ssl_verification) {
$Client->setConfig('ssl_verify_peer', false);
+ $Client->setConfig('ssl_verify_peer_name', false);
+ $Client->setConfig('ssl_verify_host', false);
}
return $Client;
diff --git a/app/plugins/CoreServer/src/Model/Table/MatchServerAttributesTable.php b/app/plugins/CoreServer/src/Model/Table/MatchServerAttributesTable.php
new file mode 100644
index 000000000..8909a309c
--- /dev/null
+++ b/app/plugins/CoreServer/src/Model/Table/MatchServerAttributesTable.php
@@ -0,0 +1,239 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ // Timestamp behavior handles created/modified updates
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('CoreServer.MatchServers');
+ $this->belongsTo('Types');
+
+ $this->setDisplayField('attribute');
+
+ $this->setPrimaryLink('CoreServer.match_server_id');
+ $this->setRequiresCO(true);
+ $this->setRedirectGoal('index');
+
+ $this->setAutoViewVars([
+ 'attributes' => [
+ 'type' => 'hash',
+ 'hash' => Hash::combine($this->supportedAttributes(), '{s}.wire', '{s}.label')
+ ],
+ // Because of the way autoviewvars calculates variable names, "requireds" is correct
+ 'requireds' => [
+ 'type' => 'enum',
+ 'class' => 'RequiredEnum'
+ ],
+ 'addressTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'Addresses.type'
+ ],
+ 'emailAddressTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'EmailAddresses.type'
+ ],
+ 'identifierTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'Identifiers.type'
+ ],
+ 'nameTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'Names.type'
+ ],
+ 'telephoneNumberTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'TelephoneNumbers.type'
+ ],
+ 'types' => [
+ 'type' => 'type'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => ['platformAdmin', 'coAdmin'],
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => ['platformAdmin', 'coAdmin'],
+ 'index' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Obtain the set of attributes supported for Match requests.
+ *
+ * @since COmanage Registry v5.2.0
+ * @return array Array of supported attributes
+ */
+
+ public function supportedAttributes(): array {
+ return [
+ // The array key should match the wire name
+ 'addresses' => [
+ 'label' => __d('controller', 'Addresses', [1]),
+ 'model' => 'Addresses',
+ // array values are "registry name" => "attribute dictionary name"
+ 'attributes' => [
+ // We'll use the virtual attribute to get a formatted address.
+ // We could provide individual attributes (similar to Name) but
+ // we don't have any actual use cases for this yet.
+ 'formatted_address' => 'formatted'
+ ],
+ 'type' => 'Addresses.type',
+ 'wire' => 'addresses'
+ ],
+ 'dateOfBirth' => [
+ 'label' => __d('field', 'date_of_birth'),
+ 'model' => 'ExternalIdentities',
+ // Note the use of singular "attribute", vs "attributes" for MVPAs
+ 'attribute' => 'date_of_birth',
+ 'type' => false,
+ 'wire' => 'dateOfBirth'
+ ],
+ 'emailAddresses' => [
+ 'label' => __d('field', 'mail'),
+ 'model' => 'EmailAddresses',
+ 'attributes' => [
+ 'mail' => 'address'
+ ],
+ // type corresponds to Types::$supportedAttributes, and will automatically
+ // be injected into the wire representation (ie: do not list it under
+ // "attributes")
+ 'type' => 'EmailAddresses.type',
+ 'wire' => 'emailAddresses'
+ ],
+ 'identifiers' => [
+ 'label' => __d('field', 'identifier'),
+ 'model' => 'Identifiers',
+ 'attributes' => [
+ 'identifier' => 'identifier'
+ ],
+ 'type' => 'Identifiers.type',
+ 'wire' => 'identifiers'
+ ],
+ 'names' => [
+ 'label' => __d('field', 'name'),
+ 'model' => 'Names',
+ 'attributes' => [
+ 'honorific' => 'prefix',
+ 'given' => 'given',
+ 'middle' => 'middle',
+ 'family' => 'family',
+ 'suffix' => 'suffix'
+ ],
+ 'type' => 'Names.type',
+ 'wire' => 'names'
+ ],
+ 'telephoneNumbers' => [
+ 'label' => __d('field', 'TelephoneNumbers.number'),
+ 'model' => 'TelephoneNumbers',
+ 'attributes' => [
+ // We'll use the virtual attribute to get a formatted number
+ 'formatted_number' => 'number'
+ ],
+ 'type' => 'TelephoneNumbers.type',
+ 'wire' => 'telephoneNumbers'
+ ]
+ ];
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('match_server_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('match_server_id');
+
+ $validator->add('attribute', [
+ 'content' => ['rule' => ['inList', Hash::extract($this->supportedAttributes(), '{s}.wire')]]
+ ]);
+ $validator->notEmptyString('attribute');
+
+ $validator->add('type_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ // Required really depends on attribute
+ $validator->allowEmptyString('type_id');
+
+ $validator->add('required', [
+ 'content' => ['rule' => ['inList', RequiredEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('required');
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/CoreServer/src/Model/Table/MatchServersTable.php b/app/plugins/CoreServer/src/Model/Table/MatchServersTable.php
index 6b095d270..f11adea97 100644
--- a/app/plugins/CoreServer/src/Model/Table/MatchServersTable.php
+++ b/app/plugins/CoreServer/src/Model/Table/MatchServersTable.php
@@ -34,7 +34,11 @@
use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
+use Cake\Utility\Hash;
+use Cake\Utility\Inflector;
use Cake\Validation\Validator;
+use App\Lib\Enum\SuspendableStatusEnum;
+use App\Lib\Enum\RequiredEnum;
class MatchServersTable extends HttpServersTable {
/**
@@ -56,6 +60,9 @@ public function initialize(array $config): void {
// Define associations
$this->belongsTo('Servers');
+ $this->hasMany('CoreServer.MatchServerAttributes')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
$this->setDisplayField('hostname');
@@ -73,10 +80,340 @@ public function initialize(array $config): void {
'table' => [
'add' => ['platformAdmin', 'coAdmin'],
'index' => ['platformAdmin', 'coAdmin']
+ ],
+ 'related' => [
+ 'table' => [
+ 'CoreServer.MatchServerAttributes'
+ ]
]
]);
}
+ /**
+ * Assemble attributes from a person record into a format suitable for wire
+ * transfer.
+ *
+ * @since COmanage Registry v4.0.0
+ * @param array $matchAttributes Match Attribute configuration
+ * @param array $queryAttributes Identity attributes for querying
+ * @return array Array of data suitable for conversion to JSON
+ * @throws InvalidArgumentException
+ * @throws RuntimeException
+ */
+
+ protected function assembleRequestAttributes(
+ array $matchAttributes,
+ array $queryAttributes
+ ): array {
+ $matchRequest = [];
+
+ $supportedAttrs = $this->MatchServerAttributes->supportedAttributes();
+
+ foreach($matchAttributes as $mattr) {
+ if($mattr->required == RequiredEnum::NotPermitted) {
+ continue;
+ }
+
+ $found = false;
+
+ // This is the key used by supportedAttributes(), which is also the value
+ // stored in the database for 'attribute' by the form
+ $attrKey = $mattr->attribute;
+
+ // $model = (eg) EmailAddresses
+ $model = $supportedAttrs[$attrKey]['model'];
+ // $tableName = (eg) email_addresses
+ $tableName = Inflector::tableize($model);
+ // $wire = (eg) emailAddresses
+ $wire = $supportedAttrs[$attrKey]['wire'];
+
+ if(isset($supportedAttrs[$attrKey]['attribute'])) {
+ // This is a singleton value on OrgIdentity, eg "date_of_birth"
+
+ // XXX date_of_birth is expected to be YYYY-MM-DD but we don't currently try to reformat it...
+
+ if(!empty($queryAttributes[ $supportedAttrs[$attrKey]['attribute'] ])) {
+ $matchRequest['sorAttributes'][$wire] = $queryAttributes[ $supportedAttrs[$attrKey]['attribute'] ];
+ $found = true;
+ }
+ } elseif(isset($supportedAttrs[$attrKey]['attributes'])) {
+ // This is an MVEA, eg "emailAddress" or "telephoneNumber"
+
+ // When assembling attributes from MVEAs, we include all available attributes.
+ // The Match server can ignore the ones it doesn't care about.
+
+ // We don't try to reformat the attribute (strip spaces, slashes, etc) since
+ // the match engine should be configured to treat the attribute appropriately
+ // (eg: alphanumeric). That is, unless a formatting function is specified
+ // to (eg) assemble a TelephoneNumber from its parts.
+
+ // $type = (eg) official (as configured for this Match Server instance)
+ $type = $mattr->type->value;;
+
+ if(!empty($queryAttributes[$tableName])) {
+ $obj = Hash::extract($queryAttributes[$tableName], '{n}[type='.$type.']');
+
+ if(!empty($obj)) {
+ foreach($obj as $o) {
+ // Assemble the record
+ $attrs = ['type' => $type];
+
+ if(!empty($supportedAttrs[$attrKey]['attributes'])) {
+ foreach($supportedAttrs[$attrKey]['attributes'] as $ra => $ad) {
+ // $ra = Registry Attribute, $ad = Attribute Dictionary attribute
+ // We use isset() rather than !empty() to avoid issues with
+ // "blank" values, including 0
+ if(isset($o[$ra])) {
+ $attrs[$ad] = $o[$ra];
+ }
+ }
+ }
+ /* So far we don't need a function in PE since we use virtual attributes
+ on the entity instead
+ else {
+ // Call the function
+ $fn = $supportedAttrs[$attrKey]['function'];
+
+ $attrs[ $supportedAttrs[$attrKey]['wireField'] ] = $fn($o);
+ } */
+
+ // Make sure we have something other than type to work with
+ if(count(array_keys($attrs)) > 1) {
+ $matchRequest['sorAttributes'][$wire][] = $attrs;
+ $found = true;
+ }
+ }
+ }
+ }
+ } else {
+ throw new \LogicException('NOT IMPLEMENTED: ' . $attrKey);
+ }
+
+ if(!$found && $mattr->required == RequiredEnum::Required) {
+ throw new \InvalidArgumentException(__d('core_server', 'error.MatchServers.attr.req', [$attrKey, $mattr->id]));
+ }
+ }
+
+ if(empty($matchRequest)) {
+ // We didn't find any attributes, so throw an error
+
+ throw new \RuntimeException(__d('core_server', 'error.MatchServers.attr.none'));
+ }
+
+ return $matchRequest;
+ }
+
+ /**
+ * Perform an ID Match Reference Identifier or Update Attributes Request.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $serverId Server ID (NOT Match Server ID)
+ * @param string $sorLabel System of Record Label
+ * @param string $sorId System of Record ID (Source Key)
+ * @param array $attributes Available attributes used to perform match request with
+ * @param string $referenceId Reference ID, for forced reconciliation request
+ * @param string $action Requested action: "request" (Reference ID) or "update" (Match Attributes)
+ * @return string|array|bool Reference ID or (on 300 response) array of choices, or true (Update request)
+ * @throws InvalidArgumentException
+ * @throws UnexpectedValueException
+ */
+
+ protected function doRequest(
+ int $serverId,
+ string $sorLabel,
+ string $sorId,
+ array $attributes,
+ ?string $referenceId=null,
+ string $action='request'
+ ): string|array|bool {
+ // Pull the Match Server configuration
+
+ $matchServer = $this->Servers->get(
+ $serverId,
+ [ 'contain' => [
+ 'MatchServers' => ['MatchServerAttributes' => 'Types']
+ ]]
+ );
+
+ if($matchServer->status != SuspendableStatusEnum::Active) {
+ throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Servers', [1]), $serverId]));
+ }
+
+ // Assemble the match request attributes using the provided entity attributes
+ // Let any exceptions bubble up
+ $matchRequest = $this->assembleRequestAttributes(
+ $matchServer->match_server->match_server_attributes,
+ $attributes
+ );
+
+ if($referenceId) {
+ // Insert the requested Reference ID into the message body
+
+ $matchRequest['referenceId'] = $referenceId;
+ }
+
+ $MatchClient = $this->createHttpClient($matchServer->match_server->id);
+
+ $url = "/people/" . urlencode($sorLabel) . "/" . urlencode($sorId);
+ $options = [
+ 'headers' =>['Content-Type' => 'application/json']
+ ];
+
+ if($action == 'request') {
+ // Before we submit the PUT, we do a GET (Request Current Values) to see
+ // if there is already a reference ID available. This is primarily to
+ // handle a potential match (202) situation (ie: we previously tried to
+ // get a reference ID but got a 202 instead). Deployers could enable the
+ // Match Callback API, in which case this wouldn't be necessary.
+
+ // An alternate approach here would be to store the Match Request ID
+ // returned as part of the 202 response, however that ID is optional
+ // (maybe it should be required?) and we would need to track it somewhere
+ // (in the OIS Record?). It would be nice to link to the pending request
+ // though...
+
+ $response = $MatchClient->get($url);
+
+ if($response->getStatusCode() == 200) {
+ $body = $response->getJson();
+
+ if(!empty($body['meta']['referenceId'])) {
+ $this->llog('trace', "Received existing Reference ID " . $body['meta']['referenceId'] . " for $sorLabel / $sorId from Match server " . $matchServer->match_server->id);
+
+ // The pending match has been resolved
+ return $body['meta']['referenceId'];
+ }
+ }
+ }
+
+ $response = $MatchClient->put($url, json_encode($matchRequest), $options);
+
+ $body = $response->getJson();
+
+ $this->llog('trace', "Match server " . $matchServer->match_server->id
+ . " returned " . $response->getStatusCode()
+ . " for $sorLabel / $sorId");
+
+ // If we get anything other than a 200/201 back, throw an error. This includes
+ // 202, which we handle by simply generating a slightly different error.
+
+ if($response->getStatusCode() == 202) {
+ $requestId = "?";
+
+ if(!empty($body['matchRequest'])) {
+ // Match Request is an optional part of the response
+ $requestId = $body['matchRequest'];
+ }
+
+ throw new \UnexpectedValueException(__d('core_server', 'result.MatchServers.match.accepted', $requestId));
+ }
+
+ if($response->getStatusCode() == 300) {
+ $candidates = [];
+
+ // Inject the "new" candidate to make it easier for the calling code
+ $candidates[] = [
+ 'referenceId' => 'new',
+ 'sorRecords' => [
+ [
+ 'meta' => [
+ 'referenceId' => 'new'
+ ],
+ 'sorAttributes' => $matchRequest['sorAttributes']
+ ]
+ ]
+ ];
+
+ $candidates = array_merge($candidates, $body['candidates']);
+ return $candidates;
+ }
+
+ if($response->getStatusCode() != 200 && $response->getStatusCode() != 201) {
+ $error = $response->reasonPhrase;
+
+ // If an error was provided in the response, use that instead
+ if(!empty($body['error'])) {
+ $error = $body['error'];
+ }
+
+ $this->llog('error', "Match Server " . $matchServer->match_server->id . " returned error $error");
+
+ throw new \RuntimeException(__d('core_server', 'error.MatchServers.response', [$error]));
+ }
+
+ if($action == 'request') {
+ // We expect a reference ID
+ $this->llog('trace', "Received Reference ID " . $body['referenceId'] . " for $sorLabel / $sorId from Match server " . $matchServer->match_server->id);
+
+ return $body['referenceId'];
+ }
+
+ // There is no Reference ID returned for an Update Match Attributes request
+ return true;
+ }
+
+ /**
+ * Perform an ID Match Reference Identifier Request.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param integer $serverId Server ID
+ * @param string $sorLabel System of Record Label
+ * @param string $sorId System of Record ID (Source Key)
+ * @param array $attributes Available attributes used to perform match request with
+ * @param string $referenceId Reference ID, for forced reconciliation request
+ * @return string|array Reference ID or (on 300 response) array of choices
+ * @throws InvalidArgumentException
+ * @throws UnexpectedValueException
+ */
+
+ public function requestReferenceIdentifier(
+ int $serverId,
+ string $sorLabel,
+ string $sorId,
+ array $attributes,
+ ?string $referenceId=null
+ ): string|array {
+ return $this->doRequest(
+ serverId: $serverId,
+ sorLabel: $sorLabel,
+ sorId: $sorId,
+ attributes: $attributes,
+ referenceId: $referenceId,
+ action: 'request'
+ );
+ }
+
+ /**
+ * Perform an ID Match Update Match Attributes Request.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $serverId Server ID (NOT Match Server ID)
+ * @param string $sorLabel System of Record Label
+ * @param string $sorId System of Record ID (Source Key)
+ * @param array $attributes Available attributes used to perform match request with
+ * @return boolean true on success
+ * @throws InvalidArgumentException
+ */
+
+ public function updateMatchAttributes(
+ int $serverId,
+ string $sorLabel,
+ string $sorId,
+ array $attributes
+ ): bool {
+ // This is basically the same request as requestReferenceIdentifier().
+ // If we don't throw an exception then we successfully processed the request.
+
+ return $this->doRequest(
+ serverId: $serverId,
+ sorLabel: $sorLabel,
+ sorId: $sorId,
+ attributes: $attributes,
+ action: 'update'
+ );
+ }
+
+
/**
* Set validation rules.
*
diff --git a/app/plugins/CoreServer/src/config/plugin.json b/app/plugins/CoreServer/src/config/plugin.json
index f64e58cbd..7bf87729f 100644
--- a/app/plugins/CoreServer/src/config/plugin.json
+++ b/app/plugins/CoreServer/src/config/plugin.json
@@ -3,9 +3,9 @@
"server": [
"HttpServers",
"MatchServers",
+ "Oauth2Servers",
"SmtpServers",
- "SqlServers",
- "Oauth2Servers"
+ "SqlServers"
]
},
"schema": {
@@ -38,6 +38,18 @@
"match_servers_i1": { "columns": [ "server_id" ] }
}
},
+ "match_server_attributes": {
+ "columns": {
+ "id": {},
+ "match_server_id": { "type": "integer", "foreignkey": { "table": "match_servers", "column": "id" }, "notnull": true },
+ "attribute": { "type": "string", "size": 80 },
+ "type_id": { "notnull": false },
+ "required": {}
+ },
+ "indexes": {
+ "match_server_attributes_i1": { "columns": [ "match_server_id" ]}
+ }
+ },
"oauth2_servers": {
"columns": {
"id": {},
diff --git a/app/plugins/CoreServer/templates/MatchServerAttributes/columns.inc b/app/plugins/CoreServer/templates/MatchServerAttributes/columns.inc
new file mode 100644
index 000000000..588605a76
--- /dev/null
+++ b/app/plugins/CoreServer/templates/MatchServerAttributes/columns.inc
@@ -0,0 +1,43 @@
+ [
+ 'type' => 'link'
+ ],
+ 'type_id' => [
+ 'type' => 'fk'
+ ],
+ 'required' => [
+ 'type' => 'enum',
+ 'class' => 'RequiredEnum'
+ ]
+];
diff --git a/app/plugins/CoreServer/templates/MatchServerAttributes/fields.inc b/app/plugins/CoreServer/templates/MatchServerAttributes/fields.inc
new file mode 100644
index 000000000..333c89e45
--- /dev/null
+++ b/app/plugins/CoreServer/templates/MatchServerAttributes/fields.inc
@@ -0,0 +1,133 @@
+
+
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'attribute',
+ 'fieldOptions' => [
+ 'onChange' => 'resetType()'
+ ]
+ ]]);
+
+ // type_id is a hidden field used to persist the type
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'type_id',
+ 'fieldOptions' => [
+ 'type' => 'integer'
+ ]
+ ]]);
+
+ // These are display widgets that are filtered to the correct available types
+ foreach ([
+ 'address_type_id',
+ 'email_address_type_id',
+ 'identifier_type_id',
+ 'name_type_id',
+ 'telephone_number_type_id'
+ ] as $field) {
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field,
+ 'fieldLabel' => __d('field', 'type'),
+ 'fieldOptions' => [
+ 'onChange' => 'copyType("' . $field . '")'
+ ]
+ ]
+ ]);
+ }
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'required'
+ ]]);
+}
\ No newline at end of file
diff --git a/app/plugins/CoreServer/templates/MatchServers/fields-nav.inc b/app/plugins/CoreServer/templates/MatchServers/fields-nav.inc
new file mode 100644
index 000000000..a5f996e57
--- /dev/null
+++ b/app/plugins/CoreServer/templates/MatchServers/fields-nav.inc
@@ -0,0 +1,39 @@
+ 'list',
+ 'order' => 'Default',
+ 'label' => __d('core_server', 'controller.MatchServerAttributes', [99]),
+ 'link' => [
+ 'plugin' => 'CoreServer',
+ 'controller' => 'match_server_attributes',
+ 'action' => 'index',
+ 'match_server_id' => $vv_obj->id
+ ],
+ 'class' => ''
+];
diff --git a/app/plugins/CoreServer/templates/MatchServers/fields.inc b/app/plugins/CoreServer/templates/MatchServers/fields.inc
index 550143398..b5aae1e1d 100644
--- a/app/plugins/CoreServer/templates/MatchServers/fields.inc
+++ b/app/plugins/CoreServer/templates/MatchServers/fields.inc
@@ -27,14 +27,3 @@
// At least initially we use only the HttpServer fields
include ROOT . DS . "plugins" . DS . "CoreServer" . DS . "templates" . DS . "HttpServers" . DS . "fields.inc";
-
-// Render the callback API endpoint
-
-print $this->element('form/listItem', [
- 'arguments' => [
- 'fieldName' => 'api_endpoint',
- 'fieldOptions' => [
- 'readOnly' => true,
- 'default' => $vv_api_endpoint
- ]
- ]]);
\ No newline at end of file
diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po
index f63a9ef83..2005d0e44 100644
--- a/app/resources/locales/en_US/controller.po
+++ b/app/resources/locales/en_US/controller.po
@@ -33,6 +33,9 @@ msgstr "{0,plural,=1{Ad Hoc Attribute} other{Ad Hoc Attributes}}"
msgid "ApiUsers"
msgstr "{0,plural,=1{API User} other{API Users}}"
+msgid "Apis"
+msgstr "{0,plural,=1{API} other{APIs}}"
+
msgid "AuthenticationEvents"
msgstr "{0,plural,=1{Authentication Event} other{Authentication Events}}"
diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po
index f86d6a80c..b9040adb6 100644
--- a/app/resources/locales/en_US/error.po
+++ b/app/resources/locales/en_US/error.po
@@ -340,6 +340,15 @@ msgstr "Petition {0} is not in Finalizing status"
msgid "Pipelines.eis.record.adopted"
msgstr "Source Key {0} has been adopted by Person ID {1} and is no longer eligible for syncing"
+msgid "Pipelines.match.external.empty"
+msgstr "Received unexpected empty Reference Identifier from server"
+
+msgid "Pipelines.match.external.response"
+msgstr "Received unexpected multiple choice response from server"
+
+msgid "Pipelines.match.multiple"
+msgstr "Match Strategy found more than one matching Person for Reference ID {0}"
+
msgid "Pipelines.plugin.notimpl"
msgstr "Pipeline plugin does not implement {0}"
diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po
index 7ecb1e294..8350a7254 100644
--- a/app/resources/locales/en_US/field.po
+++ b/app/resources/locales/en_US/field.po
@@ -375,6 +375,12 @@ msgstr "Authentication Event"
msgid "Cos.member.not"
msgstr "{0} (Not a Member)"
+msgid "CoSettings.authn_events_api_disable"
+msgstr "Disable API User Authentication Events"
+
+msgid "CoSettings.authn_events_api_disable.desc"
+msgstr "If checked, Authentication Events will not be recorded for API User logins"
+
msgid "CoSettings.default_address_type_id"
msgstr "Default Address Type"
@@ -749,6 +755,9 @@ msgstr "Email Address Type"
msgid "Pipelines.match_identifier_type_id"
msgstr "Identifier Type"
+msgid "Pipelines.match_server_id"
+msgstr "Match Server"
+
msgid "Pipelines.match_strategy"
msgstr "Match Strategy"
diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po
index 783986b96..848744e1e 100644
--- a/app/resources/locales/en_US/result.po
+++ b/app/resources/locales/en_US/result.po
@@ -141,9 +141,6 @@ msgstr "Identifiers Assigned ({0})"
msgid "IdentifierAssignments.queued.ok"
msgstr "Identifiers Assignment for context {0} queued for CO {1}"
-msgid "Names.primary_name"
-msgstr "Primary Name Updated"
-
msgid "Jobs.canceled"
msgstr "Job {0} canceled"
@@ -153,6 +150,9 @@ msgstr "Job canceled by {0}"
msgid "Jobs.registered"
msgstr "Started via JobCommand by {0} (uid {1})"
+msgid "Names.primary_name"
+msgstr "Primary Name Updated"
+
# These are for generating history records
msgid "Notifications.A"
msgstr "Notification acknowledged: {0}"
@@ -208,12 +208,18 @@ msgstr "Created new External Identity via Pipeline {0} ({1}) using Source {2} ({
msgid "Pipelines.matched"
msgstr "Pipeline {0} ({1}) matched EIS {2} ({3}) source key {4} to Person using Match Strategy {5}"
+msgid "Pipelines.matched.external"
+msgstr "Reference Identifier {0} obtained from match server"
+
msgid "Pipelines.requested"
msgstr "Pipeline {0} ({1}) linked to requested Person {2} for EIS {3} ({4}) source key {5}"
msgid "Pipelines.started"
msgstr "Pipeline {0} ({1}) started for EIS {2} ({3}) source key {4}"
+msgid "Pipelines.updated.external"
+msgstr "Sent Update Match Attributes Request"
+
msgid "ProvisioningTargets.queued.ok"
msgstr "Reprovisioning for {0} queued for {1} ({2})"
diff --git a/app/src/Controller/ApisController.php b/app/src/Controller/ApisController.php
new file mode 100644
index 000000000..c9a29ab85
--- /dev/null
+++ b/app/src/Controller/ApisController.php
@@ -0,0 +1,41 @@
+ [
+ 'Apis.description' => 'asc'
+ ]
+ ];
+}
\ No newline at end of file
diff --git a/app/src/Controller/AuthenticationEventsController.php b/app/src/Controller/AuthenticationEventsController.php
index 2c835c02a..c991d03ba 100644
--- a/app/src/Controller/AuthenticationEventsController.php
+++ b/app/src/Controller/AuthenticationEventsController.php
@@ -33,7 +33,7 @@
use Cake\Log\Log;
use Cake\ORM\TableRegistry;
-class AuthenticationEventsController extends MVEAController {
+class AuthenticationEventsController extends StandardController {
public $paginate = [
'order' => [
'AuthenticationEvents.id' => 'desc'
@@ -42,23 +42,4 @@ class AuthenticationEventsController extends MVEAController {
// Cached permissions
protected ?array $permCache = null;
-
- /**
- * Callback run prior to the request action.
- *
- * @since COmanage Registry v5.0.0
- * @param EventInterface $event Cake Event
- */
-
- public function beforeFilter(\Cake\Event\EventInterface $event) {
- // If an identifier was passed in, use that to filter the index query.
- // (Authz is handled in the closure passed to setIndexFilter by AuthenticationEventsTable.)
- $targetIdentifier = $this->getRequest()->getQuery('authenticated_identifier');
-
- if($targetIdentifier) {
- $this->AuthenticationEvents->setIndexFilter(['authenticated_identifier' => \App\Lib\Util\StringUtilities::urlbase64decode($targetIdentifier)]);
- }
-
- return parent::beforeFilter($event);
- }
}
\ No newline at end of file
diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php
index 1054d3c13..7ae9ac416 100644
--- a/app/src/Controller/Component/RegistryAuthComponent.php
+++ b/app/src/Controller/Component/RegistryAuthComponent.php
@@ -94,13 +94,13 @@ protected function authenticateApiUser(): bool {
try {
// validateKey takes care of all validity logic, as well as rehashing (if needed)
- if($ApiUsers->validateKey($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'], $_SERVER['REMOTE_ADDR'])) {
- $this->authenticatedUser = $_SERVER['PHP_AUTH_USER'];
- $this->authenticatedApiUser = true;
- $this->llog('debug', "Authenticated API User \"" . $this->authenticatedUser . "\"");
+ $this->cache['api_user']['co_id'] = $ApiUsers->validateKey($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'], $_SERVER['REMOTE_ADDR']);
+
+ $this->authenticatedUser = $_SERVER['PHP_AUTH_USER'];
+ $this->authenticatedApiUser = true;
+ $this->llog('debug', "Authenticated API User \"" . $this->authenticatedUser . "\"");
- return true;
- }
+ return true;
}
catch(\Exception $e) {
$this->llog('debug', "User authentication failed: " . $e->getMessage());
@@ -205,11 +205,28 @@ public function beforeFilter(EventInterface $event) {
}
if($authok) {
- $AuthenticationEvents = TableRegistry::getTableLocator()->get('AuthenticationEvents');
+ // Record an Authentication Event, unless disabled for the current CO,
+ // which should have been returned during authenticateApiUsre().
+
+ $skipRecording = false;
- $AuthenticationEvents->record(identifier: $this->authenticatedUser,
- eventType: AuthenticationEventEnum::ApiLogin,
- remoteIp: $_SERVER['REMOTE_ADDR']);
+ if(!empty($this->cache['api_user']['co_id'])) {
+ $CoSettings = TableRegistry::getTableLocator()->get('CoSettings');
+
+ $settings = $CoSettings->find()
+ ->where(['co_id' => $this->cache['api_user']['co_id']])
+ ->firstOrFail();
+
+ $skipRecording = ($settings->authn_events_api_disable === true);
+ }
+
+ if(!$skipRecording) {
+ $AuthenticationEvents = TableRegistry::getTableLocator()->get('AuthenticationEvents');
+
+ $AuthenticationEvents->record(identifier: $this->authenticatedUser,
+ eventType: AuthenticationEventEnum::ApiLogin,
+ remoteIp: $_SERVER['REMOTE_ADDR']);
+ }
return true;
}
diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php
index e28248549..f3021cadc 100644
--- a/app/src/Controller/DashboardsController.php
+++ b/app/src/Controller/DashboardsController.php
@@ -87,6 +87,11 @@ public function configuration() {
'controller' => 'api_users',
'action' => 'index'
],
+ __d('controller', 'Apis', [99]) => [
+ 'icon' => 'api',
+ 'controller' => 'apis',
+ 'action' => 'index'
+ ],
__d('controller', 'Cous', [99]) => [
'icon' => 'people_outline',
'controller' => 'cous',
@@ -179,7 +184,7 @@ public function configuration() {
'controller' => 'plugins',
'action' => 'index'
],
- __d('controller', "TrafficDetours", [99]) => [
+ __d('controller', 'TrafficDetours', [99]) => [
'icon' => 'fork_right',
'controller' => 'traffic_detours',
'action' => 'index'
@@ -235,6 +240,19 @@ public function configuration() {
]
];
+ if($co->isCOmanageCO()) {
+ $artifactMenuItems[__d('controller', 'AuthenticationEvents', [99])] = [
+ // Authentication Events are only visible to the Platform Administrators
+ // since they are tied to Identifiers, not CO-specific information.
+ 'icon' => 'lock',
+ 'iconClass' => 'material-symbols-outlined',
+ 'controller' => 'authentication_events',
+ 'action' => 'index',
+ // Suppress injection of CO ID
+ 'platform' => true
+ ];
+ }
+
ksort($artifactMenuItems);
$this->set('vv_artifacts_menu_items', $artifactMenuItems);
diff --git a/app/src/Controller/StandardApiController.php b/app/src/Controller/StandardApiController.php
index c3947b67d..49dd9fb10 100644
--- a/app/src/Controller/StandardApiController.php
+++ b/app/src/Controller/StandardApiController.php
@@ -31,6 +31,7 @@
use Cake\ORM\TableRegistry;
use Cake\Utility\Inflector;
+use App\Lib\Enum\SuspendableStatusEnum;
class StandardApiController extends AppController {
/**
@@ -46,4 +47,77 @@ public function initialize(): void {
// We want API auth, not Web Auth
$this->RegistryAuth->setConfig('apiUser', true);
}
+
+ /**
+ * Calculate authorization for the current request.
+ *
+ * @since COmanage Registry v5.2.0
+ * @return bool True if the current request is permitted, false otherwise
+ */
+
+ public function calculatePermission(): bool {
+ $request = $this->getRequest();
+ $action = $request->getParam('action');
+ $authuser = null;
+
+ // We let RegistryAuth handle the authentication
+ if(!$this->RegistryAuth->isApiUser()) {
+ // We don't localize exception in this function or call llog directly because
+ // any exception we throw will be caught by RegistryAuthComponent and logged there
+ throw new \InvalidArgumentException("RegistryAuth did not provide API User in calculatePermission");
+ }
+
+ $authUser = $this->RegistryAuth->getAuthenticatedUser();
+
+ $authorized = false;
+
+ // For authorization, we need to find the corresponding entry in Apis.
+ // First we need the API User ID.
+
+ $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers');
+
+ // RegistryAuthComponent took care of all the validation, we just need the ID
+ $apiuser = $ApiUsers->find()
+ ->where(['username' => $authUser])
+ ->firstOrFail();
+
+ // Next we need the Plugin's Entry Point Map to tell us what Entry Point Model the
+ // current controller points to.
+
+ if(!isset($this->entryPointMap[$action])) {
+ throw new \RuntimeException("Plugin did not provide Entry Point Map value for $action");
+ }
+
+ // Find the associated API configuration
+
+ $api = $ApiUsers->Apis->find()
+ ->where([
+ 'api_user_id' => $apiuser->id,
+ 'plugin' => $this->getPlugin() . "." . $this->entryPointMap[$action]
+ ])
+ ->firstOrFail();
+
+ // We manually check status (as opposed to updating the find) to faciliate logging
+
+ if($api->status != SuspendableStatusEnum::Active) {
+ throw new \InvalidArgumentException("API " . $api->id . " is not active");
+ }
+
+ // If we get here the API User is authorized for the requested plugin configuration
+
+ return true;
+ }
+
+ /**
+ * Indicate whether this Controller will handle some or all authnz.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param EventInterface $event Cake event, ie: from beforeFilter
+ * @return string "no", "open", "authz", or "yes"
+ */
+
+ public function willHandleAuth(\Cake\Event\EventInterface $event): string {
+ // We always take over authz
+ return 'authz';
+ }
}
\ No newline at end of file
diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php
index 9b5012df4..01bd08bfb 100644
--- a/app/src/Lib/Enum/ActionEnum.php
+++ b/app/src/Lib/Enum/ActionEnum.php
@@ -48,6 +48,7 @@ class ActionEnum extends StandardEnum {
const GroupOwnerAdded = 'ACGO';
const GroupOwnerDeleted = 'DCGO';
const IdentifierAutoAssigned = 'AIDA';
+ const MatchAttributesUpdated = 'UMAT';
const MVEAAdded = 'AMVE';
const MVEADeleted = 'DMVE';
const MVEAEdited = 'EMVE';
@@ -63,4 +64,5 @@ class ActionEnum extends StandardEnum {
const PersonPipelineStarted = 'SCPL';
const PersonRoleRelinked = 'LCPR';
const PersonStatusRecalculated = 'RCPS';
+ const ReferenceIdentifierObtained = 'OIDR';
}
\ No newline at end of file
diff --git a/app/src/Lib/Traits/AutoViewVarsTrait.php b/app/src/Lib/Traits/AutoViewVarsTrait.php
index 853fbb1e4..49ecbf123 100644
--- a/app/src/Lib/Traits/AutoViewVarsTrait.php
+++ b/app/src/Lib/Traits/AutoViewVarsTrait.php
@@ -31,7 +31,9 @@
use App\Lib\Enum\SuspendableStatusEnum;
use App\Lib\Util\FunctionUtilities;
-use \Cake\ORM\TableRegistry;
+use App\Lib\Util\StringUtilities;
+use Cake\ORM\TableRegistry;
+use Cake\Utility\Hash;
trait AutoViewVarsTrait {
// Array (and configuration) of view variables to automatically populate
@@ -105,17 +107,16 @@ public function calculateAutoViewVars(int|null $coId, Object $obj = null): \Gene
// Inject configuration. Since we're only ever looking at the types
// table, inject the current CO along with the requested attribute
$avv['model'] = 'Types';
- if(\is_array($avv['attribute'])) {
- $avv['where'] = [
- 'attribute IN' => $avv['attribute'],
- 'status' => SuspendableStatusEnum::Active
- ];
- } else {
- $avv['where'] = [
- 'attribute' => $avv['attribute'],
- 'status' => SuspendableStatusEnum::Active
- ];
+ $avv['where']['status'] = SuspendableStatusEnum::Active;
+ if(!empty($avv['attribute'])) {
+ if(\is_array($avv['attribute'])) {
+ $avv['where']['attribute IN'] = $avv['attribute'];
+ } else {
+ $avv['where']['attribute'] = $avv['attribute'];
+ }
}
+ // else we allow for 'attribute' to be omitted for cases where type_id
+ // can be populated by multiple types (eg Match Server Attributes)
// fall through
case 'auxiliary':
// XXX add list as in match?
@@ -202,8 +203,49 @@ public function calculateAutoViewVars(int|null $coId, Object $obj = null): \Gene
$generatedValue = $table->getParents($coId);
break;
case 'plugin':
- $PluginTable = TableRegistry::getTableLocator()->get('Plugins');
- $generatedValue = $PluginTable->getActivePluginModels($avv['pluginType']);
+ if(!empty($avv['pluginType'])) {
+ // Return the list of Entry Point Models implementing the specified Plugin Type
+ // (eg: "source")
+ $PluginTable = TableRegistry::getTableLocator()->get('Plugins');
+ $generatedValue = $PluginTable->getActivePluginModels($avv['pluginType']);
+ } elseif(!empty($avv['model'])) {
+ // Return the list of instantiations of the specified Entry Point Model
+ // (eg: "ApiConnector.ApiSources")
+ $ModelTable = TableRegistry::getTableLocator()->get($avv['model']);
+
+ // We need to find the parent ("pluggable") model, but there actually isn't a way
+ // for a Plugin to explicitly say what it's pluggable model parent is. (The reverse
+ // is possible via the PluggableModel Trait.) Instead, we walk the model's primary
+ // links until we find one that implements the Pluggable functions, at which point
+ // it's safe to assume that's the correct parent model since Entry Point Models
+ // can't implement two pluggable interfaces.
+ $primaryLinks = $ModelTable->getPrimaryLinks();
+
+ foreach($primaryLinks as $l) {
+ $PluggableTable = TableRegistry::getTableLocator()->get(StringUtilities::foreignKeyToClassName($l));
+
+ if(method_exists($PluggableTable, "getPluggableModelType")) {
+ // This is the correct primary link. Note we don't necessarily know how to
+ // filter inactive records since the only column PluggableTrait requires is
+ // "plugin".
+
+ // The entity field holding the related model
+ $modelKey = StringUtilities::pluginToEntityField($avv['model']);
+
+ // For now we don't filter on status because not all Pluggable models
+ // use it consistency. (Specifically, EISs use SyncModeEnum instead.)
+ $generatedValue = $PluggableTable->find('list', [
+ 'keyField' => $modelKey.'.id',
+ 'valueField' => 'description'
+ ])
+ ->where(['plugin' => $avv['model']])
+ ->contain(StringUtilities::pluginModel($avv['model']))
+ ->all();
+
+ break;
+ }
+ }
+ }
break;
default:
// XXX I18n? and in match?
diff --git a/app/src/Lib/Traits/TableMetaTrait.php b/app/src/Lib/Traits/TableMetaTrait.php
index 671503ea1..a91aa4eac 100644
--- a/app/src/Lib/Traits/TableMetaTrait.php
+++ b/app/src/Lib/Traits/TableMetaTrait.php
@@ -124,12 +124,12 @@ protected function filterMetadataForCopy(
// HasMany
foreach($entity->$m as $s) {
- $ret[$m][] = $this->filterMetadataForDuplicate($t, $s);
+ $ret[$m][] = $this->filterMetadataForCopy($t, $s);
}
} elseif(!empty($entity->$m1)) {
// HasOne
- $ret[$m1] = $this->filterMetadataForDuplicate($t, $entity->$m1);
+ $ret[$m1] = $this->filterMetadataForCopy($t, $entity->$m1);
}
} elseif(is_array($v)) {
// $k is the model name (EnrollmentFlowSteps) and $v is an array of related models
@@ -149,12 +149,12 @@ protected function filterMetadataForCopy(
// HasMany
foreach($entity->$m as $s) {
- $ret[$m][] = $this->filterMetadataForDuplicate($t, $s, $v);
+ $ret[$m][] = $this->filterMetadataForCopy($t, $s, $v);
}
} elseif(!empty($entity->$m1)) {
// HasOne
- $ret[$m1] = $this->filterMetadataForDuplicate($t, $entity->$m1, $v);
+ $ret[$m1] = $this->filterMetadataForCopy($t, $entity->$m1, $v);
}
}
}
diff --git a/app/src/Lib/Util/TableUtilities.php b/app/src/Lib/Util/TableUtilities.php
index c97680cc6..599e8e274 100644
--- a/app/src/Lib/Util/TableUtilities.php
+++ b/app/src/Lib/Util/TableUtilities.php
@@ -99,26 +99,43 @@ public static function treeTraversalFromPrimaryLink(
// Get a table reference
$ModelTable = TableRegistry::getTableLocator()->get($primaryLinkModelName);
- // Get the Record from the database
- $resp = $ModelTable->find()
- ->where(['id' => $primaryLinkValue])
- ->first()
- ->toArray();
-
- // Find all the foreign keys and fetch the rest of the tree
- foreach($resp as $col => $val) {
- if (
- $val !== null
- && $col !== $primaryLinkKey
- && str_ends_with($col, '_id')
- ) {
- $fkModel = StringUtilities::foreignKeyToClassName(($col));
- $fk_table = Inflector::underscore($fkModel);
- if (\in_array($fk_table, $listOfTables, true)) {
- self::treeTraversalFromPrimaryLink($col, $val, $results);
+
+ try {
+ // Get the Record from the database
+ $resp = $ModelTable->find()
+ ->where(['id' => $primaryLinkValue])
+ ->firstOrFail()
+ ->toArray();
+
+ // Find all the foreign keys and fetch the rest of the tree
+ foreach($resp as $col => $val) {
+ if (
+ $val !== null
+ && $col !== $primaryLinkKey
+ && str_ends_with($col, '_id')
+ ) {
+ $fkModel = StringUtilities::foreignKeyToClassName(($col));
+ $fk_table = Inflector::underscore($fkModel);
+ if (\in_array($fk_table, $listOfTables, true)) {
+ self::treeTraversalFromPrimaryLink($col, $val, $results);
+ }
}
}
}
+ catch(\Exception $e) {
+ // Because this code is trying to get the parent table from the foreign key name,
+ // it doesn't work for foreign keys to plugin provided tables. (For example, when
+ // trying to view an External Identity Source connected to a Pipeline that uses an
+ // External Match Strategy configured for a Match Server), the edit view of the EIS
+ // no longer renders because at some point we run into a foreign key of match_server_id
+ // and there's no way to resolve that to CoreServer::MatchServersTable since the
+ // database doesn't know what plugin provides a table.
+
+ // Note that $ModelTable _is_ created in this case, because Cake by default creates
+ // a stub model if it can't find the corresponding model definition, so what actually
+ // fails is the call to first(), and then the chain to toArray(). As a workaround,
+ // we use firstOrFail() instead to cause an exception to be thrown, and then we ignore it.
+ }
}
/**
diff --git a/app/src/Model/Entity/Address.php b/app/src/Model/Entity/Address.php
index f58bc0d80..387ffca52 100644
--- a/app/src/Model/Entity/Address.php
+++ b/app/src/Model/Entity/Address.php
@@ -41,4 +41,32 @@ class Address extends Entity {
'id' => false,
'slug' => false,
];
+
+ /**
+ * Generate a formatted address.
+ *
+ * @since COmanage Registry v5.2.0
+ * @return string Formatted address
+ * @todo This is an incredibly simplistic initial implementation that is not locale aware
+ */
+
+ protected function _getFormattedAddress(): string {
+ $a = "";
+
+ foreach([
+ 'street',
+ 'room',
+ 'locality',
+ 'state',
+ 'postal_code',
+ 'country'
+ ] as $f) {
+ if(!empty($this->$f)) {
+ if($a != "") { $a .= ", "; }
+ $a .= $this->$f;
+ }
+ }
+
+ return $a;
+ }
}
\ No newline at end of file
diff --git a/app/src/Model/Entity/Api.php b/app/src/Model/Entity/Api.php
new file mode 100644
index 000000000..f453372b2
--- /dev/null
+++ b/app/src/Model/Entity/Api.php
@@ -0,0 +1,42 @@
+ true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
\ No newline at end of file
diff --git a/app/src/Model/Entity/TelephoneNumber.php b/app/src/Model/Entity/TelephoneNumber.php
index 305cf34b6..0b068c11b 100644
--- a/app/src/Model/Entity/TelephoneNumber.php
+++ b/app/src/Model/Entity/TelephoneNumber.php
@@ -49,7 +49,7 @@ class TelephoneNumber extends Entity {
* @return string Formatted telephone number
*/
- protected function _getFormattedNumber() {
+ protected function _getFormattedNumber(): string {
// Start with number since it's always required, then prepend and/or append
$n = $this->number;
diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php
index 72cde2371..d9341a2cf 100644
--- a/app/src/Model/Table/AddressesTable.php
+++ b/app/src/Model/Table/AddressesTable.php
@@ -97,7 +97,7 @@ public function initialize(array $config): void {
$this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']);
$this->setRequiresCO(true);
- // Models that AcceptCoId should be expicitly added to StandardApiController::initialize()
+ // Models that AcceptCoId should be explicitly added to AppController::beforeFilter()
$this->setAcceptsCoId(true);
$this->setRedirectGoal('self');
$this->setRedirectGoal(action: 'delete', goal: 'deleted');
diff --git a/app/src/Model/Table/ApiUsersTable.php b/app/src/Model/Table/ApiUsersTable.php
index 169a5b17b..34840f236 100644
--- a/app/src/Model/Table/ApiUsersTable.php
+++ b/app/src/Model/Table/ApiUsersTable.php
@@ -66,6 +66,8 @@ public function initialize(array $config): void {
// Define associations
$this->belongsTo('Cos');
+
+ $this->hasMany('Apis');
$this->setDisplayField('username');
@@ -92,6 +94,11 @@ public function initialize(array $config): void {
'table' => [
'add' => ['platformAdmin', 'coAdmin'],
'index' => ['platformAdmin', 'coAdmin']
+ ],
+ 'related' => [
+ 'table' => [
+ 'AuthenticationEvents'
+ ]
]
]);
}
@@ -193,11 +200,11 @@ public function getUserPrivilege(string $username) {
* @param string $username API Username
* @param string $apiKey API Key to validate
* @param string $remoteIp IP Address of request
- * @return boolean true if the API Key validates
+ * @return int The CO ID for the API User (on success)
* @throws InvalidArgumentException
*/
- public function validateKey(string $username, string $apiKey, string $remoteIp) {
+ public function validateKey(string $username, string $apiKey, string $remoteIp): int {
// First pull the ApiUser record for $username. Note we don't know which
// CO we're querying for, so $username requires the CO name as a prefix
// (except for legacy usernames, which are assumed to be part of the
@@ -261,7 +268,7 @@ public function validateKey(string $username, string $apiKey, string $remoteIp)
throw new \InvalidArgumentException(__d('error', 'auth.api.ip', [$remoteIp, $username]));
}
- return true;
+ return $apiUser->co_id;
}
/**
diff --git a/app/src/Model/Table/ApisTable.php b/app/src/Model/Table/ApisTable.php
new file mode 100644
index 000000000..740b9d258
--- /dev/null
+++ b/app/src/Model/Table/ApisTable.php
@@ -0,0 +1,138 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('ApiUsers');
+ $this->belongsTo('Cos');
+
+ $this->setPluginRelations();
+
+ $this->setDisplayField('description');
+
+ $this->setPrimaryLink('co_id');
+ $this->setRequiresCO(true);
+
+ $this->setAutoViewVars([
+ 'apiUsers' => [
+ 'type' => 'select',
+ 'model' => 'ApiUsers'
+ ],
+ 'plugins' => [
+ 'type' => 'plugin',
+ 'pluginType' => 'api'
+ ],
+ 'statuses' => [
+ 'type' => 'enum',
+ 'class' => 'SuspendableStatusEnum'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'configure' => ['platformAdmin', 'coAdmin'],
+ 'delete' => ['platformAdmin', 'coAdmin'],
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => ['platformAdmin', 'coAdmin'],
+ 'index' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('co_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('co_id');
+
+ $this->registerStringValidation($validator, $schema, 'description', true);
+
+ $validator->add('status', [
+ 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('status');
+
+ $this->registerStringValidation($validator, $schema, 'plugin', true);
+
+ $validator->add('api_user_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('api_user_id');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/AuthenticationEventsTable.php b/app/src/Model/Table/AuthenticationEventsTable.php
index f74a64cc6..8b1e945fc 100644
--- a/app/src/Model/Table/AuthenticationEventsTable.php
+++ b/app/src/Model/Table/AuthenticationEventsTable.php
@@ -73,7 +73,7 @@ public function initialize(array $config): void {
$this->setRequiresCO(false);
$this->setAutoViewVars([
- 'authentication_events' => [
+ 'authenticationEvents' => [
'type' => 'enum',
'class' => 'AuthenticationEventEnum'
]
@@ -95,8 +95,8 @@ public function initialize(array $config): void {
// We set $manages = true here because we need to return index
// permission for related models calculation in identifiers?person_id=X
// (so the link to Authentication Events renders). However, in
- // setIndexFilter below we will reject requests without a $targetIdentifier
- // which effectively denies such requests.
+ // getIndexFilter below we will reject requests without a $targetIdentifier
+ // for non-platform admins.
$manages = true;
}
@@ -113,20 +113,6 @@ public function initialize(array $config): void {
]
];
});
-
- $this->setIndexFilter(function (\Cake\Http\ServerRequest $r): array {
- // This will be checked for authz in RegistryAuthComponent
- $targetIdentifier = $r->getQuery('authenticated_identifier');
-
- // Note that in setPermissions above we permit index operations when no
- // targetIdentifier is specified. We reject that here though since index
- // views require a targetIdentifier.
- if(!$targetIdentifier) {
- throw new \InvalidArgumentException(__d('error', 'input.notprov', 'authenticated_identifier'));
- }
-
- return ['authenticated_identifier' => StringUtilities::urlbase64decode($targetIdentifier)];
- });
}
/**
diff --git a/app/src/Model/Table/CoSettingsTable.php b/app/src/Model/Table/CoSettingsTable.php
index 33884a2f5..875da642e 100644
--- a/app/src/Model/Table/CoSettingsTable.php
+++ b/app/src/Model/Table/CoSettingsTable.php
@@ -230,6 +230,7 @@ public function addDefaults(int $coId): int {
'default_pronoun_type_id' => null,
'default_telephone_number_type_id' => null,
'default_url_type_id' => null,
+ 'authn_events_api_disable' => false,
'email_smtp_server_id' => null,
'email_delivery_address_type_id' => null,
'permitted_fields_name' => PermittedNameFieldsEnum::HGMFS,
@@ -391,6 +392,11 @@ public function validationDefault(Validator $validator): Validator {
]);
$validator->allowEmptyString('default_url_type_id');
+ $validator->add('authn_events_api_disable', [
+ 'content' => ['rule' => ['boolean']]
+ ]);
+ $validator->allowEmptyString('authn_events_api_disable');
+
$validator->add('email_delivery_address_type_id', [
'content' => ['rule' => 'isInteger']
]);
diff --git a/app/src/Model/Table/ExternalIdentitySourcesTable.php b/app/src/Model/Table/ExternalIdentitySourcesTable.php
index 8eef5d03b..06acd84bf 100644
--- a/app/src/Model/Table/ExternalIdentitySourcesTable.php
+++ b/app/src/Model/Table/ExternalIdentitySourcesTable.php
@@ -343,12 +343,13 @@ public function searchableAttributes(int $id) {
* Sync an External Identity from a Source to a Person via a Pipeline.
*
* @since COmanage Registry v5.0.0
- * @param int $id External Identity Source ID
- * @param string $sourceKey EIS Backend Source Key
- * @param bool $force Whether to force the full Pipeline to run even if the backend record didn't change
- * @param bool $syncOnly Whether to skip identifier assignment and provisioning
- * @param int $personId If set, request the Pipeline to link to this Person (for create operations only)
- * @return string Record status (new, unchanged, unknown, updated)
+ * @param int $id External Identity Source ID
+ * @param string $sourceKey EIS Backend Source Key
+ * @param bool $force Whether to force the full Pipeline to run even if the backend record didn't change
+ * @param bool $syncOnly Whether to skip identifier assignment and provisioning
+ * @param int $personId If set, request the Pipeline to link to this Person (for create operations only)
+ * @param string $referenceId Reference ID to assign to the provided EIS record
+ * @return string Record status (new, unchanged, unknown, updated)
*/
public function sync(
@@ -356,7 +357,8 @@ public function sync(
string $sourceKey,
bool $force=true,
bool $syncOnly=false,
- ?int $personId=null
+ ?int $personId=null,
+ ?string $referenceId=null
): string {
// All work is actually handled by the Pipeline, but we need our configuration
// to know which Pipeline.
@@ -373,7 +375,9 @@ public function sync(
force: $force,
personId: $personId,
// Do we want the Pipeline to assign identifiers and provision?
- syncOnly: $syncOnly
+ syncOnly: $syncOnly,
+ // Reference ID should only be provided when handling Match Callback requests
+ referenceId: $referenceId
);
}
diff --git a/app/src/Model/Table/JobsTable.php b/app/src/Model/Table/JobsTable.php
index 5a74f03fd..7e23e3cb2 100644
--- a/app/src/Model/Table/JobsTable.php
+++ b/app/src/Model/Table/JobsTable.php
@@ -634,8 +634,14 @@ protected function validateJobParameters(string $plugin, int $coId, array $param
// or it doesn't.
$className = StringUtilities::foreignKeyToClassName($p);
$Table = TableRegistry::getTableLocator()->get($className);
-
- $vals = explode(',', $val);
+
+ $vals = [];
+
+ if(is_int($val)) {
+ $vals = [$val];
+ } else {
+ $vals = explode(',', $val);
+ }
foreach($vals as $v) {
$entity = $Table->get($val);
diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php
index 5f2b22acb..7779d2165 100644
--- a/app/src/Model/Table/NamesTable.php
+++ b/app/src/Model/Table/NamesTable.php
@@ -101,7 +101,7 @@ public function initialize(array $config): void {
$this->setPrimaryLink(['external_identity_id', 'person_id']);
$this->setAllowLookupPrimaryLink(['primary', 'unfreeze']);
$this->setRequiresCO(true);
- // Models that AcceptCoId should be explicitly added to StandardApiController::initialize()
+ // Models that AcceptCoId should be explicitly added to AppController::beforeFilter()
$this->setAcceptsCoId(true);
$this->setRedirectGoal('self');
$this->setRedirectGoal(action: 'delete', goal: 'deleted');
diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php
index e844a5648..7a95f9839 100644
--- a/app/src/Model/Table/PipelinesTable.php
+++ b/app/src/Model/Table/PipelinesTable.php
@@ -125,6 +125,11 @@ public function initialize(array $config): void {
'type' => 'type',
'attribute' => 'Identifiers.type'
],
+ 'matchServers' => [
+ 'type' => 'select',
+ 'model' => 'Servers',
+ 'where' => ['plugin' => 'CoreServer.MatchServers']
+ ],
'matchStrategies' => [
'type' => 'enum',
'class' => 'MatchStrategyEnum'
@@ -522,27 +527,28 @@ protected function duplicateFilterEntityData($entity): array {
return array_filter($newdata, 'is_scalar');
}
- /**
- * Execute the specified Pipeline on the provided EIS data.
- *
- * @param int $id Pipeline ID
- * @param int $eisId Exxternal Identity Source ID
- * @param array $eisBackendRecord Record returned by EIS Backend
- * @param bool $force Force the Pipeline to run all steps, even if no changes were detected
- * @param int|null $personId If set, for create operations only use this as the target Person ID
- * @param bool $syncOnly If true, do not run Finalize steps
- * @return string Record status (new, unchanged, unknown, updated)
- * @throws \Exception
- * @since COmanage Registry v5.0.0
- */
+ /**
+ * Execute the specified Pipeline on the provided EIS data.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param int $id Pipeline ID
+ * @param int $eisId Exxternal Identity Source ID
+ * @param array $eisBackendRecord Record returned by EIS Backend
+ * @param bool $force Force the Pipeline to run all steps, even if no changes were detected
+ * @param int $personId If set, for create operations only use this as the target Person ID
+ * @param bool $syncOnly If true, do not run Finalize steps
+ * @param string $referenceId Reference ID to assign to the provided EIS record
+ * @return string Record status (new, unchanged, unknown, updated)
+ */
public function execute(
- int $id,
- int $eisId,
- array $eisBackendRecord,
- bool $force=false,
- ?int $personId=null,
- bool $syncOnly=false
+ int $id,
+ int $eisId,
+ array $eisBackendRecord,
+ bool $force=false,
+ ?int $personId=null,
+ bool $syncOnly=false,
+ ?string $referenceId=null
): string {
// We broadly split the Pipeline into two parts, "Sync" and "Finalize".
// This is to support being called from within an Enrollment Flow to connect
@@ -589,12 +595,17 @@ public function execute(
// (2) Match against an existing Person or create a new Person, in
// accordance with the Pipeline's Match Strategy
+
+ // Do we already have a Reference ID for this EIS Record?
+ $origReferenceId = $eisRecord['record']->reference_identifier;
+
$personInfo = $this->obtainPerson(
$pipeline,
$eis,
$eisRecord['record'],
$eisBackendRecord['entity_data'],
- $personId
+ $personId,
+ $referenceId
);
$person = $personInfo['person'];
@@ -618,6 +629,36 @@ public function execute(
$eisBackendRecord['entity_data']
);
+ $newReferenceId = $eisRecord['record']->reference_identifier;
+
+ // Perform some record keeping if a new Reference Identifier was assigned
+
+ if(!empty($newReferenceId) && ($newReferenceId !== $origReferenceId)) {
+ // Attach the Reference Identifier to the Person. Where we're creating a
+ // new Person, there won't be much else on the Person record at this point,
+ // but that will change quickly at step (4).
+
+ $Identifiers = TableRegistry::getTableLocator()->get('Identifiers');
+
+ $rid = $Identifiers->newEntity([
+ 'identifier' => $newReferenceId,
+ 'person_id' => $person->id,
+ 'type_id' => $Identifiers->Types->getTypeId($eis->co_id, 'Identifiers.type', 'reference'),
+ 'login' => false,
+ 'status' => SuspendableStatusEnum::Active
+ ]);
+
+ $Identifiers->saveOrFail($rid);
+
+ // We can now also record Reference ID history
+
+ $this->Cos->People->ExternalIdentities->recordHistory(
+ entity: $externalIdentity,
+ action: ActionEnum::ReferenceIdentifierObtained,
+ comment: __d('result', 'Pipelines.matched.external', [$newReferenceId])
+ );
+ }
+
// If the Person record was matched or requested (meaning it isn't new) create a
// History Record here, now that we have an External Identity
@@ -705,17 +746,17 @@ public function execute(
}
}
- /**
- * Pipeline step to create or update the External Identity Source Record.
- *
- * @param Pipeline $pipeline Pipeline
- * @param ExternalIdentitySource $eis External Identity Source
- * @param string $sourceKey Source Key
- * @param ?string $sourceRecord Source Record
- * @param ?int $personId Person ID
- * @return array ExtIdentitySourceRecord and change status
- * @since COmanage Registry v5.0.0
- */
+ /**
+ * Pipeline step to create or update the External Identity Source Record.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param Pipeline $pipeline Pipeline
+ * @param ExternalIdentitySource $eis External Identity Source
+ * @param string $sourceKey Source Key
+ * @param string $sourceRecord Source Record
+ * @param int $personId Person ID (XXX ???)
+ * @return array ExtIdentitySourceRecord and change status
+ */
protected function manageEISRecord(
Pipeline $pipeline,
@@ -1016,8 +1057,9 @@ protected function mapIdentifier(int $typeId, string $identifier): ?int {
* @param Pipeline $pipeline Pipeline
* @param ExternalIdentitySource $eis External Identity Source
* @param ExtIdentitySourceRecord $eisRecord External Identity Source Record
- * @param array|null $eisAttributes Attributes provided by EIS Backend
- * @param int|null $personId For create operations, use this as the target Person ID, if set
+ * @param array $eisAttributes Attributes provided by EIS Backend
+ * @param int $personId For create operations, use this as the target Person ID, if set
+ * @param string $referenceId For create operations, Reference ID to assign
* @return array 'person': Person object
* 'status': 'linked', 'created', 'matched', 'requested'
* 'strategy': If status = 'matched', the MatchStrategy
@@ -1026,9 +1068,11 @@ protected function mapIdentifier(int $typeId, string $identifier): ?int {
protected function obtainPerson(
Pipeline $pipeline,
ExternalIdentitySource $eis,
- ExtIdentitySourceRecord $eisRecord,
- ?array $eisAttributes = null,
- ?int $personId = null
+ // We pass by reference so searchByApi can update reference_identifier
+ ExtIdentitySourceRecord &$eisRecord,
+ ?array $eisAttributes,
+ ?int $personId=null,
+ ?string $referenceId=null
): array {
// Shorthand...
$sourceKey = $eisRecord->source_key;
@@ -1038,6 +1082,41 @@ protected function obtainPerson(
if(!empty($eisRecord->external_identity_id)) {
$this->llog('trace', "Using previously linked Person " . $eisRecord->external_identity->person->id . " for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey");
+
+ if($pipeline->match_strategy == MatchStrategyEnum::External
+ && !empty($eisRecord->reference_identifier)
+ && !empty($eisAttributes)) {
+ // If we have a Person already and we are using an External match strategy,
+ // and there is a Reference Identifier in the EIS record, issue an update
+ // match attributes request. Note we might not actually be updaing any relevant
+ // attribtues, but for most use cases this should be an inexpensive enough
+ // operation that we don't need to optimize it just yet.
+
+ // We check for a Reference Identifier because if we don't have one we don't
+ // know enough about the state of the Match server, and it's not clear that
+ // we should send the Update Match Attributes Request (which might be
+ // interpreted as a New Match Request instead). We don't actually need to
+ // use it in the request, though, since the Match Server uses SOR Label +
+ // SOR ID (Source Key) to identify our request.
+
+ // Hand off to MatchServer to process the request
+
+ $MatchServers = TableRegistry::getTableLocator()->get('CoreServer.MatchServers');
+
+ $MatchServers->updateMatchAttributes(
+ serverId: $pipeline->match_server_id,
+ sorLabel: $eis->sor_label,
+ sorId: $eisRecord->source_key,
+ attributes: $eisAttributes
+ );
+
+ $this->Cos->People->ExternalIdentities->recordHistory(
+ entity: $eisRecord->external_identity,
+ action: ActionEnum::MatchAttributesUpdated,
+ comment: __d('result', 'Pipelines.updated.external')
+ );
+ }
+
return [
'person' => $eisRecord->external_identity->person,
'status' => 'linked'
@@ -1047,7 +1126,7 @@ protected function obtainPerson(
if(empty($eisAttributes)) {
// We shouldn't get here since attributes should only be null on a delete,
// which should only happen for previously processed records.
- throw new \RuntimeException('$eisAttributes unexpectedly empty in Pipeline::obtainPerson');
+ throw new \RuntimeException('$eisAttributes unexpectedly empty in Pipeline::obtainPerson (invalid source key?)');
}
// If there was a Person ID provided in the function call, use that
@@ -1085,8 +1164,36 @@ protected function obtainPerson(
);
break;
case MatchStrategyEnum::External:
-// XXX If we get a reference ID, attach it to the $eisRecord here CFM-33
- throw new \RuntimeException('NOT IMPLEMENTED');
+ if($referenceId) {
+ // The reference ID was provided by the Match Callback API, and we are
+ // reprocessing the record. Use the asserted ID instead of calling the API again
+ // (though calling the API again should result in the same reference ID).
+ $this->llog('trace', "Linking provided Reference ID " . $referenceId . " for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey");
+
+ // We need to look up the Reference ID type ID to pass to searchByAttribute
+ // so it can convert it back to a label. Not the most efficient method, but
+ // this should be a relatively infrequent operation.
+ $Types = TableRegistry::getTableLocator()->get('Types');
+
+ $person = $this->searchByAttribute(
+ $eis,
+ $eisRecord,
+ MatchStrategyEnum::Identifier,
+ $Types->getTypeId(
+ coId: $eis->co_id,
+ attribute: 'Identifiers.type',
+ value: 'reference'
+ ),
+ ['identifiers' => [0 => ['type' => 'reference', 'identifier' => $referenceId]]]
+ );
+ } else {
+ $person = $this->searchByApi(
+ $eis,
+ $eisRecord,
+ $pipeline->match_server_id,
+ $eisAttributes
+ );
+ }
break;
case MatchStrategyEnum::NoMatching:
// No matching configured, so just fall through and create a new Person
@@ -1249,6 +1356,110 @@ public function relink(
}
}
+ /**
+ * Search for an existing Person using the ID Match API.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param ExternalIdentitySource $eis External Identity Source
+ * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record
+ * @param int $serverId Server ID (NOT Match Server ID)
+ * @param array $attributes Attributes to use for searching
+ * @return Person Person if found, null otherwise
+ * @throws InvalidArgumentException
+ */
+
+ protected function searchByApi(
+ ExternalIdentitySource $eis,
+ ExtIdentitySourceRecord &$eisRecord,
+ int $serverId,
+ array $attributes
+ ): ?Person {
+ // By the time the Pipeline is called, $attributes (while an array) should be
+ // normalized to the Registry data model (though we haven't yet called
+ // mapAttributesToCO).
+
+ // Hand off to MatchServer to process the request
+
+ $MatchServers = TableRegistry::getTableLocator()->get('CoreServer.MatchServers');
+
+ $referenceId = $MatchServers->requestReferenceIdentifier(
+ serverId: $serverId,
+ sorLabel: $eis->sor_label,
+ sorId: $eisRecord->source_key,
+ attributes: $attributes
+ );
+
+ // On error, including 202, an exception is thrown and we don't continue.
+ // If we get a Reference ID back, look for an existing CO Person with it.
+
+ if(is_array($referenceId)) {
+ // We received a 300 response, which is not supported in a Pipeline context
+ // and probably means the admin misconfigured the Match Server.
+
+ throw new \RuntimeException('error', 'Pipelines.match.external.response');
+ }
+
+ if(empty($referenceId)) {
+ // We shouldn't get here with an empty Reference ID, but check just in case
+
+ throw new \RuntimeException('error', 'Pipelines.match.external.empty');
+ }
+
+ // Note we can't record history here because we don't necessarily have an
+ // External Identity yet (let alone a Person). (We _could_ record history if
+ // we find an existing Person with the Reference Identifier attached.)
+
+ // Attach the Reference ID to the EIS Record. Note because we're working with
+ // a passed by reference entity, the calling function should be able to see
+ // it without having to pull an updated record from the database.
+
+ $EISRecords = TableRegistry::getTableLocator()->get('ExtIdentitySourceRecords');
+
+ $eisRecord->reference_identifier = $referenceId;
+
+ $EISRecords->save($eisRecord);
+
+ // Look for Identifiers associated with People in the current CO.
+ // We do _not_ filter on Person status -- if a Person is Suspended but has
+ // the current Reference Identifier we still want to link to that Person.
+ // We _do_ filter on Identifier status -- if an Identifier is no longer
+ // Active it may be on the record for historical reasons.
+
+ $Identifiers = TableRegistry::getTableLocator()->get("Identifiers");
+
+ $matches = $Identifiers->find()
+ // Select DISTINCT to avoid issues with the same Identifier
+ // being attached to the same Person multiple times due to
+ // coming from multiple SORs
+ ->distinct('Identifiers.person_id')
+ ->where([
+ 'Identifiers.identifier' => $referenceId,
+ 'Identifiers.status' => SuspendableStatusEnum::Active,
+ 'Identifiers.person_id IS NOT NULL',
+ 'People.co_id' => $eis->co_id
+ ])
+ ->contain(['People' => 'PrimaryName']) // XXX do we need PrimaryName?
+ ->all();
+
+ // We find all(), but really we should get back 0 or 1 records.
+
+ if($matches->count() == 1) {
+ $person = $matches->first();
+
+ $this->llog('trace', "Mapped Reference ID $referenceId to Person " . $person->id);
+
+ return $person;
+ } elseif($matches->count() == 0) {
+ $this->llog('trace', "No existing Person record found for Reference ID $referenceId");
+ // No match
+ } else {
+ // This is an error, we shouldn't have more than 1 matching Person
+ throw new \InvalidArgumentException('Pipelines.match.multiple', [$referenceId]);
+ }
+
+ return null;
+ }
+
/**
* Search for an existing Person using an attribute provided in the EIS Record.
*
@@ -1762,7 +1973,16 @@ protected function syncPerson(
// a Person object here (it would have been created by obtainPerson if there
// wasn't one at the start of the process).
- // Start with the directly related models
+ // First handle attributes stored directly on the Person, which currently consists
+ // solely of date_of_birth.
+
+ if(!empty($externalIdentity->date_of_birth) || !empty($person->date_of_birth)) {
+ $person->date_of_birth = $externalIdentity->date_of_birth;
+
+ $this->Cos->People->saveOrFail($person, ['associated' => false]);
+ }
+
+ // Next proceed with the directly related models
foreach([
'Addresses',
diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php
index fb73d950c..63ce7f92d 100644
--- a/app/src/Model/Table/TelephoneNumbersTable.php
+++ b/app/src/Model/Table/TelephoneNumbersTable.php
@@ -96,7 +96,7 @@ public function initialize(array $config): void {
$this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']);
$this->setRequiresCO(true);
- // Models that AcceptCoId should be expicitly added to StandardApiController::initialize()
+ // Models that AcceptCoId should be explicitly added to AppController::beforeFilter()
$this->setAcceptsCoId(true);
$this->setRedirectGoal('self');
$this->setRedirectGoal(action: 'delete', goal: 'deleted');
diff --git a/app/templates/Apis/columns.inc b/app/templates/Apis/columns.inc
new file mode 100644
index 000000000..2909122e0
--- /dev/null
+++ b/app/templates/Apis/columns.inc
@@ -0,0 +1,63 @@
+ [
+ 'type' => 'link',
+ 'sortable' => true
+ ],
+ 'plugin' => [
+ 'type' => 'echo',
+ 'sortable' => true
+ ],
+ 'api_user_id' => [
+ 'type' => 'fk',
+ 'sortable' => true
+ ],
+ 'status' => [
+ 'type' => 'enum',
+ 'class' => 'SuspendableStatusEnum',
+ 'sortable' => true
+ ]
+];
+
+// $rowActions appear as row-level menu items in the index view gear icon
+$rowActions = [
+ [
+ 'action' => 'configure',
+ 'label' => __d('operation', 'configure.plugin'),
+ 'icon' => 'electrical_services'
+ ]
+];
+
+/*
+// When the $bulkActions variable exists in a columns.inc config, the "Bulk edit" switch will appear in the index.
+$bulkActions = [
+ // TODO: develop bulk actions. For now, use a placeholder.
+ 'delete' => true
+];
+*/
\ No newline at end of file
diff --git a/app/templates/Apis/fields.inc b/app/templates/Apis/fields.inc
new file mode 100644
index 000000000..5fa07ba6c
--- /dev/null
+++ b/app/templates/Apis/fields.inc
@@ -0,0 +1,51 @@
+
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field
+ ]]);
+ }
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'plugin',
+ 'labelIsTextOnly' => true
+ ]
+ ]);
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'api_user_id'
+ ]]);
+}
diff --git a/app/templates/CoSettings/fields.inc b/app/templates/CoSettings/fields.inc
index 562030eec..09b7b7d14 100644
--- a/app/templates/CoSettings/fields.inc
+++ b/app/templates/CoSettings/fields.inc
@@ -133,4 +133,9 @@ if($vv_action == 'edit') {
'arguments' => [
'fieldName' => 'email_delivery_address_type_id'
]]);
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'authn_events_api_disable'
+ ]]);
}
diff --git a/app/templates/Dashboards/configuration.php b/app/templates/Dashboards/configuration.php
index 368b41b47..adfbef5a9 100644
--- a/app/templates/Dashboards/configuration.php
+++ b/app/templates/Dashboards/configuration.php
@@ -90,12 +90,17 @@
$iconClass = !empty($cfg['iconClass']) ? $cfg['iconClass'] : 'material-symbols';
$linkContent = '' . $cfg['icon'] . ''
. '';
+ $linkUrl = [
+ 'plugin' => null,
+ 'controller' => $cfg['controller'],
+ 'action' => $cfg['action']
+ ];
+ if(!isset($cfg['platform']) || !$cfg['platform']) {
+ $linkUrl['?']['co_id'] = $vv_cur_co->id;
+ }
print $this->Html->link(
$linkContent,
- ['plugin' => null,
- 'controller' => $cfg['controller'],
- 'action' => $cfg['action'],
- '?' => ['co_id' => $vv_cur_co->id]],
+ $linkUrl,
['escape' => false]
);
?>
diff --git a/app/templates/Identifiers/columns.inc b/app/templates/Identifiers/columns.inc
index 770654fb4..2bacb34bc 100644
--- a/app/templates/Identifiers/columns.inc
+++ b/app/templates/Identifiers/columns.inc
@@ -34,7 +34,7 @@ $indexColumns = [
]
];
-$indexActions = [
+$rowActions = [
[
'controller' => 'authentication_events',
'action' => 'index',
diff --git a/app/templates/Identifiers/fields-nav.inc b/app/templates/Identifiers/fields-nav.inc
new file mode 100644
index 000000000..59f806711
--- /dev/null
+++ b/app/templates/Identifiers/fields-nav.inc
@@ -0,0 +1,45 @@
+identifier) && $vv_obj->isLogin()) {
+ $topLinks = [
+ [
+ 'icon' => 'lock',
+ 'order' => 'Default',
+ 'label' => __d('controller', 'AuthenticationEvents', [99]),
+ 'link' => [
+ 'controller' => 'authentication_events',
+ 'action' => 'index',
+ '?' => [
+ 'authenticated_identifier' =>
+ \App\Lib\Util\StringUtilities::urlbase64encode($vv_obj->identifier)
+ ]
+ ],
+ 'class' => ''
+ ]
+ ];
+}
\ No newline at end of file
diff --git a/app/templates/Pipelines/fields.inc b/app/templates/Pipelines/fields.inc
index d4f1821ab..349767fb4 100644
--- a/app/templates/Pipelines/fields.inc
+++ b/app/templates/Pipelines/fields.inc
@@ -33,13 +33,29 @@
var strategy = document.getElementById('match-strategy').value;
if(strategy == '= \App\Lib\Enum\MatchStrategyEnum::EmailAddress ?>') {
- hideFields(['match-identifier-type-id'], isPageLoad);
+ hideFields([
+ 'match-identifier-type-id',
+ 'match-server-id'
+ ], isPageLoad);
showFields(['match-email-address-type-id'], isPageLoad);
+ } else if(strategy == '= \App\Lib\Enum\MatchStrategyEnum::External ?>') {
+ hideFields([
+ 'match-email-address-type-id',
+ 'match-identifier-type-id'
+ ], isPageLoad);
+ showFields(['match-server-id'], isPageLoad);
} else if(strategy == '= \App\Lib\Enum\MatchStrategyEnum::Identifier ?>') {
- hideFields(['match-email-address-type-id'], isPageLoad);
+ hideFields([
+ 'match-email-address-type-id',
+ 'match-server-id'
+ ], isPageLoad);
showFields(['match-identifier-type-id'], isPageLoad);
} else {
- hideFields(['match-email-address-type-id', 'match-identifier-type-id'], isPageLoad);
+ hideFields([
+ 'match-email-address-type-id',
+ 'match-identifier-type-id',
+ 'match-server-id'
+ ], isPageLoad);
}
}
@@ -75,6 +91,7 @@ if($vv_action == 'add' || $vv_action == 'edit') {
foreach([
'match_email_address_type_id',
'match_identifier_type_id',
+ 'match_server_id',
// 'sync_on_update',
// 'sync_on_delete',
'sync_status_on_delete',
diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php
index 4819012d7..419d99486 100644
--- a/app/templates/Standard/index.php
+++ b/app/templates/Standard/index.php
@@ -317,7 +317,17 @@
'?' => [$tableFK => $entity->id]
];
}
-
+
+ if(!empty($a['query'])) {
+ $fn = $a['query'];
+
+ if(!empty($actionUrl['?'])) {
+ $actionUrl['?'] = array_merge($actionUrl['?'], $fn($entity));
+ } else {
+ $actionUrl['?'] = $fn($entity);
+ }
+ }
+
// Generate the link text and urls:
if (!empty($a['confirm'])) {
diff --git a/app/vendor/cakephp-plugins.php b/app/vendor/cakephp-plugins.php
index 79406d9c3..2a207981b 100644
--- a/app/vendor/cakephp-plugins.php
+++ b/app/vendor/cakephp-plugins.php
@@ -5,6 +5,7 @@
'plugins' => [
'Bake' => $baseDir . '/vendor/cakephp/bake/',
'Cake/TwigView' => $baseDir . '/vendor/cakephp/twig-view/',
+ 'CoreApi' => $baseDir . '/plugins/CoreApi/',
'CoreAssigner' => $baseDir . '/plugins/CoreAssigner/',
'CoreEnroller' => $baseDir . '/plugins/CoreEnroller/',
'CoreJob' => $baseDir . '/plugins/CoreJob/',
diff --git a/app/vendor/composer/autoload_psr4.php b/app/vendor/composer/autoload_psr4.php
index 72b5df30c..2dbb2f4f2 100644
--- a/app/vendor/composer/autoload_psr4.php
+++ b/app/vendor/composer/autoload_psr4.php
@@ -77,6 +77,8 @@
'CoreEnroller\\' => array($baseDir . '/plugins/CoreEnroller/src'),
'CoreAssigner\\Test\\' => array($baseDir . '/plugins/CoreAssigner/tests'),
'CoreAssigner\\' => array($baseDir . '/plugins/CoreAssigner/src'),
+ 'CoreApi\\Test\\' => array($baseDir . '/plugins/CoreApi/tests'),
+ 'CoreApi\\' => array($baseDir . '/plugins/CoreApi/src'),
'Composer\\XdebugHandler\\' => array($vendorDir . '/composer/xdebug-handler/src'),
'Composer\\Spdx\\' => array($vendorDir . '/composer/spdx-licenses/src'),
'Composer\\Semver\\' => array($vendorDir . '/composer/semver/src'),
diff --git a/app/vendor/composer/autoload_static.php b/app/vendor/composer/autoload_static.php
index 32a07e523..d5738fcc7 100644
--- a/app/vendor/composer/autoload_static.php
+++ b/app/vendor/composer/autoload_static.php
@@ -157,6 +157,8 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e
'CoreEnroller\\' => 13,
'CoreAssigner\\Test\\' => 18,
'CoreAssigner\\' => 13,
+ 'CoreApi\\Test\\' => 13,
+ 'CoreApi\\' => 8,
'Composer\\XdebugHandler\\' => 23,
'Composer\\Spdx\\' => 14,
'Composer\\Semver\\' => 16,
@@ -474,6 +476,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e
array (
0 => __DIR__ . '/../..' . '/plugins/CoreAssigner/src',
),
+ 'CoreApi\\Test\\' =>
+ array (
+ 0 => __DIR__ . '/../..' . '/plugins/CoreApi/tests',
+ ),
+ 'CoreApi\\' =>
+ array (
+ 0 => __DIR__ . '/../..' . '/plugins/CoreApi/src',
+ ),
'Composer\\XdebugHandler\\' =>
array (
0 => __DIR__ . '/..' . '/composer/xdebug-handler/src',