Apex Sharing Gotchas in Salesforce

Apex Sharing Gotchas in SalesforceIn order to give access to data records to other users in your org who would otherwise not have access to it, you can take advantage of Apex Sharing.  It can apply to standard and custom objects but comes with some complexities that can catch you off-guard. I wrote this article with the hope that it will help others facing similar issues.

We will be addressing these 2 issues with Apex Sharing.

1. How does Salesforce handle granting different access levels to the same record and same user in a single transaction?

2. How can you identify a share for deletion given multiple share(s) can exist for the same record and user?

First a quick overview of how Apex Sharing works.  To share a record with another user or group, you need to provide the following details.

1. Type of share to create

2. Record Id to share

3. User or Group to share with

4. Level of Access (Read-Only or Read-Write)

5. Reason for Share. For  Standard Objects, this is ‘Manual Sharing’. For Custom Objects, you can define Apex Sharing Reasons. Apex Sharing Reasons are really helpful in uniquely identifying shares for redundancy and to mark for deletion.

Use Case

When building a Community, we need the ability to grant record level access to Accounts, Contacts based on the User’s Role(s) in the Community. The challenge is that access to the Accounts and Contacts is not through the standard Salesforce Account Hierarchy and its Contacts but rather is controlled through a custom object (called CommunityAccess) with references to the related Account and Contact records. Furthermore a Community User can have multiple Role(s)  requiring different access levels to the same record so the standard Salesforce Role membership was not sufficient. Here are the business requirements.

1. Grant access to a User for a given Role to an Account record and all of its child(ren) Account records.

2. Remove access to the Account record and all of its child(ren) Account records when User no longer holds that Role in the Community, preserving any other Role(s) (Access) the User still holds. For instance- if User previously held Edit writes but that access is being revoked but still should have Read access, make sure Read access remains intact.

3. Grant access to a User for a given Role to a Community Access record and all Community Access records for the same Account and child(ren) Accounts.

4. Remove access to the Community Access record and all Community Access records associated with the Account and its child(ren) Accounts from the User when the given Role membership is revoked. Again – need to ensure that if User should still have Read access if Edit access is being revoked that it is preserved.

Handling Different Access Levels

Given the above requirements, we need a way to share and remove shares programmatically.

The first issue we faced was determining how Salesforce handles multiple share records in a single DML statement for the same record and User but different access levels. For example

Record to Share = ‘Account ABC’

User to share with = ‘John Smith’

Access Level = ‘Read’

AND

Record to Share = ‘Account ABC’

User to share with = ‘John Smith’

Access Level = ‘Edit’

It would seem to make sense that Salesforce would keep the higher level of access, however, that not what we found to be true. It is purely based on the order in the list submitted for insert so whichever record is added last is the one that takes precedence. Essentially Salesforce matches on Record and User as the unique key ignoring the access level. And the behavior is consistent if you split the records into 2 separate DML statements – the last record added overwrites any previous record.  So how can we successfully implement our requirements given this behavior? There are two different answers depending on whether it is for a standard or custom object. Let us start with the easy solution.

Custom objects – use Apex Sharing Reasons – this will take care of itself – create an Apex Sharing Reason for each Access Level something like ‘Community Access Read’ and ‘Community Access Read-Write’.

Standard objects – this is where it gets fun. If you have worked with Apex Sharing before, then you have probably built up a set of Apex Share records to insert as you traverse through your records to share and then do a single insert. So as you build up your list, you need to check for duplicates. The following code snippets will help you handle this use case nicely.

Share Wrapper Class Code

Here we define a wrapper class that tracks the current shares for each object type being shared and ensures that the highest level of access is retained. This is used in the Add Share Record Code snippet.

public class ApexSharing {

    /**
     * Holds all pending shares for a particular record of type objName. Used to ensure
     * we only add a single share for the record being shared and user if different
     * access levels are being added. This is to prevent SF default behavior of keeping
     * the last element in the list thereby discarding any earlier list elements
     */
    public class ShareWrapper {
        protected String objName;
        protected Map<Id, List<SObject>> sharesByParentId = new Map<Id, List<SObject>>();
        protected String accessLevelFieldName = 'AccessLevel';
        protected String parentIdFieldName = 'ParentId';

        public ShareWrapper(String objName) {
            this.objName = objName;
            if (!objName.endsWithIgnoreCase('__c')) {
                accessLevelFieldName = objName + 'AccessLevel';
                parentIdFieldName = objName + 'Id';
            }
        }
        public List<SObject> getShares(Id parentId) {
            return sharesByParentId.get(parentId);
        }

        /**
         * Return all shares for our given objName and all Records being shared.
         *
         * @return list of Share records to be inserted into database
         */
        public List<SObject> getAllShares() {
            List<SObject> sharesToAdd = new List<SObject>();
            for (Id recordToShareId : sharesByParentId.keyset()) {
                sharesToAdd.addAll(sharesByParentId.get(recordToShareId));
            }
            return sharesToAdd;
        }

        /**
         * Add shareToAdd to our list if we do not already have a Share for the same Access or higher. If we have a share
         * for lower/lesser access, replace it with the new share and do not add a redundant one
         *
         * @param shareToAdd share record to add for our object name and the record id within shareToAdd
         */
        public void add(SObject shareToAdd) {
            Id recordToShareId = (Id)shareToAdd.get(parentIdFieldName);

            if (sharesByParentId.get(recordToShareId) == null) {
                sharesByParentId.put(recordToShareId, new List<SObject>());
            }

            Boolean blnAddShare = true;
            for (SObject share : getShares(recordToShareId)) {
                if (share.get('UserOrGroupId') == shareToAdd.get('UserOrGroupId') &&
                        share.get('RowCause') == shareToAdd.get('RowCause')) {
                    blnAddShare = false; // avoid adding duplicates
                    // if share's current access is READ and we are adding EDIT, then replace share in map
                    if (share.get(accessLevelFieldName) == 'READ' && shareToAdd.get(accessLevelFieldName) == 'EDIT') {
                        // replace our share
                        share.put(accessLevelFieldName, 'EDIT');
                    }
                }
            }
            if (blnAddShare) {
                sharesByParentId.get(recordToShareId).add(shareToAdd);
            }
        }
    }
}
 
Add Share Record Code
public class ApexSharing {
    private Map<String, ShareWrapper> records = new Map<String, ShareWrapper>();
    
    /**
     * builds share record based on sobject and adds to list
     * @param  shareRecord [sobject record to share]
     * @param  userOrGroup [user or group to share record with]
     * @param  shareLevel  [access level]
     * @param  cause       [sharing reason. defaults to "Manual Sharing"]
     * @return             [void]
     */
    public void add(SObject shareRecord, Id userOrGroup, String shareLevel, String cause) {
        // make sure we do not create a share for our record owner.
        if (shareRecord.get('OwnerId') == userOrGroup) {
            return;
        }
        String objName = shareRecord.getSObjectType().getDescribe().getName();
        SObject sh;
        Id parentId = (Id) shareRecord.get('Id');
        // custom objects are different than standard
        if(objName.endsWithIgnoreCase('__c')) {
            sh = newSObject(objName.replace('__c','__Share'));
            sh.put('AccessLevel', shareLevel);
            sh.put('ParentId', parentId);        }
        else {
            sh = newSObject(objName + 'Share');
            sh.put(objName + 'AccessLevel',shareLevel);
            sh.put(objName + 'Id', parentId);
            // AccountShare object has additional properties
            if(objName.equalsIgnoreCase('Account')) {
                // need to add access levels for Contacts, Opps and Cases to Account. if you
                // require EDIT for these child relationships, change here.
                sh.put('ContactAccessLevel', 'READ');
                sh.put('CaseAccessLevel', 'READ');
                sh.put('OpportunityAccessLevel', 'READ');
            }
        }

        // general data
        sh.put('UserOrGroupId', userOrGroup);
        if(cause != null) {
            sh.put('RowCause', cause);
        }
        // add to list
        ShareWrapper wrapper = records.get(objName);
        if (wrapper == null) {
            wrapper = new ShareWrapper(objName);
            records.put(objName, wrapper);
        }
        wrapper.add(sh);
    }
}

Then when you want to add your shares, you would simply execute this code

for (String objName : records.keyset()) {
    insert records.get(objName).getAllShares();
}

Removing  Apex Sharing by Access Level

For our business case, as mentioned previously, the same User can have more than 1 Role (and hence access) to the same record and subsequently can have one Role and access revoked while the other access remains. How can we identify a share for deletion given multiple share(s) can exist for same record/user.

  • Custom Object: Use the Apex Sharing Reason
  • Standard Object: Need to build a Map of records to remove and determine the level of access a User should have and ensure that the appropriate share records remain in place.
Mark a Record for Deletion Code

Use the remove method to mark a record for deletion. It will add the record to a Map of records keyed off object name. Then the actual deletion of records is done by the next snippet of code.

public class ApexSharing { 

    private Map<String, List<SObject>> recordsToDelete = new Map<String, List<SObject>>();

    public void remove(SObject shareRecord, Id userOrGroup, String shareLevel, String cause) {
        // do not share a record with its owner - it results in an error
        if (shareRecord.get('OwnerId') == userOrGroup) {
            // do nothing
            return;
        }

        String objName = shareRecord.getSObjectType().getDescribe().getName();
        SObject sh;
        Id parentId = (Id) shareRecord.get('Id');

        // custom objects are different than standard
        if(objName.endsWithIgnoreCase('__c')) {
            sh = newSObject(objName.replace('__c','__Share'));
            sh.put('AccessLevel', shareLevel);
            sh.put('ParentId', parentId);
        }
        else {
            sh = new SObject(objName + 'Share');
            sh.put(objName + 'AccessLevel',shareLevel);
            sh.put(objName + 'Id', parentId);
            // AccountShare object has additional properties
            if(objName.equalsIgnoreCase('Account')) {
                // need to add access levels for Contacts, Opps and Cases to Account. if you 
                // require EDIT for these child relationships, change here. 
                sh.put('ContactAccessLevel', 'READ');
                sh.put('CaseAccessLevel', 'READ');
                sh.put('OpportunityAccessLevel', 'READ');
            }
        }

        // general data
        sh.put('UserOrGroupId', userOrGroup);
        if(cause != null) {
            sh.put('RowCause', cause);
        }
        // mark record for deletion later. 
        if (recordsToDelete.get(objName) == null) {
            recordsToDelete.put(objName, new List<SObject>());
        }
        recordsToDelete.get(objName).add(sh);
    } 
}
Delete Shares Code

The above method will add the record whose share needs to be removed to a map keyed off Object Name. Once all records have been marked for deletion, you need to actually remove them. That can be achieved with this logic.

    public class ApexSharing {
    ....
    // this is for deleting standard objects for deletion. While it works for custom objects
    // equally well, it is unnecessary as the Apex Sharing Reason can help uniquely identify
    // the share to remove.
    public void unshare() {
        // now we want to remove all the shares we have marked for deletion.

        Map<String, SObject> mapSharesByKey = new Map<String, SObject>();
        Map<String, ShareRemoveHelper> helpers = new Map<String, ShareRemoveHelper>();
        
        for (String objName : recordsToDelete.keyset()) {
            // create a helper for each object type
            // ShareRemoveHelper creating a unique key to be used in deleting the correct record
            // unique key is conctenation of recordToShare Id, userOrGroup Id, access level, and row cause 
            ShareRemoveHelper helper = new ShareRemoveHelper(objName);
            helpers.put(objName, helper);
            for (SObject record : recordsToDelete.get(objName)) {
                String key = helper.add(record);
                mapSharesByKey.put(key, record);
            }
        }

        // now for each object type being removed, get list of helpers and build up our
        // query string
        for (String objName : helpers.keyset()) {
            ShareRemoveHelper helper = helpers.get(objName);
            List<SObject> sharesToRemove = new List<SObject>();
            String soqlQuery = 'select Id, RowCause, UserOrGroupId, ';
            soqlQuery += helper.parentIdFieldName + ', ' + helper.shareLevelFieldName +
                    ' from ' + helper.shareObjectName + ' where UserOrGroupId in (' +
                    helper.getGranteeIds() + ') and ' + helper.parentIdFieldName + ' in (' +
                    helper.getParentIds() + ') and ' + helper.shareLevelFieldName + ' in (' +
                    helper.getShareLevels() + ')';
            for (SObject sobj : Database.query(soqlQuery)) {
                String key = sobj.get(helper.parentIdFieldName) + '_' + sobj.get('RowCause') +
                        '_' + sobj.get('UserOrGroupId') + '_' + sobj.get(helper.shareLevelFieldName);
                key = key.toLowerCase();
                if (mapSharesByKey.get(key) != null) {
                    sharesToRemove.add(sobj);
                }
            }
            if (!sharesToRemove.isEmpty()) {
                delete sharesToRemove;
            }
        }    
    }

    public class ShareRemoveHelper {
        public String objName {get; set;}
        public String shareObjectName {get; set;}
        public String parentIdFieldName {get; set;}
        public String shareLevelFieldName {get; set;}
        public Map<String, SObject> shares {get; set;}
        public Set<String> granteeIds {get; set;}
        public Set<String> parentIds {get; set;}
        public Set<String> shareLevels {get; set;}

        public ShareRemoveHelper(String objName) {
            this.objName = objName;
            this.shareObjectName = objName.endsWith('__c') ?
                    objName.replace('__c', '__Share') : (objName + 'Share');
            this.parentIdFieldName = objName.endsWith('__c') ? 'ParentId' : (objName + 'Id') ;
            this.shareLevelFieldName = objName.endsWith('__c') ? 'AccessLevel' : (objName + 'AccessLevel');
            this.granteeIds = new Set<String>();
            this.parentIds = new Set<String>();
            this.shareLevels = new Set<String>();
            this.shares = new Map<String, SObject>();
        }

        public String add(SObject record) {
            String parentId = (String)record.get(parentIdFieldName);
            String shareLevel = (String)record.get(shareLevelFieldName);
            granteeIds.add((String)record.get('UserOrGroupId'));
            parentIds.add((String)parentId);
            shareLevels.add((String)shareLevel);
            String key = parentId + '_' + (record.get('RowCause') == null ? 'Manual' : record.get('RowCause')) +
                    '_' + record.get('UserOrGroupId') + '_' + shareLevel;
            key = key.toLowerCase();
            Log.log('=====> Adding object with Key = ' + key);
            shares.put(key, record);
            return key;
        }

        public String getGranteeIds() {
            return getQueryString(granteeIds);
        }

        public String getParentIds() {
            return getQueryString(parentIds);
        }

        public String getShareLevels() {
            return getQueryString(shareLevels);
        }

        private String getQueryString(Set<String> values) {
            String queryString = '';
            for (String value : values) {
                queryString += ''' + value + '',';
            }
            queryString = queryString.substringBeforeLast(',');
            return queryString;
        }
    }
}

So to put it all together, the code to create sharing records would look something like this for Accounts.

ApexSharing as = new ApexSharing();
List<Account> acctsForReadAccess = new List<Account>();
List<Account> acctsForEditAccess = new List<Account>();

// populate your lists

Id granteeId;
String accessLevel = 'READ';
String rowCause = 'Manual Sharing';
for (Account acct : acctsForReadAccess) {
     as.add(acct, granteeId, accessLevel, rowCause);
}

accessLevel = 'EDIT';
for (Account acct : acctsForEditAccess) {
     as.add(acct, granteeId, accessLevel, rowCause);
}

as.share();

And to delete records – it would look like this

ApexSharing as = new ApexSharing();
List<Account> acctsForReadAccess = new List<Account>();
List<Account> acctsForEditAccess = new List<Account>();

// populate lists

Id granteeId;
String accessLevel = 'READ';
String rowCause = 'Manual Sharing';
for (Account acct : acctsForReadAccess) {
     as.remove(acct, granteeId, accessLevel, rowCause);
}

accessLevel = 'EDIT';
for (Account acct : acctsForEditAccess) {
     as.remove(acct, granteeId, accessLevel, rowCause);
}
as.unshare();

I hope this is useful to other folks struggling with Apex Sharing.

BONUS: Governors Limit Issues

You are more than likely going to hit Governors Limit when building out a custom sharing model.  So here are a few tips to help you avoid them.

  • Avoid nested loops
  • Use Apex Queues to handle multiple records
  • Limit your SOQL queries to a certain number of records – use this in conjunction with #2.
  • Consider using standard Salesforce Groups and assigning the Share records to the Groups rather than the individual Users. Then when a Users access is revoked, remove their Group membership and hence their access to the shared records.

Need more help with Apex sharing or other Salesforce development? We can help!

about the author

OpFocus