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