CRM-16173 - WhitelistSubscriber - Filtering for API requests (entity/action/params)
authorTim Otten <totten@civicrm.org>
Sat, 28 Mar 2015 03:27:05 +0000 (20:27 -0700)
committerTim Otten <totten@civicrm.org>
Tue, 14 Jul 2015 04:00:07 +0000 (21:00 -0700)
Civi/API/Provider/StaticProvider.php
Civi/API/Subscriber/WhitelistSubscriber.php [new file with mode: 0644]
Civi/API/WhitelistRule.php [new file with mode: 0644]
api/v3/utils.php
tests/phpunit/Civi/API/Subscriber/WhitelistSubscriberTest.php [new file with mode: 0644]

index d9d6beede0e3d078534fc4c2ce3ac733a37dafdf..b1f7cd23375f70a6e7f4c1f9770be3f0b35e67c1 100644 (file)
@@ -135,13 +135,7 @@ class StaticProvider extends AdhocProvider {
    * @throws \API_Exception
    */
   public function doGet($apiRequest) {
-    $id = @$apiRequest['params']['id'];
-    if ($id && isset($this->records[$id])) {
-      return civicrm_api3_create_success(array($id => $this->records[$id]));
-    }
-    else {
-      return civicrm_api3_create_success(array());
-    }
+    return _civicrm_api3_basic_array_get($apiRequest['entity'], $apiRequest['params'], $this->records, 'id', $this->fields);
   }
 
   /**
diff --git a/Civi/API/Subscriber/WhitelistSubscriber.php b/Civi/API/Subscriber/WhitelistSubscriber.php
new file mode 100644 (file)
index 0000000..74296a5
--- /dev/null
@@ -0,0 +1,121 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.6                                                |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2014                                |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM.                                    |
+ |                                                                    |
+ | CiviCRM is free software; you can copy, modify, and distribute it  |
+ | under the terms of the GNU Affero General Public License           |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception.   |
+ |                                                                    |
+ | CiviCRM is distributed in the hope that it will be useful, but     |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of         |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.               |
+ | See the GNU Affero General Public License for more details.        |
+ |                                                                    |
+ | You should have received a copy of the GNU Affero General Public   |
+ | License and the CiviCRM Licensing Exception along                  |
+ | with this program; if not, contact CiviCRM LLC                     |
+ | at info[AT]civicrm[DOT]org. If you have questions about the        |
+ | GNU Affero General Public License or the licensing of CiviCRM,     |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
+ +--------------------------------------------------------------------+
+ */
+namespace Civi\API\Subscriber;
+
+use Civi\API\Events;
+use Civi\API\Event\AuthorizeEvent;
+use Civi\API\Event\RespondEvent;
+use Civi\API\WhitelistRule;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * The WhitelistSubscriber listens to API requests and matches them against
+ * a whitelist of allowed API calls. If an API call does NOT appear in the
+ * whitelist, then it generates an error.
+ *
+ * @package Civi
+ * @copyright CiviCRM LLC (c) 2004-2014
+ */
+class WhitelistSubscriber implements EventSubscriberInterface {
+
+  /**
+   * @return array
+   */
+  public static function getSubscribedEvents() {
+    return array(
+      Events::AUTHORIZE => array('onApiAuthorize', Events::W_EARLY),
+      Events::RESPOND => array('onApiRespond', Events::W_MIDDLE),
+    );
+  }
+
+  /**
+   * Array(WhitelistRule).
+   *
+   * @var array
+   */
+  protected $rules;
+
+  /**
+   * Array (scalar $reqId => WhitelistRule $rule).
+   *
+   * @var array
+   */
+  protected $activeRules;
+
+  /**
+   * @param array $rules
+   *   Array of WhitelistRule.
+   * @see WhitelistRule
+   */
+  public function __construct($rules) {
+    $this->rules = array();
+    foreach ($rules as $rule) {
+      /** @var WhitelistRule $rule */
+      if ($rule->isValid()) {
+        $this->rules[] = $rule;
+      }
+      else {
+        throw new \CRM_Core_Exception("Invalid rule");
+      }
+    }
+  }
+
+  /**
+   * Determine which, if any, whitelist rules apply this request.
+   * Reject unauthorized requests.
+   *
+   * @param AuthorizeEvent $event
+   * @throws \CRM_Core_Exception
+   */
+  public function onApiAuthorize(AuthorizeEvent $event) {
+    $apiRequest = $event->getApiRequest();
+    if (empty($apiRequest['params']['check_permissions']) || $apiRequest['params']['check_permissions'] !== 'whitelist') {
+      return;
+    }
+    foreach ($this->rules as $rule) {
+      if (TRUE === $rule->matches($apiRequest)) {
+        $this->activeRules[$apiRequest['id']] = $rule;
+        return;
+      }
+    }
+    throw new \CRM_Core_Exception('The request does not match any active API authorizations.');
+  }
+
+  /**
+   * Apply any filtering rules based on the chosen whitelist rule.
+   * @param RespondEvent $event
+   */
+  public function onApiRespond(RespondEvent $event) {
+    $apiRequest = $event->getApiRequest();
+    $id = $apiRequest['id'];
+    if (isset($this->activeRules[$id])) {
+      $event->setResponse($this->activeRules[$id]->filter($apiRequest, $event->getResponse()));
+      unset($this->activeRules[$id]);
+    }
+  }
+
+}
diff --git a/Civi/API/WhitelistRule.php b/Civi/API/WhitelistRule.php
new file mode 100644 (file)
index 0000000..428e119
--- /dev/null
@@ -0,0 +1,282 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.6                                                |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2014                                |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM.                                    |
+ |                                                                    |
+ | CiviCRM is free software; you can copy, modify, and distribute it  |
+ | under the terms of the GNU Affero General Public License           |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception.   |
+ |                                                                    |
+ | CiviCRM is distributed in the hope that it will be useful, but     |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of         |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.               |
+ | See the GNU Affero General Public License for more details.        |
+ |                                                                    |
+ | You should have received a copy of the GNU Affero General Public   |
+ | License and the CiviCRM Licensing Exception along                  |
+ | with this program; if not, contact CiviCRM LLC                     |
+ | at info[AT]civicrm[DOT]org. If you have questions about the        |
+ | GNU Affero General Public License or the licensing of CiviCRM,     |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
+ +--------------------------------------------------------------------+
+ */
+namespace Civi\API;
+
+/**
+ * A WhitelistRule is used to determine if an API call is authorized.
+ * For example:
+ *
+ * @code
+ * new WhitelistRule(array(
+ *   'entity' => 'Contact',
+ *   'actions' => array('get','getsingle'),
+ *   'required' => array('contact_type' => 'Organization'),
+ *   'fields' => array('id', 'display_name', 'sort_name', 'created_date'),
+ * ));
+ * @endcode
+ *
+ * This rule would allow API requests that attempt to get contacts of type "Organization",
+ * but only a handful of fields ('id', 'display_name', 'sort_name', 'created_date')
+ * can be filtered or returned.
+ *
+ * Class WhitelistRule
+ * @package Civi\API\Subscriber
+ */
+class WhitelistRule {
+
+  static $IGNORE_FIELDS = array(
+    'check_permissions',
+    'debug',
+    'offset',
+    'option_offset',
+    'option_limit',
+    'option_sort',
+    'options',
+    'return',
+    'rowCount',
+    'sequential',
+    'sort',
+    'version',
+  );
+
+  /**
+   * Create a batch of rules from an array.
+   *
+   * @param array $rules
+   * @return array
+   */
+  public static function createAll($rules) {
+    $whitelist = array();
+    foreach ($rules as $rule) {
+      $whitelist[] = new WhitelistRule($rule);
+    }
+    return $whitelist;
+  }
+
+  /**
+   * @var int
+   */
+  public $version;
+
+  /**
+   * Entity name or '*' (all entities)
+   *
+   * @var string
+   */
+  public $entity;
+
+  /**
+   * List of actions which match, or '*' (all actions)
+   *
+   * @var string|array
+   */
+  public $actions;
+
+  /**
+   * List of key=>value pairs that *must* appear in $params.
+   *
+   * If there are no required fields, use an empty array.
+   *
+   * @var array
+   */
+  public $required;
+
+  /**
+   * List of fields which may be optionally inputted or returned, or '*" (all fields)
+   *
+   * @var array
+   */
+  public $fields;
+
+  public function __construct($ruleSpec) {
+    $this->version = $ruleSpec['version'];
+
+    if ($ruleSpec['entity'] === '*') {
+      $this->entity = '*';
+    }
+    else {
+      $this->entity = Request::normalizeEntityName($ruleSpec['entity'], $ruleSpec['version']);
+    }
+
+    if ($ruleSpec['actions'] === '*') {
+      $this->actions = '*';
+    }
+    else {
+      $this->actions = array();
+      foreach ((array) $ruleSpec['actions'] as $action) {
+        $this->actions[] = Request::normalizeActionName($action, $ruleSpec['version']);
+      }
+    }
+
+    $this->required = $ruleSpec['required'];
+    $this->fields = $ruleSpec['fields'];
+  }
+
+  /**
+   * @return bool
+   */
+  public function isValid() {
+    if (empty($this->version)) {
+      return FALSE;
+    }
+    if (empty($this->entity)) {
+      return FALSE;
+    }
+    if (!is_array($this->actions) && $this->actions !== '*') {
+      return FALSE;
+    }
+    if (!is_array($this->fields) && $this->fields !== '*') {
+      return FALSE;
+    }
+    if (!is_array($this->required)) {
+      return FALSE;
+    }
+    return TRUE;
+  }
+
+  /**
+   * @param array $apiRequest
+   *   Parsed API request.
+   * @return string|TRUE
+   *   If match, return TRUE. Otherwise, return a string with an error code.
+   */
+  public function matches($apiRequest) {
+    if (!$this->isValid()) {
+      return 'invalid';
+    }
+
+    if ($this->version != $apiRequest['version']) {
+      return 'version';
+    }
+    if ($this->entity !== '*' && $this->entity !== $apiRequest['entity']) {
+      return 'entity';
+    }
+    if ($this->actions !== '*' && !in_array($apiRequest['action'], $this->actions)) {
+      return 'action';
+    }
+
+    // These params *must* be included for the API request to proceed.
+    foreach ($this->required as $param => $value) {
+      if (!isset($apiRequest['params'][$param])) {
+        return 'required-missing-' . $param;
+      }
+      if ($value !== '*' && $apiRequest['params'][$param] != $value) {
+        return 'required-wrong-' . $param;
+      }
+    }
+
+    // These params *may* be included at the caller's discretion
+    if ($this->fields !== '*') {
+      $activatedFields = array_keys($apiRequest['params']);
+      $activatedFields = preg_grep('/^api\./', $activatedFields, PREG_GREP_INVERT);
+      if ($apiRequest['action'] == 'get') {
+        // Kind'a silly we need to (re(re))parse here for each rule; would be more
+        // performant if pre-parsed by Request::create().
+        $options = _civicrm_api3_get_options_from_params($apiRequest['params'], TRUE, $apiRequest['entity'], 'get');
+        $return = \CRM_Utils_Array::value('return', $options, array());
+        $activatedFields = array_merge($activatedFields, array_keys($return));
+      }
+
+      $unknowns = array_diff(
+        $activatedFields,
+        array_keys($this->required),
+        $this->fields,
+        self::$IGNORE_FIELDS
+      );
+
+      if (!empty($unknowns)) {
+        return 'unknown-' . implode(',', $unknowns);
+      }
+    }
+
+    return TRUE;
+  }
+
+  /**
+   * Ensure that the return values comply with the whitelist's
+   * "fields" policy.
+   *
+   * Most API's follow a convention where the result includes
+   * a 'values' array (which in turn is a list of records). Unfortunately,
+   * some don't. If the API result doesn't meet our expectation,
+   * then we probably don't know what's going on, so we abort the
+   * request.
+   *
+   * This will probably break some of the layered-sugar APIs (like
+   * getsingle, getvalue). Just use the meat-and-potatoes API instead.
+   * Or craft a suitably targeted patch.
+   *
+   * @param array $apiRequest
+   *   API request.
+   * @param array $apiResult
+   *   API result.
+   * @return array
+   *   Modified API result.
+   * @throws \API_Exception
+   */
+  public function filter($apiRequest, $apiResult) {
+    if ($this->fields === '*') {
+      return $apiResult;
+    }
+    if (isset($apiResult['values']) && empty($apiResult['values'])) {
+      // No data; filtering doesn't matter.
+      return $apiResult;
+    }
+    if (is_array($apiResult['values'])) {
+      $firstRow = \CRM_Utils_Array::first($apiResult['values']);
+      if (is_array($firstRow)) {
+        $fields = $this->filterFields(array_keys($firstRow));
+        $apiResult['values'] = \CRM_Utils_Array::filterColumns($apiResult['values'], $fields);
+        return $apiResult;
+      }
+    }
+    throw new \API_Exception(sprintf('Filtering failed for %s.%s. Unrecognized result format.', $apiRequest['entity'], $apiRequest['action']));
+  }
+
+  /**
+   * Determine which elements in $keys are acceptable under
+   * the whitelist policy.
+   *
+   * @param array $keys
+   *   List of possible keys.
+   * @return array
+   *   List of acceptable keys.
+   */
+  protected function filterFields($keys) {
+    $r = array();
+    foreach ($keys as $key) {
+      if (in_array($key, $this->fields)) {
+        $r[] = $key;
+      }
+      elseif (preg_match('/^api\./', $key)) {
+        $r[] = $key;
+      }
+    }
+    return $r;
+  }
+
+}
index c1d78b2e99c73567405a6ae3167a38343e45af72..6231715f8064ff2e4ec4fb00f84d02ccbb4da336 100644 (file)
@@ -2300,7 +2300,7 @@ function _civicrm_api3_basic_array_get($entity, $params, $records, $idCol, $fiel
       if ($k == 'id') {
         $k = $idCol;
       }
-      if (in_array($k, $fields) && $record[$k] !== $v) {
+      if (in_array($k, $fields) && $record[$k] != $v) {
         $match = FALSE;
         break;
       }
diff --git a/tests/phpunit/Civi/API/Subscriber/WhitelistSubscriberTest.php b/tests/phpunit/Civi/API/Subscriber/WhitelistSubscriberTest.php
new file mode 100644 (file)
index 0000000..9cfc9e3
--- /dev/null
@@ -0,0 +1,404 @@
+<?php
+namespace Civi\API\Subscriber;
+
+use Civi\API\Kernel;
+use Civi\API\WhitelistRule;
+use Civi\Core\Container;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+
+require_once 'CiviTest/CiviUnitTestCase.php';
+
+/**
+ * The WhitelistSubscriber enforces security policies
+ * based on API whitelists. This test combines a number
+ * of different policies with different requests and
+ * determines if the policies are correctly enforced.
+ *
+ * Testing breaks down into a few major elements:
+ *  - A pair of hypothetical API entities, "Widget"
+ *    and "Sprocket".
+ *  - A library of possible Widget and Sprocket API
+ *    calls (and their expected results).
+ *  - A library of possible whitelist rules.
+ *  - A list of test cases which attempt to execute
+ *    each API call while applying different
+ *    whitelist rules.
+ *
+ */
+class WhitelistSubscriberTest extends \CiviUnitTestCase {
+
+  protected function getFixtures() {
+    $recs = array();
+
+    $recs['widget'] = array(
+      1 => array(
+        'id' => 1,
+        'widget_type' => 'foo',
+        'provider' => 'george jetson',
+        'title' => 'first widget',
+        'comments' => 'this widget is the bomb',
+      ),
+      2 => array(
+        'id' => 2,
+        'widget_type' => 'bar',
+        'provider' => 'george jetson',
+        'title' => 'second widget',
+        'comments' => 'this widget is a bomb',
+      ),
+      3 => array(
+        'id' => 3,
+        'widget_type' => 'foo',
+        'provider' => 'cosmo spacely',
+        'title' => 'third widget',
+        'comments' => 'omg, that thing is a bomb! widgets are bombs! get out!',
+      ),
+      8 => array(
+        'id' => 8,
+        'widget_type' => 'bax',
+        'provider' => 'cosmo spacely',
+        'title' => 'fourth widget',
+        'comments' => 'todo: rebuild garage',
+      ),
+    );
+
+    $recs['sprocket'] = array(
+      1 => array(
+        'id' => 1,
+        'sprocket_type' => 'whiz',
+        'provider' => 'cosmo spacely',
+        'title' => 'first sprocket',
+        'comment' => 'this sprocket is so good i could eat it up',
+        'widget_id' => 2,
+      ),
+      5 => array(
+        'id' => 5,
+        'sprocket_type' => 'bang',
+        'provider' => 'george jetson',
+        'title' => 'second sprocket',
+        'comment' => 'this green sprocket was made by soylent',
+        'widget_id' => 2,
+      ),
+      7 => array(
+        'id' => 7,
+        'sprocket_type' => 'quux',
+        'provider' => 'cosmo spacely',
+        'title' => 'third sprocket',
+        'comment' => 'sprocket green is people! sprocket green is people!',
+        'widget_id' => 3,
+      ),
+      8 => array(
+        'id' => 8,
+        'sprocket_type' => 'baz',
+        'provider' => 'george jetson',
+        'title' => 'fourth sprocket',
+        'comment' => 'see also: cooking.com/hannibal/1981420-sprocket-fava',
+        'widget_id' => 3,
+      ),
+    );
+
+    return $recs;
+  }
+
+  public function restrictionCases() {
+    $calls = $rules = array();
+    $recs = $this->getFixtures();
+
+    $calls['Widget.get-all'] = array(
+      'entity' => 'Widget',
+      'action' => 'get',
+      'params' => array('version' => 3),
+      'expectedResults' => $recs['widget'],
+    );
+    $calls['Widget.get-foo'] = array(
+      'entity' => 'Widget',
+      'action' => 'get',
+      'params' => array('version' => 3, 'widget_type' => 'foo'),
+      'expectedResults' => array(1 => $recs['widget'][1], 3 => $recs['widget'][3]),
+    );
+    $calls['Widget.get-spacely'] = array(
+      'entity' => 'Widget',
+      'action' => 'get',
+      'params' => array('version' => 3, 'provider' => 'cosmo spacely'),
+      'expectedResults' => array(3 => $recs['widget'][3], 8 => $recs['widget'][8]),
+    );
+    $calls['Widget.get-spacely=>title'] = array(
+      'entity' => 'Widget',
+      'action' => 'get',
+      'params' => array('version' => 3, 'provider' => 'cosmo spacely', 'return' => array('title')),
+      'expectedResults' => array(
+        3 => array('id' => 3, 'title' => 'third widget'),
+        8 => array('id' => 8, 'title' => 'fourth widget'),
+      ),
+    );
+    $calls['Widget.get-spacely-foo'] = array(
+      'entity' => 'Widget',
+      'action' => 'get',
+      'params' => array('version' => 3, 'provider' => 'cosmo spacely', 'widget_type' => 'foo'),
+      'expectedResults' => array(3 => $recs['widget'][3]),
+    );
+    $calls['Sprocket.get-all'] = array(
+      'entity' => 'Sprocket',
+      'action' => 'get',
+      'params' => array('version' => 3),
+      'expectedResults' => $recs['sprocket'],
+    );
+    $calls['Widget.get-bar=>title + Sprocket.get=>provider'] = array(
+      'entity' => 'Widget',
+      'action' => 'get',
+      'params' => array(
+        'version' => 3,
+        'widget_type' => 'bar',
+        'return' => array('title'),
+        'api.Sprocket.get' => array(
+          'widget_id' => '$value.id',
+          'return' => array('provider'),
+        ),
+      ),
+      'expectedResults' => array(
+        2 => array(
+          'id' => 2,
+          'title' => 'second widget',
+          'api.Sprocket.get' => array(
+            'count' => 2,
+            'version' => 3,
+            'values' => array(
+              0 => array('id' => 1, 'provider' => 'cosmo spacely'),
+              1 => array('id' => 5, 'provider' => 'george jetson'),
+            ),
+            // This is silly:
+            'undefined_fields' => array('entity_id', 'entity_table', 'widget_id', 'api.has_parent'),
+          ),
+        ),
+      ),
+    );
+
+    $rules['*.*'] = array(
+      'version' => 3,
+      'entity' => '*',
+      'actions' => '*',
+      'required' => array(),
+      'fields' => '*',
+    );
+    $rules['Widget.*'] = array(
+      'version' => 3,
+      'entity' => 'Widget',
+      'actions' => '*',
+      'required' => array(),
+      'fields' => '*',
+    );
+    $rules['Sprocket.*'] = array(
+      'version' => 3,
+      'entity' => 'Sprocket',
+      'actions' => '*',
+      'required' => array(),
+      'fields' => '*',
+    );
+    $rules['Widget.get'] = array(
+      'version' => 3,
+      'entity' => 'Widget',
+      'actions' => 'get',
+      'required' => array(),
+      'fields' => '*',
+    );
+    $rules['Sprocket.get'] = array(
+      'version' => 3,
+      'entity' => 'Sprocket',
+      'actions' => 'get',
+      'required' => array(),
+      'fields' => '*',
+    );
+    $rules['Sprocket.get=>title,misc'] = array(
+      'version' => 3,
+      'entity' => 'Sprocket',
+      'actions' => 'get',
+      'required' => array(),
+      // To call api.Sprocket.get via chaining, you must accept superfluous fields.
+      // It would be a mistake for the whitelist mechanism to approve these
+      // automatically, so instead we have to enumerate them. Ideally, ChainSubscriber
+      // wouldn't generate superfluous fields.
+      'fields' => array('id', 'title', 'widget_id', 'entity_id', 'entity_table'),
+    );
+    $rules['Sprocket.get=>provider,misc'] = array(
+      'version' => 3,
+      'entity' => 'Sprocket',
+      'actions' => 'get',
+      'required' => array(),
+      // To call api.Sprocket.get via chaining, you must accept superfluous fields.
+      // It would be a mistake for the whitelist mechanism to approve these
+      // automatically, so instead we have to enumerate them. Ideally, ChainSubscriber
+      // wouldn't generate superfluous fields.
+      'fields' => array('id', 'provider', 'widget_id', 'entity_id', 'entity_table'),
+    );
+    $rules['Widget.get-foo'] = array(
+      'version' => 3,
+      'entity' => 'Widget',
+      'actions' => 'get',
+      'required' => array('widget_type' => 'foo'),
+      'fields' => '*',
+    );
+    $rules['Widget.get-spacely'] = array(
+      'version' => 3,
+      'entity' => 'Widget',
+      'actions' => 'get',
+      'required' => array('provider' => 'cosmo spacely'),
+      'fields' => '*',
+    );
+    $rules['Widget.get-bar=>title'] = array(
+      'version' => 3,
+      'entity' => 'Widget',
+      'actions' => 'get',
+      'required' => array('widget_type' => 'bar'),
+      'fields' => array('id', 'title'),
+    );
+    $rules['Widget.get-spacely=>title'] = array(
+      'version' => 3,
+      'entity' => 'Widget',
+      'actions' => 'get',
+      'required' => array('provider' => 'cosmo spacely'),
+      'fields' => array('id', 'title'),
+    );
+    $rules['Widget.get-spacely=>widget_type'] = array(
+      'version' => 3,
+      'entity' => 'Widget',
+      'actions' => 'get',
+      'required' => array('provider' => 'cosmo spacely'),
+      'fields' => array('id', 'widget_type'),
+    );
+    $rules['Widget.getcreate'] = array(
+      'version' => 3,
+      'entity' => 'Widget',
+      'actions' => array('get', 'create'),
+      'required' => array(),
+      'fields' => '*',
+    );
+    $rules['Widget.create'] = array(
+      'version' => 3,
+      'entity' => 'Widget',
+      'actions' => 'create',
+      'required' => array(),
+      'fields' => '*',
+    );
+
+    $c = array();
+
+    $c[] = array($calls['Widget.get-all'], array($rules['*.*']), TRUE);
+    $c[] = array($calls['Widget.get-all'], array($rules['Widget.*']), TRUE);
+    $c[] = array($calls['Widget.get-all'], array($rules['Widget.get']), TRUE);
+    $c[] = array($calls['Widget.get-all'], array($rules['Widget.create']), FALSE);
+    $c[] = array($calls['Widget.get-all'], array($rules['Widget.getcreate']), TRUE);
+    $c[] = array($calls['Widget.get-all'], array($rules['Sprocket.*']), FALSE);
+
+    $c[] = array($calls['Sprocket.get-all'], array($rules['*.*']), TRUE);
+    $c[] = array($calls['Sprocket.get-all'], array($rules['Sprocket.*']), TRUE);
+    $c[] = array($calls['Sprocket.get-all'], array($rules['Widget.*']), FALSE);
+    $c[] = array($calls['Sprocket.get-all'], array($rules['Widget.get']), FALSE);
+
+    $c[] = array($calls['Widget.get-spacely'], array($rules['Widget.*']), TRUE);
+    $c[] = array($calls['Widget.get-spacely'], array($rules['Widget.get-spacely']), TRUE);
+    $c[] = array($calls['Widget.get-spacely'], array($rules['Widget.get-foo']), FALSE);
+    $c[] = array($calls['Widget.get-spacely'], array($rules['Widget.get-foo'], $rules['Sprocket.*']), FALSE);
+    $c[] = array(
+      // we do a broad get, but 'fields' filtering kicks in and restricts the results
+      array_merge($calls['Widget.get-spacely'], array(
+        'expectedResults' => $calls['Widget.get-spacely=>title']['expectedResults'],
+      )),
+      array($rules['Widget.get-spacely=>title']),
+      TRUE,
+    );
+
+    $c[] = array($calls['Widget.get-foo'], array($rules['Widget.*']), TRUE);
+    $c[] = array($calls['Widget.get-foo'], array($rules['Widget.get-foo']), TRUE);
+    $c[] = array($calls['Widget.get-foo'], array($rules['Widget.get-spacely']), FALSE);
+
+    $c[] = array($calls['Widget.get-spacely=>title'], array($rules['*.*']), TRUE);
+    $c[] = array($calls['Widget.get-spacely=>title'], array($rules['Widget.*']), TRUE);
+    $c[] = array($calls['Widget.get-spacely=>title'], array($rules['Widget.get-spacely']), TRUE);
+    $c[] = array($calls['Widget.get-spacely=>title'], array($rules['Widget.get-spacely=>title']), TRUE);
+
+    // We request returning title field, but the rule doesn't allow title to be returned.
+    // Need it to fail so that control could pass to another rule which does allow it.
+    $c[] = array($calls['Widget.get-spacely=>title'], array($rules['Widget.get-spacely=>widget_type']), FALSE);
+
+    // One rule would allow, one would be irrelevant. The order of the two rules shouldn't matter.
+    $c[] = array(
+      $calls['Widget.get-spacely=>title'],
+      array($rules['Widget.get-spacely=>widget_type'], $rules['Widget.get-spacely=>title']),
+      TRUE,
+    );
+    $c[] = array(
+      $calls['Widget.get-spacely=>title'],
+      array($rules['Widget.get-spacely=>title'], $rules['Widget.get-spacely=>widget_type']),
+      TRUE,
+    );
+
+    $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['*.*']), TRUE);
+    $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['Widget.get-bar=>title'], $rules['Sprocket.get']), TRUE);
+    $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['Widget.get'], $rules['Sprocket.get=>title,misc']), FALSE);
+    $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['Widget.get'], $rules['Sprocket.get=>provider,misc']), TRUE);
+    $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['Widget.get-foo'], $rules['Sprocket.get']), FALSE);
+    $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['Widget.get']), FALSE);
+
+    return $c;
+  }
+
+  protected function setUp() {
+    parent::setUp();
+  }
+
+  /**
+   * @param array $apiRequest
+   *   Array(entity=>$,action=>$,params=>$,expectedResults=>$).
+   * @param array $rules
+   *   Whitelist - list of allowed API calls/patterns.
+   * @param bool $expectSuccess
+   *   TRUE if the call should succeed.
+   *   Success implies that the 'expectedResults' are returned.
+   *   Failure implies that the standard error message is returned.
+   * @dataProvider restrictionCases
+   */
+  public function testEach($apiRequest, $rules, $expectSuccess) {
+    \CRM_Core_DAO_AllCoreTables::init(TRUE);
+
+    $recs = $this->getFixtures();
+
+    \CRM_Core_DAO_AllCoreTables::registerEntityType('Widget', 'CRM_Fake_DAO_Widget', 'fake_widget');
+    $widgetProvider = new \Civi\API\Provider\StaticProvider(3, 'Widget',
+      array('id', 'widget_type', 'provider', 'title'),
+      array(),
+      $recs['widget']
+    );
+
+    \CRM_Core_DAO_AllCoreTables::registerEntityType('Sprocket', 'CRM_Fake_DAO_Sprocket', 'fake_sprocket');
+    $sprocketProvider = new \Civi\API\Provider\StaticProvider(
+      3,
+      'Sprocket',
+      array('id', 'sprocket_type', 'widget_id', 'provider', 'title', 'comment'),
+      array(),
+      $recs['sprocket']
+    );
+
+    $whitelist = WhitelistRule::createAll($rules);
+
+    $dispatcher = new EventDispatcher();
+    $kernel = new Kernel($dispatcher);
+    $kernel->registerApiProvider($sprocketProvider);
+    $kernel->registerApiProvider($widgetProvider);
+    $dispatcher->addSubscriber(new WhitelistSubscriber($whitelist));
+    $dispatcher->addSubscriber(new ChainSubscriber());
+
+    $apiRequest['params']['debug'] = 1;
+    $apiRequest['params']['check_permissions'] = 'whitelist';
+    $result = $kernel->run($apiRequest['entity'], $apiRequest['action'], $apiRequest['params']);
+
+    if ($expectSuccess) {
+      $this->assertAPISuccess($result);
+      $this->assertTrue(is_array($apiRequest['expectedResults']));
+      $this->assertTreeEquals($apiRequest['expectedResults'], $result['values']);
+    }
+    else {
+      $this->assertAPIFailure($result);
+      $this->assertRegExp('/The request does not match any active API authorizations./', $result['error_message']);
+    }
+  }
+
+}