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";
   }