diff --git a/README.md b/README.md
index 0123528de..b4f29651d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# COmanage Match
-COmanage Match is a utility for identifying potential duplicate records from multiple authoritatize
+COmanage Match is a utility for identifying potential duplicate records from multiple authoritative
systems. COmanage Match is a product of the COmanage Project.
More information is available in the [COmanage wiki](https://spaces.at.internet2.edu/display/COmanage),
diff --git a/app/config/schema/schema.xml b/app/config/schema/schema.xml
index 5ec554265..93742884a 100644
--- a/app/config/schema/schema.xml
+++ b/app/config/schema/schema.xml
@@ -196,4 +196,29 @@
matchgrid_id
+
+
+
+
+
+
+
+ REFERENCES matchgrids(id)
+
+
+ REFERENCES systems_of_record(id)
+
+
+
+
+
+
+
+ matchgrid_id
+
+
+
+ username
+
+
\ No newline at end of file
diff --git a/app/src/Controller/ApiUsersController.php b/app/src/Controller/ApiUsersController.php
new file mode 100644
index 000000000..9579cc250
--- /dev/null
+++ b/app/src/Controller/ApiUsersController.php
@@ -0,0 +1,66 @@
+cur_mg->id) ? $this->cur_mg->id : null;
+
+ $platformAdmin = $this->Authorization->isPlatformAdmin($user['username']);
+
+ $mgAdmin = $this->Authorization->isMatchAdmin($user['username'], $mgid);
+
+ if(!$platformAdmin && !$mgid) {
+ // Normally this is done in AppController::setMatchgrid, but since we
+ // allow empty Matchgrid ID we have to manually check here.
+ throw new \RuntimeException(__('match.er.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/AppController.php b/app/src/Controller/AppController.php
index 3599ac199..4eb2bedec 100644
--- a/app/src/Controller/AppController.php
+++ b/app/src/Controller/AppController.php
@@ -142,6 +142,8 @@ public function beforeRender(\Cake\Event\Event $event) {
*/
protected function setMatchgrid() {
+ // Note: TierApiController overrides this.
+
// $this->name = Models
$modelsName = $this->name;
@@ -162,6 +164,11 @@ protected function setMatchgrid() {
}
}
+ if($this->request->is('post')) {
+ // Accept the matchgrid ID from the posted data
+ $mgid = $this->request->getData('matchgrid_id');
+ }
+
if(!$mgid) {
// Try to map the requested object ID
$param = (int)$this->request->getParam('pass.0');
@@ -176,9 +183,9 @@ protected function setMatchgrid() {
}
}
- if(!$mgid) {
+ if(!$mgid && !$this->$modelsName->allowEmptyMatchgrid()) {
// If we get this far without a Matchgrid ID, something went wrong.
- throw new RuntimeException(__('match.er.mgid'));
+ throw new \RuntimeException(__('match.er.mgid'));
}
if($mgid) {
diff --git a/app/src/Controller/Component/AuthorizationComponent.php b/app/src/Controller/Component/AuthorizationComponent.php
index 80f5df1bb..689dfb46f 100644
--- a/app/src/Controller/Component/AuthorizationComponent.php
+++ b/app/src/Controller/Component/AuthorizationComponent.php
@@ -184,6 +184,7 @@ public function menuPermissions($username, $matchgridId=null) {
return [
// Manage configuration of the current matchgrid
+ 'api_users' => $platformAdmin || $mgAdmin,
'attributes' => $platformAdmin || $mgAdmin,
'attribute_groups' => $platformAdmin || $mgAdmin,
'rules' => $platformAdmin || $mgAdmin,
diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php
index 4db053c27..5ff2600c4 100644
--- a/app/src/Controller/StandardController.php
+++ b/app/src/Controller/StandardController.php
@@ -162,7 +162,7 @@ public function edit($id) {
// in afterSave
if($this->$modelsName->save($obj)) {
$this->Flash->success(__('match.rs.saved'));
-
+
return $this->generateRedirect();
}
@@ -249,7 +249,9 @@ protected function getPrimaryLink(bool $lookup=false) {
} elseif($this->request->getData($modelName . "." . $ret['linkattr'])) {
$ret['linkvalue'] = $this->request->getData($modelName . "." . $ret['linkattr']);
} else {
- throw new \RuntimeException(__('match.er.primary_link', [ $ret['linkattr'] ]));
+ if(!$this->$modelsName->allowEmptyPrimaryLink()) {
+ throw new \RuntimeException(__('match.er.primary_link', [ $ret['linkattr'] ]));
+ }
}
}
}
@@ -275,7 +277,9 @@ public function index() {
$link = $this->getPrimaryLink();
if(!empty($link['linkattr'])) {
- $query = $this->$modelsName->find()->where([$link['linkattr'] => $this->request->getQuery($link['linkattr'])]);
+ // If a link attribute is defined but no value is provided, then query
+ // where the link attribute is NULL
+ $query = $this->$modelsName->find()->where([$link['linkattr'].' IS' => $this->request->getQuery($link['linkattr'])]);
} else {
$query = $this->$modelsName->find();
}
diff --git a/app/src/Controller/TierApiController.php b/app/src/Controller/TierApiController.php
index 4c2d685eb..ee498f682 100644
--- a/app/src/Controller/TierApiController.php
+++ b/app/src/Controller/TierApiController.php
@@ -34,8 +34,9 @@
use \App\Lib\Enum\ConfidenceModeEnum;
use \App\Lib\Enum\ResolutionModeEnum;
+use \App\Lib\Enum\StatusEnum;
-class TierApiController extends StandardController {
+class TierApiController extends AppController {
// Set by dispatched functions to control results
protected $statusCode = 500;
protected $result = [];
@@ -84,6 +85,41 @@ public function initialize() {
// $this->loadComponent('Csrf');
}
+ /**
+ * Callback run prior to the request action.
+ *
+ * @since COmanage Match v1.0.0
+ * @param Event $event Cake Event
+ */
+
+ public function beforeFilter(\Cake\Event\Event $event) {
+ // We need the current matchgrid (if set) before we configuration authentication
+
+ $mgid = $this->request->getParam('matchgrid_id');
+
+ $this->loadComponent('Auth', [
+ 'authorize' => 'Controller',
+ 'authenticate' => [
+ 'Basic' => [
+ 'fields' => ['username' => 'username', 'password' => 'password'],
+ 'userModel' => 'ApiUsers',
+ // Custom finder to constrain users to the request matchgrid
+ // We don't currently use this since Platform API users can access
+ // any matchgrid, but won't have the matchgrid ID set. (We could
+ // retrieve where $mgid||null, but we still have to filter in
+ // isAuthorized anyway.)
+// 'finder' => ['withinMatchgrid' => ['matchgrid' => $mgid]]
+ // But we do want to pull SoR information for authz purposes
+ 'finder' => 'authorization'
+ ]
+ ],
+ 'storage' => 'Memory',
+ 'unauthorizedRedirect' => false
+ ]);
+
+ parent::beforeFilter($event);
+ }
+
/**
* Handle an API Request Current Values request, ie: GET /v1/people/sor/sorid
*
@@ -491,15 +527,76 @@ public function inventory() {
$this->render('response');
}
-// XXX docblock
-
+ /**
+ * 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) {
- //debug('isAuthorized');
-// debug($this->request->session()->read('Auth'));
- return true;
+ Log::write('debug', 'TierApiController::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.
- // By default deny access.
+ if(!$this->cur_mg) {
+ Log::write('debug', "TierApiController::isAuthorized() Requested matchgrid " . $mgid . " not found");
+ return false;
+ }
+
+ if($this->cur_mg->status != StatusEnum::Active) {
+ Log::write('debug', "TierApiController::isAuthorized() Requested matchgrid " . $mgid . " is not Active");
+ return false;
+ }
+
+ // (1) A Platform API user ($user['matchgrid_id'] is NULL) may perform any action.
+
+ if(!empty($user['username']) && !$user['matchgrid_id']) {
+ Log::write('debug', 'TierApiController::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', 'TierApiController::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', 'TierApiController::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', "TierApiController::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();
}
/**
@@ -577,6 +674,32 @@ public function search() {
$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->loadModel('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
*
diff --git a/app/src/Lib/Traits/MatchgridLinkTrait.php b/app/src/Lib/Traits/MatchgridLinkTrait.php
index 1a0a3ead2..e90a81229 100644
--- a/app/src/Lib/Traits/MatchgridLinkTrait.php
+++ b/app/src/Lib/Traits/MatchgridLinkTrait.php
@@ -33,15 +33,30 @@ trait MatchgridLinkTrait {
// Does the associated model require a matchgrid ID?
private $requiresMatchgrid = false;
+ // If we normally require a matchgrid, can we proceed without one?
+ private $allowEmptyMatchgrid = false;
+
// Actions that can have an unkeyed (ie: self asserted) matchgrid ID
private $unkeyedActions = ['add', 'index'];
+ /**
+ * If the associated controller normally requires a Matchgrid ID, whether the
+ * Matchgrid ID can be empty.
+ *
+ * @since COmanage Match v1.0.0
+ * @return boolean true if empty Matchgrid IDs are permitted
+ */
+
+ public function allowEmptyMatchgrid() {
+ return $this->allowEmptyMatchgrid;
+ }
+
/**
* Check to see whether the specified action is allowed to assert a matchgrid ID
* directly (ie: not via lookup of an associated record).
*
* @param string $action Action
- * @return boolean true if permitted, false otherwise
+ * @return boolean true if permitted, false otherwise
*/
public function allowUnkeyedMatchgrid(string $action) {
@@ -52,8 +67,8 @@ public function allowUnkeyedMatchgrid(string $action) {
* Calculate the Matchgrid ID associated with the requested object ID.
*
* @since COmanage Match v1.0.0
- * @param Integer $id Matchgrid ID
- * @return Integer Matchgrid ID
+ * @param int $id Matchgrid ID
+ * @return int Matchgrid ID
* @throws Cake\Datasource\Exception\RecordNotFoundException
*/
@@ -69,18 +84,30 @@ public function calculateMatchgridId(int $id) {
* Determine if the associated controller requires a Matchgrid ID.
*
* @since COmanage Match v1.0.0
- * @return Boolean True if a Matchgrid ID is required, false otherwise
+ * @return boolean True if a Matchgrid ID is required, false otherwise
*/
public function requiresMatchgrid() {
return $this->requiresMatchgrid;
}
+ /**
+ * Set if the associated controller normally requires a Matchgrid ID, whether the
+ * Matchgrid ID can be empty.
+ *
+ * @since COmanage Match v1.0.0
+ * @param boolean $allowEmpty True if the Matchgrid ID is permitted to be empty
+ */
+
+ public function setAllowEmptyMatchgrid(bool $allowEmpty) {
+ $this->allowEmptyMatchgrid = $allowEmpty;
+ }
+
/**
* Set if the associated controller requires a Matchgrid ID.
*
* @since COmanage Match v1.0.0
- * @param $required Boolean True if a Matchgrid ID is required, false otherwise
+ * @param boolean $required Boolean True if a Matchgrid ID is required, false otherwise
*/
public function setRequiresMatchgrid(bool $required) {
diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php
index dda7a28b0..3acd00ca0 100644
--- a/app/src/Lib/Traits/PrimaryLinkTrait.php
+++ b/app/src/Lib/Traits/PrimaryLinkTrait.php
@@ -33,6 +33,20 @@ trait PrimaryLinkTrait {
// Primary Link field (eg: model:matchgrid_id)
private $primaryLink = null;
+ // Allow empty primary link?
+ private $allowEmpty = false;
+
+ /**
+ * Whether the primary link is permitted to be empty.
+ *
+ * @since COmanage Match v1.0.0
+ * @param boolean $allowEmpty true if the primary link is permitted to be empty
+ */
+
+ public function allowEmptyPrimaryLink() {
+ return $this->allowEmpty;
+ }
+
/**
* Generate an ORM Query for the Primary Link.
*
@@ -57,6 +71,17 @@ public function getPrimaryLink() {
return $this->primaryLink;
}
+ /**
+ * Set whether the primary link is permitted to be empty.
+ *
+ * @since COmanage Match v1.0.0
+ * @param boolean $allowEmpty true if the primary link is permitted to be empty
+ */
+
+ public function setAllowEmptyPrimaryLink(bool $allowEmpty) {
+ $this->allowEmpty = $allowEmpty;
+ }
+
/**
* Set the primary link attribute.
*
diff --git a/app/src/Locale/en_US/default.po b/app/src/Locale/en_US/default.po
index f80d10808..e3ed51c44 100644
--- a/app/src/Locale/en_US/default.po
+++ b/app/src/Locale/en_US/default.po
@@ -38,6 +38,13 @@ msgstr "Powered By"
msgid "match.meta.version"
msgstr "Version {0}"
+### Banners
+msgid "match.banner.api_users.matchgrid"
+msgstr "This page is for configuring Matchgrid API Users. Platform API Users can only be created by Platform Administrators via the platform level menu option."
+
+msgid "match.banner.api_users.platform"
+msgstr "This page is for configuring Platform API Users, which have full read/write access to the entire platform. To create API Users restricted to a given Matchgrid, go to the management page for the desired Matchgrid and select API Users from there."
+
### Command Line text
msgid "match.cmd.db.ok"
msgstr "Database schema update successful"
@@ -64,6 +71,9 @@ msgid "match.cmd.se.salt"
msgstr "- Generating salt file"
### Controllers (Models)
+msgid "match.ct.api_users"
+msgstr "{0,plural,=1{API User} other{API Users}}"
+
msgid "match.ct.attribute_groups"
msgstr "{0,plural,=1{Attribute Group} other{Attribute Groups}}"
@@ -160,6 +170,9 @@ msgstr "Delete Failed"
msgid "match.er.file"
msgstr "Cannot read file {0}"
+msgid "match.er.format"
+msgstr "Invalid format"
+
msgid "match.er.mgid"
msgstr "Could not find Matchgrid ID in request"
@@ -186,6 +199,8 @@ msgid "matchgrid.er.search_type"
msgstr "Unknown search type '{0}'"
### Fields
+### Keys of the form match.fd.MyModels.field_name[.desc] will apply only to MyModels.field_name
+### Keys of the form match.fd.field_name[.desc] will apply if no model specific key is found
msgid "match.fd.action"
msgstr "Action"
@@ -195,6 +210,12 @@ msgstr "Alphanumeric"
msgid "match.fd.api_name"
msgstr "API Name"
+msgid "match.fd.ApiUsers.username"
+msgstr "API Username"
+
+msgid "match.fd.ApiUsers.username.desc"
+msgstr "Username must begin with matchgrid name and a dot (for Matchgrid API Users), or must not contain a dot (for Platform API Users)"
+
msgid "match.fd.case_sensitive"
msgstr "Case Sensitive"
@@ -219,6 +240,9 @@ msgstr "Null Equivalents"
msgid "match.fd.ordr"
msgstr "Order"
+msgid "match.fd.password"
+msgstr "Password"
+
msgid "match.fd.permission"
msgstr "Permission"
diff --git a/app/src/Model/Entity/ApiUser.php b/app/src/Model/Entity/ApiUser.php
new file mode 100644
index 000000000..ce4e8f306
--- /dev/null
+++ b/app/src/Model/Entity/ApiUser.php
@@ -0,0 +1,56 @@
+ true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+
+ /**
+ * Hash the password on save for use with Cake's BasicAuthenticate.
+ *
+ * @since COmanage Match v1.0.0
+ * @param string $password Password
+ */
+
+ protected function _setPassword($password) {
+ // Note this is only called when the password is changed, not on every save.
+
+ if(strlen($password) > 0) {
+ return (new DefaultPasswordHasher)->hash($password);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/ApiUsersTable.php b/app/src/Model/Table/ApiUsersTable.php
new file mode 100644
index 000000000..44d7f3f5a
--- /dev/null
+++ b/app/src/Model/Table/ApiUsersTable.php
@@ -0,0 +1,202 @@
+addBehavior('Timestamp');
+
+ // Define associations
+ $this->belongsTo('Matchgrids');
+ $this->belongsTo('SystemsOfRecord')
+ // Cake Inflection is not working correctly and is determing the
+ // foreign key to be named "s_id"
+ ->setForeignKey('system_of_record_id')
+ ->setProperty('system_of_record');
+
+ $this->setDisplayField('username');
+
+ $this->setPrimaryLink('matchgrid_id');
+ $this->setRequiresMatchgrid(true);
+ $this->setAllowEmptyMatchgrid(true);
+ $this->setAllowEmptyPrimaryLink(true);
+
+ $this->setAutoViewVars([
+ 'systemsOfRecord' => [
+ 'type' => 'select',
+ 'model' => 'SystemsOfRecord',
+ 'find' => 'filterPrimaryLink'
+ ]
+ ]);
+ }
+
+ /**
+ * Callback to construct RulesChecker.
+ *
+ * @since COmanage Match v1.0.0
+ * @param RulesChecker $rules Cake RulesChecker
+ * @return RulesChecker Cake RulesChecker
+ */
+
+ public function buildRules(RulesChecker $rules) {
+ $rules->add(
+ [$this, 'checkUsername'],
+ 'checkUsername',
+ ['errorField' => 'username',
+ 'message' => __('match.er.format')]
+ );
+
+ return $rules;
+ }
+
+ /**
+ * Verify the requested username is conformant with API Username rules.
+ *
+ * @since COmanage Match v1.0.0
+ * @param ApiUser $entity ApiUser object
+ * @param array $options Options as passed via buildRules
+ * @return boolean True if the rule check passes, false if not
+ * @throws Cake\Datasource\Exception\RecordNotFoundException
+ */
+
+ public function checkUsername(\App\Model\Entity\ApiUser $entity, array $options) {
+ // We currently require API usernames to be formatted as
+ // (1) No dots for Platform API users (eg: "admin")
+ // (2) Prefixed with matchgrid name+dot for Matchgrid API users (eg: "matchgrid.sis")
+ // This is to simplify runtime authentication, even with matchgrid_id being
+ // associated with the record in the api_users table. Largely the concern is
+ // that otherwise if the same username is registered as both a Platform and
+ // Matchgrid API user, it isn't possible to know which one the client is trying
+ // to authenticate as in a Matchgrid context. (In a Platform context we can
+ // simply ignore the Matchgrid API user, but a Platform API user has access
+ // to the Matchgrid APIs.)
+
+ if($entity->isDirty('username')) {
+ // Username has been changed, so verify new syntax
+
+ if($entity->matchgrid_id) {
+ // This is a Matchgrid API user, so make sure it is prefixed with the matchgrid name
+
+ $matchgrids = TableRegistry::get('Matchgrids');
+ // This throws Cake\Datasource\Exception\RecordNotFoundException if not found
+ $matchgrid = $matchgrids->get($entity->matchgrid_id);
+
+ $prefix = $matchgrid->table_name . ".";
+
+ if(strncmp($entity->username, $prefix, strlen($prefix))) {
+ return false;
+ }
+ } else {
+ // This is a Platform API user, so make sure it does not have a .
+
+ if(strstr($entity->username, '.')) {
+ return false;
+ }
+ }
+ }
+
+ // Checks complete
+ return true;
+ }
+
+ /**
+ * Custom finder to obtain SoR and Matchgrid info with user data.
+ *
+ * @since COmanage Match v1.0.0
+ * @param Query $query Cake Query object
+ * @param array $options Query options
+ * @return Query Cake Query object
+ */
+
+ public function findAuthorization(\Cake\ORM\Query $query, array $options) {
+ // Modify the find to only be within the requested matchgrid.
+
+ return $query->contain(['Matchgrids', 'SystemsOfRecord']);
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Match v1.0.0
+ * @param Validator $validator Validator
+ * @return $validator Validator
+ */
+
+ public function validationDefault(Validator $validator) {
+ $validator->add(
+ 'username',
+ 'length',
+ [ 'rule' => [ 'maxLength', 128 ] ]
+ );
+ // notEmpty is old style, use notBlank
+ $validator->notBlank('username');
+
+ $validator->add(
+ 'password',
+ 'length',
+ [ 'rule' => [ 'maxLength', 80 ] ]
+ );
+ $validator->notBlank('password');
+
+ $validator->add(
+ 'matchgrid_id',
+ 'content',
+ [ 'rule' => 'isInteger' ]
+ );
+ $validator->allowEmpty('matchgrid_id');
+
+ $validator->add(
+ 'system_of_record_id',
+ 'content',
+ [ 'rule' => 'isInteger' ]
+ );
+ $validator->allowEmpty('system_of_record_id');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/MatchgridsTable.php b/app/src/Model/Table/MatchgridsTable.php
index f7579d9a7..0a28d8e3f 100644
--- a/app/src/Model/Table/MatchgridsTable.php
+++ b/app/src/Model/Table/MatchgridsTable.php
@@ -54,6 +54,8 @@ public function initialize(array $config) {
$this->addBehavior('Timestamp');
// Define associations
+ $this->hasMany('ApiUsers')
+ ->setDependent(true);
$this->hasMany('Attributes')
->setDependent(true);
$this->hasMany('AttributeGroups')
diff --git a/app/src/Model/Table/PermissionsTable.php b/app/src/Model/Table/PermissionsTable.php
index 09abc24ac..99166ab83 100644
--- a/app/src/Model/Table/PermissionsTable.php
+++ b/app/src/Model/Table/PermissionsTable.php
@@ -32,6 +32,8 @@
use Cake\ORM\Table;
use Cake\Validation\Validator;
+use \App\Lib\Enum\PermissionEnum;
+
class PermissionsTable extends Table {
use \App\Lib\Traits\AutoViewVarsTrait;
diff --git a/app/src/Model/Table/SystemsOfRecordTable.php b/app/src/Model/Table/SystemsOfRecordTable.php
index 286b75b57..6aaeb0942 100644
--- a/app/src/Model/Table/SystemsOfRecordTable.php
+++ b/app/src/Model/Table/SystemsOfRecordTable.php
@@ -50,6 +50,8 @@ public function initialize(array $config) {
$this->addBehavior('Timestamp');
// Define associations
+ $this->hasMany('ApiUsers');
+
$this->belongsTo('Matchgrids');
$this->setDisplayField('label');
@@ -96,7 +98,7 @@ public function validationDefault(Validator $validator) {
ResolutionModeEnum::Interactive
] ] ]
);
- $validator->notEmpty('confidence_mode');
+ $validator->notEmpty('resolution_mode');
return $validator;
}
diff --git a/app/src/Template/ApiUsers/columns.inc b/app/src/Template/ApiUsers/columns.inc
new file mode 100644
index 000000000..f42854726
--- /dev/null
+++ b/app/src/Template/ApiUsers/columns.inc
@@ -0,0 +1,48 @@
+ [
+ 'type' => 'link'
+ ]
+];
+
+if(!empty($vv_cur_mg)) {
+ $indexColumns = array_merge($indexColumns, [
+ 'system_of_record_id' => [
+ 'type' => 'fk'
+ ]
+ ]);
+}
\ No newline at end of file
diff --git a/app/src/Template/ApiUsers/fields.inc b/app/src/Template/ApiUsers/fields.inc
new file mode 100644
index 000000000..e7cbe73c3
--- /dev/null
+++ b/app/src/Template/ApiUsers/fields.inc
@@ -0,0 +1,43 @@
+table_name)) {
+ // This prefix requirement is enforced in ApiUsersTable
+ $def = $vv_cur_mg->table_name . ".";
+}
+
+// This view does not support read-only
+if($action == 'add' || $action == 'edit') {
+ print $this->Field->control('username', ['default' => $def]);
+ print $this->Field->control('password');
+ if(isset($vv_cur_mg)) {
+ // Don't require a system of record ID if we're not in a matchgrid context
+ print $this->Field->control('system_of_record_id', ['empty' => true], false);
+ }
+}
diff --git a/app/src/Template/Element/menuMain.ctp b/app/src/Template/Element/menuMain.ctp
index b1acde7dd..650e6bca2 100644
--- a/app/src/Template/Element/menuMain.ctp
+++ b/app/src/Template/Element/menuMain.ctp
@@ -36,9 +36,10 @@ MENU DOESNT ALIGN
// Matchgrid specific models
$models = [
- 'attributes' => 'edit',
- 'attribute_groups' => 'storage',
- 'rules' => 'assignment',
+ 'api_users' => 'vpn_key',
+ 'attributes' => 'edit',
+ 'attribute_groups' => 'storage',
+ 'rules' => 'assignment',
'systems_of_record' => 'gavel',
];
@@ -66,44 +67,31 @@ MENU DOESNT ALIGN
} else {
// Only render platform level configuration when not in the context of a matchgrid
- // Matchgrids
- if($vv_menu_permissions['matchgrids']) {
- print '";
- }
+ $models = [
+ 'matchgrids' => 'grid_on',
+ 'permissions' => 'lock',
+ 'api_users' => 'vpn_key'
+ ];
- // Permissions
- if($vv_menu_permissions['permissions']) {
- print '";
+ $linkContent = '' . $icon . '';
+
+ print $this->Html->link(
+ $linkContent,
+ ['plugin' => null,
+ 'controller' => $model,
+ 'action' => 'index'],
+ ['class' => 'mdl-js-ripple-effect',
+ 'escape' => false]
+ );
+
+ print "";
+ }
}
}
?>
diff --git a/app/src/Template/Error/error400.ctp b/app/src/Template/Error/error400.ctp
index 6b538b7f4..b0e494ceb 100644
--- a/app/src/Template/Error/error400.ctp
+++ b/app/src/Template/Error/error400.ctp
@@ -1,10 +1,43 @@
layout = 'error';
+// XXX this appears to be the best we can do for now...
+$restful = !strncmp($url, "/api/", 5);
+
+if($restful) {
+ $this->layout = 'rest';
+} else {
+ $this->layout = 'error';
+}
-if (Configure::read('debug')) :
+if (0 && Configure::read('debug')) :
$this->layout = 'dev_error';
$this->assign('title', $message);
@@ -31,8 +64,12 @@ endif;
$this->end();
endif;
?>
+
+= json_encode(['error' => $message]); ?>
+
= h($message) ?>
= __d('cake', 'Error') ?>:
= __d('cake', 'The requested address {0} was not found on this server.', "'{$url}'") ?>
+name;
$tableName = \Cake\Utility\Inflector::tableize($this->name);
?>
= $vv_title; ?>
-Form->create($vv_obj);
- }
-
- $linkId = null;
-
- if(!empty($vv_primary_link)) {
- if(!empty($this->request->getQuery($vv_primary_link))) {
- $linkId = $this->request->getQuery($vv_primary_link);
- } elseif(!empty($this->request->getData($vv_primary_link))) {
- $linkId = $this->request->getData($vv_primary_link);
- } elseif(!empty($vv_obj->$vv_primary_link)) {
- $linkId = $vv_obj->$vv_primary_link;
- }
+
+
+
+ info
+
+
+Form->create($vv_obj);
+}
+
+$linkId = null;
+
+if(!empty($vv_primary_link)) {
+ if(!empty($this->request->getQuery($vv_primary_link))) {
+ $linkId = $this->request->getQuery($vv_primary_link);
+ } elseif(!empty($this->request->getData($vv_primary_link))) {
+ $linkId = $this->request->getData($vv_primary_link);
+ } elseif(!empty($vv_obj->$vv_primary_link)) {
+ $linkId = $vv_obj->$vv_primary_link;
}
-
- print $this->Field->startControlSet($this->name, $action, ($action == 'add' || $action == 'edit'));
-
- include(APP . "Template/" . $modelsName . "/fields.inc");
-
- if($action == 'add' || $action == 'edit') {
- if(!empty($linkId)) {
- // Hidden values used to link to parent objects (eg: matchgrid_id)
- print $this->Form->hidden($vv_primary_link, ['value' => $linkId]);
- }
-
- print $this->Field->submit(__('match.op.save'));
- print $this->Form->end();
+}
+
+print $this->Field->startControlSet($this->name, $action, ($action == 'add' || $action == 'edit'));
+
+include(APP . "Template/" . $modelsName . "/fields.inc");
+
+if($action == 'add' || $action == 'edit') {
+ if(!empty($linkId)) {
+ // Hidden values used to link to parent objects (eg: matchgrid_id)
+ print $this->Form->hidden($vv_primary_link, ['value' => $linkId]);
}
- print $this->Field->endControlSet();
+ print $this->Field->submit(__('match.op.save'));
+ print $this->Form->end();
+}
+
+print $this->Field->endControlSet();
diff --git a/app/src/Template/Standard/index.ctp b/app/src/Template/Standard/index.ctp
index b1d828974..4aff6667c 100644
--- a/app/src/Template/Standard/index.ctp
+++ b/app/src/Template/Standard/index.ctp
@@ -47,7 +47,7 @@ if(!empty($vv_primary_link) && !empty($this->request->getQuery($vv_primary_link)
$linkFilter = [$vv_primary_link => $this->request->getQuery($vv_primary_link)];
}
-function _column_key($c) {
+function _column_key($modelsName, $c) {
if(strpos($c, "_id", strlen($c)-3)) {
// Key is of the form field_id, use .ct label instead
$k = \Cake\Utility\Inflector::pluralize(substr($c, 0, strlen($c)-3));
@@ -55,11 +55,30 @@ function _column_key($c) {
return __('match.ct.'.$k, [1]);
}
+ // Look for a model specific key first
+ $label = __('match.fd.'.$modelsName.'.'.$c);
+
+ if($label != 'match.fd.'.$modelsName.'.'.$c) {
+ return $label;
+ }
+
+ // Otherwise look for the general key
return __('match.fd.'.$c);
}
?>
+
= $vv_title; ?>
+
+
+ info
+
+
+Html->link(__('match.op.add.a', __('match.ct.'.$tableName, [1])),
@@ -67,10 +86,11 @@ if($vv_permissions['add']) {
['class' => 'addbutton']);
}
?>
+
$cfg): ?>
- | = _column_key($col); ?> |
+ = _column_key($modelsName, $col); ?> |
= __('match.fd.action'); ?> |
diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php
index fb69c6bcf..c72bec7d4 100644
--- a/app/src/View/Helper/FieldHelper.php
+++ b/app/src/View/Helper/FieldHelper.php
@@ -37,15 +37,19 @@ class FieldHelper extends Helper {
// Is this read-only or read-write?
protected $editable = true;
+ // Our current modelname
+ protected $modelName = null;
+
/**
* Emit a form control.
*
* @since COmanage Match v1.0.0
- * @param String $fieldName Form field
- * @param Array $options FormHelper control options
- * @param Boolean $required True if this attribute is required
- * @param String $labelText Label text (fieldName language key used by default)
- * @return String HTML for control
+ * @param string $fieldName Form field
+ * @param array $options FormHelper control options
+ * @param boolean $required True if this attribute is required
+ * @param string $labelText Label text (fieldName language key used by default)
+ * @param string $default Default value for field
+ * @return string HTML for control
*/
public function control(string $fieldName,
@@ -61,20 +65,29 @@ public function control(string $fieldName,
} else {
// We autogenerate field labels and descriptions from the field name.
// Fields of the form foo_id map to the singular form of match.ct.foos.
- // All others map to match.fd.foo.
- // XXX we'll need something more complicated when two tables have the same field name
- // but need different descriptions... maybe match.fd.fieldname.tablename.desc?
+ // All others map first to match.fd.Model.foo, then to match.fd.foo
+ // if no Model specific key is found.
- $label = __("match.fd.".$fieldName);
+ $label = __("match.fd.".$this->modelName.".".$fieldName);
$desc = null;
$f = null;
+ if($label == "match.fd.".$this->modelName.".".$fieldName) {
+ // Model specific label not found, try again
+
+ $label = __("match.fd.".$fieldName);
+ }
+
if(preg_match('/^(.*?)_id$/', $fieldName, $f)) {
$label = __("match.ct.".\Cake\Utility\Inflector::pluralize($f[1]), [1]);
} else {
// We try to automagically determine if a description for the field exists by
// looking for the corresponding .desc language translation.
- $desc = __("match.fd.".$fieldName.".desc");
+ $desc = __("match.fd.".$this->modelName.".".$fieldName.".desc");
+
+ if($desc == "match.fd.".$this->modelName.".".$fieldName.".desc") {
+ $desc = __("match.fd.".$fieldName.".desc");
+ }
// If the description is the literal key we just generated, there is no description
if($desc == "match.fd.".$fieldName.".desc") {
@@ -127,6 +140,8 @@ public function submit($label) {
*/
public function endControlSet() {
+ $this->modelName = null;
+
return "\n";
}
@@ -142,6 +157,7 @@ public function endControlSet() {
public function startControlSet($modelName, $action, $editable=true) {
$this->editable = $editable;
+ $this->modelName = $modelName;
return '