API/DAO - Improve permissioning sql clauses: support field names and OR
authorcolemanw <coleman@civicrm.org>
Sun, 17 Sep 2023 22:11:03 +0000 (18:11 -0400)
committercolemanw <coleman@civicrm.org>
Wed, 20 Sep 2023 11:30:24 +0000 (07:30 -0400)
Before: selectWhereClause was limited to returning arrays of clauses which would be
joined with AND. They could not reference any other fields on the entity.

After: selectWhereClause can return sub-arrays which are joined with OR,
and can reference any field on the entity using {curly_brace} syntax.

Stricter type checking emits noisy deprecations if arrays are expected and strings are given.

CRM/Contact/BAO/Query.php
CRM/Core/BAO/Note.php
CRM/Core/DAO.php
CRM/Utils/SQL.php
ext/financialacls/financialacls.php
ext/oauth-client/CRM/OAuth/BAO/OAuthContactToken.php
tests/phpunit/CRM/Utils/SQLTest.php

index 9f015588926862e3ce62480577a300184e4fb738..23499c9219b4f9a9c615d626baf1b1eef88c04c5 100644 (file)
@@ -5082,7 +5082,9 @@ civicrm_relationship.start_date > {$today}
         $clauses = $subclauses = [];
         foreach ($bao->addSelectWhereClause() as $field => $vals) {
           if ($vals && $field !== 'id') {
-            $clauses[] = $bao->tableName() . ".$field " . $vals;
+            foreach ($vals as $val) {
+              $clauses[] = $bao->tableName() . ".$field " . $val;
+            }
           }
           elseif ($vals) {
             $subclauses[] = "$field " . implode(" AND $field ", (array) $vals);
index 8df7525a989efc702a738631135cf2d94ee99f3a..d84e9b320f170378cd395dae4a4bf2ee4fe9dc13 100644 (file)
@@ -526,4 +526,14 @@ WHERE participant.contact_id = %1 AND  note.entity_table = 'civicrm_participant'
     }
   }
 
+  public function addSelectWhereClause(): array {
+    $clauses = [];
+    $relatedClauses = self::getDynamicFkAclClauses('entity_table', 'entity_id');
+    if ($relatedClauses) {
+      // Nested array will be joined with OR
+      $clauses['entity_table'] = [$relatedClauses];
+    }
+    return $clauses;
+  }
+
 }
index fd0b562fd605b6b236f6a0d0efada23aa987d228..7568c03f6e6345c854928eeed5e82924bcead733 100644 (file)
@@ -3059,33 +3059,38 @@ SELECT contact_id
    *
    * Return format is in the form of fieldname => clauses starting with an operator. e.g.:
    * ```
-   *   array(
-   *     'location_type_id' => array('IS NOT NULL', 'IN (1,2,3)')
-   *   )
+   *   [
+   *     // Each string in the array will get joined with AND
+   *     'location_type_id' => ['IS NOT NULL', 'IN (1,2,3)'],
+   *     // Each sub-array in the array will get joined with OR, field names must be enclosed in curly braces
+   *     'privacy' => [
+   *                    ['= 0', '= 1 AND {contact_id} = 456'],
+   *                  ],
+   *   ]
    * ```
    *
    * Note that all array keys must be actual field names in this entity. Use subqueries to filter on other tables e.g. custom values.
+   * The query strings MAY reference other fields in this entity; but they must be enclosed in {curly_braces}.
    *
    * @return array
    */
   public function addSelectWhereClause(): array {
     $clauses = [];
-    $fields = $this->fields();
-    // Notes should check permissions on the entity_id field, not the contact_id field
-    $skipContactCheckFor = ['Note'];
+    $fields = $this::getSupportedFields();
     foreach ($fields as $fieldName => $field) {
       // Clause for contact-related entities like Email, Relationship, etc.
-      if (strpos($field['name'], 'contact_id') === 0 && !in_array($field['entity'], $skipContactCheckFor) && ($field['FKClassName'] ?? NULL) == 'CRM_Contact_DAO_Contact') {
+      if (str_starts_with($fieldName, 'contact_id') && ($field['FKClassName'] ?? NULL) === 'CRM_Contact_DAO_Contact') {
         $contactClause = CRM_Utils_SQL::mergeSubquery('Contact');
         if (!empty($contactClause)) {
-          $clauses[$field['name']] = $contactClause;
+          $clauses[$fieldName] = $contactClause;
         }
       }
       // Clause for an entity_table/entity_id combo
-      if ($field['name'] === 'entity_id' && isset($fields['entity_table'])) {
-        $relatedClauses = self::getDynamicFkAclClauses();
+      if ($fieldName === 'entity_id' && isset($fields['entity_table'])) {
+        $relatedClauses = self::getDynamicFkAclClauses('entity_table', 'entity_id');
         if ($relatedClauses) {
-          $clauses['id'] = 'IN (SELECT id FROM `' . $this->tableName() . '` WHERE (' . implode(') OR (', $relatedClauses) . '))';
+          // Nested array will be joined with OR
+          $clauses['entity_table'] = [$relatedClauses];
         }
       }
     }
@@ -3093,20 +3098,19 @@ SELECT contact_id
     return $clauses;
   }
 
-  protected static function getDynamicFkAclClauses(): array {
+  protected static function getDynamicFkAclClauses($entityTableField, $entityIdField): array {
     $relatedClauses = [];
-    $relatedEntities = static::buildOptions('entity_table', 'get');
+    $relatedEntities = static::buildOptions($entityTableField, 'get');
     foreach ((array) $relatedEntities as $table => $ent) {
-      // Prevent recursion
-      if (!empty($ent) && $table !== static::getTableName()) {
-        $ent = CRM_Core_DAO_AllCoreTables::getEntityNameForTable($table);
-        $subquery = CRM_Utils_SQL::mergeSubquery($ent);
-        if ($subquery) {
-          $relatedClauses[] = "(entity_table = '$table' AND entity_id " . implode(' AND entity_id ', $subquery) . ")";
-        }
-        else {
-          $relatedClauses[] = "(entity_table = '$table')";
-        }
+      // Ensure $ent is the machine name of the entity not a translated title
+      $ent = CRM_Core_DAO_AllCoreTables::getEntityNameForTable($table);
+      // Prevent infinite recursion
+      $subquery = $table === static::getTableName() ? NULL : CRM_Utils_SQL::mergeSubquery($ent);
+      if ($subquery) {
+        $relatedClauses[] = "= '$table' AND {{$entityIdField}} " . implode(" AND {{$entityIdField}} ", $subquery);
+      }
+      else {
+        $relatedClauses[] = "= '$table'";
       }
     }
     return $relatedClauses;
@@ -3125,17 +3129,32 @@ SELECT contact_id
     if ($tableAlias === NULL) {
       $tableAlias = $bao->tableName();
     }
-    $clauses = [];
-    foreach ((array) $bao->addSelectWhereClause() as $field => $vals) {
-      $clauses[$field] = NULL;
-      if ($vals) {
-        $clauses[$field] = "(`$tableAlias`.`$field` " . implode(" AND `$tableAlias`.`$field` ", (array) $vals) . ')';
-        if (empty(static::getSupportedFields()[$field]['required'])) {
-          $clauses[$field] = "(`$tableAlias`.`$field` IS NULL OR {$clauses[$field]})";
+    $finalClauses = [];
+    $fields = static::getSupportedFields();
+    foreach ((array) $bao->addSelectWhereClause() as $fieldName => $fieldClauses) {
+      $finalClauses[$fieldName] = NULL;
+      if ($fieldClauses) {
+        if (!is_array($fieldClauses)) {
+          CRM_Core_Error::deprecatedWarning('Expected array of selectWhereClauses for ' . $bao->tableName() . '.' . $fieldName . ', instead got ' . json_encode($fieldClauses));
+          $fieldClauses = (array) $fieldClauses;
+        }
+        $formattedClauses = [];
+        foreach (CRM_Utils_SQL::prefixFieldNames($fieldClauses, array_keys($fields), $tableAlias) as $subClause) {
+          // Arrays of arrays get joined with OR (similar to CRM_Core_Permission::check)
+          if (is_array($subClause)) {
+            $formattedClauses[] = "(`$tableAlias`.`$fieldName` " . implode(" OR `$tableAlias`.`$fieldName` ", $subClause) . ')';
+          }
+          else {
+            $formattedClauses[] = "(`$tableAlias`.`$fieldName` " . $subClause . ')';
+          }
+        }
+        $finalClauses[$fieldName] = '(' . implode(' AND ', $formattedClauses) . ')';
+        if (empty($fields[$fieldName]['required'])) {
+          $finalClauses[$fieldName] = "(`$tableAlias`.`$fieldName` IS NULL OR {$finalClauses[$fieldName]})";
         }
       }
     }
-    return $clauses;
+    return $finalClauses;
   }
 
   /**
index 981d7cb6e550b8f479cf2d18248e82d83beefd00..0c50dd795ad43cee3c2ca835ee299362dec4de89 100644 (file)
@@ -57,22 +57,57 @@ class CRM_Utils_SQL {
    * @return array
    */
   public static function mergeSubquery($entity, $joinColumn = 'id') {
-    require_once 'api/v3/utils.php';
-    $baoName = _civicrm_api3_get_BAO($entity);
+    $baoName = CRM_Core_DAO_AllCoreTables::getBAOClassName(CRM_Core_DAO_AllCoreTables::getFullName($entity));
     $bao = new $baoName();
-    $clauses = $subclauses = [];
-    foreach ((array) $bao->addSelectWhereClause() as $field => $vals) {
-      if ($vals && $field == $joinColumn) {
-        $clauses = array_merge($clauses, (array) $vals);
+    $fields = $bao::getSupportedFields();
+    $mergeClauses = $subClauses = [];
+    foreach ((array) $bao->addSelectWhereClause() as $fieldName => $fieldClauses) {
+      if ($fieldClauses) {
+        foreach ((array) $fieldClauses as $fieldClause) {
+          $formattedClause = CRM_Utils_SQL::prefixFieldNames($fieldClause, array_keys($fields), $bao->tableName());
+          // Same as join column with no additional fields - can be added directly
+          if ($fieldName === $joinColumn && $fieldClause === $formattedClause) {
+            $mergeClauses[] = $formattedClause;
+          }
+          // Arrays of arrays get joined with OR (similar to CRM_Core_Permission::check)
+          elseif (is_array($formattedClause)) {
+            $subClauses[] = "($fieldName " . implode(" OR $fieldName ", $formattedClause) . ')';
+          }
+          else {
+            $subClauses[] = "$fieldName $formattedClause";
+          }
+        }
       }
-      elseif ($vals) {
-        $subclauses[] = "$field " . implode(" AND $field ", (array) $vals);
+    }
+    if ($subClauses) {
+      $mergeClauses[] = "IN (SELECT `$joinColumn` FROM `" . $bao->tableName() . "` WHERE " . implode(' AND ', $subClauses) . ")";
+    }
+    return $mergeClauses;
+  }
+
+  /**
+   * Walk a nested array and replace "{field_name}" with "`tableAlias`.`field_name`"
+   *
+   * @param string|array $clause
+   * @param array $fieldNames
+   * @param string $tableAlias
+   * @return string|array
+   */
+  public static function prefixFieldNames(&$clause, array $fieldNames, string $tableAlias) {
+    if (is_array($clause)) {
+      foreach ($clause as $index => $subclause) {
+        $clause[$index] = self::prefixFieldNames($subclause, $fieldNames, $tableAlias);
       }
     }
-    if ($subclauses) {
-      $clauses[] = "IN (SELECT `$joinColumn` FROM `" . $bao->tableName() . "` WHERE " . implode(' AND ', $subclauses) . ")";
+    if (is_string($clause) && str_contains($clause, '{')) {
+      $find = $replace = [];
+      foreach ($fieldNames as $fieldName) {
+        $find[] = '{' . $fieldName . '}';
+        $replace[] = '`' . $tableAlias . '`.`' . $fieldName . '`';
+      }
+      $clause = str_replace($find, $replace, $clause);
     }
-    return $clauses;
+    return $clause;
   }
 
   /**
index 0e079fe4010b8fa259ba8ce2d279f73c7b01841f..48b93922b32a2567fd528d42e57a5c22dabaa486 100644 (file)
@@ -93,19 +93,19 @@ function financialacls_civicrm_selectWhereClause($entity, &$clauses) {
     case 'MembershipType':
     case 'ContributionRecur':
     case 'Contribution':
-      $clauses['financial_type_id'] = _financialacls_civicrm_get_type_clause();
+      $clauses['financial_type_id'][] = _financialacls_civicrm_get_type_clause();
       break;
 
     case 'Membership':
-      $clauses['membership_type_id'] = _financialacls_civicrm_get_membership_type_clause();
+      $clauses['membership_type_id'][] = _financialacls_civicrm_get_membership_type_clause();
       break;
 
     case 'FinancialType':
-      $clauses['id'] = _financialacls_civicrm_get_type_clause();
+      $clauses['id'][] = _financialacls_civicrm_get_type_clause();
       break;
 
     case 'FinancialAccount':
-      $clauses['id'] = _financialacls_civicrm_get_accounts_clause();
+      $clauses['id'][] = _financialacls_civicrm_get_accounts_clause();
       break;
 
   }
index 6fbe616ac61ec0ac27013733968a0948e4efae44..9786a68c1b34c2c407643d315e123fde4f6d084d 100644 (file)
@@ -109,11 +109,11 @@ class CRM_OAuth_BAO_OAuthContactToken extends CRM_OAuth_DAO_OAuthContactToken {
     }
     // With 'manage my' permission, limit to just the current user
     elseif ($loggedInContactID && CRM_Core_Permission::check(['manage my OAuth contact tokens'])) {
-      $clauses['contact_id'] = "= $loggedInContactID";
+      $clauses['contact_id'][] = "= $loggedInContactID";
     }
     // No permission, return nothing
     else {
-      $clauses['contact_id'] = "= -1";
+      $clauses['contact_id'][] = "= -1";
     }
     CRM_Utils_Hook::selectWhereClause($this, $clauses);
     return $clauses;
index 80d336fce7e2fccaf83fadc011c723aae1d5da4c..e37833e333ffd6c2688258afaf2392e6d82ba59c 100644 (file)
@@ -37,6 +37,21 @@ class CRM_Utils_SQLTest extends CiviUnitTestCase {
     }
   }
 
+  public function testPrefixFieldNames(): void {
+    $exampleFieldNames = ['one', 'two', 'three'];
+    $tableAlias = 'foo';
+    $clause = [
+      '{one} = 1',
+      ['{two} = {three}', '`{threee}` IN ({one}, "{twothree}")'],
+    ];
+    $expected = [
+      '`foo`.`one` = 1',
+      ['`foo`.`two` = `foo`.`three`', '`{threee}` IN (`foo`.`one`, "{twothree}")'],
+    ];
+    CRM_Utils_SQL::prefixFieldNames($clause, $exampleFieldNames, $tableAlias);
+    $this->assertEquals($expected, $clause);
+  }
+
   /**
    * Test isSSLDSN
    * @dataProvider dsnProvider