3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
14 * A WhitelistRule is used to determine if an API call is authorized.
18 * new WhitelistRule(array(
19 * 'entity' => 'Contact',
20 * 'actions' => array('get','getsingle'),
21 * 'required' => array('contact_type' => 'Organization'),
22 * 'fields' => array('id', 'display_name', 'sort_name', 'created_date'),
26 * This rule would allow API requests that attempt to get contacts of type "Organization",
27 * but only a handful of fields ('id', 'display_name', 'sort_name', 'created_date')
28 * can be filtered or returned.
31 * @package Civi\API\Subscriber
35 public static $IGNORE_FIELDS = [
51 * Create a batch of rules from an array.
56 public static function createAll($rules) {
58 foreach ($rules as $rule) {
59 $whitelist[] = new WhitelistRule($rule);
70 * Entity name or '*' (all entities)
77 * List of actions which match, or '*' (all actions)
84 * List of key=>value pairs that *must* appear in $params.
86 * If there are no required fields, use an empty array.
93 * List of fields which may be optionally inputted or returned, or '*" (all fields)
99 public function __construct($ruleSpec) {
100 $this->version
= $ruleSpec['version'];
102 if ($ruleSpec['entity'] === '*') {
106 $this->entity
= Request
::normalizeEntityName($ruleSpec['entity'], $ruleSpec['version']);
109 if ($ruleSpec['actions'] === '*') {
110 $this->actions
= '*';
114 foreach ((array) $ruleSpec['actions'] as $action) {
115 $this->actions
[] = Request
::normalizeActionName($action, $ruleSpec['version']);
119 $this->required
= $ruleSpec['required'];
120 $this->fields
= $ruleSpec['fields'];
126 public function isValid() {
127 if (empty($this->version
)) {
130 if (empty($this->entity
)) {
133 if (!is_array($this->actions
) && $this->actions
!== '*') {
136 if (!is_array($this->fields
) && $this->fields
!== '*') {
139 if (!is_array($this->required
)) {
146 * @param array $apiRequest
147 * Parsed API request.
148 * @return string|TRUE
149 * If match, return TRUE. Otherwise, return a string with an error code.
151 public function matches($apiRequest) {
152 if (!$this->isValid()) {
156 if ($this->version
!= $apiRequest['version']) {
159 if ($this->entity
!== '*' && $this->entity
!== $apiRequest['entity']) {
162 if ($this->actions
!== '*' && !in_array($apiRequest['action'], $this->actions
)) {
166 // These params *must* be included for the API request to proceed.
167 foreach ($this->required
as $param => $value) {
168 if (!isset($apiRequest['params'][$param])) {
169 return 'required-missing-' . $param;
171 if ($value !== '*' && $apiRequest['params'][$param] != $value) {
172 return 'required-wrong-' . $param;
176 // These params *may* be included at the caller's discretion
177 if ($this->fields
!== '*') {
178 $activatedFields = array_keys($apiRequest['params']);
179 $activatedFields = preg_grep('/^api\./', $activatedFields, PREG_GREP_INVERT
);
180 if ($apiRequest['action'] == 'get') {
181 // Kind'a silly we need to (re(re))parse here for each rule; would be more
182 // performant if pre-parsed by Request::create().
183 $options = _civicrm_api3_get_options_from_params($apiRequest['params'], TRUE, $apiRequest['entity'], 'get');
184 $return = \CRM_Utils_Array
::value('return', $options, []);
185 $activatedFields = array_merge($activatedFields, array_keys($return));
188 $unknowns = array_diff(
190 array_keys($this->required
),
195 if (!empty($unknowns)) {
196 return 'unknown-' . implode(',', $unknowns);
204 * Ensure that the return values comply with the whitelist's
207 * Most API's follow a convention where the result includes
208 * a 'values' array (which in turn is a list of records). Unfortunately,
209 * some don't. If the API result doesn't meet our expectation,
210 * then we probably don't know what's going on, so we abort the
213 * This will probably break some of the layered-sugar APIs (like
214 * getsingle, getvalue). Just use the meat-and-potatoes API instead.
215 * Or craft a suitably targeted patch.
217 * @param array $apiRequest
219 * @param array $apiResult
222 * Modified API result.
223 * @throws \API_Exception
225 public function filter($apiRequest, $apiResult) {
226 if ($this->fields
=== '*') {
229 if (isset($apiResult['values']) && empty($apiResult['values'])) {
230 // No data; filtering doesn't matter.
233 if (is_array($apiResult['values'])) {
234 $firstRow = \CRM_Utils_Array
::first($apiResult['values']);
235 if (is_array($firstRow)) {
236 $fields = $this->filterFields(array_keys($firstRow));
237 $apiResult['values'] = \CRM_Utils_Array
::filterColumns($apiResult['values'], $fields);
241 throw new \
API_Exception(sprintf('Filtering failed for %s.%s. Unrecognized result format.', $apiRequest['entity'], $apiRequest['action']));
245 * Determine which elements in $keys are acceptable under
246 * the whitelist policy.
249 * List of possible keys.
251 * List of acceptable keys.
253 protected function filterFields($keys) {
255 foreach ($keys as $key) {
256 if (in_array($key, $this->fields
)) {
259 elseif (preg_match('/^api\./', $key)) {