'Contact', * 'actions' => array('get','getsingle'), * 'required' => array('contact_type' => 'Organization'), * 'fields' => array('id', 'display_name', 'sort_name', 'created_date'), * )); * ``` * * 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 { public static $IGNORE_FIELDS = [ '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 = []; 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 = []; 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, []); $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 = []; foreach ($keys as $key) { if (in_array($key, $this->fields)) { $r[] = $key; } elseif (preg_match('/^api\./', $key)) { $r[] = $key; } } return $r; } }