Skip to content
Permalink
main
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Go to file
…ntroller (CO-2470)
0 contributors

Users who have contributed to this file

766 lines (606 sloc) 28.4 KB
<?php
/**
* COmanage Match TAP API Controller
*
* 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 match
* @since COmanage Match v1.0.0
* @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
*/
declare(strict_types = 1);
namespace App\Controller;
use Cake\Mailer\Mailer;
use Cake\Log\Log;
use Cake\Routing\Router;
use \App\Lib\Enum\ConfidenceModeEnum;
use \App\Lib\Enum\MatchgridActionEnum;
use \App\Lib\Enum\ResolutionModeEnum;
use \App\Lib\Enum\StatusEnum;
class TapApiController extends AppController {
// Set by dispatched functions to control results
protected $statusCode = 500;
protected $result = [];
/**
* Perform Cake Controller initialization.
*
* @since COmanage Match v1.0.0
*/
public function initialize(): void {
// We have substantial differences from AppController::initialize(), so we
// completely override here.
$this->loadComponent('RequestHandler');
$this->loadComponent('Auth', [
'authorize' => 'Controller',
'authenticate' => [
'Basic' => [
'fields' => ['username' => 'username', 'password' => 'password'],
'userModel' => 'ApiUsers',
// We pull SoR information for authz purposes (handled in isAuthorized)
'finder' => 'authorization'
]
],
'storage' => 'Memory',
'unauthorizedRedirect' => false
]);
}
/**
* Handle an API Request Current Values request, ie: GET /v1/people/sor/sorid
*
* @since COmanage Match v1.0.0
*/
public function current() {
if(!empty($this->request->getQueryParams())) {
// This is actually a Search Only request via GET, but routes.php doesn't
// quite want to send the request to search(). Regardless, we don't
// (currently) support search over GET, so throw an error.
// And as of API v1.0.0, search over GET has been removed.
throw new \Cake\Http\Exception\MethodNotAllowedException("GET not supported for Search Only, use POST instead");
}
$this->dispatch('doCurrent');
}
/**
* Dispatch an API request.
*
* @since COmanage Match v1.0.0
* @param string $func Function to call
*/
protected function dispatch(string $func) {
// Note that in general routes.php will prevent us from being called without
// our core parameters (typically 'sor' or 'sorid', according to the
// requested operation).
$matchgridId = (int)$this->request->getParam('matchgrid_id');
try {
$MatchService = new \App\Lib\Match\MatchService();
$MatchService->connectDatabase();
$MatchService->setConfig($matchgridId);
$this->$func($MatchService);
$MatchService->disconnectDatabase();
}
catch(\Exception $e) {
$statusCode = 500;
$this->result['error'] = $e->getMessage();
Log::write('error', $e->getMessage());
}
$this->viewBuilder()->setLayout('rest');
$this->response = $this->response->withStatus($this->statusCode);
// Set the result data and render the default API response view
$this->set('vv_result', $this->result);
$this->render('response');
}
/**
* Handle an API Request Current Values request, ie: GET /v1/people/sor/sorid
*
* @since COmanage Match v1.0.0
* @param MatchService $MatchService Match Service
*/
protected function doCurrent(\App\Lib\Match\MatchService $MatchService) {
$sor = $this->request->getParam('sor');
$sorid = $this->request->getParam('sorid');
$resultManager = $MatchService->getSorAttributes($sor, $sorid);
if($resultManager->count()==0) {
$this->statusCode = 404;
} else {
$this->statusCode = 200;
$this->result = $resultManager->getResultsForJson('current');
}
// We shouldn't get more than one row
}
/**
* Handle an API Inventory of Requests request, , ie: GET /v1/people/sor
*
* @since COmanage Match v1.0.0
* @param MatchService $MatchService Match Service
*/
protected function doInventory(\App\Lib\Match\MatchService $MatchService) {
$sor = $this->request->getParam('sor');
$this->result['sorids'] = $MatchService->getSorIds($sor);
$this->statusCode = 200;
}
/**
* Perform a match operation.
*
* @since COmanage Match v1.0.0
* @param boolean $searchOnly If true, do not cause any state changes
*/
protected function doMatchRequest(bool $searchOnly=false) {
$statusCode = 500;
$result = [];
$matchgridId = (int)$this->request->getParam('matchgrid_id');
$sor = $this->request->getParam('sor');
$sorid = $this->request->getParam('sorid');
// Request attributes are here (json body)
$json = $this->request->getParsedBody();
Log::write('debug', $sor . "/" . $sorid . ($searchOnly ? " Search" : " Match") . " request received for matchgrid " . $matchgridId);
try {
if(empty($json)) {
throw new \InvalidArgumentException('No JSON record found or body not successfully parsed');
}
// getParsedBody can apparently return either an array or an object
// according to its mood. We originally assumed an object, so if we get
// an array we'll convert it to an object.
if(is_array($json)) {
$json = (object)$json;
}
$AttributeManager = new \App\Lib\Match\AttributeManager();
$MatchService = new \App\Lib\Match\MatchService();
$MatchgridHistory = $this->getTableLocator()->get('MatchgridHistoryRecords');
$MatchService->connectDatabase();
$AttributeManager->parseFromJSON($json);
Log::write('debug', $sor . "/" . $sorid . " Obtaining configuration for matchgrid " . $matchgridId);
$MatchService->setConfig($matchgridId);
// We don't use transactions here because for the most part they wouldn't
// accomplish anything. ie:
// (1) For Update Match Attributes, getRequestIdForSorId() results will be static
// (unless two admin operations happen simultaneously, which shouldn't
// ordinarily happen).
// (2) Forced Reconciliation only performs one write operation.
// (3) Search/Update could use transactions in theory, but we'd need to use
// a read lock ("FOR UPDATE", or $dbc->RowLock()), and if there are no
// existing records, the read lock won't lock anything. Even if there
// is a match, we're not updating the prior record, we're updating the
// new record (which presumably no other actor is modifying at the same
// time).
// The Reference ID included in the request, if any
$requestedReferenceId = $AttributeManager->getRequestedReferenceId();
// The current row ID for this $sor + $sorid, if any
$curid = $MatchService->getRequestIdForSorId($sor, $sorid);
// The current Reference ID for the current row ID, if any
$currentReferenceId = ($curid ? $MatchService->getReferenceIdForRequest($curid) : null);
if(!$searchOnly
&& !$requestedReferenceId
&& $curid
&& $currentReferenceId) {
// If we already have a record for sor+sorid and no referenceId was
// provided, this is an Update Match Attributes request. However, if
// there is no referenceId in the matchgrid entry, then we instead want
// to reprocess the record as a standard search request
// XXX Shouldn't we log matchgrid ID?
// see also PE LogBehavior
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;
} elseif(!$searchOnly && $requestedReferenceId) {
// Forced Reconciliation request. Skip the search and jump to the insert.
// (attachReferenceId will insert or update as appropriate.)
if($requestedReferenceId != "new") {
// Check that the requested SOR/SORID doesn't already have a Reference ID.
// If it does, a Matchgrid administrator should make whatever changes are
// requested, not the SOR over the API.
if($currentReferenceId) {
Log::write('debug', $sor . "/". $sorid . " Rejecting forced reconciliation request for Reference ID " . $requestedReferenceId . " (already reconciled)");
throw new \InvalidArgumentException(__('match.er.reconcile.done.api', [$curid]));
}
// Next check that the requested Reference ID is a valid candidate
// (basically by re-performing the search).
$results = $MatchService->searchReferenceId($sor, $sorid, $AttributeManager);
// 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'));
}
if($results->count() == 0) {
// No match
Log::write('debug', $sor . "/". $sorid . " Rejecting forced reconciliation request for Reference ID " . $requestedReferenceId . " (no matches)");
throw new \InvalidArgumentException(__('match.er.reconcile.invalid.api'));
} else {
$refIds = $results->getReferenceIds();
if(!in_array($requestedReferenceId, $refIds)) {
// Requested Reference ID is not in the candidate pool
Log::write('debug', $sor . "/". $sorid . " Rejecting forced reconciliation request for Reference ID " . $requestedReferenceId . " (invalid candidate)");
throw new \InvalidArgumentException(__('match.er.reconcile.invalid.api'));
}
}
// Note that we don't further check that Reference ID matches an
// existing record (as we do via the UI) because we've effectively
// already just performed that check (a candidate Reference ID must
// by definition already be in use).
}
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];
$statusCode = 200;
} else {
// Perform a search, and insert or update if not Search Only
$results = $MatchService->searchReferenceId($sor, $sorid, $AttributeManager);
// 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'));
}
if($results->count() == 0) {
// No match
if($searchOnly) {
$statusCode = 404;
} else {
if($curid) {
// We need to treat this as a forced reconciliation request, but it's really
// an SOR performing an update match attributes request on a record without
// an existing referenceid
$referenceId = $MatchService->attachReferenceId($sor, $sorid, $AttributeManager, 'new');
} else {
$referenceId = $MatchService->assignReferenceId($sor, $sorid, $AttributeManager);
}
$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
$refIds = $results->getReferenceIds();
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]);
}
$result = ['referenceId' => $refIds[0]];
}
$statusCode = 200;
} else {
// Potential match
// Pull the SOR configuration
$SoR = $this->getTableLocator()->get('SystemsOfRecord');
$sorobj = $SoR->find('all')->where(['matchgrid_id' => $matchgridId, 'label' => $sor])->firstOrFail();
if(!$searchOnly && $sorobj->resolution_mode == ResolutionModeEnum::External) {
$statusCode = 202;
$matchRequest = $MatchService->insertPending($sor, $sorid, $AttributeManager);
$result['matchRequest'] = $matchRequest;
// Do we have an SoR-specific or Matchgrid-wide notification address?
$notify = null;
if(!empty($sorobj->notification_email)) {
$notify = $sorobj->notification_email;
} else {
$MatchgridSettings = $this->getTableLocator()->get('MatchgridSettings');
$notify = $MatchgridSettings->getNotificationEmail($matchgridId);
}
if($notify) {
// We currently just do everything here, but maybe this moves to
// somewhere in Lib at some point?
$url = "/matchgrids/reconcile/" . $this->cur_mg->id . "?rowid=" . $matchRequest;
$email = new Mailer('default');
$email->setViewVars([
'vv_matchgrid_id' => $this->cur_mg->id,
'vv_pending_url' => Router::url($url, true)
]);
$email->viewBuilder()->setTemplate('potential_match');
$email->setEmailFormat('text');
$email->setTo($notify);
$email->deliver();
Log::write('debug', $sor . "/" . $sorid . " Potential match requiring resolution, notification sent to " . $notify);
}
} else {
// Interactive SOR, or searchOnly (which can't return a 202)
$statusCode = 300;
$result['candidates'] = $results->getResultsForJson();
}
}
}
$MatchService->disconnectDatabase();
}
// Coerce errors into a 400 or 500 response
catch(\InvalidArgumentException $e) {
$statusCode = 400;
$result['error'] = $e->getMessage();
Log::write('error', $e->getMessage());
}
catch(\RuntimeException $e) {
$statusCode = 500;
$result['error'] = $e->getMessage();
Log::write('error', $e->getMessage());
}
catch(\Exception $e) {
$statusCode = 500;
$result['error'] = $e->getMessage();
Log::write('error', $e->getMessage());
}
Log::write('debug', $sor . "/" . $sorid . " Preparing response with status code " . $statusCode);
$this->viewBuilder()->setLayout('rest');
$this->response = $this->response->withStatus($statusCode); // Can also pass a custom phrase
// Set the result data and render the default API response view
$this->set('vv_result', $result);
$this->render('response');
}
/**
* Handle an API Join Reference Identifiers request, ie: PUT /v1/referenceIds/id
*
* @since COmanage Match v1.0.0
* @param MatchService $MatchService Match Service
*/
protected function doMerge(\App\Lib\Match\MatchService $MatchService) {
// Reference ID we want to keep / merge to
$targetId = $this->request->getParam('id');
// getParsedBody could be on object, or it might be an array!
$json = $this->request->getParsedBody();
if($targetId && !empty($json['referenceIds'])) {
$MatchService->merge($targetId, $json['referenceIds']);
$this->statusCode = 200;
} else {
$this->statusCode = 400;
}
}
/**
* Handle an API Delete Current Values request
*
* @since COmanage Match v1.0.0
* @param MatchService $MatchService Match Service
*/
protected function doRemove(\App\Lib\Match\MatchService $MatchService) {
$sor = $this->request->getParam('sor');
$sorId = $this->request->getParam('sorid');
if($MatchService->remove($sor, $sorId)) {
$this->statusCode = 200;
} else {
$this->statusCode = 404;
}
}
/**
* Handle an API Match Request request, ie: GET /v1/matchRequests/id
*
* @since COmanage Match v1.0.0
* @param MatchService $MatchService Match Service
*/
protected function doViewMatchRequest(\App\Lib\Match\MatchService $MatchService) {
$results = $MatchService->getRequest((int)$this->request->getParam('id'));
if($results->count() == 0) {
// No such request ID
$this->statusCode = 404;
return;
}
// Parse the original request
$origReq = $results->getResultsForJson('current');
if(!empty($origReq['meta']['referenceId'])) {
// This is a resolved request, so handle it a bit differently
$this->statusCode = 200;
$this->result = $origReq;
return;
}
// 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;
$this->result['matchRequest'] = $this->request->getParam('id');
// Extract the SOR and SORID
$sor = $origReq['meta']['sorLabel'];
$sorid = $origReq['meta']['sorId'];
// Use AttributeManager to parse the current record back into database format for searching
$AttributeManager = new \App\Lib\Match\AttributeManager();
// We have an array but parseFromJSON wants an object
$AttributeManager->parseFromJSON(json_decode(json_encode($origReq)));
$results = $MatchService->searchReferenceId($sor, $sorid, $AttributeManager);
// Count could be 0 if we failed to match any rules at all (canonical or potential)
if($results->count() > 0) {
$this->result['candidates'] = $results->getResultsForJson('search');
}
// Insert the original request as "new"
$origReq['referenceId'] = 'new';
$this->result['candidates'][] = $origReq;
}
/**
* Handle various API Match Requests requests, ie: GET /v1/matchRequests
*
* @since COmanage Match v1.0.0
* @param MatchService $MatchService Match Service
*/
protected function doViewMatchRequests(\App\Lib\Match\MatchService $MatchService) {
if(!empty($this->request->getQuery('status'))) {
// Obtain pending/resolved requests
$r = $MatchService->getRequests($this->request->getQuery('status'));
} elseif(!empty($this->request->getQuery('referenceId'))) {
// Obtain SOR Records request
$r = $MatchService->getRequestsForReferenceId($this->request->getQuery('referenceId'));
}
$this->result['matchRequests'] = $r->getResultsForJson('pending');
$this->statusCode = 200;
}
/**
* Handle an API Inventory of Requests request, , ie: GET /v1/people/sor
*
* @since COmanage Match v1.0.0
*/
public function inventory() {
$this->dispatch('doInventory');
}
/**
* Authorization for this Controller, called by Auth component
* - postcondition: $vv_permissions set with calculated permissions for this Controller
*
* @since COmanage Match v1.0.0
* @param Array $user Array of user data
* @return Boolean True if authorized for the current action, false otherwise
*/
public function isAuthorized(Array $user) {
// Unlike most other controllers, authorization for the API is determined by
// the data available in the request.
Log::write('debug', 'TapApiController::isAuthorized() request for ' . $user['username']);
// Because we're using BasicAuthenticate, $user will have the record from api_users.
// This is what the API User requested:
$sor = $this->request->getParam('sor');
$mgid = (int)$this->request->getParam('matchgrid_id');
// Authorization is as follows:
// (0) Make sure the Matchgrid is active and the requested SOR exists.
if(!$this->cur_mg) {
Log::write('debug', "TapApiController::isAuthorized() Requested matchgrid " . $mgid . " not found");
throw new \Cake\Http\Exception\ForbiddenException(__('match.er.unauthorized'));
//throw new \Cake\Http\Exception\ForbiddenException("Requested matchgrid " . $mgid . " not found");
}
if($this->cur_mg->status != StatusEnum::Active) {
Log::write('debug', "TapApiController::isAuthorized() Requested matchgrid " . $mgid . " is not Active");
throw new \Cake\Http\Exception\ForbiddenException(__('match.er.unauthorized'));
//throw new \Cake\Http\Exception\ForbiddenException("Requested matchgrid " . $mgid . " is not Active");
}
if($sor && $mgid) {
$this->SystemsOfRecord = $this->fetchTable('SystemsOfRecord');
$count = $this->SystemsOfRecord->find()->where(['matchgrid_id' => $mgid, 'label' => $sor])->count();
if($count == 0) {
Log::write('debug', "TapApiController::isAuthorized() Requested SOR " . $sor . " not found");
throw new \Cake\Http\Exception\ForbiddenException(__('match.er.unauthorized'));
//throw new \Cake\Http\Exception\ForbiddenException("Requested SOR " . $sor . " not found");
}
}
// (1) A Platform API user ($user['matchgrid_id'] is NULL) may perform any action.
if(!empty($user['username']) && !$user['matchgrid_id']) {
Log::write('debug', 'TapApiController::isAuthorized() ' . $user['username'] . ' authorized as Platform API User');
return true;
}
// (2) A Matchgrid API user ($user['matchgrid_id'] is NOT NULL, $user['system_of_record_id'] is NULL)
// may perform any action within the requested Matchgrid.
if(!empty($user['username'])
&& !empty($user['matchgrid_id']) // This should always be 1 or greater since SERIAL starts at 1
&& !$user['system_of_record_id'] // This is empty for Matchgrid API users
&& $user['matchgrid_id'] == $mgid) {
Log::write('debug', 'TapApiController::isAuthorized() ' . $user['username'] . ' authorized as Matchgrid API User for Matchgrid ' . $this->cur_mg->table_name . " (" . $this->cur_mg-> id . ")");
return true;
}
// (3) A System of Record API user ($user['matchgrid_id'] is NOT NULL, $user['system_of_record_id'] is NOT NULL)
// may perform any action within the requested Matchgrid + SOR.
if(!empty($user['username'])
&& !empty($user['matchgrid_id']) // This should always be 1 or greater since SERIAL starts at 1
&& !empty($user['system_of_record']['label'])
&& !empty($sor)
&& $user['matchgrid_id'] == $mgid
&& $sor == $user['system_of_record']['label']) {
Log::write('debug', 'TapApiController::isAuthorized() ' . $user['username'] . ' authorized as System of Record API User for Matchgrid ' . $this->cur_mg->table_name . " (" . $this->cur_mg-> id . "), SOR " . $user['system_of_record']['label'] . " (" . $user['system_of_record_id']. ")");
return true;
}
Log::write('debug', "TapApiController::isAuthorized() No authorization found for " . $user['username']);
// XXX These are both equivalent and generate giant stack traces in the error.log
// Can we catch them somehow and prevent them from rendering?
// Note BasicAuthenticate failure (ie: password incorrect) also dumps a stack trace
return false;
// throw new \Cake\Http\Exception\ForbiddenException();
}
/**
* Handle an API Reference Identifier (Match) request, ie: PUT /v1/people/sor/sorid
*
* @since COmanage Match v1.0.0
*/
public function match() {
$this->doMatchRequest();
}
/**
* Handle an Join Reference Identifiers request, ie: PUT /v1/referenceIds/id
*
* @since COmanage Match v1.0.0
*/
public function merge() {
$this->dispatch('doMerge');
}
/**
* Handle an API Delete Current Values request
*
* @since COmanage Match v1.0.0
*/
public function remove() {
$this->dispatch('doRemove');
}
/**
* Handle an API Search-Only request, ie: POST or (not yet supported) GET /v1/people/sor/sorid
*
* @since COmanage Match v1.0.0
*/
public function search() {
// Note a GET Search Only request will end up at current() due to routes.php.
$this->doMatchRequest(true);
}
/**
* Determine the (requested) current Matchgrid and make it available to the
* rest of the application.
*
* @since COmanage Match v1.0.0
* @throws Cake\Datasource\Exception\RecordNotFoundException
* @throws \InvalidArgumentException
*/
protected function setMatchgrid() {
// This overrides (and does not call) AppController::setMatchgrid since we
// have more specific requirements here.
// For now we can just trust the passed parameter since the first thing that
// happens after we run is isAuthorized() will verify authz.
$mgid = $this->request->getParam('matchgrid_id');
if($mgid) {
$this->Matchgrids = $this->fetchTable('Matchgrids');
// This throws Cake\Datasource\Exception\RecordNotFoundException which
// we just let pass up the stack.
$this->cur_mg = $this->Matchgrids->findById($mgid)->firstOrFail();
}
}
/**
* Handle an API Match Request request, ie: GET /v1/matchRequest/id
*
* @since COmanage Match v1.0.0
*/
public function viewMatchRequest() {
$this->dispatch('doViewMatchRequest');
}
/**
* Handle various API Match Requests requests, ie: GET /v1/matchRequests
*
* @since COmanage Match v1.0.0
*/
public function viewMatchRequests() {
$this->dispatch('doViewMatchRequests');
}
}