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 +--------------------------------------------------------------------+
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
17 class CRM_Contact_BAO_Contact_Permission
{
22 public static $useTempTable = TRUE;
25 * Set whether to use a temporary table or not when building ACL Cache
26 * @param bool $useTemporaryTable
28 public static function setUseTemporaryTable($useTemporaryTable = TRUE) {
29 self
::$useTempTable = $useTemporaryTable;
33 * Get variable for determining if we should use Temporary Table or not
36 public static function getUseTemporaryTable() {
37 return self
::$useTempTable;
41 * Check which of the given contact IDs the logged in user
42 * has permissions for the operation type according to:
43 * - general permissions (e.g. 'edit all contacts')
44 * - deletion status (unless you have 'access deleted contacts')
46 * - permissions inherited through relationships (also second degree if enabled)
48 * @param array $contact_ids
50 * @param int $type the type of operation (view|edit)
52 * @see CRM_Contact_BAO_Contact_Permission::allow
55 * list of contact IDs the logged in user has the given permission for
57 public static function allowList($contact_ids, $type = CRM_Core_Permission
::VIEW
) {
59 if (empty($contact_ids)) {
60 // empty contact lists would cause trouble in the SQL. And be pointless.
64 // make sure the the general permissions are given
65 if (CRM_Core_Permission
::check('edit all contacts')
66 ||
$type == CRM_Core_Permission
::VIEW
&& CRM_Core_Permission
::check('view all contacts')
69 // if the general permission is there, all good
70 if (CRM_Core_Permission
::check('access deleted contacts')) {
71 // if user can access deleted contacts -> fine
75 // if the user CANNOT access deleted contacts, these need to be filtered
76 $contact_id_list = implode(',', $contact_ids);
77 $filter_query = "SELECT DISTINCT(id) FROM civicrm_contact WHERE id IN ($contact_id_list) AND is_deleted = 0";
78 $query = CRM_Core_DAO
::executeQuery($filter_query);
79 while ($query->fetch()) {
80 $result_set[(int) $query->id
] = TRUE;
82 return array_keys($result_set);
87 $contactID = CRM_Core_Session
::getLoggedInContactID();
88 if (empty($contactID)) {
92 // make sure the cache is filled
93 self
::cache($contactID, $type);
96 $operation = ($type == CRM_Core_Permission
::VIEW
) ?
'View' : 'Edit';
98 // add clause for deleted contacts, if the user doesn't have the permission to access them
99 $LEFT_JOIN_DELETED = $AND_CAN_ACCESS_DELETED = '';
100 if (!CRM_Core_Permission
::check('access deleted contacts')) {
101 $LEFT_JOIN_DELETED = "LEFT JOIN civicrm_contact ON civicrm_contact.id = contact_id";
102 $AND_CAN_ACCESS_DELETED = "AND civicrm_contact.is_deleted = 0";
106 $contact_id_list = implode(',', $contact_ids);
109 FROM civicrm_acl_contact_cache
111 WHERE contact_id IN ({$contact_id_list})
112 AND user_id = {$contactID}
113 AND operation = '{$operation}'
114 {$AND_CAN_ACCESS_DELETED}";
115 $result = CRM_Core_DAO
::executeQuery($query);
116 while ($result->fetch()) {
117 $result_set[(int) $result->contact_id
] = TRUE;
120 // if some have been rejected, double check for permissions inherited by relationship
121 if (count($result_set) < count($contact_ids)) {
122 $rejected_contacts = array_diff_key($contact_ids, $result_set);
123 // @todo consider storing these to the acl cache for next time, since we have fetched.
124 $allowed_by_relationship = self
::relationshipList($rejected_contacts, $type);
125 foreach ($allowed_by_relationship as $contact_id) {
126 $result_set[(int) $contact_id] = TRUE;
130 return array_keys($result_set);
134 * Check if the logged in user has permissions for the operation type.
138 * @param int|string $type the type of operation (view|edit)
141 * true if the user has permission, false otherwise
143 public static function allow($id, $type = CRM_Core_Permission
::VIEW
) {
144 // get logged in user
145 $contactID = CRM_Core_Session
::getLoggedInContactID();
147 // first: check if contact is trying to view own contact
148 if ($contactID == $id && ($type == CRM_Core_Permission
::VIEW
&& CRM_Core_Permission
::check('view my contact')
149 ||
$type == CRM_Core_Permission
::EDIT
&& CRM_Core_Permission
::check('edit my contact'))
154 // FIXME: push this somewhere below, to not give this permission so many rights
155 $isDeleted = (bool) CRM_Core_DAO
::getFieldValue('CRM_Contact_DAO_Contact', $id, 'is_deleted');
156 if (CRM_Core_Permission
::check('access deleted contacts') && $isDeleted) {
160 // short circuit for admin rights here so we avoid unneeeded queries
161 // some duplication of code, but we skip 3-5 queries
162 if (CRM_Core_Permission
::check('edit all contacts') ||
163 ($type == CRM_ACL_API
::VIEW
&& CRM_Core_Permission
::check('view all contacts'))
168 // check permission based on relationship, CRM-2963
169 if (self
::relationshipList([$id], $type)) {
173 // We should probably do a cheap check whether it's in the cache first.
174 // check permission based on ACL
178 $permission = CRM_ACL_API
::whereClause($type, $tables, $whereTables, NULL, FALSE, FALSE, TRUE);
179 $from = CRM_Contact_BAO_Query
::fromClause($whereTables);
184 WHERE contact_a.id = %1 AND $permission
188 if (CRM_Core_DAO
::singleValueQuery($query, [1 => [$id, 'Integer']])) {
195 * Fill the acl contact cache for this ACLed contact id if empty.
197 * @param int $userID - contact_id of the ACLed user
198 * @param int|string $type the type of operation (view|edit)
199 * @param bool $force - Should we force a recompute.
202 public static function cache($userID, $type = CRM_Core_Permission
::VIEW
, $force = FALSE) {
203 // FIXME: maybe find a better way of keeping track of this. @eileen pointed out
204 // that somebody might flush the cache away from under our feet,
205 // but the alternative would be a SQL call every time this is called,
206 // and a complete rebuild if the result was an empty set...
207 if (!isset(Civi
::$statics[__CLASS__
]['processed'])) {
208 Civi
::$statics[__CLASS__
]['processed'] = [
209 CRM_Core_Permission
::VIEW
=> [],
210 CRM_Core_Permission
::EDIT
=> [],
214 if ($type == CRM_Core_Permission
::VIEW
) {
215 $operationClause = " operation IN ( 'Edit', 'View' ) ";
219 $operationClause = " operation = 'Edit' ";
222 $queryParams = [1 => [$userID, 'Integer']];
225 // skip if already calculated
226 if (!empty(Civi
::$statics[__CLASS__
]['processed'][$type][$userID])) {
230 // run a query to see if the cache is filled
233 FROM civicrm_acl_contact_cache
237 $count = CRM_Core_DAO
::singleValueQuery($sql, $queryParams);
239 Civi
::$statics[__CLASS__
]['processed'][$type][$userID] = 1;
244 // grab a lock so other processes don't compete and do the same query
245 $lock = Civi
::lockManager()->acquire("data.core.aclcontact.{$userID}");
246 if (!$lock->isAcquired()) {
247 // this can cause inconsistent results since we don't know if the other process
248 // will fill up the cache before our calling routine needs it.
249 // The default 3 second timeout should be enough for the other process to finish.
250 // However this routine does not return the status either, so basically
251 // its a "lets return and hope for the best"
258 $permission = CRM_ACL_API
::whereClause($type, $tables, $whereTables, $userID, FALSE, FALSE, TRUE);
260 $from = CRM_Contact_BAO_Query
::fromClause($whereTables);
261 /* Ends up something like this:
262 CREATE TEMPORARY TABLE civicrm_temp_acl_contact_cache1310 (SELECT DISTINCT 2960 as user_id, contact_a.id as contact_id, 'View' as operation
263 FROM civicrm_contact contact_a LEFT JOIN civicrm_group_contact_cache `civicrm_group_contact_cache-ACL` ON contact_a.id = `civicrm_group_contact_cache-ACL`.contact_id
264 LEFT JOIN civicrm_acl_contact_cache ac ON ac.user_id = 2960 AND ac.contact_id = contact_a.id AND ac.operation = 'View'
265 WHERE ( `civicrm_group_contact_cache-ACL`.group_id IN (14, 25, 46, 47, 48, 49, 50, 51) ) AND (contact_a.is_deleted = 0)
266 AND ac.user_id IS NULL*/
267 /*$sql = "SELECT DISTINCT $userID as user_id, contact_a.id as contact_id, '{$operation}' as operation
269 LEFT JOIN civicrm_acl_contact_cache ac ON ac.user_id = $userID AND ac.contact_id = contact_a.id AND ac.operation = '{$operation}'
271 AND ac.user_id IS NULL
273 $sql = "SELECT DISTINCT $userID as user_id, contact_a.id as contact_id, '{$operation}' as operation
276 $useTempTable = self
::getUseTemporaryTable();
278 $aclContactsTempTable = CRM_Utils_SQL_TempTable
::build()->setCategory('aclccache')->setMemory();
279 $tempTable = $aclContactsTempTable->getName();
280 $aclContactsTempTable->createWithColumns('user_id int, contact_id int, operation varchar(255), UNIQUE UI_user_contact_operation (user_id,contact_id,operation)');
281 CRM_Core_DAO
::executeQuery("INSERT INTO {$tempTable} (user_id, contact_id, operation) {$sql}");
282 CRM_Core_DAO
::executeQuery("INSERT IGNORE INTO civicrm_acl_contact_cache (user_id, contact_id, operation) SELECT user_id, contact_id, operation FROM {$tempTable}");
283 $aclContactsTempTable->drop();
286 CRM_Core_DAO
::executeQuery("INSERT IGNORE INTO civicrm_acl_contact_cache (user_id, contact_id, operation) {$sql}");
289 // Add in a row for the logged in contact. Do not try to combine with the above query or an ugly OR will appear in
290 // the permission clause.
291 if (CRM_Core_Permission
::check('edit my contact') ||
292 ($type == CRM_Core_Permission
::VIEW
&& CRM_Core_Permission
::check('view my contact'))) {
293 if (!CRM_Core_DAO
::singleValueQuery("
294 SELECT count(*) FROM civicrm_acl_contact_cache WHERE user_id = %1 AND contact_id = %1 AND operation = '{$operation}' LIMIT 1", $queryParams)) {
295 CRM_Core_DAO
::executeQuery("INSERT IGNORE INTO civicrm_acl_contact_cache ( user_id, contact_id, operation ) VALUES(%1, %1, '{$operation}')", $queryParams);
298 Civi
::$statics[__CLASS__
]['processed'][$type][$userID] = 1;
303 * @param string $contactAlias
307 public static function cacheClause($contactAlias = 'contact_a') {
308 if (CRM_Core_Permission
::check('view all contacts') ||
309 CRM_Core_Permission
::check('edit all contacts')
311 if (is_array($contactAlias)) {
313 foreach ($contactAlias as $alias) {
315 $wheres[] = "$alias.is_deleted = 0";
317 return [NULL, '(' . implode(' AND ', $wheres) . ')'];
321 return [NULL, "$contactAlias.is_deleted = 0"];
325 $contactID = (int) CRM_Core_Session
::getLoggedInContactID();
326 self
::cache($contactID);
328 if (is_array($contactAlias) && !empty($contactAlias)) {
329 //More than one contact alias
331 foreach ($contactAlias as $k => $alias) {
332 $clauses[] = " INNER JOIN civicrm_acl_contact_cache aclContactCache_{$k} ON {$alias}.id = aclContactCache_{$k}.contact_id AND aclContactCache_{$k}.user_id = $contactID ";
335 $fromClause = implode(" ", $clauses);
339 $fromClause = " INNER JOIN civicrm_acl_contact_cache aclContactCache ON {$contactAlias}.id = aclContactCache.contact_id ";
340 $whereClase = " aclContactCache.user_id = $contactID AND $contactAlias.is_deleted = 0";
343 return [$fromClause, $whereClase];
347 * Generate acl subquery that can be placed in the WHERE clause of a query or the ON clause of a JOIN.
349 * This is specifically for VIEW operations.
351 * @return string|null
353 public static function cacheSubquery() {
354 if (!CRM_Core_Permission
::check([['view all contacts', 'edit all contacts']])) {
355 $contactID = (int) CRM_Core_Session
::getLoggedInContactID();
356 self
::cache($contactID);
357 return "IN (SELECT contact_id FROM civicrm_acl_contact_cache WHERE user_id = $contactID)";
363 * Filter a list of contact_ids by the ones that the
364 * currently active user as a permissioned relationship with
366 * @param array $contact_ids
367 * List of contact IDs to be filtered
370 * access type CRM_Core_Permission::VIEW or CRM_Core_Permission::EDIT
373 * List of contact IDs that the user has permissions for
375 public static function relationshipList($contact_ids, $type) {
378 // no processing empty lists (avoid SQL errors as well)
379 if (empty($contact_ids)) {
383 // get the currently logged in user
384 $contactID = CRM_Core_Session
::getLoggedInContactID();
385 if (empty($contactID)) {
389 // compile a list of queries (later to UNION)
391 $contact_id_list = implode(',', $contact_ids);
393 // add a select statement for each direction
394 $directions = [['from' => 'a', 'to' => 'b'], ['from' => 'b', 'to' => 'a']];
396 // CRM_Core_Permission::VIEW is satisfied by either CRM_Contact_BAO_Relationship::VIEW or CRM_Contact_BAO_Relationship::EDIT
397 if ($type == CRM_Core_Permission
::VIEW
) {
398 $is_perm_condition = ' IN ( ' . CRM_Contact_BAO_Relationship
::EDIT
. ' , ' . CRM_Contact_BAO_Relationship
::VIEW
. ' ) ';
401 $is_perm_condition = ' = ' . CRM_Contact_BAO_Relationship
::EDIT
;
404 // NORMAL/SINGLE DEGREE RELATIONSHIPS
405 foreach ($directions as $direction) {
406 $user_id_column = "contact_id_{$direction['from']}";
407 $contact_id_column = "contact_id_{$direction['to']}";
409 // add clause for deleted contacts, if the user doesn't have the permission to access them
410 $LEFT_JOIN_DELETED = $AND_CAN_ACCESS_DELETED = '';
411 if (!CRM_Core_Permission
::check('access deleted contacts')) {
412 $LEFT_JOIN_DELETED = "LEFT JOIN civicrm_contact ON civicrm_contact.id = {$contact_id_column} ";
413 $AND_CAN_ACCESS_DELETED = "AND civicrm_contact.is_deleted = 0";
417 SELECT civicrm_relationship.{$contact_id_column} AS contact_id
418 FROM civicrm_relationship
420 WHERE civicrm_relationship.{$user_id_column} = {$contactID}
421 AND civicrm_relationship.{$contact_id_column} IN ({$contact_id_list})
422 AND civicrm_relationship.is_active = 1
423 AND civicrm_relationship.is_permission_{$direction['from']}_{$direction['to']} {$is_perm_condition}
424 $AND_CAN_ACCESS_DELETED";
427 // FIXME: secondDegRelPermissions should be a setting
428 $config = CRM_Core_Config
::singleton();
429 if ($config->secondDegRelPermissions
) {
430 foreach ($directions as $first_direction) {
431 foreach ($directions as $second_direction) {
432 // add clause for deleted contacts, if the user doesn't have the permission to access them
433 $LEFT_JOIN_DELETED = $AND_CAN_ACCESS_DELETED = '';
434 if (!CRM_Core_Permission
::check('access deleted contacts')) {
435 $LEFT_JOIN_DELETED = "LEFT JOIN civicrm_contact first_degree_contact ON first_degree_contact.id = second_degree_relationship.contact_id_{$second_direction['from']}\n";
436 $LEFT_JOIN_DELETED .= "LEFT JOIN civicrm_contact second_degree_contact ON second_degree_contact.id = second_degree_relationship.contact_id_{$second_direction['to']} ";
437 $AND_CAN_ACCESS_DELETED = "AND first_degree_contact.is_deleted = 0\n";
438 $AND_CAN_ACCESS_DELETED .= "AND second_degree_contact.is_deleted = 0 ";
442 SELECT second_degree_relationship.contact_id_{$second_direction['to']} AS contact_id
443 FROM civicrm_relationship first_degree_relationship
444 LEFT JOIN civicrm_relationship second_degree_relationship ON first_degree_relationship.contact_id_{$first_direction['to']} = second_degree_relationship.contact_id_{$second_direction['from']}
446 WHERE first_degree_relationship.contact_id_{$first_direction['from']} = {$contactID}
447 AND second_degree_relationship.contact_id_{$second_direction['to']} IN ({$contact_id_list})
448 AND first_degree_relationship.is_active = 1
449 AND first_degree_relationship.is_permission_{$first_direction['from']}_{$first_direction['to']} {$is_perm_condition}
450 AND second_degree_relationship.is_active = 1
451 AND second_degree_relationship.is_permission_{$second_direction['from']}_{$second_direction['to']} {$is_perm_condition}
452 $AND_CAN_ACCESS_DELETED";
457 // finally UNION the queries and call
458 $query = "(" . implode(")\nUNION DISTINCT (", $queries) . ")";
459 $result = CRM_Core_DAO
::executeQuery($query);
460 while ($result->fetch()) {
461 $result_set[(int) $result->contact_id
] = TRUE;
463 return array_keys($result_set);
467 * @param int $contactID
468 * @param CRM_Core_Form $form
469 * @param bool $redirect
473 public static function validateOnlyChecksum($contactID, &$form, $redirect = TRUE) {
474 // check if this is of the format cs=XXX
475 if (!CRM_Contact_BAO_Contact_Utils
::validChecksum($contactID,
476 CRM_Utils_Request
::retrieve('cs', 'String', $form, FALSE)
480 // also set a message in the UF framework
481 $message = ts('You do not have permission to edit this contact record. Contact the site administrator if you need assistance.');
482 CRM_Utils_System
::setUFMessage($message);
484 $config = CRM_Core_Config
::singleton();
485 CRM_Core_Error
::statusBounce($message,
486 $config->userFrameworkBaseURL
488 // does not come here, we redirect in the above statement
493 // set appropriate AUTH source
494 self
::initChecksumAuthSrc(TRUE, $form);
496 // so here the contact is posing as $contactID, lets set the logging contact ID variable
498 CRM_Core_DAO
::executeQuery('SET @civicrm_user_id = %1',
499 [1 => [$contactID, 'Integer']]
506 * @param bool $checkSumValidationResult
509 public static function initChecksumAuthSrc($checkSumValidationResult = FALSE, $form = NULL) {
510 $session = CRM_Core_Session
::singleton();
511 if ($checkSumValidationResult && $form && CRM_Utils_Request
::retrieve('cs', 'String', $form, FALSE)) {
512 // if result is already validated, and url has cs, set the flag.
513 $session->set('authSrc', CRM_Core_Permission
::AUTH_SRC_CHECKSUM
);
515 elseif (($session->get('authSrc') & CRM_Core_Permission
::AUTH_SRC_CHECKSUM
) == CRM_Core_Permission
::AUTH_SRC_CHECKSUM
) {
516 // if checksum wasn't present in REQUEST OR checksum result validated as FALSE,
517 // and flag was already set exactly as AUTH_SRC_CHECKSUM, unset it.
518 $session->set('authSrc', CRM_Core_Permission
::AUTH_SRC_UNKNOWN
);
523 * @param int $contactID
524 * @param CRM_Core_Form $form
525 * @param bool $redirect
529 public static function validateChecksumContact($contactID, &$form, $redirect = TRUE) {
530 if (!self
::allow($contactID, CRM_Core_Permission
::EDIT
)) {
531 // check if this is of the format cs=XXX
532 return self
::validateOnlyChecksum($contactID, $form, $redirect);