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

This sounds like it will cover our use case as well (national federation with many state-level organizations all using the same instance). I'm excited to try it out!

I imagine this is more efficient & reliable than using ACLs on Smart Groups. (Which works... sort of.)

But the bigger issues when I've addressed tiered permissioning is Groups. Under your implementation, I'd guess that users can see & user any groups, though of course they'll only return records to which they have access. But then everyone sees everyone's groups, which can clutter up the interface.

Ideally, region1 users could create sub-groups to which only they would have permission. This worked well under the existing groups-based ACL system except for one very key oddity: having permission to create a group doesn't mean you have permission to see or use the groups you create. That still has to be explicitly defined by the super-admin. I wrote some very rough code to run in a Drupal block for the CiviCRM 1.x API. But it broke completely on upgrade to 2.0, and because of the problems with using Smart Groups, we opted to just open up the groups to all users anyway. I will probably rewrite it from scratch as an actual Drupal module for an upcoming project.

The code override option sounds really useful. I would strongly urge for this to also be implemented via a hook so that a path to override code can be set programatically by modules/components/add-ons, and not just through the admin interface. The admin interface could then simply list all modules that provide overrides, and let the user specify priority/conflict resolution - i.e., which modules wins if more than one provide the same file.

Found here:

http://issues.civicrm.org/jira/browse/CRM-3225

--
Dave Hansen-Lange
Web Developer
Advomatic LLC
http://advomatic.com
Hong Kong office

Anonymous (not verified)
2010-01-19 - 16:59

We've got Drupal 6 and CiviCRM 3.0x soon to be 3.1 - so I'm curious to know how much of this implementation is still relevant to newer versions and how much is different ?
( given this was written with drupal 5 + civi2.2 )