Skip to main content

Field Level Security in Apex: WITH SECURITY_ENFORCED vs. Security.stripInaccessible

Every time your users access Lightning (web) component or Visualforce page the background Apex code is being run in user context. What does it mean? If there's with sharing keyword on your class definition or you are inheriting with sharing from another class, sharing rules are enforced. In other words SOQL query will return only records, that are visible for current user. And that's great, this is one security concern less. However object level security and field level security permissions are not respected, therefore results of database queries will contain fields, that current user doesn't have access to. There are 3 different ways, how to ensure your user will not see, what he's not supposed to see.
  • WITH SECURITY_ENFORCED clause on SOQL queries
  • Security class and its method stripInaccessible
  • DescribeFieldResult class and its method isAccessible
Let's inspect them one by one.

Let's get our playground ready

First, we need to get our laboratory ready for experiments. Imagine we are implementing Salesforce for soon-to-be-awesome company called "Miracle Workers". Their sales representatives are working with accounts and contacts, but they do not need access to contacts' mailing addresses. Finance guys need access only to account to issue invoices.

Therefore we have set up following sharing defaults:

ObjectSharing
AccountPublic read
ContactPrivate

And profiles:

Profile nameObject level securityField level security
Sales RepresentativeAccount: Read
Contact: Read, Edit
Account.Name: Read
Contact.Name: Read
Contact.Phone: Read
FinanceAccount: ReadAccount.Name: Read

Due to internal company processes some individuals will need access to contacts addresses, so we've created also one permission set:

Permission set nameObject level securityField level security
Contact AddressContact: ReadContact.MailingAddress: Read

One important aspect of our today's experiments is we will need to run these experiments in context of different users. Therefore we are going to use Salesforce testing framework and its great method System.runAs(User).

To skip unnecessary code, I will only claim, that I've created testing class FLSTest with @testSetup method creating following data.

Users:

NameProfilePermission set
William ShattnerSales Representative
Leonard NimoySales RepresentativeContact Address
DeForest KelleyFinance

Account: Sub Pop Records

Contacts:

NamePhoneMailingStreetOwner.Name
Eddie Vedder123456789Pearl st. 24William Shattner
Kurt Cobain987654321Nirvana st. 5Leonard Nimoy
Layne Staley555555555Chains st. 34DeForest Kelley

Simple SELECT

Just for the record, let me verify, that what I've been saying about field level security, is true with following test method.

@isTest
private static void testSelect() {
    Map<String, User> users = getUsers();
    Account wsAccount;
    Account lnAccount;
    Account dkAccount;
    System.runAs(users.get('William Shattner')) {
        wsAccount = [SELECT Name, (SELECT Name, Phone, MailingStreet FROM Contacts) FROM Account];
    }
    System.runAs(users.get('Leonard Nimoy')) {
        lnAccount = [SELECT Name, (SELECT Name, Phone, MailingStreet FROM Contacts) FROM Account];
    }
    System.runAs(users.get('DeForest Kelley')) {
        dkAccount = [SELECT Name, (SELECT Name, Phone, MailingStreet FROM Contacts) FROM Account];
    }
    System.debug(wsAccount);
    System.debug(wsAccount.Contacts);
    System.debug(lnAccount);
    System.debug(lnAccount.Contacts);
    System.debug(dkAccount);
    System.debug(dkAccount.Contacts);
}

Results:

Account:{Name=Sub Pop Records, Id=0013X00002UYISaQAP}
(Contact:{AccountId=0013X00002UYIRIQA5, Id=0033X00002tiiBMQAY, Name=Eddie Vedder, Phone=123456789, MailingStreet=Pearl st. 24})
Account:{Name=Sub Pop Records, Id=0013X00002UYISaQAP}
(Contact:{AccountId=0013X00002UYIRIQA5, Id=0033X00002tiiBNQAY, Name=Kurt Cobain, Phone=987654321, MailingStreet=Nirvana st. 5})
Account:{Name=Sub Pop Records, Id=0013X00002UYISaQAP}
(Contact:{AccountId=0013X00002UYIRIQA5, Id=0033X00002tiiBOQAY, Name=Layne Staley, Phone=555555555, MailingStreet=Chains st. 34})

As you can see, sharing rules are respected – Account Sub Pop Records is visible for all users, but each user sees just Contact he owns. On the other hand, object level security permissions were skipped as user DeForest Kelley sees Contact and field level security permissions were skipped as users William Shattner and Deforest Kelley can see Contact.MailingStreet.

WITH SECURITY_ENFORCED

First way to ensure object and field security permissions is WITH SECURITY_ENFORCED clause on SOQL query. This will raise exception in case SOQL query tries to access something, that's not visible for the user.

@isTest
private static void testSelectWithSecurityEnforced() {
    Map<String, User> users = getUsers();

    System.runAs(users.get('William Shattner')) {
        try {
            Account wsAccount = [SELECT Name, (SELECT Name, Phone, MailingStreet FROM Contacts) FROM Account WITH SECURITY_ENFORCED];
            System.debug(wsAccount);
            System.debug(wsAccount.Contacts);
        } catch (Exception ex) {
            System.debug(ex.getMessage());
        }
    }
    System.runAs(users.get('Leonard Nimoy')) {
        try {
            Account lnAccount = [SELECT Name, (SELECT Name, Phone, MailingStreet FROM Contacts) FROM Account WITH SECURITY_ENFORCED];
            System.debug(lnAccount);
            System.debug(lnAccount.Contacts);
        } catch (Exception ex) {
            System.debug(ex.getMessage());
        }
    }
    System.runAs(users.get('DeForest Kelley')) {
        try {
            Account dkAccount = [SELECT Name, (SELECT Name, Phone, MailingStreet FROM Contacts) FROM Account WITH SECURITY_ENFORCED];
            System.debug(dkAccount);
            System.debug(dkAccount.Contacts);
        } catch (Exception ex) {
            System.debug(ex.getMessage());
        }
    }
}

Results:

Insufficient permissions: secure query included inaccessible field
Account:{Name=Sub Pop Records, Id=0013X00002UYIkxQAH}
(Contact:{AccountId=0013X00002UYIkxQAH, Id=0033X00002tiiVNQAY, Name=Kurt Cobain, Phone=987654321, MailingStreet=Nirvana st. 5})
Insufficient permissions: secure query included inaccessible field

Well, it works, but is it really useful? In my opinion in most cases you need to know, that you've tried to query fields without sufficient permissions, but you also need your results! Ideally with unpopulated inaccessible fields.

Imagine we have Lightning (web) component used by sales representatives. Important part of that component is to display contacts. Using WITH SECURITY_ENFORCED would render this component useless for most of the sales representatives, even though you've only wanted to hide MailingAddress from them!

The only possible use case I see now is to cut off user from component completely, which should be handled in profiles and permission sets.

Brand new Security class and its stripInaccessible method

In Winter '20 release Salesforce has introduced Security class with powerful method stripInaccessible(accessCheckType, sourceRecords). This method strip inaccessible fields from records, that have already been retrieved or have been deserialized from other source. Salesforce itself even instructs to use this method to verify accessibility before inserting any record obtained from untrusted source. This slightly implies, that stripInaccessible also checks for sharing rules, but it is not!

@isTest
private static void testStripInaccessible() {
    Map<String, User> users = getUsers();
    SObjectAccessDecision wsStrippedRecords;
    SObjectAccessDecision lnStrippedRecords;
    SObjectAccessDecision dkStrippedRecords;
       
    System.runAs(users.get('William Shattner')) {
        wsStrippedRecords = Security.stripInaccessible(AccessType.READABLE, [SELECT Name, (SELECT Name, Phone, MailingStreet FROM Contacts) FROM Account]);
    }
    System.runAs(users.get('Leonard Nimoy')) {
        lnStrippedRecords = Security.stripInaccessible(AccessType.READABLE, [SELECT Name, (SELECT Name, Phone, MailingStreet FROM Contacts) FROM Account]);
    }
    System.runAs(users.get('DeForest Kelley')) {
        dkStrippedRecords = Security.stripInaccessible(AccessType.READABLE, [SELECT Name, (SELECT Name, Phone, MailingStreet FROM Contacts) FROM Account]);
    }
   System.debug(((List<Account>) wsStrippedRecords.getRecords())[0].Contacts);        
    System.debug(((List<Account>) lnStrippedRecords.getRecords())[0].Contacts);
    System.debug(((List<Account>) dkStrippedRecords.getRecords())[0].Contacts);
}

Results:

(Contact:{AccountId=0013X00002UYJh3QAH, Id=0033X00002tijeUQAQ, Name=Eddie Vedder, Phone=123456789})
(Contact:{AccountId=0013X00002UYJh3QAH, Id=0033X00002tijeVQAQ, Name=Kurt Cobain, Phone=987654321, MailingStreet=Nirvana st. 5})
()

The only draw back of this approach is, you will get "FATAL_ERROR System.SObjectException: SObject row was retrieved via SOQL without querying the requested field" exception, if you try to access stripped properties. Fortunately, there is a beautiful way, how to over come this. Following example is not really sophisticated, but clear enough to explain the concept.

@isTest
private static void testUnpopulateInaccessible() {
    Map<String, User> users = getUsers();
    SObjectAccessDecision wsStrippedRecords;
        
    System.runAs(users.get('William Shattner')) {
        wsStrippedRecords = Security.stripInaccessible(AccessType.READABLE, [SELECT Name, (SELECT Name, Phone, MailingStreet FROM Contacts) FROM Account]);
    }
    List<Contact>>contacts = ((List) wsStrippedRecords.getRecords())[0].Contacts;
    for (Contact contact : contacts) {
        for(String fieldName : wsStrippedRecords.getRemovedFields().get('Contact')) {
            contact.put(fieldName, null);
        }
    }
    System.debug(contacts);
}

Result:

(Contact:{AccountId=0013X00002UYJu5QAH, Id=0033X00002tijqBQAQ, Name=Eddie Vedder, Phone=123456789, MailingStreet=null})

If you are clever - and I think we've been clever so far - you will find this method way more flexible than WITH SECURITY_ENFORCED. You can also use it in the completely same way as WITH SECURITY_ENFORCED, if you raise your own exception in case SObjectAccessDecision.getRemovedFields() returns non empty map.

As said before, Security.stripInaccessible doesn't help with sharing. This means, when you are working with records, that were provided to you by the source you have no visibility over, you have to perform another check yourself.

DescribeFieldResult class

This is the oldest way to manage field level security access. It still works, but it's not as easy to use as Security.stripInaccessible. The thing is you have to check for each field yourself.

In following example we will perform semi-generic check of queried Contact records. We will pretend, that we won't have any information about what fields will be queried. On the other hand we will assume, there will be no subquery on related records.

To find out, which fields were actually queried, we are going to use method getPopulatedFieldsAsMap. Because this method doesn't consider field with null value to be populated field, we need to assess each record separately.

@isTest
private static void testIsAccessible() {
    Map<String, User> users = getUsers();
    List<Contact> wsContacts;
    System.runAs(users.get('William Shattner')) {
        wsContacts = [SELECT Name, Phone, MailingStreet FROM Contact];
        Map<String, Schema.SObjectField> fieldMap = Schema.SObjectType.Contact.fields.getMap();
        Map<String, Boolean> fieldToAccessibility = new Map<String, Boolean>();
        for (Contact contact : wsContacts) {
            Set<String> populatedFields = contact.getPopulatedFieldsAsMap().keySet();
            for (String fieldName : populatedFields) {
                Boolean isAccessible = fieldToAccessibility.get(fieldName);
                if (isAccessible == null) {
                    isAccessible = fieldMap.get(fieldName).getDescribe().isAccessible();
                    fieldToAccessibility.put(fieldName,isAccessible);
                }
                if (!isAccessible) {
                    contact.put(fieldName, null);
                }
            }
        }
     }
     System.debug(wsContacts);
}

Results:

(Contact:{Name=Eddie Vedder, Phone=123456789, MailingStreet=null, Id=0033X00002tjA7oQAE})

Benchmark

There is one last thing I am really interested in and that's performance. I've benchmarked all discussed approaches, even though each of them is doing something little different. Code below can be hugely improved for specific use case, therefore results are only indicative.

@isTest
private static void testBenchmark() {
    Map<String, User> users = getUsers();
    List<Contact> contactsToInsert = new List<Contact>();
    for(Integer i = 0; i < 1000; i++) {
        contactsToInsert.add(new Contact(
            FirstName = 'Chris',
            LastName = 'Cornell',
            Phone = '111111111',
            MailingStreet = 'Garden st. 63',
            OwnerId = users.get('William Shattner').Id
        ));
    }
    insert contactsToInsert;        
         
    //TEST WITH SECURITY_ENFORCED
    System.runAs(users.get('William Shattner')) {      
        try {
            List<Contact> wsContacts = [SELECT Name, Phone, MailingStreet FROM Contact WITH SECURITY_ENFORCED];
        } catch(Exception ex) {                
        }
    }
        
    //TEST Security class
    System.runAs(users.get('William Shattner')) {
        SObjectAccessDecision wsAccessDecision = Security.stripInaccessible(AccessType.READABLE, [SELECT Name, Phone, MailingStreet FROM Contact]);
        List<Contact> contacts = wsAccessDecision.getRecords();
        Set<String> removedFields = wsAccessDecision.getRemovedFields().get('Contact');
        for (Contact contact : contacts) {
            for(String fieldName : removedFields) {
                contact.put(fieldName, null);
             }
       }
    }
     
    //TEST DescribeFieldResult class
    System.runAs(users.get('William Shattner')) {      
        List<Contact> wsContacts = [SELECT Name, Phone, MailingStreet FROM Contact];
        Map<String, Schema.SObjectField> fieldMap = Schema.SObjectType.Contact.fields.getMap();
        Map<String, Boolean> fieldToAccessibility = new Map<String, Boolean>();
        for(Contact contact : wsContacts) {
            Set<String> populatedFields = contact.getPopulatedFieldsAsMap().keySet();
            for (String fieldName : populatedFields) {
                Boolean isAccessible = fieldToAccessibility.get(fieldName);
                if (isAccessible == null) {
                    isAccessible = fieldMap.get(fieldName).getDescribe().isAccessible();
                    fieldToAccessibility.put(fieldName,isAccessible);
                }
                if (!isAccessible) {
                    contact.put(fieldName, null);
                }
            }
        }            
    }
}

Results:

WITH SECURITY_ENFORCED:           6 ms
Security.stripInaccessible:       250 ms
DescribeFieldResult.isAccessible: 590 ms

I guess, we have all expected these result. Not only Security.stripInaccessible is easier to work with, but it is also way faster than DescribeFieldResult.isAccessible. WITH SECURITY_ENFORCED doesn't cost anything, but I struggle to find good use case for it.

Looking for an experienced Salesforce Architect?

  • Are you considering Salesforce for your company but unsure of where to begin?
  • Planning a Salesforce implementation and in need of seasoned expertise?
  • Already using Salesforce but not fully satisfied with the outcome?
  • Facing roadblocks in your Salesforce implementation and require assistance to progress?

Feel free to review my profile and reach out for tailored support to meet your needs!

Comments

  1. Thanks for such detailed explamation.

    Security.stripInaccessible is not returning removed field detail if inaccessible field is empty in all records.

    Example:
    User : ABC, Profile : MyProfile, permissionset: MyPermissionset
    Object : ObjA, Field : FieldA, FieldB, FieldC
    Access of FieldA is removed by permissionset MyPermissionset.
    There is single record in ObjA and FieldA is empty(NULL).

    Now, I called stripInaccessible() for records of ObjA and try to get removed fields by calling getRemovedFields() method.

    Expected result: It should return FieldA which is Inaccessible by user.
    Actual Result : It is retuning no field.
    Note: If FieldA has value(Non-empty) then it is working as expected.

    ReplyDelete
  2. WITH SECURITY_ENFORCED is valuable to ISV's who know that each field in many of the queries that the app makes are necessary, and the required check using isAccessable() is slower. Thanks for your analysis!

    ReplyDelete
  3. This is a superb write up and a very clever solution for adding back in null values on removed fields with getRemovedFields()! Thank you for also taking the time to test the performance and share the results. Seriously, it's one of the finest blog posts like this I've read in a while.

    To add onto what anonymous april 2020 notes, another aspect of the stripInaccessible method -- or rather a scenario not to use the stripInaccessible style -- is that it doesn’t "support AggregateResult SObject. If the source records are of AggregateResult SObject type, an exception is thrown." (Thanks again! I felt compelled to add to this post's usefulness!)

    ReplyDelete
  4. Please help me understand, that adding in NULL fields is a great solution, however, in the stripInaccessible and DescribeFieldResult in order to add NULL value, we have used PUT() on A LIST. If there is any correction, help me with the code.

    ReplyDelete

Post a Comment

About author

My photo
Jan Binder
Experienced Salesforce Technical Architect and Team Lead with a proven track record of delivering successful enterprise projects for major global companies across diverse industries, including automotive, oil & gas, and construction.