Implementing a custom ACL system in CiviCRM

Published
2008-06-18 01:23
Written by
From a forum post by Chris Burgess (with Green Party NZ). A related blog post is: Another approach to ACL and permissioning for hierarchical organizations Our (Drupal5+CiviCRM2.0) site needed a regional ACL implementation in order to give us the ability to easily grant access for our internal staff to contacts in their geographic region. Because we have staff at a provincial, regional and branch level, we needed to be able to configure permissions for each level. We did this by implementing code which overrides the ACL implementation in CiviCRM. I'd appreciate any input and suggestions on our methodology - it's a work in progress, but it's working well for us so far. I haven't tested this with Joomla, but I hope that my notes below help someone roll a similar implementation there.

Running custom local code

First of all, I wanted to be able to make local code changes and still easily track CiviCRM SVN updates to v2.0 branch. At the CiviCRM NZ meetup, Lobo worked with us to create a method (available in 2.1 by default) to override CiviCRM code by inserting your own into the include path before CiviCRM's default files get loaded (just like the template system does). You can skip this step if your normal method is just to hack the local source ... that's how I used to work too - but I'm getting too lazy to keep track of local modifications :P So: to make this happen on 2.0, you need to (short form):
cd $CIVICRM_PATH
svn merge -r14315:14316 http://svn.civicrm.org/civicrm/trunk .
svn merge -r14329:14330 http://svn.civicrm.org/civicrm/trunk .
Once this is done, there's a new setting in CiviCRM › Administer CiviCRM › Global Settings › Directories labelled Custom PHP Path Directory. Then we need to create a new CRM directory, and copy any .php file we wish to modify to that folder (preserving the original CRM/Foo/Bar/Baz.php path). Now we're ready to build in our own ACLs!

Setting up the custom data

Each contact in our system has a custom data group named "Geography", and in this group they will have a custom data field for each of "Province", "Electorate" and "Branch". The simplest method would have been to give our staff access only to people within their OWN province, electorate or branch, but this didn't fit our organisational model - so we needed to add a second custom data group named "Regional Access" and insert a multi-select here for each province, electorate and branch, allowing a staff user to have access to any number of regional areas. We also wanted to add a second ACL level. We have some "VIP" contacts, who we don't want ordinary staff contacting. We added a VIP contact custom data field, and we grant access to these VIP contacts ONLY to users who have a custom permission we define, "headoffice view".

Implementing a local ACL

Now it's time to add our local ACL rules, which will override the normal ACL rules. These rules will only apply if a user does NOT have "Edit all contacts" permission granted via their Drupal role. That permission will see that the ACL checks through the system are skipped completely. To keep things brief, [url=http://svn.civicrm.org/civicrm/branches/v2.0/CRM/ACL/BAO/ACL.php]here's the original ACL.php file we modify[/url]. We replace a block of the function CRM_ACL_BAO_ACL::whereClause() to inject our own query logic which checks drupal permissions and custom data for the logged-in user against the custom data of the contact that is (potentially) being accessed.
    public static function whereClause( $type, &$tables, &$whereTables, $contactID = null ) {
        require_once 'CRM/ACL/BAO/Cache.php';
        $acls =& CRM_ACL_BAO_Cache::build( $contactID );
        // CRM_Core_Error::debug( "a: $contactID", $acls );
	$ids = array( );
        if ( ! empty( $acls ) ) {
	  $aclKeys = array_keys( $acls );
	  $aclKeys = implode( ',', $aclKeys );
	  
	  $query = "
SELECT   a.operation, a.object_id
  FROM   civicrm_acl_cache c, civicrm_acl a
 WHERE   c.acl_id       =  a.id
   AND   a.is_active    =  1
   AND   a.object_table = 'civicrm_saved_search'
   AND   a.id        IN ( $aclKeys )
ORDER BY a.object_id
";
	  //CRM_Core_Error::debug( 'q', $query );
	  $dao =& CRM_Core_DAO::executeQuery( $query, CRM_Core_DAO::$_nullArray );
	  
	  // do an or of all the where clauses u see
	  while ( $dao->fetch( ) ) {
            if ( ! $dao->object_id ) {
	      return ' ( 1 ) ';
            }
            // make sure operation matches the type TODO
            if ( $type == CRM_ACL_API::VIEW ||
                 ( $type == CRM_ACL_API::EDIT &&
                   $dao->operation == 'Edit' || $dao->operation == 'All' ) ) {
	      $ids[] = $dao->object_id;
            }
	  }
        }
	$clauses = array( );
        if ( ! empty( $ids ) ) {
	  $ids = implode( ',', $ids );
	  $query = "
SELECT g.where_clause, g.select_tables, g.where_tables
  FROM civicrm_group g
 WHERE g.id IN ( $ids )
";
	  $dao =& CRM_Core_DAO::executeQuery( $query, CRM_Core_DAO::$_nullArray );
	  while ( $dao->fetch( ) ) {
            // currently operation is restrcited to VIEW/EDIT
            if ( $dao->where_clause ) {
	      $clauses[] = $dao->where_clause;
	      if ( $dao->select_tables ) {
		$tables = array_merge( $tables,
				       unserialize( $dao->select_tables ) );
	      }
	      if ( $dao->where_tables ) {
		$whereTables = array_merge( $whereTables,
					    unserialize( $dao->where_tables ) );
	      }
            }
	  }
	}


	/**
	 * modifications:
	 * 1. Exclude VIPs unless we have headoffice view
	 * 2. Restrict to logged in user's granted province/branch/electorate
	 */
	if ( !user_access( 'headoffice view' ) ) {
	  /**
	   * Exclude access to users with vip_status = 1
	   */
	  $denyClauses[] = 'custom_value_1_Special_Data.vip_status != 1 OR custom_value_1_Special_Data.vip_status IS NULL' ;
	  $whereTables['custom_value_1_Special_Data'] = 
	    $tables['custom_value_1_Special_Data'] = 
	    'LEFT JOIN custom_value_1_Special_Data ON custom_value_1_Special_Data.entity_id = contact_a.id' ;
	  /**
	   * Restrict access to contacts whose electorate matches the
	   * current user's regional_access.electorates
	   *
	   * Load custom data with regional ACLs for this logged in
	   * user, and construct extra whereClauses to ensure they
	   * don't see outside their sandbox
	   */
         /*
          * Get the logged-in user's contact ID
          */
	  $session   =& CRM_Core_Session::singleton( );
	  $contactID =  $session->get( 'userID' );
	  /**
	   * TODO: optimise these three queries
	   */
         /*
          * Find the custom field IDs of the fields we restrict on, based on
          * the field label and custom data group title
          */
	  $electorate_field_id = CRM_Core_BAO_CustomField::getCustomFieldId( array( 'field_label' => 'Electorates', 
										    'group_title' => 'Regional Access', ) ) ;
	  $province_field_id   = CRM_Core_BAO_CustomField::getCustomFieldId( array( 'field_label' => 'Provinces', 
										    'group_title' => 'Regional Access', ) ) ;
	  $branch_field_id     = CRM_Core_BAO_CustomField::getCustomFieldId( array( 'field_label' => 'Branches', 
										    'group_title' => 'Regional Access', ) ) ;
	  $electorate_field_name = 'custom_' . $electorate_field_id ;
	  $province_field_name   = 'custom_' . $province_field_id ;
	  $branch_field_name     = 'custom_' . $branch_field_id ;
	  $params    = array( 'contact_id' => $contactID, 
			      'return.'.$electorate_field_name => 1,
			      'return.'.$province_field_name => 1,
			      'return.'.$branch_field_name => 1
			      ) ;
	  require_once('api/v2/Contact.php');
	  $contact   = civicrm_contact_get( $params ) ;
	  // add electorate ACL
	  if ( isset( $contact[$electorate_field_name] ) ) {
	    $electorates = explode( CRM_Core_DAO::VALUE_SEPARATOR, $contact[$electorate_field_name] ) ;
	    // remove empty strings which appear at beginning and end of array
	    $electorates = array_diff( $electorates, array( '' ) ) ;
	    if ( !empty( $electorates ) ) {
	      $sqlElectorates = "'" . implode("','", $electorates) . "'" ;
	      $clauses[] = 'custom_value_1_Geography.electorate IN ( ' . $sqlElectorates . ' )' ;
	      $whereTables['custom_value_1_Geography'] = $tables['custom_value_1_Geography'] =
		'LEFT JOIN custom_value_1_Geography ON custom_value_1_Geography.entity_id = contact_a.id' ;
	    }
	    //	    $clauses[] = 'custom_value_1_Geography.electorate LIKE \'%\'' ;
	  }
	  // add province ACL
	  if ( isset( $contact[$province_field_name] ) ) {
	    $provinces = explode( CRM_Core_DAO::VALUE_SEPARATOR, $contact[$province_field_name] ) ;
	    //	    CRM_Core_Error::debug($provinces);
	    // remove empty strings which appear at beginning and end of array
	    $provinces = array_diff( $provinces, array( '' ) ) ;
	    if ( !empty( $provinces ) ) {
	      $sqlProvinces = "'" . implode("','", $provinces) . "'" ;
	      $clauses[] = 'custom_value_1_Geography.province IN ( ' . $sqlProvinces . ' )' ;
	      $whereTables['custom_value_1_Geography'] = $tables['custom_value_1_Geography'] =
		'LEFT JOIN custom_value_1_Geography ON custom_value_1_Geography.entity_id = contact_a.id' ;
	    }
	  }
	  // add branch ACL
	  if ( isset( $contact[$branch_field_name] ) ) {
	    $branchs = explode( CRM_Core_DAO::VALUE_SEPARATOR, $contact[$branch_field_name] ) ;
	    // remove empty strings which appear at beginning and end of array
	    $branchs = array_diff( $branchs, array( '' ) ) ;
	    if ( !empty( $branchs ) ) {
	      $sqlBranchs = "'" . implode("','", $branchs) . "'" ;
	      $clauses[] = 'custom_value_1_Geography.branch IN ( ' . $sqlBranchs . ' )' ;
	      $whereTables['custom_value_1_Geography'] = $tables['custom_value_1_Geography'] =
		'LEFT JOIN custom_value_1_Geography ON custom_value_1_Geography.entity_id = contact_a.id' ;
	    }
	  }
	}
	$dbc = array( $clauses, $whereTables );
//	CRM_Core_Error::debug_var('qry', $dbc);
        /**
         * In the original function, all the various clauses are OR'd together here.
         *
         * We want to ensure that one of our clauses - the VIP exclusion - is AND'd,
         * so we have kept it separate from the other $clauses, in $denyClauses
         */
	if ( ! empty( $clauses ) ) {
	  $restr = ' ( ' . implode( ' OR ', $clauses ) . ' ) ' ;
	  if ( ! empty( $denyClauses ) ) {
	    $restr .= ' AND ( ' . implode( ' AND ', $denyClauses ) . ' )' ;
	  }
        } else {
           $restr = ' ( 0 ) ';
        }
//	CRM_Core_Error::debug_var('restr', $restr);
	return $restr ;
    }
Then, because we need to be able to efficiently look up the custom field ID in order to do the above, we also copy into our custom PHP CRM/Core/BAO/CustomField.php and add this additional function.
    /**
     * Make it possible to find a generated custom field ID by looking
     * up the field's label / column name
     * 
     * @param array details to identify the custom field - ideally,
     * custom group name as well as either custom field label or name,
     * but custom group name / title is not required
     *
     * @return custom field ID
     */
    public static function getCustomFieldId( $params ) {
      if ( !is_array( $params ) ||
	   ( !isset( $params['field_name'] ) && 
	     !isset( $params['field_label'] ) ) ) {
	return civicrm_create_error( "Custom field name or label is required" ) ;
      }
      $args = $where = array() ;
      if ( isset( $params['group_title'] ) || isset( $params['group_name'] ) ) {
	$join = "JOIN civicrm_custom_group ON civicrm_custom_group.id = civicrm_custom_field.custom_group_id" ;
	if ( isset( $params['group_title'] ) ) {
	  $where[] = "civicrm_custom_group.title = %1" ;
	  $args[1] = array( $params['group_title'], 'String' ) ;
	}
	if ( isset( $params['group_name'] ) ) {
	  $where[] = "civicrm_custom_group.name = %2" ;
	  $args[2] = array( $params['group_name'], 'String' ) ;
	}
      }
      if ( isset( $params['field_name'] ) ) {
	$where[] = "civicrm_custom_field.column_name = %3" ;
	$args[3] = array( $params['field_name'], 'String' )  ;
      }
      if ( isset( $params['field_label'] ) ) {
	$where[] = "civicrm_custom_field.column_name = %4" ;	
	$args[4] = array( $params['field_label'], 'String' ) ;
      }
      $sql = "SELECT civicrm_custom_field.id FROM civicrm_custom_field " . 
	$join . 
	" WHERE " . implode( " AND ", $where ) ;
      return CRM_Core_DAO::singleValueQuery( $sql, $args );
    }
(I'm hoping similar functionality to my CRM_Core_BAO_CustomField::getCustomFieldID() will be in 2.1 by default - or that someone will show me the existing function I missed!) I'm sure there's plenty of room for improvement, but that's what we started with.

The results

As intended, the users who don't have "edit all users" (or "view all users") permission see no people, until they are assigned some regional access entries under that custom data group. If your implementation wanted to recycle your geography - or even non-custom data like the "State" or "City" field - it could do exactly that, and even simpler.

Things to investigate further

Can this be made more efficient? Can some of this data be cached? I'm pretty sure the above code is evaluated only once per query (eg for a contact search) applying its custom SQL restriction to the query (and thus the resultset). What unexpected results come of this? (I'll follow this up in a moment! We hit one already.) Your thoughts / input / criticism welcome - please dig in :)
Filed under

Comments