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