diff --git a/app/config/schema/endpoint-notification.json b/app/config/schema/endpoint-notification.json new file mode 100644 index 000000000..cd7898bfc --- /dev/null +++ b/app/config/schema/endpoint-notification.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://github.internet2.edu/COmanage/match/blob/main/app/config/schema/endpoint-notification.json", + "title": "COmanage Match Endpoint Notification Message Format", + "description": "COmanage Match Endpoint Notification Message Format v1", + + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "source": { + "description": "Source of this notification", + "const": "COmanage Match" + }, + "event": { + "description": "Event described in this notification", + "const": "match-resolution" + }, + "format": { + "description": "Notification message format version", + "const": "1" + } + }, + "required": [ "source", "event", "format" ] + }, + "sor": { + "description": "System of Record Label for notification subject", + "type": "string" + }, + "sorid": { + "description": "System of Record Identifier for notification subject", + "type": "string" + }, + "matchRequest": { + "description": "Match Request ID, as generated on original Match Request", + "type": "string" + }, + "referenceId": { + "description": "Match Reference Identifier", + "type": "string" + }, + "resolutionTime": { + "description": "Time request was resolved by the Identity Match service", + "type": "string", + "format": "date-time" + } + }, + "required": [ "sor", "sorid", "matchRequest", "referenceId", "resolutionTime" ] +} \ No newline at end of file diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index af00bdb21..bdf1496b9 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -48,6 +48,23 @@ "changelog": false }, + "endpoints": { + "columns": { + "id": {}, + "matchgrid_id": {}, + "description": {}, + "url": { "type": "string", "size": 256 }, + "username": { "type": "string", "size": 128 }, + "password": { "type": "string", "size": 256 } + }, + "indexes": { + "endpoints_i1": { + "columns": [ "matchgrid_id" ] + } + }, + "changelog": false + }, + "matchgrid_settings": { "columns": { "id": {}, @@ -55,11 +72,15 @@ "referenceid_method": { "type": "string", "size": 2 }, "referenceid_start": { "type": "integer" }, "referenceid_prefix": { "type": "string", "size": 32 }, - "notification_email": { "type": "string", "size": 80 } + "notification_email": { "type": "string", "size": 80 }, + "resolution_notification_endpoint_id": { "type": "integer", "foreignkey": { "table": "endpoints", "column": "id" } } }, "indexes": { "matchgrid_settings_i1": { "columns": [ "matchgrid_id" ] + }, + "matchgrid_settings_i2": { + "columns": [ "resolution_notification_endpoint_id" ] } }, "changelog": false @@ -208,7 +229,7 @@ "columns": { "id": {}, "matchgrid_id": {}, - "label": { "type": "string", "size": 80 }, + "label": { "type": "string", "size": 64 }, "resolution_mode": { "type": "string", "size": 2 }, "notification_email": { "type": "string", "size": 80 } }, @@ -241,6 +262,25 @@ } }, "changelog": false + }, + + "matchgrid_history_records": { + "columns": { + "id": {}, + "matchgrid_id": {}, + "sor": { "type": "string", "size": 64 }, + "sorid": { "type": "string", "size": 64 }, + "action": { "type": "string", "size": 4 }, + "comment": { "type": "string", "size": 256 }, + "remote_ip": { "type": "string", "size": 80 }, + "actor_identifier": { "type": "string", "size": 256 } + }, + "indexes": { + "matchgrid_history_records_i1": { + "columns": [ "matchgrid_id", "sor_label", "sorid" ] + } + }, + "changelog": false } }, diff --git a/app/resources/locales/en_US/default.po b/app/resources/locales/en_US/default.po index fc2416610..bac5000cd 100644 --- a/app/resources/locales/en_US/default.po +++ b/app/resources/locales/en_US/default.po @@ -117,12 +117,18 @@ msgstr "{0,plural,=1{Attribute Map} other{Attribute Maps}}" msgid "match.ct.Attributes" msgstr "{0,plural,=1{Attribute} other{Attributes}}" +msgid "match.ct.Endpoints" +msgstr "{0,plural,=1{Endpoint} other{Endpoints}}" + msgid "match.ct.MatchgridSettings" msgstr "{0,plural,=1{Matchgrid Settings} other{Matchgrid Settings}}" msgid "match.ct.Matchgrids" msgstr "{0,plural,=1{Matchgrid} other{Matchgrids}}" +msgid "match.ct.MatchgridHistoryRecords" +msgstr "{0,plural,=1{Matchgrid History Record} other{Matchgrid History Records}}" + msgid "match.ct.MatchgridRecords" msgstr "{0,plural,=1{Matchgrid Record} other{Matchgrid Records}}" @@ -155,6 +161,39 @@ msgstr "Suspended" msgid "match.en.ConfidenceModeEnum.S.badge" msgstr "Danger" +msgid "match.en.MatchgridActionEnum.FRRA" +msgstr "Forced Reconciliation Request (API)" + +msgid "match.en.MatchgridActionEnum.FRRU" +msgstr "Forced Reconciliation Request (UI)" + +msgid "match.en.MatchgridActionEnum.NEWA" +msgstr "New Match Request (API, response: {0})" + +msgid "match.en.MatchgridActionEnum.NEWU" +msgstr "New Match Request (UI)" + +msgid "match.en.MatchgridActionEnum.REDU" +msgstr "Reprocess Reconciliation Request (UI, previous Reference ID: {0})" + +msgid "match.en.MatchgridActionEnum.REPN" +msgstr "Endpoint Notified of Resolution (response: {0} {1})" + +msgid "match.en.MatchgridActionEnum.RIRA" +msgstr "Reference ID Reassignment Request (API, from Reference ID {0})" + +msgid "match.en.MatchgridActionEnum.RIRU" +msgstr "Reference ID Reassignment Request (UI, from Reference ID {0})" + +msgid "match.en.MatchgridActionEnum.RPMU" +msgstr "Administrator Resolved Pending Match Request" + +msgid "match.en.MatchgridActionEnum.UPDA" +msgstr "Update Match Request (API)" + +msgid "match.en.MatchgridActionEnum.UPDU" +msgstr "Update Match Request (UI)" + msgid "match.en.PermissionEnum.A" msgstr "Platform Administrator" @@ -336,6 +375,9 @@ msgstr "Requested Reference ID must already be in use, or be the keyword 'new'" msgid "match.fd.action" msgstr "Action" +msgid "match.fd.actor_identifier" +msgstr "Actor Identifier" + msgid "match.fd.all" msgstr "All" @@ -369,12 +411,18 @@ msgstr "Crosscheck Attribute" msgid "match.fd.case_sensitive" msgstr "Case Sensitive" +msgid "match.fd.comment" +msgstr "Comment" + msgid "match.fd.confidence_mode" msgstr "Confidence Mode" msgid "match.fd.copy_of" msgstr "Copy of {0}" +msgid "match.fd.created" +msgstr "Created" + msgid "match.fd.description" msgstr "Description" @@ -441,6 +489,9 @@ msgstr "Reference ID Initial Value" msgid "match.fd.referenceid_start.desc" msgstr "For sequence based Reference IDs, the first value to assign" +msgid "match.fd.remote_ip" +msgstr "Remote IP" + msgid "match.fd.req" msgstr "* Denotes Required Field" @@ -453,6 +504,12 @@ msgstr "Required" msgid "match.fd.resolution_mode" msgstr "Resolution Mode" +msgid "match.fd.resolution_notification_endpoint" +msgstr "Resolution Notification Endpoint" + +msgid "match.fd.resolution_time" +msgstr "Resolution Time" + msgid "match.fd.RuleAttributes.match_empty" msgstr "Match Empty Values" @@ -498,6 +555,9 @@ msgstr "Table Name" msgid "match.fd.table_name.desc" msgstr "Unique name for matchgrid, must be a valid SQL identifier (will be prefixed mg_ for actual table name)" +msgid "match.fd.url" +msgstr "URL" + msgid "match.fd.username" msgstr "Username" @@ -511,6 +571,9 @@ msgstr "Welcome to {0}." msgid "match.in.matchgrid.display" msgstr "Display all records associated with this Matchgrid." +msgid "match.in.matchgrid.history" +msgstr "Display transactions (history records) associated with this Matchgrid." + msgid "match.in.matchgrid.reconcile" msgstr "Resolve matching records when COmanage determines the same person may be coming from multiple systems of record." @@ -569,6 +632,9 @@ msgstr "Are you sure you wish to delete this record ({0})?" msgid "match.op.display" msgstr "Display" +msgid "match.op.display.history" +msgstr "Display History" + msgid "match.op.display.records" msgstr "Display Records" @@ -653,6 +719,12 @@ msgstr "Matchgrid Selection" msgid "match.op.skip_to_content" msgstr "Skip to main content" +msgid "match.op.view" +msgstr "View" + +msgid "match.op.view.a" +msgstr "View {0}" + ### Results msgid "match.rs.AttributeMappings.install" msgstr "Attribute Mapping successfully installed" diff --git a/app/src/Controller/Component/AuthorizationComponent.php b/app/src/Controller/Component/AuthorizationComponent.php index 5ba649930..a9bc11d2a 100644 --- a/app/src/Controller/Component/AuthorizationComponent.php +++ b/app/src/Controller/Component/AuthorizationComponent.php @@ -188,6 +188,7 @@ public function menuPermissions($username, $matchgridId=null) { 'attribute_maps' => $platformAdmin || $mgAdmin, 'attributes' => $platformAdmin || $mgAdmin, 'display' => $platformAdmin || $mgAdmin, // || $recMgr, this isn't yet implemented in the controller + 'endpoints' => $platformAdmin || $mgAdmin, 'matchgrid_settings' => $platformAdmin || $mgAdmin, 'rules' => $platformAdmin || $mgAdmin, 'systems_of_record' => $platformAdmin || $mgAdmin, diff --git a/app/src/Controller/EndpointsController.php b/app/src/Controller/EndpointsController.php new file mode 100644 index 000000000..1718ea7b3 --- /dev/null +++ b/app/src/Controller/EndpointsController.php @@ -0,0 +1,66 @@ + [ + 'Endpoints.serverurl' => 'asc' + ] + ]; + + /** + * Authorization for this Controller, called by Auth component + * - postcondition: $vv_permissions set with calculated permissions for this Controller + * + * @since COmanage Match v1.1.0 + * @param Array $user Array of user data + * @return Boolean True if authorized for the current action, false otherwise + */ + + public function isAuthorized(Array $user) { + $mgid = isset($this->cur_mg->id) ? $this->cur_mg->id : null; + + $platformAdmin = $this->Authorization->isPlatformAdmin($user['username']); + + $mgAdmin = $this->Authorization->isMatchAdmin($user['username'], $mgid); + + $p = [ + 'add' => $platformAdmin || $mgAdmin, + 'delete' => $platformAdmin || $mgAdmin, + 'edit' => $platformAdmin || $mgAdmin, + 'index' => $platformAdmin || $mgAdmin, + 'view' => false + ]; + + $this->set('vv_permissions', $p); + return $p[$this->request->getParam('action')]; + } +} \ No newline at end of file diff --git a/app/src/Controller/MatchgridHistoryRecordsController.php b/app/src/Controller/MatchgridHistoryRecordsController.php new file mode 100644 index 000000000..40f0ce05c --- /dev/null +++ b/app/src/Controller/MatchgridHistoryRecordsController.php @@ -0,0 +1,93 @@ + [ + 'MatchgridHistoryRecords.id' => 'desc' + ] + ]; + + /** + * Authorization for this Controller, called by Auth component + * - postcondition: $vv_permissions set with calculated permissions for this Controller + * + * @since COmanage Match v1.1.0 + * @param Array $user Array of user data + * @return Boolean True if authorized for the current action, false otherwise + */ + + public function isAuthorized(Array $user) { + $mgid = isset($this->cur_mg->id) ? $this->cur_mg->id : null; + + $platformAdmin = $this->Authorization->isPlatformAdmin($user['username']); + + $mgAdmin = $this->Authorization->isMatchAdmin($user['username'], $mgid); + + $p = [ + 'add' => $platformAdmin || $mgAdmin, + 'delete' => false, + 'edit' => false, + 'index' => $platformAdmin || $mgAdmin, + 'view' => $platformAdmin || $mgAdmin + ]; + + $this->set('vv_permissions', $p); + return $p[$this->request->getParam('action')]; + } + + /** + * Generate a redirect for a Standard Object operation. + * + * @since COmanage Match v1.0.0 + * @return \Cake\Http\Response + */ + + public function generateRedirect() { + $reqData = $this->request->getData(); + + if($this->request->getParam('action') == 'add' + && !empty($reqData['matchgrid_id']) + && !empty($reqData['sor']) + && !empty($reqData['sorid'])) { + return $this->redirect([ + 'action' => 'index', + '?' => [ + 'matchgrid_id' => $reqData['matchgrid_id'], + 'sor' => $reqData['sor'], + 'sorid' => $reqData['sorid'] + ] + ]); + } + + return $this->redirect($redirect); + } +} \ No newline at end of file diff --git a/app/src/Controller/MatchgridRecordsController.php b/app/src/Controller/MatchgridRecordsController.php index 72c04d996..cd0c345fd 100644 --- a/app/src/Controller/MatchgridRecordsController.php +++ b/app/src/Controller/MatchgridRecordsController.php @@ -34,6 +34,7 @@ use Cake\Event\EventInterface; use \App\Lib\Enum\ConfidenceModeEnum; +use \App\Lib\Enum\MatchgridActionEnum; use \App\Lib\Match\AttributeManager; use \App\Lib\Match\MatchService; @@ -121,6 +122,16 @@ public function add() { $AttributeManager->parseFromArray($reqData); + $MatchgridHistory = $this->getTableLocator()->get('MatchgridHistoryRecords'); + + $MatchgridHistory->record($this->cur_mg->id, + $sor, + $sorid, + MatchgridActionEnum::NewMatchRequestUI, + __('match.en.MatchgridActionEnum.NEWU'), + $this->request->getEnv('REMOTE_ADDR'), + $this->request->getSession()->read('Auth.User.username')); + // performMatch will issue a redirect on success $this->performMatch($MatchService, $sor, $sorid, $AttributeManager); } @@ -231,6 +242,8 @@ public function edit($id) { $AttributeManager = new AttributeManager(); $AttributeManager->parseFromArray($reqData); + + $MatchgridHistory = $this->getTableLocator()->get('MatchgridHistoryRecords'); // We behave differently depending on Reference ID state // Note $reqData is an array while $obj is an object @@ -240,6 +253,14 @@ public function edit($id) { Log::write('debug', $sor . "/". $sorid . " Updating existing SOR attributes for Row ID " . $id); + $MatchgridHistory->record($this->cur_mg->id, + $sor, + $sorid, + MatchgridActionEnum::UpdateMatchRequestAPI, + __('match.en.MatchgridActionEnum.UPDU'), + $this->request->getEnv('REMOTE_ADDR'), + $this->request->getSession()->read('Auth.User.username')); + $MatchService->updateSorAttributes((int)$id, $AttributeManager); } else { // Join/Split, possibly with updated attributes @@ -258,7 +279,15 @@ public function edit($id) { } Log::write('debug', $sor . "/". $sorid . " Updating Reference ID and attributes for Row ID " . $id); - + + $MatchgridHistory->record($this->cur_mg->id, + $sor, + $sorid, + MatchgridActionEnum::ReferenceIDReassignedUI, + __('match.en.MatchgridActionEnum.RIRU', $obj->referenceid), + $this->request->getEnv('REMOTE_ADDR'), + $this->request->getSession()->read('Auth.User.username')); + $MatchService->attachReferenceId($sor, $sorid, $AttributeManager, $reqData['referenceid']); } } elseif(!empty($reqData['referenceid']) && empty($obj->referenceid)) { @@ -276,6 +305,14 @@ public function edit($id) { Log::write('debug', $sor . "/". $sorid . " Processing forced reconciliation request for Reference ID " . $reqData['referenceid']); + $MatchgridHistory->record($this->cur_mg->id, + $sor, + $sorid, + MatchgridActionEnum::ForcedReconciliationRequestUI, + __('match.en.MatchgridActionEnum.FRRU'), + $this->request->getEnv('REMOTE_ADDR'), + $this->request->getSession()->read('Auth.User.username')); + $MatchService->attachReferenceId($sor, $sorid, $AttributeManager, $reqData['referenceid']); } elseif(empty($reqData['referenceid']) && !empty($obj->referenceid)) { // By removing an existing Reference ID, the admin is effectively asking @@ -287,12 +324,28 @@ public function edit($id) { // rematch to the same record $MatchService->removeReferenceId($sor, $sorid); + $MatchgridHistory->record($this->cur_mg->id, + $sor, + $sorid, + MatchgridActionEnum::RereconcileRequestUI, + __('match.en.MatchgridActionEnum.REDU', $obj->referenceid), + $this->request->getEnv('REMOTE_ADDR'), + $this->request->getSession()->read('Auth.User.username')); + $this->performMatch($MatchService, $sor, $sorid, $AttributeManager); } elseif(empty($reqData['referenceid']) && empty($obj->referenceid)) { // Treat like add(), but possibly updating an existing Match request Log::write('debug', $sor . "/". $sorid . " Re-executing match rules for Row ID " . $id); + $MatchgridHistory->record($this->cur_mg->id, + $sor, + $sorid, + MatchgridActionEnum::RereconcileRequestUI, + __('match.en.MatchgridActionEnum.REDU', "none"), + $this->request->getEnv('REMOTE_ADDR'), + $this->request->getSession()->read('Auth.User.username')); + $this->performMatch($MatchService, $sor, $sorid, $AttributeManager); } diff --git a/app/src/Controller/MatchgridsController.php b/app/src/Controller/MatchgridsController.php index 5c455c824..97112f42b 100644 --- a/app/src/Controller/MatchgridsController.php +++ b/app/src/Controller/MatchgridsController.php @@ -29,9 +29,10 @@ namespace App\Controller; -use Cake\Log\Log; +use \Cake\Log\Log; +use \Cake\Event\EventInterface; use \App\Lib\Enum\PermissionEnum; -use Cake\Event\EventInterface; +use \App\Lib\Enum\MatchgridActionEnum; class MatchgridsController extends StandardController { public $pagination = [ @@ -209,8 +210,80 @@ public function reconcile(string $id) { // Cake Form tampering protection should ensure that $req['referenceid'] // is valid and one we originally proposed. $refId = $MatchService->attachReferenceId($sor, $sorid, $AttributeManager, $req['referenceid']); - + $this->Flash->success(__('match.rs.refid.assigned', [$refId])); + + // Record resolution in MatchgridHistory + $MatchgridHistory = $this->getTableLocator()->get('MatchgridHistoryRecords'); + + $MatchgridHistory->record((int)$id, + $sor, + $sorid, + MatchgridActionEnum::ResolvedPendingMatchUI, + __('match.en.MatchgridActionEnum.RPMU'), + $this->request->getEnv('REMOTE_ADDR'), + $this->request->getSession()->read('Auth.User.username')); + + // If configured, send a resolution notification + $MatchgridSettings = $this->getTableLocator()->get('MatchgridSettings'); + + $endPoint = $MatchgridSettings->getNotificationEndpoint((int)$id); + + if(!empty($endPoint)) { + $Http = new \Cake\Http\Client([ + 'auth' => [ + 'username' => $endPoint->username, + 'password' => $endPoint->password + ] + ]); + + $message = [ + 'meta' => [ + 'source' => "COmanage Match", + 'event' => "match-resolution", + 'format' => "1" + ], + 'sor' => (string)$sor, + 'sorid' => (string)$sorid, + 'matchRequest' => (string)$rowid, + 'referenceId' => (string)$refId, + 'resolutionTime' => gmdate('Y-m-d\TH:i:s\Z') + ]; + + $response = $Http->post( + $endPoint->url, + json_encode($message), + ['type' => 'json'] + ); + + $statusCode = $response->getStatusCode(); + + // Create a suitable history record + + if($statusCode >= 200 && $statusCode <= 299) { + // Success + + $MatchgridHistory->record((int)$id, + $sor, + $sorid, + MatchgridActionEnum::ResolutionEndpointNotified, + __('match.en.MatchgridActionEnum.REPN', [$statusCode, $response->getReasonPhrase()]), + $this->request->getEnv('REMOTE_ADDR'), + $this->request->getSession()->read('Auth.User.username')); + } else { + // Anything else is treated as an error + + $errorMessage = $response->getJson(); + + $MatchgridHistory->record((int)$id, + $sor, + $sorid, + MatchgridActionEnum::ResolutionEndpointNotified, + __('match.en.MatchgridActionEnum.REPN', [$statusCode, $errorMessage['error'] ?? $response->getReasonPhrase()]), + $this->request->getEnv('REMOTE_ADDR'), + $this->request->getSession()->read('Auth.User.username')); + } + } // Redirect back to list of pending requests return $this->redirect([ diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index 21410016f..ea6b8743c 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -477,10 +477,55 @@ protected function populateAutoViewVars(object $obj=null) { } } -// XXX still need to generalize this -/* - public function view($id = null) { - $matchgrid = $this->Matchgrids->findById($id)->firstOrFail(); - $this->set(compact('matchgrid')); - }*/ + /** + * Handle a view action for a Standard object. + * + * @since COmanage Match v1.1.0 + * @param Integer $id Object ID + */ + + public function view($id) { + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $tableName = models + $tableName = $this->$modelsName->getTable(); + + $query = $this->$modelsName->findById($id); + + // AssociationTrait + if(method_exists($this->$modelsName, "getViewContains")) { + $query = $query->contain($this->$modelsName->getViewContains()); + } + + try { + // Pull the current record + $obj = $query->firstOrFail(); + } + catch(\Exception $e) { + // findById throws Cake\Datasource\Exception\RecordNotFoundException + + $this->Flash->error($e->getMessage()); + return $this->generateRedirect(); + } + + $this->set('vv_obj', $obj); + + // PrimaryLinkTrait + $this->getPrimaryLink(); + + // AutoViewVarsTrait + $this->populateAutoViewVars($obj); + + // Default view title is edit object display field + $field = $this->$modelsName->getDisplayField(); + + if(!empty($obj->$field)) { + $this->set('vv_title', __('match.op.view.a', $obj->$field)); + } else { + $this->set('vv_title', __('match.op.view.a', __('match.ct.'.$modelsName, [1]))); + } + + // Let the view render + $this->render('/Standard/add-edit-view'); + } } diff --git a/app/src/Controller/TierApiController.php b/app/src/Controller/TierApiController.php index e70a5017a..78a91bae1 100644 --- a/app/src/Controller/TierApiController.php +++ b/app/src/Controller/TierApiController.php @@ -34,6 +34,7 @@ use Cake\Routing\Router; use \App\Lib\Enum\ConfidenceModeEnum; +use \App\Lib\Enum\MatchgridActionEnum; use \App\Lib\Enum\ResolutionModeEnum; use \App\Lib\Enum\StatusEnum; @@ -196,6 +197,7 @@ protected function doMatchRequest(bool $searchOnly=false) { $AttributeManager = new \App\Lib\Match\AttributeManager(); $MatchService = new \App\Lib\Match\MatchService(); + $MatchgridHistory = $this->getTableLocator()->get('MatchgridHistoryRecords'); $MatchService->connect(); @@ -237,6 +239,14 @@ protected function doMatchRequest(bool $searchOnly=false) { Log::write('debug', $sor . "/". $sorid . " Updating existing SOR attributes for Row ID " . $curid); + $MatchgridHistory->record($this->cur_mg->id, + $sor, + $sorid, + MatchgridActionEnum::UpdateMatchRequestAPI, + __('match.en.MatchgridActionEnum.UPDA'), + $this->request->getEnv('REMOTE_ADDR'), + $this->Auth->user()['username']); + $MatchService->updateSorAttributes($curid, $AttributeManager); $statusCode = 200; @@ -287,6 +297,14 @@ protected function doMatchRequest(bool $searchOnly=false) { Log::write('debug', $sor . "/". $sorid . " Processing forced reconciliation request for Reference ID " . $requestedReferenceId); + $MatchgridHistory->record($this->cur_mg->id, + $sor, + $sorid, + MatchgridActionEnum::ForcedReconciliationRequestAPI, + __('match.en.MatchgridActionEnum.FRRA'), + $this->request->getEnv('REMOTE_ADDR'), + $this->Auth->user()['username']); + $referenceId = $MatchService->attachReferenceId($sor, $sorid, $AttributeManager, $requestedReferenceId); $result = ['referenceId' => $referenceId]; @@ -296,7 +314,7 @@ protected function doMatchRequest(bool $searchOnly=false) { // Perform a search, and insert or update if not Search Only $results = $MatchService->searchReferenceId($sor, $sorid, $AttributeManager); - // Did any rules run succesffully? If not (eg: no attributes provided in the + // Did any rules run successfully? If not (eg: no attributes provided in the // request, no rules defined) then throw an error. if(empty($results->getSuccessfulRules())) { throw new \RuntimeException(__('match.er.rules.unsuccessful')); @@ -320,6 +338,14 @@ protected function doMatchRequest(bool $searchOnly=false) { $result = ['referenceId' => $referenceId]; $statusCode = 201; + + $MatchgridHistory->record($this->cur_mg->id, + $sor, + $sorid, + MatchgridActionEnum::NewMatchRequestAPI, + __('match.en.MatchgridActionEnum.NEWA', $statusCode), + $this->request->getEnv('REMOTE_ADDR'), + $this->Auth->user()['username']); } } elseif($results->getConfidenceMode() == ConfidenceModeEnum::Canonical) { // Exact match @@ -328,6 +354,14 @@ protected function doMatchRequest(bool $searchOnly=false) { if(!empty($refIds[0])) { if(!$searchOnly) { + $MatchgridHistory->record($this->cur_mg->id, + $sor, + $sorid, + MatchgridActionEnum::NewMatchRequestAPI, + __('match.en.MatchgridActionEnum.NEWA', 200), + $this->request->getEnv('REMOTE_ADDR'), + $this->Auth->user()['username']); + // This should also correctly handle an update match attribute request // that did not originally have a referenceid $MatchService->attachReferenceId($sor, $sorid, $AttributeManager, (string)$refIds[0]); @@ -489,7 +523,7 @@ protected function doViewMatchRequest(\App\Lib\Match\MatchService $MatchService) return; } - // It's plausible (but unlikely) that a pending match could becoume canonically resolvable + // It's plausible (but unlikely) that a pending match could become canonically resolvable // after it has been submitted (eg: if some bad conflicting data was cleaned up), but for // the moment we don't try to catch that. $this->statusCode = 300; diff --git a/app/src/Lib/Enum/MatchgridActionEnum.php b/app/src/Lib/Enum/MatchgridActionEnum.php new file mode 100644 index 000000000..2c0a4a226 --- /dev/null +++ b/app/src/Lib/Enum/MatchgridActionEnum.php @@ -0,0 +1,45 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/MatchgridHistoryRecord.php b/app/src/Model/Entity/MatchgridHistoryRecord.php new file mode 100644 index 000000000..a72aaa486 --- /dev/null +++ b/app/src/Model/Entity/MatchgridHistoryRecord.php @@ -0,0 +1,40 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Table/EndpointsTable.php b/app/src/Model/Table/EndpointsTable.php new file mode 100644 index 000000000..62a4e17f5 --- /dev/null +++ b/app/src/Model/Table/EndpointsTable.php @@ -0,0 +1,121 @@ +addBehavior('Timestamp'); + + // Define associations + $this->hasMany('MatchgridSettings') + ->setForeignKey('resolution_notification_endpoint_id') + ->setProperty('resolution_notification_endpoint'); + + $this->belongsTo('Matchgrids'); + + $this->setDisplayField('description'); + + $this->setPrimaryLink('matchgrid_id'); + $this->setRequiresMatchgrid(true); + } + + /** + * Set validation rules. + * + * @since COmanage Match v1.1.0 + * @param Validator $validator Validator + * @return \Cake\Validation\Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $validator->add( + 'matchgrid_id', + 'content', + [ 'rule' => 'isInteger' ] + ); + $validator->notBlank('matchgrid_id'); + + $validator->add( + 'description', + 'length', + [ 'rule' => [ 'maxLength', 128 ], + 'message' => __('match.er.val.length', [128]) ] + ); + $validator->allowEmptyString('description'); + + $validator->add( + 'url', + 'url', + [ 'rule' => [ 'url', true ]] + ); + + $validator->add( + 'url', + 'length', + [ 'rule' => [ 'maxLength', 256 ], + 'message' => __('match.er.val.length', [256]) ] + ); + $validator->notEmptyString('url'); + + $validator->add( + 'username', + 'length', + [ 'rule' => [ 'maxLength', 128 ], + 'message' => __('match.er.val.length', [128]) ] + ); + $validator->allowEmptyString('username'); + + $validator->add( + 'password', + 'length', + [ 'rule' => [ 'maxLength', 256 ], + 'message' => __('match.er.val.length', [256]) ] + ); + $validator->allowEmptyString('password'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/MatchgridHistoryRecordsTable.php b/app/src/Model/Table/MatchgridHistoryRecordsTable.php new file mode 100644 index 000000000..a44e24c1a --- /dev/null +++ b/app/src/Model/Table/MatchgridHistoryRecordsTable.php @@ -0,0 +1,183 @@ +addBehavior('Timestamp'); + + // Define associations + + $this->belongsTo('Matchgrids'); + + // We don't use "comment" here because it might be lengthy + $this->setDisplayField('id'); + + $this->setPrimaryLink('matchgrid_id'); + $this->setRequiresMatchgrid(true); + + $this->setAutoViewVars([ + 'sor' => [ + 'type' => 'list', + 'model' => 'SystemsOfRecord', + 'find' => 'filterPrimaryLink', + // Because matchgrids use the actual SOR label and not systems_of_record_id + // we need a custom select field + 'fields' => ['keyField' => 'label', 'valueField' => 'label'], + 'order' => ['label' => 'ASC'] + ] + ]); + + // Always permit search on SOR, SORID, and Reference ID + $this->setSearchFilter('sor', true, null, false); + $this->setSearchFilter('sorid', true, null, false); + } + + /** + * Record a Matchgrid History Record. + * + * @since COmanage Match v1.1.0 + * @param int $matchgridId Matchgrid ID + * @param string $sorLabel System of Record Label + * @param string $sorid System of Record Identifier + * @param string $action Transaction code + * @param string $comment Human readable comment + * @param string $remoteIp Remote IP + * @param string $actorIdentifier Actor Identifier + */ + + public function record(int $matchgridId, + string $sorLabel, + string $sorid, + string $action, + string $comment, + string $remoteIp=null, + string $actorIdentifier=null) { + $obj = $this->newEntity([ + "matchgrid_id" => $matchgridId, + "sor" => $sorLabel, + "sorid" => $sorid, + "action" => $action, + "comment" => $comment, + "remote_ip" => $remoteIp, + "actor_identifier" => $actorIdentifier + ]); + + $this->saveOrFail($obj); + } + + /** + * Set validation rules. + * + * @since COmanage Match v1.1.0 + * @param Validator $validator Validator + * @return \Cake\Validation\Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $validator->add( + 'matchgrid_id', + 'content', + [ 'rule' => 'isInteger' ] + ); + $validator->notBlank('matchgrid_id'); + + $validator->add( + 'sor', + 'length', + [ 'rule' => [ 'maxLength', 80 ], + 'message' => __('match.er.val.length', [80]) ] + ); + $validator->notEmptyString('sor'); + + $validator->add( + 'sorid', + 'length', + [ 'rule' => [ 'maxLength', 64 ], + 'message' => __('match.er.val.length', [64]) ] + ); + $validator->notEmptyString('sorid'); + + $validator->add( + 'action', + 'length', + [ 'rule' => [ 'maxLength', 4 ], + 'message' => __('match.er.val.length', [4]) ] + ); + $validator->notEmptyString('action'); + + $validator->add( + 'comment', + 'length', + [ 'rule' => [ 'maxLength', 256 ], + 'message' => __('match.er.val.length', [256]) ] + ); + $validator->notEmptyString('comment'); + + $validator->add( + 'remote_ip', + 'ip', + [ 'rule' => [ 'ip' ]] + ); + + $validator->add( + 'remote_ip', + 'length', + [ 'rule' => [ 'maxLength', 80 ], + 'message' => __('match.er.val.length', [80]) ] + ); + $validator->allowEmptyString('remote_ip'); + + $validator->add( + 'actor_identifier', + 'length', + [ 'rule' => [ 'maxLength', 256 ], + 'message' => __('match.er.val.length', [256]) ] + ); + $validator->allowEmptyString('actor_identifier'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/MatchgridSettingsTable.php b/app/src/Model/Table/MatchgridSettingsTable.php index 175f8b0d6..fe2ee0a60 100644 --- a/app/src/Model/Table/MatchgridSettingsTable.php +++ b/app/src/Model/Table/MatchgridSettingsTable.php @@ -19,7 +19,7 @@ * See the License for the specific language governing permissions and * limitations under the License. * - * @link http://www.internet2.edu/comanage COmanage Project + * @link https://www.internet2.edu/comanage COmanage Project * @package match * @since COmanage Match v1.0.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) @@ -31,6 +31,7 @@ use Cake\ORM\Query; use Cake\ORM\Table; +use Cake\ORM\TableRegistry; use Cake\Validation\Validator; use \App\Lib\Enum\ReferenceIdEnum; @@ -62,6 +63,10 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Matchgrids'); + + $this->belongsTo('ResolutionNotificationEndpoints', ['className' => 'Endpoints']) + ->setForeignKey('resolution_notification_endpoint_id') + ->setProperty('resolution_notification_endpoint'); $this->setDisplayField('matchgrid_id'); @@ -72,6 +77,12 @@ public function initialize(array $config): void { 'referenceidMethods' => [ 'type' => 'enum', 'class' => 'ReferenceIdEnum' + ], + 'resolutionNotificationEndpoints' => [ + 'type' => 'select', + 'model' => 'Endpoints', + 'find' => 'filterPrimaryLink', + 'order' => ['description' => 'ASC'] ] ]); } @@ -110,6 +121,27 @@ public function getIdForMatchgrid(int $matchgridId) { public function getNotificationEmail(int $matchgridId) { return $this->lookupValue($matchgridId, 'notification_email'); } + + /** + * Get the Notification Endpoint for the specified Matchgrid. + * + * @since COmanage Match v1.1.0 + * @param int $matchgridId Matchgrid ID + * @return Endpoint Endpoint Entity + */ + + public function getNotificationEndpoint(int $matchgridId) { + $endpointId = $this->lookupValue($matchgridId, 'resolution_notification_endpoint_id'); + + if($endpointId) { + $Endpoints = TableRegistry::getTableLocator()->get('Endpoints'); + + // throws Cake\Datasource\Exception\RecordNotFoundException + return $Endpoints->get($endpointId); + } + + return null; + } /** * Get the Matchgrid Reference ID prefix. diff --git a/app/src/Model/Table/MatchgridsTable.php b/app/src/Model/Table/MatchgridsTable.php index 146ec76c2..cec19e724 100644 --- a/app/src/Model/Table/MatchgridsTable.php +++ b/app/src/Model/Table/MatchgridsTable.php @@ -68,6 +68,8 @@ public function initialize(array $config): void { ->setCascadeCallbacks(true); $this->hasMany('AttributeGroups') ->setDependent(true); + $this->hasMany('Endpoints') + ->setDependent(true); $this->hasMany('Permissions') ->setDependent(true); $this->hasMany('Rules') diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 57028dfda..6f92f215a 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -106,6 +106,17 @@ public function control(string $fieldName, } } + $control = ''; + + if(is_object($this->viewObj->$fieldName) + && get_class($this->viewObj->$fieldName) == 'Cake\I18n\FrozenTime') { + // This is a read-only timestamp, use the field to render itself + $control = $this->viewObj->$fieldName->nice(); + $isRequired = false; + } else { + $control = $this->Form->control($fieldName, $coptions); + } + $children = ''; if(!empty($childControls)) { @@ -120,6 +131,11 @@ public function control(string $fieldName, $children .= ""; } + if(!$this->editable || (isset($options['readonly']) && $options['readonly'])) { + // Read only attributes can't be required + $isRequired = false; + } + return '
  • diff --git a/app/templates/AttributeMappings/columns.inc b/app/templates/AttributeMappings/columns.inc index 87bd4a30f..d5a52c04e 100644 --- a/app/templates/AttributeMappings/columns.inc +++ b/app/templates/AttributeMappings/columns.inc @@ -43,8 +43,10 @@ $topLinks = [ 'label' => ' ' . __('match.op.AttributeMappings.install.nicknames.en'), 'link' => [ - 'action' => 'install', + 'action' => 'install', + '?' => [ 'mapping' => 'nicknames.en' + ] ], 'class' => 'buildbutton' ] diff --git a/app/templates/Endpoints/columns.inc b/app/templates/Endpoints/columns.inc new file mode 100644 index 000000000..f639fee06 --- /dev/null +++ b/app/templates/Endpoints/columns.inc @@ -0,0 +1,36 @@ + [ + 'type' => 'link', + 'cssClass' => 'row-link' + ], + 'username' => [ + 'type' => 'echo' + ] +]; \ No newline at end of file diff --git a/app/templates/Endpoints/fields.inc b/app/templates/Endpoints/fields.inc new file mode 100644 index 000000000..5d628cdf7 --- /dev/null +++ b/app/templates/Endpoints/fields.inc @@ -0,0 +1,37 @@ +Field->control('description'); + + print $this->Field->control('url'); + + print $this->Field->control('username', [], false); + + print $this->Field->control('password', [], false); +} diff --git a/app/templates/MatchgridHistoryRecords/columns.inc b/app/templates/MatchgridHistoryRecords/columns.inc new file mode 100644 index 000000000..b98dcf68d --- /dev/null +++ b/app/templates/MatchgridHistoryRecords/columns.inc @@ -0,0 +1,74 @@ + [ + 'type' => 'link', + 'sortable' => true + ], + 'sor' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'sorid' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'action' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'comment' => [ + 'type' => 'echo', + 'sortable' => false + ], + 'created' => [ + 'type' => 'echo', + 'sortable' => true + ] +]; + +// When adding a Matchgrid History Record we need to know which sor/sorid it is for. +// We need both, if one is missing we reject adding. +$addLinkFilter = function (array $currentLink, $request) { + $ret = $currentLink; + $ret['?']['sor'] = $request->getQuery('sor'); + $ret['?']['sorid'] = $request->getQuery('sorid'); + + if(empty($ret['?']['sor']) || empty($ret['?']['sorid'])) { + return false; + } + + return $ret; +}; \ No newline at end of file diff --git a/app/templates/MatchgridHistoryRecords/fields.inc b/app/templates/MatchgridHistoryRecords/fields.inc new file mode 100644 index 000000000..03b8d9b9e --- /dev/null +++ b/app/templates/MatchgridHistoryRecords/fields.inc @@ -0,0 +1,58 @@ + + \App\Lib\Enum\MatchgridActionEnum::CommentAdded, + 'actor_identifier' => $vv_user['username'], + 'remote_ip' => $this->request->getEnv('REMOTE_ADDR') + ]; + + print $this->Field->control('sor', ['readonly' => true, 'value' => $this->request->getQuery('sor')]); + + print $this->Field->control('sorid', ['readonly' => true, 'value' => $this->request->getQuery('sorid')]); + + print $this->Field->control('comment'); +} elseif($action == 'view') { + print $this->Field->control('comment'); + + print $this->Field->control('action'); + + print $this->Field->control('sor'); + + print $this->Field->control('sorid'); + + print $this->Field->control('remote_ip'); + + print $this->Field->control('actor_identifier'); + + print $this->Field->control('created'); +} \ No newline at end of file diff --git a/app/templates/MatchgridRecords/columns.inc b/app/templates/MatchgridRecords/columns.inc index 470ba8008..958746848 100644 --- a/app/templates/MatchgridRecords/columns.inc +++ b/app/templates/MatchgridRecords/columns.inc @@ -69,4 +69,19 @@ foreach($attributes as $attr) { 'sortable' => true ]; } -} \ No newline at end of file +} + +$indexActions = [ + [ + 'controller' => 'matchgrid_history_records', + 'action' => 'index', + 'query' => function ($mgid, $e) { + return [ + 'matchgrid_id' => $mgid, + 'sor' => $e->sor, + 'sorid' => $e->sorid + ]; + }, + 'icon' => 'history' + ] +]; diff --git a/app/templates/MatchgridSettings/fields.inc b/app/templates/MatchgridSettings/fields.inc index 456c7d631..9b7edf431 100644 --- a/app/templates/MatchgridSettings/fields.inc +++ b/app/templates/MatchgridSettings/fields.inc @@ -61,4 +61,9 @@ if($action == 'edit') { print $this->Field->control('referenceid_prefix', [], false); print $this->Field->control('notification_email', [], false); + + print $this->Field->control('resolution_notification_endpoint_id', + ['empty' => true], + false, + __('match.fd.resolution_notification_endpoint')); } diff --git a/app/templates/Matchgrids/configure.php b/app/templates/Matchgrids/configure.php index c7b2d66e7..29039a3f1 100644 --- a/app/templates/Matchgrids/configure.php +++ b/app/templates/Matchgrids/configure.php @@ -51,6 +51,7 @@ 'attributes' => 'edit', 'attribute_groups' => 'storage', 'attribute_maps' => 'swap_horiz', + 'endpoints' => 'lan', 'rules' => 'assignment', 'systems_of_record' => 'gavel', ]; diff --git a/app/templates/Matchgrids/manage.php b/app/templates/Matchgrids/manage.php index c33a7167e..6b548c9af 100644 --- a/app/templates/Matchgrids/manage.php +++ b/app/templates/Matchgrids/manage.php @@ -61,5 +61,15 @@ ?>
    +
    + Html->link('' . __('match.op.display.history'), + ['controller' => 'MatchgridHistoryRecords', + 'action' => 'index', + '?' => ['matchgrid_id' => $vv_cur_mg->id] + ], + ['escape' => false, 'class' => 'btn btn-default']); + ?> +
    +
    diff --git a/app/templates/Standard/add-edit-view.php b/app/templates/Standard/add-edit-view.php index ae8d18505..64fb5d644 100644 --- a/app/templates/Standard/add-edit-view.php +++ b/app/templates/Standard/add-edit-view.php @@ -55,10 +55,8 @@ Form->create($vv_obj); -} +// By default, the form will POST to the current controller +print $this->Form->create($vv_obj); $linkId = null; @@ -74,7 +72,14 @@ print $this->Field->startControlSet($vv_obj, $this->name, $action, ($action == 'add' || $action == 'edit')); -include(TEMPLATES . $modelsName . "/fields.inc"); +include(TEMPLATES . $modelsName . DS . "fields.inc"); + +if(!empty($hidden)) { + // Inject any hidden variables set by the include file + foreach($hidden as $attr => $v) { + print $this->Form->hidden($attr, ['value' => $v]); + } +} if($action == 'add' || $action == 'edit') { if(!empty($linkId)) { @@ -83,7 +88,7 @@ } print $this->Field->submit(__('match.op.save')); - print $this->Form->end(); } +print $this->Form->end(); print $this->Field->endControlSet(); diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index 78b93eedf..fae0139d0 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -92,10 +92,22 @@ function _column_key($modelsName, $c, $tz=null) {