Let the platform do the work

Visibility Framework

Overview

The visibility framework provides the capability to alter the queries Sugar uses to retrieve records from the database. This framework can allow for additional restrictions or specific logic to meet business requirements of hiding or showing only specific records. Visibility classes only apply to certain aspects of Sugar record retrieval, e.g. List Views, Dashlets, and Filter Lookups.

Custom Row Visibility

Custom visibility class files are stored under ./custom/data/visibility/. The files in this directory can be enabled or disabled by modifying a module's visibility property located in ./custom/Extension/modules/<module>/Ext/Vardefs/. Every enabled visibility class is merged into the module's definition, allowing multiple layers of logic to be added to a module.

Visibility Class

To add custom row visibility, you must create a visibility class that will extend the core SugarVisibility class ./data/SugarVisibility.php . The visibility class has the ability to override the following methods:

Name Description
addVisibilityQuery Add visibility clauses to a SugarQuery object.
addVisibilityFrom [Deprecated] Add visibility clauses to the FROM part of the query. This method should still be implemented, as not all objects have been switched over to use addVisibilityQuery() method.
addVisibilityFromQuery [Deprecated] Add visibility clauses to the FROM part of SugarQuery. This method should still be implemented, as not all objects have been switched over to use addVisibilityQuery() method.
addVisibilityWhere [Deprecated] Add visibility clauses to the WHERE part of the query. This method should still be implemented, as not all objects have been switched over to use addVisibilityQuery() method.
addVisibilityWhereQuery [Deprecated] Add visibility clauses to the WHERE part of SugarQuery. This method should still be implemented, as not all objects have been switched over to use addVisibilityQuery() method.

The visibility class should also implement Sugarcrm\Sugarcrm\Elasticsearch\Provider\Visibility\StrategyInterface so that the visibility rules are also applied to the global search.  StrategyInterface has the following functions that should be implemented:

Name Description
elasticBuildAnalysis Build Elasticsearch analysis settings.  This function can be empty if you do not need any special analyzers.
elasticBuildMapping Build Elasticsearch mapping.  This function should contain a mapping of fields that should be analyzed.
elasticProcessDocumentPreIndex Process document before it's being indexed. This function should perform any actions to the document that needs to be completed before it is indexed. 
elasticGetBeanIndexFields Bean index fields to be indexed.  This function should return an array of the fields that need to be indexed as part of your custom visibility.
elasticAddFilters Add visibility filters.  This function should apply the Elastic filters.

Example

The following example creates a custom visibility filter that determines whether Opportunity records should be displayed based on their Sales Stage.  Opportunity records with Sales Stage set to Closed Won or Closed Lost will not be displayed in the Opportunities module or global search for users with the Demo Visibility role.  

/custom/data/visibility/FilterOpportunities.php:

  <?php

use Sugarcrm\Sugarcrm\Elasticsearch\Provider\Visibility\StrategyInterface as ElasticStrategyInterface;
use Sugarcrm\Sugarcrm\Elasticsearch\Provider\Visibility\Visibility;
use Sugarcrm\Sugarcrm\Elasticsearch\Analysis\AnalysisBuilder;
use Sugarcrm\Sugarcrm\Elasticsearch\Mapping\Mapping;
use Sugarcrm\Sugarcrm\Elasticsearch\Adapter\Document;

/**
 *
 * Custom visibility class for Opportunities module:
 *
 * This demo allows to restrict access to opportunity records based on the
 * user's role and configured filtered sales stages.
 *
 * The following $sugar_config parameters are available:
 *
 * $sugar_config['custom']['visibility']['opportunities']['target_role']
 * This parameter takes a string containing the role name for which
 * the filtering should apply.
 *
 * $sugar_config['custom']['visibility']['opportunities']['filter_sales_stages']
 * This parameters takes an array of filtered sales stages. If current user is
 * member of the above configured role, then the opportunities with the sale
 * stages as configured in this array will be inaccessible.
 *
 *
 * Example configuration given that 'Demo Visibility' role exists (config_override.php):
 *
 * $sugar_config['custom']['visibility']['opportunities']['target_role'] = 'Demo Visibility';
 * $sugar_config['custom']['visibility']['opportunities']['filter_sales_stages'] = array('Closed Won', 'Closed Lost');
 *
 */
class FilterOpportunities extends SugarVisibility implements ElasticStrategyInterface
{
    /**
     * The target role name
     * @var string
     */
    protected $targetRole = '';
    /**
     * Filtered sales stages
     * @var array
     */
    protected $filterSalesStages = array();

    /**
     * {@inheritdoc}
     */
    public function __construct(SugarBean $bean, $params = null)
    {
        parent::__construct($bean, $params);
        $config = SugarConfig::getInstance();
        $this->targetRole = $config->get(
            'custom.visibility.opportunities.target_role',
            $this->targetRole
        );
        $this->filterSalesStages = $config->get(
            'custom.visibility.opportunities.filter_sales_stages',
            $this->filterSalesStages
        );
    }

    /**
     * {@inheritdoc}
     */
    public function addVisibilityWhere(&$query)
    {
        if (!$this->isSecurityApplicable()) {
            return $query;
        }
        $whereClause = sprintf(
            "%s.sales_stage NOT IN (%s)",
            $this->getTableAlias(),
            implode(',', array_map(array($this->bean->db, 'quoted'), $this->filterSalesStages))
        );
        if (!empty($query)) {
            $query .= " AND $whereClause ";
        } else {
            $query = $whereClause;
        }
        return $query;
    }

    /**
     * {@inheritdoc}
     */
    public function addVisibilityWhereQuery(SugarQuery $sugarQuery, $options = array())
    {
        $where = null;
        $this->addVisibilityWhere($where, $options);
        if (!empty($where)) {
            $sugarQuery->where()->addRaw($where);
        }
        return $sugarQuery;
    }

    /**
     * Check if we can apply our security model
     * @param User $user
     * @return false|User
     */
    protected function isSecurityApplicable(User $user = null)
    {
        $user = $user ?: $this->getCurrentUser();
        if (!$user) {
            return false;
        }
        if (empty($this->targetRole) || empty($this->filterSalesStages)) {
            return false;
        }
        if (!is_string($this->targetRole) || !is_array($this->filterSalesStages)) {
            return false;
        }
        if (!$this->isUserMemberOfRole($this->targetRole, $user)) {
            return false;
        }
        if ($user->isAdminForModule("Opportunities")) {
            return false;
        }
        return $user;
    }

    /**
     * Get current user
     * @return false|User
     */
    protected function getCurrentUser()
    {
        return empty($GLOBALS['current_user']) ? false : $GLOBALS['current_user'];
    }

    /**
     * Check if given user has a given role assigned
     * @param string $targetRole Name of the role
     * @param User $user
     * @return boolean
     */
    protected function isUserMemberOfRole($targetRole, User $user)
    {
        $roles = ACLRole::getUserRoleNames($user->id);
        return in_array($targetRole, $roles) ? true : false;
    }

    /**
     * Get table alias
     * @return string
     */
    protected function getTableAlias()
    {
        $tableAlias = $this->getOption('table_alias');
        if (empty($tableAlias)) {
            $tableAlias = $this->bean->table_name;
        }
        return $tableAlias;
    }

    /**
     * {@inheritdoc}
     */
    public function elasticBuildAnalysis(AnalysisBuilder $analysisBuilder, Visibility $provider)
    {
        // no special analyzers needed
    }

    /**
     * {@inheritdoc}
     */
    public function elasticBuildMapping(Mapping $mapping, Visibility $provider)
    {
        $mapping->addNotAnalyzedField('visibility_sales_stage');
    }

    /**
     * {@inheritdoc}
     */
    public function elasticProcessDocumentPreIndex(Document $document, \SugarBean $bean, Visibility $provider)
    {
        // populate the sales_stage into our explicit filter field
        $sales_stage = isset($bean->sales_stage) ? $bean->sales_stage : '';
        $document->setDataField('visibility_sales_stage', $sales_stage);
    }

    /**
     * {@inheritdoc}
     */
    public function elasticGetBeanIndexFields($module, Visibility $provider)
    {
        // make sure to pull sales_stage regardless of search
        return array('sales_stage');
    }

    /**
     * {@inheritdoc}
     */
    public function elasticAddFilters(User $user, Elastica\Query\BoolQuery $filter,
                                      Sugarcrm\Sugarcrm\Elasticsearch\Provider\Visibility\Visibility $provider)
    {
        if (!$this->isSecurityApplicable($user)) {
            return;
        }
        // apply elastic filter to exclude the given sales stages
        $filter->addMustNot($provider->createFilter(
            'OpportunitySalesStages',
            array(
                'filter_sales_stages' => $this->filterSalesStages,
            )
        ));
    }
}

./custom/Extension/modules/Opportunities/Ext/Vardefs/opp_visibility.php

  <?php

if (!defined('sugarEntry') || !sugarEntry) {
    die('Not A Valid Entry Point');
}

$dictionary['Opportunity']['visibility']['FilterOpportunities'] = true;

./custom/src/Elasticsearch/Provider/Visibility/Filter/OpportunitySalesStagesFilter.php

  <?php

namespace Sugarcrm\Sugarcrm\custom\Elasticsearch\Provider\Visibility\Filter;

use Sugarcrm\Sugarcrm\Elasticsearch\Provider\Visibility\Filter\FilterInterface;
use Sugarcrm\Sugarcrm\Elasticsearch\Provider\Visibility\Visibility;

/**
 *
 * Custom opportunity filter by sales_stage
 *
 * This logic can exist directly in the FilterOpportunities visibility class.
 * However by abstracting the (custom) filters makes it possible to re-use
 * them in other places as well.
 */
class OpportunitySalesStagesFilter implements FilterInterface
{
    /**
     * @var Visibility
     */
    protected $provider;

    /**
     * {@inheritdoc}
     */
    public function setProvider(Visibility $provider)
    {
        $this->provider = $provider;
    }

    /**
     * {@inheritdoc}
     */
    public function buildFilter(array $options = array())
    {
        return new \Elastica\Query\Terms(
            'visibility_sales_stage',
            $options['filter_sales_stages']
        );
    }
}

After creating the above files, log in to your Sugar instance as an administrator and navigate to Administration > Repair > Quick Repair and Rebuild.  

Next, perform a full reindex by navigating to Administration > Search and selecting the "delete existing data" option. 

Execute a cron to process all of the queued records into Elasticsearch by doing the following: 

  1. Open a command line client and navigate to your Sugar directory.
  2. Execute chmod +x bin/sugarcrm to ensure bin/sugarcrm is executable.
  3. Execute php cron.php to consume the queue.
  4. Execute bin/sugarcrm elastic:queue to see if the queue has finished.

Repeat steps 3 and 4 until the queue has 0 records.

This example requires the Sales Stage field to be part of the Opportunities module.  Navigate to Administration > Opportunities and ensure the Opportunities radio button is selected.

Create a new role named "Demo Visibility" and assign a user to this role.  Note: if you are using the sample data, do NOT use Jim as he has admin permission on the Opportunities module and will be able to view all records. We recommend using Max.

Configure your instance to filter opportunities for a given sales stages for this role by adding the following to ./config_override.php:

  <?php

$sugar_config['custom']['visibility']['opportunities']['target_role'] = 'Demo Visibility';
$sugar_config['custom']['visibility']['opportunities']['filter_sales_stages'] = array('Closed Won', 'Closed Lost');

Log in as the user to whom you assigned the Demo Visibility role. Observe that opportunity records in the sales stages "Closed Won" and "Closed Lost" are no longer accessible.

You can download a module loadable package of this example here.