diff --git a/README.md b/README.md index 0123528d..b4f29651 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 5ec55426..93742884 100644 --- a/app/config/schema/schema.xml +++ b/app/config/schema/schema.xml @@ -196,4 +196,29 @@ <col>matchgrid_id</col> </index> </table> + + <table name="api_users"> + <field name="id" type="I"> + <key /> + <autoincrement /> + </field> + <field name="matchgrid_id" type="I"> + <constraint>REFERENCES matchgrids(id)</constraint> + </field> + <field name="system_of_record_id" type="I"> + <constraint>REFERENCES systems_of_record(id)</constraint> + </field> + <field name="username" type="C" size="128" /> + <field name="password" type="C" size="255" /> + <field name="created" type="T" /> + <field name="modified" type="T" /> + + <index name="api_users_i1"> + <col>matchgrid_id</col> + </index> + + <index name="api_users_i2"> + <col>username</col> + </index> + </table> </schema> \ 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 00000000..9579cc25 --- /dev/null +++ b/app/src/Controller/ApiUsersController.php @@ -0,0 +1,66 @@ +<?php +/** + * COmanage Match API Users 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 http://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; + +class ApiUsersController extends StandardController { + /** + * 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) { + $mgid = isset($this->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 3599ac19..4eb2bede 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 80f5df1b..689dfb46 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 4db053c2..5ff2600c 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 4c2d685e..ee498f68 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 1a0a3ead..e90a8122 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 dda7a28b..3acd00ca 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 f80d1080..e3ed51c4 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 <i>API Users</i> 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 00000000..ce4e8f30 --- /dev/null +++ b/app/src/Model/Entity/ApiUser.php @@ -0,0 +1,56 @@ +<?php +/** + * COmanage Match API User Entity + * + * 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 http://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\Model\Entity; + +use Cake\Auth\DefaultPasswordHasher; +use Cake\ORM\Entity; + +class ApiUser extends Entity { + protected $_accessible = [ + '*' => 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 00000000..44d7f3f5 --- /dev/null +++ b/app/src/Model/Table/ApiUsersTable.php @@ -0,0 +1,202 @@ +<?php +/** + * COmanage Match API Users Table + * + * 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 http://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\Model\Table; + +use Cake\ORM\RulesChecker; +use Cake\ORM\Table; +use Cake\ORM\TableRegistry; +use Cake\Validation\Validator; + +use \App\Lib\Enum\ResolutionModeEnum; + +class ApiUsersTable extends Table { + use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\MatchgridLinkTrait; + use \App\Lib\Traits\PrimaryLinkTrait; + + /** + * Perform Cake Model initialization. + * + * @since COmanage Match v1.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config) { + $this->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 f7579d9a..0a28d8e3 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 09abc24a..99166ab8 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 286b75b5..6aaeb094 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 00000000..f4285472 --- /dev/null +++ b/app/src/Template/ApiUsers/columns.inc @@ -0,0 +1,48 @@ +<?php +/** + * COmanage Match API Users Index Columns + * + * 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 http://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) + */ + +$banners = []; + +if(!empty($vv_cur_mg)) { + $banners[] = __('match.banner.api_users.matchgrid'); +} else { + $banners[] = __('match.banner.api_users.platform'); +} + +$indexColumns = [ + 'username' => [ + '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 00000000..e7cbe73c --- /dev/null +++ b/app/src/Template/ApiUsers/fields.inc @@ -0,0 +1,43 @@ +<?php +/** + * COmanage Match API Users Fields + * + * 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 http://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) + */ + +$def = ""; + +if(!empty($vv_cur_mg->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 b1acde7d..650e6bca 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 '<li class="configMenu">'; - - $linkContent = '<em class="material-icons" aria-hidden="true">grid_on</em><span class="menuTitle">' - . __('match.ct.matchgrids', [99]) - . '</span><span class="mdl-ripple"></span>'; - - print $this->Html->link( - $linkContent, - ['plugin' => null, - 'controller' => 'matchgrids', - 'action' => 'index'], - ['class' => 'mdl-js-ripple-effect', - 'escape' => false] - ); - - print "</li>"; - } + $models = [ + 'matchgrids' => 'grid_on', + 'permissions' => 'lock', + 'api_users' => 'vpn_key' + ]; - // Permissions - if($vv_menu_permissions['permissions']) { - print '<li class="configMenu">'; + foreach($models as $model => $icon) { + if($vv_menu_permissions[$model]) { + print '<li class="configMenu">'; - $linkContent = '<em class="material-icons" aria-hidden="true">lock</em><span class="menuTitle">' - . __('match.ct.permissions', [99]) - . '</span><span class="mdl-ripple"></span>'; - - print $this->Html->link( - $linkContent, - ['plugin' => null, - 'controller' => 'permissions', - 'action' => 'index'], - ['class' => 'mdl-js-ripple-effect', - 'escape' => false] - ); - - print "</li>"; + $linkContent = '<em class="material-icons" aria-hidden="true">' . $icon . '</em><span class="menuTitle">' + . __('match.ct.'.$model, [99]) + . '</span><span class="mdl-ripple"></span>'; + + print $this->Html->link( + $linkContent, + ['plugin' => null, + 'controller' => $model, + 'action' => 'index'], + ['class' => 'mdl-js-ripple-effect', + 'escape' => false] + ); + + print "</li>"; + } } } ?> diff --git a/app/src/Template/Error/error400.ctp b/app/src/Template/Error/error400.ctp index 6b538b7f..b0e494ce 100644 --- a/app/src/Template/Error/error400.ctp +++ b/app/src/Template/Error/error400.ctp @@ -1,10 +1,43 @@ <?php +/** + * COmanage Match 400 Error View + * + * 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 http://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) + */ + use Cake\Core\Configure; use Cake\Error\Debugger; -$this->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; ?> +<?php if($restful): ?> +<?= json_encode(['error' => $message]); ?> +<?php else: // $restful ?> <h2><?= h($message) ?></h2> <p class="error"> <strong><?= __d('cake', 'Error') ?>: </strong> <?= __d('cake', 'The requested address {0} was not found on this server.', "<strong>'{$url}'</strong>") ?> </p> +<?php endif; // $restful \ No newline at end of file diff --git a/app/src/Template/Standard/add-edit-view.ctp b/app/src/Template/Standard/add-edit-view.ctp index 45e91ce1..6dac8f37 100644 --- a/app/src/Template/Standard/add-edit-view.ctp +++ b/app/src/Template/Standard/add-edit-view.ctp @@ -34,36 +34,49 @@ $modelsName = $this->name; $tableName = \Cake\Utility\Inflector::tableize($this->name); ?> <h1><?= $vv_title; ?></h1> -<?php - if($action == 'add' || $action == 'edit') { - // By default, the form will POST to the current controller - print $this->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; - } + +<?php +// XXX this doesn't work yet because we don't include fields.inc until later +// either create a second file to include earlier, or use a function to emit +// the fields (which would be more consistent with how Views render...) +if(!empty($banners)) { + foreach($banners as $b): ?> +<div class="co-info-topbox"> + <em class="material-icons">info</em> + <?php print $b; ?> +</div> +<?php endforeach; // $banners +} + +if($action == 'add' || $action == 'edit') { + // By default, the form will POST to the current controller + print $this->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 b1d82897..4aff6667 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); } ?> + <h1><?= $vv_title; ?></h1> + <?php +if(!empty($banners)) { + foreach($banners as $b): ?> +<div class="co-info-topbox"> + <em class="material-icons">info</em> + <?php print $b; ?> +</div> +<?php endforeach; // $banners +} + if($vv_permissions['add']) { // XXX This renders left instead of right? print $this->Html->link(__('match.op.add.a', __('match.ct.'.$tableName, [1])), @@ -67,10 +86,11 @@ if($vv_permissions['add']) { ['class' => 'addbutton']); } ?> + <table> <tr> <?php foreach($indexColumns as $col => $cfg): ?> - <th><?= _column_key($col); ?></th> + <th><?= _column_key($modelsName, $col); ?></th> <?php endforeach; ?> <th><?= __('match.fd.action'); ?></th> </tr> diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index fb69c6bc..c72bec7d 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 "</ul>\n"; } @@ -142,6 +157,7 @@ public function endControlSet() { public function startControlSet($modelName, $action, $editable=true) { $this->editable = $editable; + $this->modelName = $modelName; return '<ul id"' . $action . '_' . $modelName . '" class="fields form-list">' . "\n"; }