Merge pull request #17836 from seamuslee001/dev_core_1874
[civicrm-core.git] / Civi / API / WhitelistRule.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
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 +--------------------------------------------------------------------+
10 */
11 namespace Civi\API;
12
13 /**
14 * A WhitelistRule is used to determine if an API call is authorized.
15 * For example:
16 *
17 * ```
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'),
23 * ));
24 * ```
25 *
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.
29 *
30 * Class WhitelistRule
31 * @package Civi\API\Subscriber
32 */
33 class WhitelistRule {
34
35 public static $IGNORE_FIELDS = [
36 'check_permissions',
37 'debug',
38 'offset',
39 'option_offset',
40 'option_limit',
41 'option_sort',
42 'options',
43 'return',
44 'rowCount',
45 'sequential',
46 'sort',
47 'version',
48 ];
49
50 /**
51 * Create a batch of rules from an array.
52 *
53 * @param array $rules
54 * @return array
55 */
56 public static function createAll($rules) {
57 $whitelist = [];
58 foreach ($rules as $rule) {
59 $whitelist[] = new WhitelistRule($rule);
60 }
61 return $whitelist;
62 }
63
64 /**
65 * @var int
66 */
67 public $version;
68
69 /**
70 * Entity name or '*' (all entities)
71 *
72 * @var string
73 */
74 public $entity;
75
76 /**
77 * List of actions which match, or '*' (all actions)
78 *
79 * @var string|array
80 */
81 public $actions;
82
83 /**
84 * List of key=>value pairs that *must* appear in $params.
85 *
86 * If there are no required fields, use an empty array.
87 *
88 * @var array
89 */
90 public $required;
91
92 /**
93 * List of fields which may be optionally inputted or returned, or '*" (all fields)
94 *
95 * @var array
96 */
97 public $fields;
98
99 public function __construct($ruleSpec) {
100 $this->version = $ruleSpec['version'];
101
102 if ($ruleSpec['entity'] === '*') {
103 $this->entity = '*';
104 }
105 else {
106 $this->entity = Request::normalizeEntityName($ruleSpec['entity'], $ruleSpec['version']);
107 }
108
109 if ($ruleSpec['actions'] === '*') {
110 $this->actions = '*';
111 }
112 else {
113 $this->actions = [];
114 foreach ((array) $ruleSpec['actions'] as $action) {
115 $this->actions[] = Request::normalizeActionName($action, $ruleSpec['version']);
116 }
117 }
118
119 $this->required = $ruleSpec['required'];
120 $this->fields = $ruleSpec['fields'];
121 }
122
123 /**
124 * @return bool
125 */
126 public function isValid() {
127 if (empty($this->version)) {
128 return FALSE;
129 }
130 if (empty($this->entity)) {
131 return FALSE;
132 }
133 if (!is_array($this->actions) && $this->actions !== '*') {
134 return FALSE;
135 }
136 if (!is_array($this->fields) && $this->fields !== '*') {
137 return FALSE;
138 }
139 if (!is_array($this->required)) {
140 return FALSE;
141 }
142 return TRUE;
143 }
144
145 /**
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.
150 */
151 public function matches($apiRequest) {
152 if (!$this->isValid()) {
153 return 'invalid';
154 }
155
156 if ($this->version != $apiRequest['version']) {
157 return 'version';
158 }
159 if ($this->entity !== '*' && $this->entity !== $apiRequest['entity']) {
160 return 'entity';
161 }
162 if ($this->actions !== '*' && !in_array($apiRequest['action'], $this->actions)) {
163 return 'action';
164 }
165
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;
170 }
171 if ($value !== '*' && $apiRequest['params'][$param] != $value) {
172 return 'required-wrong-' . $param;
173 }
174 }
175
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));
186 }
187
188 $unknowns = array_diff(
189 $activatedFields,
190 array_keys($this->required),
191 $this->fields,
192 self::$IGNORE_FIELDS
193 );
194
195 if (!empty($unknowns)) {
196 return 'unknown-' . implode(',', $unknowns);
197 }
198 }
199
200 return TRUE;
201 }
202
203 /**
204 * Ensure that the return values comply with the whitelist's
205 * "fields" policy.
206 *
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
211 * request.
212 *
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.
216 *
217 * @param array $apiRequest
218 * API request.
219 * @param array $apiResult
220 * API result.
221 * @return array
222 * Modified API result.
223 * @throws \API_Exception
224 */
225 public function filter($apiRequest, $apiResult) {
226 if ($this->fields === '*') {
227 return $apiResult;
228 }
229 if (isset($apiResult['values']) && empty($apiResult['values'])) {
230 // No data; filtering doesn't matter.
231 return $apiResult;
232 }
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);
238 return $apiResult;
239 }
240 }
241 throw new \API_Exception(sprintf('Filtering failed for %s.%s. Unrecognized result format.', $apiRequest['entity'], $apiRequest['action']));
242 }
243
244 /**
245 * Determine which elements in $keys are acceptable under
246 * the whitelist policy.
247 *
248 * @param array $keys
249 * List of possible keys.
250 * @return array
251 * List of acceptable keys.
252 */
253 protected function filterFields($keys) {
254 $r = [];
255 foreach ($keys as $key) {
256 if (in_array($key, $this->fields)) {
257 $r[] = $key;
258 }
259 elseif (preg_match('/^api\./', $key)) {
260 $r[] = $key;
261 }
262 }
263 return $r;
264 }
265
266 }