├── README.md └── src ├── classes ├── DMLManager.cls ├── DMLManager.cls-meta.xml ├── Test_DMLManager.cls └── Test_DMLManager.cls-meta.xml └── package.xml /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | The purpose of DMLManager is to act as a low-risk remediation to Apex that was not architected with CRUD/FLS enforcement in mind. There are limitations, outlined below, but in 95% of cases, the additional SOQL query and retrieved row increases are far better than the risk of extensive code refactoring. 3 | 4 | The topic is documented here: 5 | 6 | http://wiki.developerforce.com/index.php/Testing_CRUD_and_FLS_Enforcement 7 | 8 | and here: 9 | 10 | https://developer.salesforce.com/page/Enforcing_CRUD_and_FLS 11 | 12 | # Usage 13 | 14 | ``` 15 | List contactList = [SELECT Id, FirstName, LastName FROM Contact]; 16 | 17 | //Manipulate the contactList 18 | ... 19 | 20 | //Instead of calling "update contactList", use: 21 | DMLManager.updateAsUser(contactList); 22 | ``` 23 | 24 | (there are overloaded versions of insert/update/upsert/delete that take either a single SObject or a List) 25 | 26 | DMLManager.CRUDException is raised for respective CRUD permission failures 27 | 28 | DMLManager.FLSException is raised for respective FLS permission problems 29 | 30 | # Goals 31 | The security review team suggests using the ESAPI [SFDCAccessController](https://code.google.com/p/force-dot-com-esapi/source/browse/trunk/src/classes/SFDCAccessController.cls) for CRUD and FLS enforcement, but this implementation has serious limitations: 32 | * It performs DML on clones of the passed in collections of SObjects -- This results in the calling code not having access to the same object references that were ultimately used in the DML. In practice, this causes problems with Lists and other collections that are used when building Master-Detail and Lookups to a parent record persisted earlier in the transaction. 33 | * The insertAsUser and updateAsUser methods require a fieldsToSet collection -- this is an unreasonable burden to compute and trying to maintain a list of affected fields is a recipe for tightly coupled and unmaintainable code 34 | * There’s no implementation of upsertAsUser. 35 | 36 | # Limitations 37 | It's not all roses and gumdrops with DMLManager. There are some things to keep in mind: 38 | * Adds an extra SOQL query for each call to insertAsUser/updateAsUser/upsertAsUser for each distinct SObjectType in the passed in collection. Additionally, since each record in the passed in collection is retrieved, the maximum query row limit (50k) per transaction may also come into play 39 | * The current implementation of upsertAsUser does not take an External ID column as a possible key candidate for upsert. We’ll gladly accept pull requests if you want to implement this feature. 40 | * While the code is optimized to only query the database if the calling User’s profile has FLS restricting them from access to a nonzero number of fields, in practice, there are many fields on Standard Objects that aren’t writable, but are indiscernible from fields that are FLS-restricted. For example. Opportunity.ExpectedRevenue is not a true formula field (isCalculated() is false), but it’s not writable so it is considered a restricted field and results in us needing to requery the database for existing records each time an Opportunity is passed through updateAsUser/upsertAsUser. 41 | 42 | # Contributors 43 | 44 | Eric Whipple [@modernapple](https://github.com/modernapple) 45 | David Esposito [@daveespo](https://github.com/daveespo) 46 | Tom Fuda [@tfuda](https://github.com/tfuda) 47 | Scott Covert [@scottbcovert] (https://github.com/scottbcovert) -------------------------------------------------------------------------------- /src/classes/DMLManager.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c), PatronManager LLC 3 | * All rights reserved. 4 | * 5 | * Redistribution and use in source and binary forms, with or without modification, 6 | * are permitted provided that the following conditions are met: 7 | * 8 | * - Redistributions of source code must retain the above copyright notice, 9 | * this list of conditions and the following disclaimer. 10 | * - Redistributions in binary form must reproduce the above copyright notice, 11 | * this list of conditions and the following disclaimer in the documentation 12 | * and/or other materials provided with the distribution. 13 | * - Neither the name of the Patron Holdings nor the names of its contributors 14 | * may be used to endorse or promote products derived from this software without specific prior written permission. 15 | * 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 18 | * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL 19 | * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 20 | * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 21 | * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 22 | * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | **/ 25 | 26 | public inherited sharing class DMLManager { 27 | // Items in the following set must be entered in lower case 28 | private static Set exceptionValues = new Set {'id','isdeleted','createddate','systemmodstamp','lastmodifiedbyid','createdbyid','lastmodifieddate'}; 29 | 30 | private static Map>> cachedRestrictedFields = new Map>>(); 31 | 32 | public enum Operation {OP_INSERT,OP_UPDATE,OP_DELETE,OP_UPSERT} 33 | 34 | // CRUD/FLS-safe DML operations. These honor the CRUD and FLS permissions of the running user 35 | public static void insertAsUser(sObject obj){performDMLOperation(new List{obj},Operation.OP_INSERT);} 36 | public static void updateAsUser(sObject obj){performDMLOperation(new List{obj},Operation.OP_UPDATE);} 37 | public static void upsertAsUser(sObject obj){performDMLOperation(new List{obj},Operation.OP_UPSERT);} 38 | public static void deleteAsUser(sObject obj){performDMLOperation(new List{obj},Operation.OP_DELETE);} 39 | public static void mergeAsUser(sObject masterObj, SObject mergeObj){ performDMLMergeOperation(masterObj, new List{mergeObj});} 40 | public static void insertAsUser(List objList){performDMLOperation(objList,Operation.OP_INSERT);} 41 | public static void updateAsUser(List objList){performDMLOperation(objList,Operation.OP_UPDATE);} 42 | public static void upsertAsUser(List objList){performDMLOperation(objList,Operation.OP_UPSERT);} 43 | public static void deleteAsUser(List objList){performDMLOperation(objList,Operation.OP_DELETE);} 44 | public static void mergeAsUser(SObject masterObj, List mergeList){ performDMLMergeOperation(masterObj, mergeList);} 45 | 46 | // Pass-thru methods to desired DML operations as configured by consumer. 47 | // Use these sparingly, and only with good reason, since the DML operations are not CRUD/FLS safe 48 | public static void insertAsSystem(sObject obj){ dmlOperations.dmlInsert(new List {obj}); } 49 | public static void updateAsSystem(sObject obj){ dmlOperations.dmlUpdate(new List {obj}); } 50 | public static void upsertAsSystem(sObject obj){ dmlOperations.dmlUpsert(new List {obj}); } 51 | public static void deleteAsSystem(sObject obj){ dmlOperations.dmlDelete(new List {obj}); } 52 | public static void mergeAsSystem(SObject masterObj, SObject mergeObj){ dmlOperations.dmlMerge(masterObj, new List {mergeObj}); } 53 | public static void insertAsSystem(List objList){ dmlOperations.dmlInsert(objList); } 54 | public static void updateAsSystem(List objList){ dmlOperations.dmlUpdate(objList); } 55 | public static void upsertAsSystem(List objList){ dmlOperations.dmlUpsert(objList); } 56 | public static void deleteAsSystem(List objList){ dmlOperations.dmlDelete(objList); } 57 | public static void mergeAsSystem(SObject masterObj, List mergeList){ dmlOperations.dmlMerge(masterObj, mergeList); } 58 | 59 | // Custom Exception Classes 60 | public virtual class DMLManagerException extends Exception{ 61 | public SObjectType objType {get; private set;} 62 | public Operation op{get; private set;} 63 | } 64 | 65 | public class CRUDException extends DMLManagerException{ 66 | public CRUDException(SObjectType objType, Operation op){ 67 | this('Access Denied: ' + op + ' on ' + objType); 68 | this.objType = objType; 69 | this.op = op; 70 | } 71 | } 72 | 73 | public class FLSException extends DMLManagerException{ 74 | public SObjectField field{get; private set;} 75 | public FLSException(SObjectType objType, SObjectField field, Operation op){ 76 | this('Access Denied: ' + op + ' on ' + objType + '.' + field); 77 | this.objType = objType; 78 | this.op = op; 79 | this.field = field; 80 | } 81 | } 82 | 83 | private static void performDMLOperation(List objList, Operation dmlOperation){ 84 | Map> objTypeMap = analyzeDMLCollection(objList, dmlOperation); 85 | 86 | checkCRUDPermission(objTypeMap.keySet(),dmlOperation); 87 | 88 | if(dmlOperation == Operation.OP_INSERT){ 89 | for(SObject obj : objList){ 90 | checkCreateAction(obj); 91 | } 92 | } else if (dmlOperation == Operation.OP_UPDATE || dmlOperation == Operation.OP_UPSERT){ 93 | 94 | Map existingRecords = getExistingRecords(objTypeMap); 95 | 96 | for(SObject obj : objList){ 97 | SObject existingRecord = existingRecords.get(obj.Id); 98 | if(obj.id != null){ 99 | checkUpdateAction(obj,existingRecord); 100 | } else { 101 | checkCreateAction(obj); 102 | } 103 | } 104 | } 105 | // If no errors have been thrown to this point, execute the dml operation via pass-through methods. 106 | if(dmlOperation == Operation.OP_INSERT){insertAsSystem(objList);} 107 | else if (dmlOperation == Operation.OP_UPDATE){updateAsSystem(objList);} 108 | else if (dmlOperation == Operation.OP_UPSERT){upsertAsSystem(objList);} 109 | else if (dmlOperation == Operation.OP_DELETE){deleteAsSystem(objList);} 110 | } 111 | 112 | /** 113 | * Checks for CRUD on the master & merge objects and throws error if they don't have the proper object permission 114 | * If all checks pass, the merge operation is performed. 115 | * 116 | * @param masterObj SObject the data will be merged into 117 | * @param mergeList List of SObjects that will be merged into the masterObj 118 | * 119 | */ 120 | private static void performDMLMergeOperation(SObject masterObj, List mergeList) { 121 | 122 | // Make sure the master object can be updated. 123 | checkCRUDPermission(new Set{masterObj.getSObjectType()},Operation.OP_UPDATE); 124 | 125 | // Make sure the merge object can be deleted. 126 | checkCRUDPermission(new Set{mergeList[0].getSObjectType()},Operation.OP_DELETE); 127 | 128 | // If no errors have been thrown to this point, execute the merge operation. 129 | mergeAsSystem(masterObj, mergeList); 130 | } 131 | 132 | private static Map getFieldMapFromExistingSObject(SObject obj){ 133 | // Get actual fields present in object. The getPopulatedFieldsAsMap method removes implicit nulls. 134 | return obj.getPopulatedFieldsAsMap(); 135 | } 136 | 137 | private static void checkCreateAction(SObject obj){ 138 | List restrictedFields = cachedRestrictedFields.get(Operation.OP_INSERT).get(obj.getSObjectType()); 139 | //Save ourselves a trip through the loop below if there are no restricted fields 140 | if(restrictedFields == null || restrictedFields.isEmpty()){ 141 | return; 142 | } 143 | 144 | Map fieldsMap = getFieldMapFromExistingSObject(obj); 145 | 146 | // If any restricted fields are present, throw an exception 147 | for(String fieldName : restrictedFields){ 148 | if(fieldsMap.get(fieldName) != null){ // if any of the restricted fields are present in the candidate, throw an exception 149 | throw new FLSException(obj.getSObjectType(),obj.getSObjectType().getDescribe().fields.getMap().get(fieldName),Operation.OP_INSERT); 150 | } 151 | } 152 | } 153 | 154 | private static void checkUpdateAction(SObject obj, SObject existingRecord){ 155 | List restrictedFields = cachedRestrictedFields.get(Operation.OP_UPDATE).get(obj.getSObjectType()); 156 | //Save ourselves a trip through the loop below if there are no restricted fields 157 | if(restrictedFields == null || restrictedFields.isEmpty()){ 158 | return; 159 | } 160 | 161 | if(existingRecord == null){ 162 | throw new DMLManagerException('DMLManager ERROR: An existing record could not be found for object with Id = ' + obj.Id); 163 | } 164 | 165 | Map fieldsMap = getFieldMapFromExistingSObject(obj); 166 | 167 | // If any of the restricted values are present and have changed in the dml candidate object, throw an exception 168 | for(String fieldName : restrictedFields){ 169 | if(fieldsMap.get(fieldName) != null && fieldsMap.get(fieldName) != existingRecord.get(fieldName) ){ 170 | throw new FLSException(obj.getSObjectType(),obj.getSObjectType().getDescribe().fields.getMap().get(fieldName),Operation.OP_UPDATE); 171 | } 172 | } 173 | } 174 | 175 | 176 | // For update and upsert operations, retrieve a Map of all existing records, for each object that has an ID. 177 | // objects without an Id are skipped, because there is no existing record in the database. 178 | private static Map getExistingRecords(Map> objTypeMap){ 179 | Map result = new Map(); 180 | 181 | Map> operationRestrictedFields = cachedRestrictedFields.get(Operation.OP_UPDATE); 182 | 183 | for(SObjectType objType : objTypeMap.keySet()){ 184 | List restrictedFields = operationRestrictedFields.get(objType); 185 | 186 | if(restrictedFields == null || restrictedFields.isEmpty()){ 187 | continue; 188 | } 189 | 190 | List seenIds = objTypeMap.get(objType); 191 | if(seenIds.isEmpty()){ 192 | continue; 193 | } 194 | 195 | String fieldList = String.join(restrictedFields,','); 196 | result.putAll((Database.query('SELECT ' + fieldList + ' FROM ' + objType.getDescribe().getName() + ' WHERE Id IN :seenIds'))); 197 | } 198 | 199 | return result; 200 | } 201 | 202 | // Check CRUD permissions for the current user on the object 203 | private static void checkCRUDPermission(Set objTypeList, Operation dmlOperation){ 204 | for(SObjectType objType : objTypeList){ 205 | DescribeSObjectResult describeObject = objType.getDescribe(); 206 | if((dmlOperation == Operation.OP_INSERT && !describeObject.isCreateable()) || 207 | (dmlOperation == Operation.OP_UPDATE && !describeObject.isUpdateable()) || 208 | (dmlOperation == Operation.OP_DELETE && !describeObject.isDeletable()) || 209 | (dmlOperation == Operation.OP_UPSERT && !(describeObject.isCreateable() && describeObject.isUpdateable()))) { 210 | throw new CRUDException(objType,dmlOperation); 211 | } 212 | } 213 | } 214 | 215 | // Get a Map of all the object types in the dml request and the list of fields for each 216 | // that the current user cannot update, based on FLS security settings 217 | private static Map> analyzeDMLCollection(List objList, Operation dmlOperation){ 218 | Map> result = new Map>(); 219 | 220 | for(SObject obj : objList){ 221 | ensureRestrictedFieldsEntry(obj, dmlOperation); 222 | 223 | List seenIds = result.get(obj.getSObjectType()); 224 | if(seenIds == null){ 225 | seenIds = new List(); 226 | result.put(obj.getSObjectType(),seenIds); 227 | } 228 | 229 | if(obj.Id == null){ 230 | continue; 231 | } 232 | 233 | seenIds.add(obj.Id); 234 | 235 | } 236 | return result; 237 | } 238 | 239 | private static void ensureRestrictedFieldsEntry(SObject obj, Operation dmlOperation){ 240 | if(dmlOperation == Operation.OP_UPSERT){ 241 | ensureRestrictedFields(obj,Operation.OP_INSERT); 242 | ensureRestrictedFields(obj,Operation.OP_UPDATE); 243 | } 244 | else{ 245 | ensureRestrictedFields(obj,dmlOperation); 246 | } 247 | } 248 | 249 | private static void ensureRestrictedFields(SObject obj, Operation dmlOperation){ 250 | Map> operationRestrictedFields = cachedRestrictedFields.get(dmlOperation); 251 | if(operationRestrictedFields == null){ 252 | operationRestrictedFields = new Map>(); 253 | cachedRestrictedFields.put(dmlOperation,operationRestrictedFields); 254 | } 255 | 256 | if(!operationRestrictedFields.containsKey(obj.getSObjectType())){ 257 | 258 | DescribeSObjectResult describeObject = obj.getSObjectType().getDescribe(); 259 | 260 | Map objectFields = describeObject.fields.getMap(); 261 | 262 | List restrictedFields = new List(); 263 | 264 | for(String nm : objectFields.keyset()){ 265 | if(!exceptionValues.contains(nm.toLowerCase())){ 266 | DescribeFieldResult fr = objectFields.get(nm).getDescribe(); 267 | if((!fr.isCalculated()) && ((dmlOperation == Operation.OP_INSERT && !fr.isCreateable()) || 268 | (dmlOperation == Operation.OP_UPDATE && !fr.isUpdateable())) 269 | ){ 270 | restrictedFields.add(fr.getName()); 271 | } // there is not an isDeletable method at the field level 272 | } 273 | } 274 | operationRestrictedFields.put(obj.getSObjectType(),restrictedFields); 275 | } 276 | } 277 | 278 | // If the consumer desires a customized DML experience, including mock SObject unit testing: 279 | // 1. Create an Apex class that either extends SimpleDML and overrides the method(s) you wish to customize 280 | // or create a new Apex class that implements the IDML interface 281 | // 2. Set DMLManager.dmlOperations property to an instance of the Apex class 282 | // 3. Execute the appropriate *AsUser or *AsSystem methods as usual 283 | private static IDML dmlOperations = new SimpleDML(); 284 | 285 | public static void setDMLImplementation(IDML dmlImplementation) { 286 | dmlOperations = dmlImplementation; 287 | } 288 | 289 | // "Operation" suffix needed since the root DML operation name is an Apex keyword. 290 | public interface IDML 291 | { 292 | void dmlInsert(List objList); 293 | void dmlUpdate(List objList); 294 | void dmlDelete(List objList); 295 | void dmlMerge(SObject masterRecord, List objList); 296 | void dmlUpsert(List objList); 297 | } 298 | 299 | public inherited sharing virtual class SimpleDML 300 | implements IDML 301 | { 302 | public virtual void dmlInsert(List objList) 303 | { 304 | insert objList; 305 | } 306 | public virtual void dmlUpdate(List objList) 307 | { 308 | update objList; 309 | } 310 | public virtual void dmlDelete(List objList) 311 | { 312 | delete objList; 313 | } 314 | public virtual void dmlMerge(SObject masterRecord, List objList) 315 | { 316 | Database.merge(masterRecord, objList); 317 | } 318 | public virtual void dmlUpsert(List objList) 319 | { 320 | upsert objList; 321 | } 322 | } 323 | } -------------------------------------------------------------------------------- /src/classes/DMLManager.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/Test_DMLManager.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c), PatronManager LLC 3 | * All rights reserved. 4 | * 5 | * Redistribution and use in source and binary forms, with or without modification, 6 | * are permitted provided that the following conditions are met: 7 | * 8 | * - Redistributions of source code must retain the above copyright notice, 9 | * this list of conditions and the following disclaimer. 10 | * - Redistributions in binary form must reproduce the above copyright notice, 11 | * this list of conditions and the following disclaimer in the documentation 12 | * and/or other materials provided with the distribution. 13 | * - Neither the name of the Patron Holdings nor the names of its contributors 14 | * may be used to endorse or promote products derived from this software without specific prior written permission. 15 | * 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 18 | * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL 19 | * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 20 | * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 21 | * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 22 | * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 23 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | **/ 25 | 26 | @isTest 27 | private class Test_DMLManager { 28 | 29 | @TestSetup 30 | static void testSetup() { 31 | createRestrictedUser(); 32 | } 33 | 34 | /** 35 | * Creates a User that has only "Read" access to Accounts, Contacts and Opps 36 | */ 37 | private static void createRestrictedUser() { 38 | // PMGR-9655 - Starting with Spring '21, the "Read Only" profile may not exist. We should default to using the 39 | // new "Minimum Access - Salesforce" profile, falling back to "Read Only" otherwise. 40 | List profileList = [SELECT Id, Name FROM Profile WHERE Name IN ('Minimum Access - Salesforce', 'Read Only') ORDER BY Name]; 41 | System.assert( 42 | profileList.size() >= 1, 43 | 'Unable to create a "restricted" User for testing purposes because we are not able to find the required User Profiles.' 44 | ); 45 | User restrictedUser = new User( 46 | Alias = 'standt', 47 | Email='standarduser@testorg.com', 48 | EmailEncodingKey='UTF-8', 49 | LastName='Testing', 50 | LanguageLocaleKey='en_US', 51 | LocaleSidKey='en_US', 52 | // Use the first Profile in the list. Because we've ordered by name, this causes us to use 53 | // "Minimum Access - Salesforce" if it's present. 54 | ProfileId = profileList[0].Id, 55 | TimeZoneSidKey='America/Los_Angeles', 56 | Username='crudmanageruser.' + Datetime.now().getTime() + '@testorg.com' // Generate a random username 57 | ); 58 | insert restrictedUser; 59 | 60 | // Create a Permission Set that grants "Read" access to Account, Contact and Opportunity 61 | PermissionSet ps = new PermissionSet(Label = 'Restricted User', Name = 'RestrictedUser'); 62 | insert ps; 63 | 64 | List objectPerms = new List(); 65 | objectPerms.add(createObjectPerms(ps.Id, 'Account', true, false, false, false)); 66 | objectPerms.add(createObjectPerms(ps.Id, 'Contact', true, false, false, false)); 67 | objectPerms.add(createObjectPerms(ps.Id, 'Opportunity', true, false, false, false)); 68 | insert objectPerms; 69 | 70 | // Assign this perm set to our restricted user 71 | PermissionSetAssignment psa = new PermissionSetAssignment(AssigneeId = restrictedUser.Id, PermissionSetId = ps.Id); 72 | insert psa; 73 | } 74 | 75 | private static ObjectPermissions createObjectPerms( 76 | Id parentId, String objectType, Boolean canRead, Boolean canCreate, Boolean canEdit, Boolean canDelete 77 | ) { 78 | return new ObjectPermissions( 79 | ParentId = parentId, 80 | SobjectType = objectType, 81 | PermissionsRead = canRead, 82 | PermissionsCreate = canCreate, 83 | PermissionsEdit = canEdit, 84 | PermissionsDelete = canDelete 85 | ); 86 | } 87 | 88 | private static User getRestrictedUser() { 89 | return [SELECT Id FROM User WHERE Username LIKE 'crudmanageruser%']; 90 | } 91 | 92 | static testMethod void systemInsert(){ 93 | Opportunity o1 = new Opportunity(Name='Original1 Opp',StageName='Won',CloseDate=Date.today()); 94 | 95 | User restrictedUser = getRestrictedUser(); 96 | 97 | System.runAs(restrictedUser){ 98 | DMLManager.insertAsSystem(new Opportunity[]{o1}); 99 | } 100 | } 101 | 102 | static testMethod void systemUpdate(){ 103 | Account a1 = new Account(Name='Apple Account'); 104 | 105 | System.runAs(new User(Id = UserInfo.getUserId())){ 106 | insert a1; 107 | } 108 | 109 | User restrictedUser = getRestrictedUser(); 110 | 111 | System.runAs(restrictedUser){ 112 | a1.Name = 'Apple Updated'; 113 | DMLManager.updateAsSystem(new Account[]{a1}); 114 | Account a1Reload = [SELECT Name FROM Account WHERE Id = :a1.Id]; 115 | System.assertEquals('Apple Updated', a1Reload.Name); 116 | } 117 | } 118 | 119 | static testMethod void systemUpsert(){ 120 | Account a1 = new Account(Name='Apple Account'); 121 | 122 | System.runAs(new User(Id = UserInfo.getUserId())){ 123 | insert a1; 124 | } 125 | 126 | Account a1Clone = new Account(Id = a1.Id, Name= 'Apple Updated'); 127 | 128 | User restrictedUser = getRestrictedUser(); 129 | 130 | System.runAs(restrictedUser){ 131 | DMLManager.upsertAsSystem(new Account[]{a1Clone}); 132 | Account a1Reload = [SELECT Name FROM Account WHERE Id = :a1.Id]; 133 | System.assertEquals('Apple Updated', a1Reload.Name); 134 | } 135 | } 136 | 137 | static testMethod void systemDelete(){ 138 | User restrictedUser = getRestrictedUser(); 139 | 140 | System.runAs(restrictedUser){ 141 | Account a1 = new Account(Name='Apple Account'); 142 | insert a1; 143 | DMLManager.deleteAsSystem(new Account[]{a1}); 144 | } 145 | } 146 | 147 | @IsTest 148 | static void mergeAsSystem_Expect_Success(){ 149 | // Insert a master account and two merge accounts 150 | List accList = new List{ 151 | new Account(Name = 'Master Account'), 152 | new Account(Name = 'Merge One'), 153 | new Account(Name = 'Merge Two') 154 | 155 | }; 156 | 157 | User restrictedUser = getRestrictedUser(); 158 | 159 | System.runAs(restrictedUser){ 160 | insert accList; 161 | 162 | Account masterAcct = [SELECT Id, Name FROM Account WHERE Name = 'Master Account']; 163 | List mergeList = [SELECT Id, Name FROM Account WHERE Name LIKE 'Merge%']; 164 | System.assertEquals(2, mergeList.size()); 165 | 166 | DMLManager.mergeAsSystem(masterAcct, mergeList); 167 | } 168 | 169 | // Make sure 'Master Account' is still there. 170 | Account masterAcct = [SELECT Id FROM Account WHERE Name = 'Master Account']; 171 | 172 | //Make sure merge accounts are gone 173 | List mergeList = [SELECT Id FROM Account WHERE Name LIKE 'Merge%']; 174 | System.assertEquals(0, mergeList.size()); 175 | 176 | } 177 | 178 | @IsTest 179 | static void mergeAsSystem_When_PassingASingleSObject_Expect_Success() { 180 | // Insert a master account and two merge accounts 181 | List accList = new List{ 182 | new Account(Name = 'Master Account'), 183 | new Account(Name = 'Merge One') 184 | }; 185 | 186 | User restrictedUser = getRestrictedUser(); 187 | 188 | System.runAs(restrictedUser){ 189 | insert accList; 190 | 191 | Account masterAcct = [SELECT Id, Name FROM Account WHERE Name = 'Master Account']; 192 | List mergeList = [SELECT Id, Name FROM Account WHERE Name LIKE 'Merge%']; 193 | System.assertEquals(1, mergeList.size()); 194 | 195 | DMLManager.mergeAsSystem(masterAcct, mergeList.get(0)); 196 | } 197 | 198 | // Make sure 'Master Account' is still there. 199 | Account masterAcct = [SELECT Id FROM Account WHERE Name = 'Master Account']; 200 | 201 | //Make sure merge accounts are gone 202 | List mergeList = [SELECT Id FROM Account WHERE Name LIKE 'Merge%']; 203 | System.assertEquals(0, mergeList.size()); 204 | } 205 | 206 | static testMethod void flsRestrictedInsert(){ 207 | Campaign c1 = new Campaign(Name = 'Test1 Campaign'); 208 | System.runAs(new User(Id = UserInfo.getUserId())){ 209 | insert new Campaign[]{c1}; 210 | } 211 | 212 | Opportunity o1 = new Opportunity(Name='Original1 Opp',CampaignId=c1.Id,StageName='Won',CloseDate=Date.today()); 213 | 214 | User restrictedUser = getRestrictedUser(); 215 | 216 | // Grant "Create" perm, in addition to "Read" 217 | assignObjectPermission(restrictedUser,'Opportunity',true,false,false); 218 | 219 | System.runAs(restrictedUser){ 220 | try{ 221 | DMLManager.insertAsUser(new Opportunity[]{o1}); 222 | System.assert(false,'Expected a DML Error! Restricted User shouldn\'t be able to insert Opportunity.CampaignId'); 223 | } 224 | catch(DMLManager.FLSException flse){ 225 | //expected 226 | System.assertEquals(Opportunity.SObjectType,flse.objType); 227 | System.assertEquals(DMLManager.Operation.OP_INSERT,flse.op); 228 | System.assertEquals(Opportunity.SObjectType.fields.CampaignId,flse.field); 229 | } 230 | } 231 | 232 | } 233 | 234 | static testMethod void flsUnrestrictedUpsert(){ 235 | Campaign c1 = new Campaign(Name = 'Test1 Campaign'); 236 | Campaign c2 = new Campaign(Name = 'Test2 Campaign'); 237 | System.runAs(new User(Id = UserInfo.getUserId())){ 238 | insert new Campaign[]{c1,c2}; 239 | } 240 | 241 | Opportunity o1 = new Opportunity(Name='Original1 Opp',CampaignId=c1.Id,StageName='Won',CloseDate=Date.today()); 242 | DMLManager.upsertAsUser(new Opportunity[]{o1}); 243 | System.assert(o1.Id != null); 244 | 245 | o1.Name='Updated1 Opp'; 246 | o1.CampaignId = c2.Id; 247 | 248 | Opportunity o2 = new Opportunity(Name='Test2 Opp',CampaignId=c2.Id,StageName='Won',CloseDate=Date.today()); 249 | 250 | DMLManager.upsertAsUser(new Opportunity[]{o1,o2}); 251 | 252 | List reload = [SELECT Id, Name, CampaignId FROM Opportunity ORDER BY Id]; 253 | System.assertEquals(reload.size(),2); 254 | System.assertEquals('Updated1 Opp',reload[0].Name); 255 | System.assertEquals(c2.Id,reload[0].CampaignId); 256 | System.assertEquals('Test2 Opp',reload[1].Name); 257 | System.assertEquals(c2.Id,reload[1].CampaignId); 258 | System.assert(reload[1].Id != null); 259 | } 260 | 261 | static testMethod void flsRestrictedUpsert(){ 262 | Campaign c1 = new Campaign(Name = 'Test1 Campaign'); 263 | Campaign c2 = new Campaign(Name = 'Test2 Campaign'); 264 | Opportunity o1 = new Opportunity(Name='Original1 Opp',CampaignId=c1.Id,StageName='Won',CloseDate=Date.today()); 265 | 266 | System.runAs(new User(Id = UserInfo.getUserId())){ 267 | insert new SObject[]{c1,c2,o1}; 268 | } 269 | 270 | System.assert(o1.Id != null); 271 | 272 | o1.Name='Updated1 Opp'; 273 | o1.CampaignId = c2.Id; 274 | 275 | Opportunity o2 = new Opportunity(Name='Test2 Opp',CampaignId=c2.Id,StageName='Won',CloseDate=Date.today()); 276 | 277 | User restrictedUser = getRestrictedUser(); 278 | 279 | // Grant "Create" and "Edit" perm, in addition to "Read" 280 | assignObjectPermission(restrictedUser,'Opportunity',true,true,false); 281 | 282 | System.runAs(restrictedUser){ 283 | try{ 284 | DMLManager.upsertAsUser(new Opportunity[]{o1,o2}); 285 | System.assert(false,'Expected a DML Error! Restricted User shouldn\'t be able to update Opportunity.CampaignId'); 286 | } 287 | catch(DMLManager.FLSException flse){ 288 | //expected 289 | System.assertEquals(Opportunity.SObjectType,flse.objType); 290 | System.assertEquals(DMLManager.Operation.OP_UPDATE,flse.op); 291 | System.assertEquals(Opportunity.SObjectType.fields.CampaignId,flse.field); 292 | } 293 | } 294 | 295 | } 296 | 297 | //Tests that FLS is enforced even if field is not selected in SOQL query 298 | static testMethod void flsRestrictedUpdateOfFieldNotSelected(){ 299 | Campaign c1 = new Campaign(Name = 'Test1 Campaign'); 300 | Campaign c2 = new Campaign(Name = 'Test2 Campaign'); 301 | Opportunity o1 = new Opportunity(Name='Original1 Opp',CampaignId=c1.Id,StageName='Won',CloseDate=Date.today()); 302 | 303 | System.runAs(new User(Id = UserInfo.getUserId())){ 304 | insert new SObject[]{c1,c2,o1}; 305 | } 306 | 307 | System.assert(o1.Id != null); 308 | 309 | Opportunity o1Reload = [SELECT Id, Name FROM Opportunity WHERE Id = :o1.Id]; 310 | o1Reload.Name='Updated1 Opp'; 311 | o1Reload.CampaignId = c2.Id; 312 | 313 | User restrictedUser = getRestrictedUser(); 314 | 315 | // Grant "Create" and "Edit" perm, in addition to "Read" 316 | assignObjectPermission(restrictedUser,'Opportunity',true,true,false); 317 | 318 | System.runAs(restrictedUser){ 319 | try{ 320 | DMLManager.updateAsUser(new Opportunity[]{o1Reload}); 321 | System.assert(false,'Expected a DML Error! Restricted User shouldn\'t be able to update Opportunity.CampaignId'); 322 | } 323 | catch(DMLManager.FLSException flse){ 324 | //expected 325 | System.assertEquals(Opportunity.SObjectType,flse.objType); 326 | System.assertEquals(DMLManager.Operation.OP_UPDATE,flse.op); 327 | System.assertEquals(Opportunity.SObjectType.fields.CampaignId,flse.field); 328 | } 329 | } 330 | } 331 | 332 | static testMethod void crudUnrestrictedInsertUpdateDelete(){ 333 | Campaign c1 = new Campaign(Name='Test1 Campaign'); 334 | DMLManager.insertAsUser(c1); 335 | 336 | //Would blow up if the Campaign wasn't inserted (List has no rows for assignment) 337 | Campaign c1Reload = [SELECT Id, Name, StartDate FROM Campaign WHERE Id = :c1.Id]; 338 | System.assert(c1Reload.StartDate == null); 339 | 340 | c1Reload.StartDate = Date.today(); 341 | 342 | DMLManager.updateAsUser(c1Reload); 343 | 344 | c1Reload = [SELECT Id, Name, StartDate FROM Campaign WHERE Id = :c1.Id]; 345 | System.assertEquals(Date.today(),c1Reload.StartDate); 346 | 347 | DMLManager.deleteAsUser(c1Reload); 348 | 349 | List reloaded = [SELECT Id, Name FROM Campaign]; 350 | System.assertEquals(0,reloaded.size()); 351 | } 352 | 353 | static testMethod void crudRestrictedInsertUpdateDelete(){ 354 | User restrictedUser = getRestrictedUser(); 355 | 356 | Campaign c1 = new Campaign(Name='Test1 Campaign'); 357 | System.runAs(new User(Id = UserInfo.getUserId())){ 358 | insert c1; 359 | } 360 | 361 | System.runAs(restrictedUser) { 362 | // First try to insert a new object (including field two) 363 | try{ 364 | Campaign c2 = new Campaign(Name='Test2 Campaign'); 365 | DMLManager.insertAsUser(c2); 366 | System.assert(false,'Expected a DML Error!, Restricted User shouldn\'t be able to insert a Campaign'); 367 | } 368 | catch(DMLManager.CRUDException crude){ 369 | //expected 370 | System.assertEquals(Campaign.SObjectType,crude.objType); 371 | System.assertEquals(DMLManager.Operation.OP_INSERT,crude.op); 372 | } 373 | 374 | // Second, try to update the already inserted (previously) object, including field two 375 | try{ 376 | c1.Name = 'Updated1 Campaign'; 377 | DMLManager.updateAsUser(c1); 378 | System.assert(false,'Expected a DML Error!, Restricted User shouldn\'t be able to update a Campaign'); 379 | } 380 | catch(DMLManager.CRUDException crude){ 381 | //expected 382 | System.assertEquals(Campaign.SObjectType,crude.objType); 383 | System.assertEquals(DMLManager.Operation.OP_UPDATE,crude.op); 384 | } 385 | 386 | try{ 387 | DMLManager.deleteAsUser(c1); 388 | System.assert(false,'Expected a DML Error!, Restricted User shouldn\'t be able to delete a Campaign'); 389 | } 390 | catch(DMLManager.CRUDException crude){ 391 | //expected 392 | System.assertEquals(Campaign.SObjectType,crude.objType); 393 | System.assertEquals(DMLManager.Operation.OP_DELETE,crude.op); 394 | } 395 | } 396 | } 397 | 398 | @IsTest 399 | static void mergeAsUser_When_UserHasCorrectPermissions_Expect_Success(){ 400 | // Insert a master account and two merge accounts 401 | List accList = new List{ 402 | new Account(Name = 'Master Account'), 403 | new Account(Name = 'Merge One'), 404 | new Account(Name = 'Merge Two') 405 | }; 406 | 407 | User restrictedUser = getRestrictedUser(); 408 | 409 | // Grant "Update" and "Delete" permissions to the Account, in addition to "Read" 410 | assignObjectPermission(restrictedUser,'Account',false,true,true); 411 | 412 | System.runAs(restrictedUser){ 413 | insert accList; 414 | 415 | Account masterAcct = [SELECT Id, Name FROM Account WHERE Name = 'Master Account']; 416 | List mergeList = [SELECT Id, Name FROM Account WHERE Name LIKE 'Merge%']; 417 | System.assertEquals(2, mergeList.size()); 418 | 419 | DMLManager.mergeAsUser(masterAcct, mergeList); 420 | } 421 | 422 | // Make sure 'Master Account' is still there. 423 | Account masterAcct = [SELECT Id FROM Account WHERE Name = 'Master Account']; 424 | 425 | //Make sure merge accounts are gone 426 | List mergeList = [SELECT Id FROM Account WHERE Name LIKE 'Merge%']; 427 | System.assertEquals(0, mergeList.size()); 428 | 429 | } 430 | 431 | @IsTest 432 | static void mergeAsUser_When_UserDoesNotHaveEditPermission_Expect_Error(){ 433 | // Insert a master account and two merge accounts 434 | List accList = new List{ 435 | new Account(Name = 'Master Account'), 436 | new Account(Name = 'Merge One'), 437 | new Account(Name = 'Merge Two') 438 | }; 439 | 440 | User restrictedUser = getRestrictedUser(); 441 | 442 | // Grant "Create" perms on Account in addition to "Read", but not "Edit" or "Delete" 443 | assignObjectPermission(restrictedUser,'Account',true,false,false); 444 | 445 | System.runAs(restrictedUser){ 446 | insert accList; 447 | 448 | Account masterAcct = [SELECT Id, Name FROM Account WHERE Name = 'Master Account']; 449 | List mergeList = [SELECT Id, Name FROM Account WHERE Name LIKE 'Merge%']; 450 | System.assertEquals(2, mergeList.size()); 451 | 452 | try { 453 | DMLManager.mergeAsUser(masterAcct, mergeList); 454 | System.assert(false,'Expected a DML Error, Restricted User shouldn\'t be able to merge Account because they don\'t have EDIT permission'); 455 | } catch (DMLManager.CRUDException crudex){ 456 | //expected 457 | System.assertEquals(Account.SObjectType,crudex.objType); 458 | System.assertEquals(DMLManager.Operation.OP_UPDATE,crudex.op); 459 | } 460 | } 461 | 462 | // 'Master Account' and merge accounts should still be there. 463 | accList = [SELECT Id FROM Account]; 464 | System.assertEquals(3, accList.size()); 465 | 466 | } 467 | 468 | @IsTest 469 | static void mergeAsUser_When_UserDoesNotHaveDeletePermission_Expect_Error(){ 470 | // Insert a master account and two merge accounts 471 | List accList = new List{ 472 | new Account(Name = 'Master Account'), 473 | new Account(Name = 'Merge One'), 474 | new Account(Name = 'Merge Two') 475 | }; 476 | 477 | User restrictedUser = getRestrictedUser(); 478 | 479 | // Grant "Create" and "Edit" perms on Account in addition to "Read", but not "Delete" 480 | assignObjectPermission(restrictedUser,'Account',true,true,false); 481 | 482 | System.runAs(restrictedUser){ 483 | insert accList; 484 | 485 | Account masterAcct = [SELECT Id, Name FROM Account WHERE Name = 'Master Account']; 486 | List mergeList = [SELECT Id, Name FROM Account WHERE Name LIKE 'Merge%']; 487 | System.assertEquals(2, mergeList.size()); 488 | 489 | try { 490 | DMLManager.mergeAsUser(masterAcct, mergeList); 491 | System.assert(false,'Expected a DML Error, Restricted User shouldn\'t be able to merge Account because they don\'t have DELETE permission'); 492 | } catch (DMLManager.CRUDException crudex){ 493 | //expected 494 | System.assertEquals(Account.SObjectType,crudex.objType); 495 | System.assertEquals(DMLManager.Operation.OP_DELETE,crudex.op); 496 | } 497 | } 498 | 499 | // 'Master Account' and merge accounts should still be there. 500 | accList = [SELECT Id FROM Account]; 501 | System.assertEquals(3, accList.size()); 502 | 503 | } 504 | 505 | static testmethod void testFailedUpdateWithErroneousId(){ 506 | Campaign c1 = new Campaign(Name = 'Test1 Campaign'); 507 | Id cId = null; 508 | 509 | insert new SObject[]{c1}; 510 | cId = c1.Id; 511 | delete c1; 512 | 513 | try{ 514 | Campaign c1Resurrected = new Campaign(Id = cId, Name = 'Resurrected Campaign'); 515 | DMLManager.upsertAsUser(c1Resurrected); 516 | System.assert(false,'Expected a DMLManagerException! Attempting to update a record with an erroneous Id should fail'); 517 | } 518 | catch(DMLManager.DMLManagerException dmle){ 519 | //expected 520 | System.assert(dmle.getMessage().contains('An existing record could not be found')); 521 | } 522 | } 523 | 524 | @IsTest 525 | static void dmlOperation_When_CustomInertDMLOperationsAreSpecified_Expect_NoChangesWithinServer() { 526 | 527 | Contact ctc = new Contact(); 528 | ctc.FirstName = 'Jeremiah'; 529 | ctc.LastName = 'Matthews'; 530 | String expectedLastName = ctc.LastName; 531 | 532 | // ------ First let's clearly assert the default DML operation is working ------ 533 | DMLManager.insertAsUser(ctc); 534 | 535 | List actualContactList = [select Id from Contact where FirstName= :ctc.FirstName and LastName= :ctc.LastName]; 536 | System.assertEquals(1, actualContactList.size(), 'The Salesforce database should contain the new Contact.'); 537 | 538 | // ------ Now let's test the DML Operation injection ------ 539 | ctc.LastName = 'Long'; 540 | 541 | // Injecting the inert DML class 542 | DMLManager.setDMLImplementation(new InertDML()); 543 | 544 | DMLManager.updateAsUser(ctc); 545 | 546 | // Since the InertDML does nothing, the DMLManager operation respectively does nothing, 547 | // therefore the Contact is NOT saved within Salesforce. 548 | actualContactList = [select LastName from Contact where Id= :ctc.Id]; 549 | System.assertEquals(expectedLastName, actualContactList.get(0).LastName, 'The Contact should retain the original data.'); 550 | } 551 | 552 | private class InertDML extends DMLManager.SimpleDML { 553 | public override void dmlDelete(List recordsToDelete) { 554 | return; 555 | } 556 | 557 | public override void dmlInsert(List recordsToInsert) { 558 | return; 559 | } 560 | 561 | public override void dmlMerge(sObject masterRecord, List duplicateRecords) { 562 | return; 563 | } 564 | 565 | public override void dmlUpdate(List recordsToUpdate) { 566 | return; 567 | } 568 | 569 | public override void dmlUpsert(List recordsToUpsert) { 570 | return; 571 | } 572 | } 573 | 574 | private static void assignObjectPermission(User u, String objectType, Boolean create, Boolean edit, Boolean remove){ 575 | PermissionSet ps = new PermissionSet(Name = 'Enable' + objectType, Label = 'Enable ' + objectType); 576 | insert ps; 577 | 578 | ObjectPermissions oPerm = new ObjectPermissions(ParentId = ps.Id, 579 | PermissionsRead = true, 580 | PermissionsCreate = create, 581 | PermissionsEdit = edit, 582 | PermissionsDelete = remove, 583 | SObjectType = objectType); 584 | 585 | insert oPerm; 586 | 587 | PermissionSetAssignment assign = new PermissionSetAssignment(AssigneeId = u.Id, PermissionSetId = ps.Id); 588 | insert assign; 589 | } 590 | } -------------------------------------------------------------------------------- /src/classes/Test_DMLManager.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | * 5 | ApexClass 6 | 7 | 39.0 8 | 9 | --------------------------------------------------------------------------------