Merge pull request #17345 from eileenmcnaughton/ev_batch
[civicrm-core.git] / Civi / API / Subscriber / DynamicFKAuthorization.php
CommitLineData
56154d36
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
41498ac5 4 | Copyright CiviCRM LLC. All rights reserved. |
56154d36 5 | |
41498ac5
TO
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 |
56154d36 9 +--------------------------------------------------------------------+
d25dd0ee 10 */
56154d36
TO
11
12namespace Civi\API\Subscriber;
13
14use Civi\API\Events;
74c303ca 15use CRM_Core_DAO_AllCoreTables as AllCoreTables;
56154d36
TO
16use Symfony\Component\EventDispatcher\EventSubscriberInterface;
17
18/**
8882ff5c
TO
19 * Given an entity which dynamically attaches itself to another entity,
20 * determine if one has permission to the other entity.
56154d36 21 *
8882ff5c
TO
22 * Example: Suppose one tries to manipulate a File which is attached to a
23 * Mailing. DynamicFKAuthorization will enforce permissions on the File by
24 * imitating the permissions of the Mailing.
56154d36 25 *
8882ff5c 26 * Note: This enforces a constraint: all matching API calls must define
29468114
TO
27 * "id" (e.g. for the file) or "entity_table+entity_id" or
28 * "field_name+entity_id".
56154d36 29 *
8882ff5c
TO
30 * Note: The permission guard does not exactly authorize the request, but it
31 * may veto authorization.
56154d36
TO
32 */
33class DynamicFKAuthorization implements EventSubscriberInterface {
34
35 /**
36 * @return array
37 */
38 public static function getSubscribedEvents() {
c64f69d9 39 return [
39b870b8 40 'civi.api.authorize' => [
c64f69d9
CW
41 ['onApiAuthorize', Events::W_EARLY],
42 ],
43 ];
56154d36
TO
44 }
45
46 /**
47 * @var \Civi\API\Kernel
6d0ef411
TO
48 *
49 * Treat as private. Marked public due to PHP 5.3-compatibility issues.
56154d36 50 */
6d0ef411 51 public $kernel;
56154d36
TO
52
53 /**
f33d2b8c
TO
54 * The entity for which we want to manage permissions.
55 *
56 * @var string
56154d36
TO
57 */
58 protected $entityName;
59
60 /**
f33d2b8c
TO
61 * The actions for which we want to manage permissions
62 *
63 * @var string[]
56154d36
TO
64 */
65 protected $actions;
66
67 /**
f33d2b8c 68 * SQL SELECT query - Given a file ID, determine the entity+table it's attached to.
56154d36
TO
69 *
70 * ex: "SELECT if(cf.id,1,0) as is_valid, cef.entity_table, cef.entity_id
71 * FROM civicrm_file cf
72 * INNER JOIN civicrm_entity_file cef ON cf.id = cef.file_id
73 * WHERE cf.id = %1"
74 *
75 * Note: %1 is a parameter
76 * Note: There are three parameters
77 * - is_valid: "1" if %1 identifies an actual record; otherwise "0"
78 * - entity_table: NULL or the name of a related table
79 * - entity_id: NULL or the ID of a row in the related table
f33d2b8c
TO
80 *
81 * @var string
56154d36
TO
82 */
83 protected $lookupDelegateSql;
84
29468114 85 /**
f33d2b8c 86 * SQL SELECT query. Get a list of (field_name, table_name, extends) tuples.
29468114
TO
87 *
88 * For example, one tuple might be ("custom_123", "civicrm_value_mygroup_4",
89 * "Activity").
f33d2b8c
TO
90 *
91 * @var string
29468114
TO
92 */
93 protected $lookupCustomFieldSql;
94
95 /**
96 * @var array
97 *
98 * Each item is an array(field_name => $, table_name => $, extends => $)
99 */
100 protected $lookupCustomFieldCache;
101
56154d36 102 /**
f33d2b8c
TO
103 * List of related tables for which FKs are allowed.
104 *
105 * @var array
56154d36
TO
106 */
107 protected $allowedDelegates;
108
109 /**
110 * @param \Civi\API\Kernel $kernel
8882ff5c
TO
111 * The API kernel.
112 * @param string $entityName
113 * The entity for which we want to manage permissions (e.g. "File" or
114 * "Note").
115 * @param array $actions
116 * The actions for which we want to manage permissions (e.g. "create",
117 * "get", "delete").
118 * @param string $lookupDelegateSql
119 * See docblock in DynamicFKAuthorization::$lookupDelegateSql.
29468114
TO
120 * @param string $lookupCustomFieldSql
121 * See docblock in DynamicFKAuthorization::$lookupCustomFieldSql.
8882ff5c
TO
122 * @param array|NULL $allowedDelegates
123 * e.g. "civicrm_mailing","civicrm_activity"; NULL to allow any.
56154d36 124 */
29468114 125 public function __construct($kernel, $entityName, $actions, $lookupDelegateSql, $lookupCustomFieldSql, $allowedDelegates = NULL) {
56154d36 126 $this->kernel = $kernel;
74c303ca 127 $this->entityName = AllCoreTables::convertEntityNameToCamel($entityName, TRUE);
56154d36
TO
128 $this->actions = $actions;
129 $this->lookupDelegateSql = $lookupDelegateSql;
29468114 130 $this->lookupCustomFieldSql = $lookupCustomFieldSql;
56154d36
TO
131 $this->allowedDelegates = $allowedDelegates;
132 }
133
134 /**
135 * @param \Civi\API\Event\AuthorizeEvent $event
8882ff5c 136 * API authorization event.
56154d36
TO
137 * @throws \API_Exception
138 * @throws \Civi\API\Exception\UnauthorizedException
139 */
140 public function onApiAuthorize(\Civi\API\Event\AuthorizeEvent $event) {
141 $apiRequest = $event->getApiRequest();
74c303ca 142 if ($apiRequest['version'] == 3 && AllCoreTables::convertEntityNameToCamel($apiRequest['entity'], TRUE) == $this->entityName && in_array(strtolower($apiRequest['action']), $this->actions)) {
29468114 143 if (isset($apiRequest['params']['field_name'])) {
c64f69d9 144 $fldIdx = \CRM_Utils_Array::index(['field_name'], $this->getCustomFields());
29468114
TO
145 if (empty($fldIdx[$apiRequest['params']['field_name']])) {
146 throw new \Exception("Failed to map custom field to entity table");
147 }
148 $apiRequest['params']['entity_table'] = $fldIdx[$apiRequest['params']['field_name']]['entity_table'];
149 unset($apiRequest['params']['field_name']);
150 }
151
56154d36
TO
152 if (/*!$isTrusted */
153 empty($apiRequest['params']['id']) && empty($apiRequest['params']['entity_table'])
154 ) {
155 throw new \API_Exception("Mandatory key(s) missing from params array: 'id' or 'entity_table'");
156 }
157
158 if (isset($apiRequest['params']['id'])) {
159 list($isValidId, $entityTable, $entityId) = $this->getDelegate($apiRequest['params']['id']);
160 if ($isValidId && $entityTable && $entityId) {
161 $this->authorizeDelegate($apiRequest['action'], $entityTable, $entityId, $apiRequest);
162 $this->preventReassignment($apiRequest['params']['id'], $entityTable, $entityId, $apiRequest);
163 return;
164 }
165 elseif ($isValidId) {
166 throw new \API_Exception("Failed to match record to related entity");
8882ff5c
TO
167 }
168 elseif (!$isValidId && strtolower($apiRequest['action']) == 'get') {
169 // The matches will be an empty set; doesn't make a difference if we
170 // reject or accept.
171 // To pass SyntaxConformanceTest, we won't veto "get" on empty-set.
56154d36
TO
172 return;
173 }
174 }
175
176 if (isset($apiRequest['params']['entity_table'])) {
2ffa31a8
PF
177 if (!\CRM_Core_DAO_AllCoreTables::isCoreTable($apiRequest['params']['entity_table'])) {
178 throw new \API_Exception("Unrecognized target entity table {$apiRequest['params']['entity_table']}");
179 }
56154d36
TO
180 $this->authorizeDelegate(
181 $apiRequest['action'],
182 $apiRequest['params']['entity_table'],
183 \CRM_Utils_Array::value('entity_id', $apiRequest['params'], NULL),
184 $apiRequest
185 );
186 return;
187 }
188
189 throw new \API_Exception("Failed to run permission check");
190 }
191 }
192
193 /**
8882ff5c
TO
194 * @param string $action
195 * The API action (e.g. "create").
196 * @param string $entityTable
197 * The target entity table (e.g. "civicrm_mailing").
e97c66ff 198 * @param int|null $entityId
8882ff5c 199 * The target entity ID.
56154d36 200 * @param array $apiRequest
8882ff5c 201 * The full API request.
066c4638 202 * @throws \Exception
56154d36
TO
203 * @throws \API_Exception
204 * @throws \Civi\API\Exception\UnauthorizedException
205 */
206 public function authorizeDelegate($action, $entityTable, $entityId, $apiRequest) {
b0558c21
PF
207 if ($this->isTrusted($apiRequest)) {
208 return;
209 }
210
56154d36
TO
211 $entity = $this->getDelegatedEntityName($entityTable);
212 if (!$entity) {
29468114
TO
213 throw new \API_Exception("Failed to run permission check: Unrecognized target entity table ($entityTable)");
214 }
215 if (!$entityId) {
216 throw new \Civi\API\Exception\UnauthorizedException("Authorization failed on ($entity): Missing entity_id");
56154d36
TO
217 }
218
29468114
TO
219 /**
220 * @var \Exception $exception
221 */
222 $exception = NULL;
6d0ef411
TO
223 $self = $this;
224 \CRM_Core_Transaction::create(TRUE)->run(function($tx) use ($entity, $action, $entityId, &$exception, $self) {
34f3bbd9
SL
225 // Just to be safe.
226 $tx->rollback();
56154d36 227
c64f69d9 228 $params = [
29468114
TO
229 'version' => 3,
230 'check_permissions' => 1,
231 'id' => $entityId,
c64f69d9 232 ];
29468114 233
5a3846f7 234 $result = $self->kernel->runSafe($entity, $self->getDelegatedAction($action), $params);
29468114 235 if ($result['is_error'] || empty($result['values'])) {
c64f69d9 236 $exception = new \Civi\API\Exception\UnauthorizedException("Authorization failed on ($entity,$entityId)", [
29468114 237 'cause' => $result,
c64f69d9 238 ]);
29468114
TO
239 }
240 });
241
242 if ($exception) {
243 throw $exception;
56154d36
TO
244 }
245 }
246
247 /**
8882ff5c
TO
248 * If the request attempts to change the entity_table/entity_id of an
249 * existing record, then generate an error.
56154d36 250 *
8882ff5c
TO
251 * @param int $fileId
252 * The main record being changed.
253 * @param string $entityTable
254 * The saved FK.
255 * @param int $entityId
256 * The saved FK.
56154d36 257 * @param array $apiRequest
8882ff5c 258 * The full API request.
56154d36
TO
259 * @throws \API_Exception
260 */
261 public function preventReassignment($fileId, $entityTable, $entityId, $apiRequest) {
262 if (strtolower($apiRequest['action']) == 'create' && $fileId && !$this->isTrusted($apiRequest)) {
29468114 263 // TODO: no change in field_name?
56154d36
TO
264 if (isset($apiRequest['params']['entity_table']) && $entityTable != $apiRequest['params']['entity_table']) {
265 throw new \API_Exception("Cannot modify entity_table");
266 }
267 if (isset($apiRequest['params']['entity_id']) && $entityId != $apiRequest['params']['entity_id']) {
268 throw new \API_Exception("Cannot modify entity_id");
269 }
270 }
271 }
272
273 /**
8882ff5c
TO
274 * @param string $entityTable
275 * The target entity table (e.g. "civicrm_mailing" or "civicrm_activity").
276 * @return string|NULL
277 * The target entity name (e.g. "Mailing" or "Activity").
56154d36
TO
278 */
279 public function getDelegatedEntityName($entityTable) {
280 if ($this->allowedDelegates === NULL || in_array($entityTable, $this->allowedDelegates)) {
281 $className = \CRM_Core_DAO_AllCoreTables::getClassForTable($entityTable);
282 if ($className) {
283 $entityName = \CRM_Core_DAO_AllCoreTables::getBriefName($className);
284 if ($entityName) {
285 return $entityName;
286 }
287 }
288 }
289 return NULL;
290 }
291
292 /**
8882ff5c
TO
293 * @param string $action
294 * API action name -- e.g. "create" ("When running *create* on a file...").
295 * @return string
296 * e.g. "create" ("Check for *create* permission on the mailing to which
297 * it is attached.")
56154d36
TO
298 */
299 public function getDelegatedAction($action) {
300 switch ($action) {
301 case 'get':
302 // reading attachments requires reading the other entity
303 return 'get';
8882ff5c 304
56154d36
TO
305 case 'create':
306 case 'delete':
8882ff5c
TO
307 // creating/updating/deleting an attachment requires editing
308 // the other entity
56154d36 309 return 'create';
8882ff5c 310
56154d36
TO
311 default:
312 return $action;
313 }
314 }
315
316 /**
317 * @param int $id
8882ff5c 318 * e.g. file ID.
a6c01b45
CW
319 * @return array
320 * (0 => bool $isValid, 1 => string $entityTable, 2 => int $entityId)
29468114 321 * @throws \Exception
56154d36
TO
322 */
323 public function getDelegate($id) {
c64f69d9
CW
324 $query = \CRM_Core_DAO::executeQuery($this->lookupDelegateSql, [
325 1 => [$id, 'Positive'],
326 ]);
56154d36 327 if ($query->fetch()) {
29468114
TO
328 if (!preg_match('/^civicrm_value_/', $query->entity_table)) {
329 // A normal attachment directly on its entity.
c64f69d9 330 return [$query->is_valid, $query->entity_table, $query->entity_id];
29468114
TO
331 }
332
333 // Ex: Translate custom-field table ("civicrm_value_foo_4") to
334 // entity table ("civicrm_activity").
c64f69d9 335 $tblIdx = \CRM_Utils_Array::index(['table_name'], $this->getCustomFields());
29468114 336 if (isset($tblIdx[$query->entity_table])) {
c64f69d9 337 return [$query->is_valid, $tblIdx[$query->entity_table]['entity_table'], $query->entity_id];
29468114
TO
338 }
339 throw new \Exception('Failed to lookup entity table for custom field.');
56154d36
TO
340 }
341 else {
c64f69d9 342 return [FALSE, NULL, NULL];
56154d36
TO
343 }
344 }
345
346 /**
347 * @param array $apiRequest
8882ff5c 348 * The full API request.
56154d36
TO
349 * @return bool
350 */
351 public function isTrusted($apiRequest) {
352 // isn't this redundant?
353 return empty($apiRequest['params']['check_permissions']) or $apiRequest['params']['check_permissions'] == FALSE;
354 }
355
29468114
TO
356 /**
357 * @return array
358 * Each item has keys 'field_name', 'table_name', 'extends', 'entity_table'
359 */
360 public function getCustomFields() {
361 $query = \CRM_Core_DAO::executeQuery($this->lookupCustomFieldSql);
c64f69d9 362 $rows = [];
29468114 363 while ($query->fetch()) {
c64f69d9 364 $rows[] = [
29468114
TO
365 'field_name' => $query->field_name,
366 'table_name' => $query->table_name,
367 'extends' => $query->extends,
368 'entity_table' => \CRM_Core_BAO_CustomGroup::getTableNameByEntityName($query->extends),
c64f69d9 369 ];
29468114
TO
370 }
371 return $rows;
372 }
373
56154d36 374}