5

Purpose: Grab all users with custom permission "Sandbox_Administrator" on sandbox creation and update their Profile id to "System Administrator".

When Code is ran a notification of successful run is received via Email. Debug logs show concluding success Debug.

When code is ran in the Developer console in Anonymous Window it functions as intended.

Written test for Sandbox_Administrator passes.

Why might this not work as intended?

CLASS:

global class SandboxPostCopyManager implements SandboxPostCopy{

        /* -------------------------------------------------------Method Calls---------------------------------------------------*/
    global void runApexClass(SandboxContext context) {
        System.debug('Sandbox copy done. ' + context.organizationId() + ' '  + context.sandboxId() + ' ' + context.sandboxName());

        List<String> ContextInfo = new List<String>();
        ContextInfo.add(context.organizationId());
        ContextInfo.add(context.sandboxId());
        ContextInfo.add(context.sandboxName());

        updatetosysadmin(ContextInfo);
    }

    /* ----------------------------------------------------------Methods----------------------------------------------------*/

    public void updatetosysadmin(List<String> c){
        List<User> userlist = GetUserWithCustomPermission('Sandbox_Administrator');
        List<User> usertoadd = new List<User>();
        if (userlist != null && !userlist.isEmpty()){
            for(user u: userlist){
                u.ProfileId = Constants.SYSADMIN_USER_PROFILE_ID;
                usertoadd.add(u);
            }
        }
        try{
            update usertoadd;
        }catch (Exception ex) {sendEmail(c,ex);}
    }

    public static List<User> GetUserWithCustomPermission(String name){
        Set<Id> permissionSetIds = new Set<Id>();
        for (SetupEntityAccess access : [
            SELECT ParentId FROM SetupEntityAccess WHERE SetupEntityId IN (
                SELECT Id FROM CustomPermission WHERE DeveloperName = :name
            )
        ]) permissionSetIds.add(access.ParentId);

        return permissionSetIds.isEmpty() ? new List<User>() : [
            SELECT Username, ProfileId FROM User WHERE Id IN (
                SELECT AssigneeId FROM PermissionSetAssignment
                WHERE PermissionSetId IN :permissionSetIds
            )
        ];

    }
    /* -------------------------------------------------------E-Mail Ex Alert---------------------------------------------------*/
    private void sendEmail(List<String> c, Exception e) {
        Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
        String[] toAddresses = new String[] {'removed'};
        string html = ''
            + '<div style="font: bold 14px Arial; margin: 10px 0;">AsyncApexJob:</div>'
            + '<table style="font: normal 11px verdana;" border="1" cellpadding="5" cellspacing="0" width="100%">'
            + '<tr style="font-weight: bold">'
            + '<td>OrganizationID</td>'
            + '<td>SandboxID</td>'
            + '<td>Sandbox Name</td>'
            + '<td>Exception</td>'
            + '</tr>'
            + '<tr>'
            + '<td>' + c.get(0) + '</td>'
            + '<td>' + c.get(1) + '</td>'
            + '<td>' + c.get(2) + '</td>'
            + '<td>' + e + '</td>'
            + '</tr>'
            + '</table>';
        mail.setToAddresses(toAddresses);
        mail.setSubject('SandboxPostCopy ' + c[2]);
        mail.setHtmlBody(html);
        if (!Test.isRunningTest() && EmailManager.EMAIL_NOTIFICATIONS_ENABLED) {
            try{
                Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
            }
            catch (Exception ex) {
                if(ex.getMessage().contains('Single email is not enabled for your organization or profile. Single email must be enabled for you to use this feature.')){
                    throw ex;
                }
            }
        }
    }
}

TEST:

@isTest(SeeAllData=false)
public class testSandboxPostCopyScript {    
    @isTest public static void runtest() {
        User sysadmin = SetupTestObjects.ADMIN_USER;
        System.runAs(sysadmin){
            SandboxPostCopyManager apexclass = new SandboxPostCopymanager();
            Test.testSandboxPostCopyScript(apexclass, UserInfo.getOrganizationId(), UserInfo.getOrganizationId(), UserInfo.getOrganizationName());
            List<User> userlist = SandboxPostCopyManager.GetUserWithCustomPermission('Sandbox_Administrator');
            if (userlist != null && !userlist.isEmpty()){
                for (User u : userlist){
                    System.assertEquals(u.ProfileId, Constants.SYSADMIN_USER_PROFILE_ID, 'testSandboxPostCopyScript Failed');
                }
            }
        }
    }
}
2
  • I've hit this error before and couldn't solve it. Hopefully someone has an idea. – Dan Wooding Apr 26 '17 at 17:48
  • @DanWooding I think I have a solution. See my answer below. – Kimball Robinson Nov 21 '18 at 22:30
6

[Edit] My solution below stopped working after a bit. We switched to having a "Template" sandbox:

  • After each release, a lead developer refreshes the template sandbox; then they run all relevant setup code on the template using a single button on an admin-only page.
  • All other developers are instructed to clone their sandboxes from the template.
  • Note, Since cloning is a new feature, I couldn't offer that in my original answer.
  • It should also possible to set up a CI/CD pipeline to deploy changes to sandboxes including a template sandbox (if desired).
  • We were careful to have a single button run all our setup functions. In some cases, this can require batching and/or scheduling.

Original answer

This is a tricky problem to solve. Among the issues are:

  • The User table is in an odd state during SandboxPostCopy, so you can't update user profiles right away. Scheduler needed.
    • If you schedule something, you must un-schedule it later.
  • The user that runs SandboxPostCopy is 'Automated Process' (alias autoproc); oddly, even as a sysadmin I can't see the debug logs for anything run by this user if it's in a @Future/scheduled/batch job. Unless I pull two interesting tricks (shown later).
  • If you have errors in a User trigger, it can be hard to eliminate--so disable triggers while developing this.
  • If you have validation formulas on the User, they may also interfere.
  • Tracking down errors is difficult once you start scheduling things.
  • You need to be careful about proper security on this code! (not shown!)

With all that said, here was (roughly) my approach -- excluding some other operations and helper code I wrote along the way.

The code below does the following:

  • Schedules a job 10 minutes into the future to run sandbox changes.
  • Within the scheduled job, the user profiles are updated as needed
    • This has to be done in a @Future if other DML operations are needed--to avoid a "mixed dml operation" exception.
  • When the job finishes, it verifies the changes were successful and de-schedules itself
  • If an error is thrown while updating users, capture the relevant part so that you can query AsyncApexJob.ExtendedStatus and not have the useful bits truncated.

First, Security

You want to be sure that not just anyone can run this code, elevating their own privileges. ALWAYS check who is calling the code and whatever other conditions make sense--you don't want random people becoming sys admins in sandboxes, and especially in production.

You need to add code to most of these functions to check if you're in a sandbox. And you also need the core function giving sysadmin access to check that the users are the right users to run it--or even restrict it to the 'Automated Process' user (once you've got everything working of course).

Now, the rest

The SandboxPostCopy class is very simple, but sets other classes up for any heavy lifting....

global class SandboxSetupPostCopy implements SandboxPostCopy {
    global void runApexClass(SandboxContext context){
        scheduleChangesThatCannotRunImmediately();
    }
}

public void scheduleChangesThatCannotRunImmediately() {
    // if you don't schedule a job this way, you get the following:
    // |EXCEPTION_THROWN|...|System.DmlException: Update failed. ... DUPLICATE_USERNAME, Duplicate Username.
    //    The username already exists in this or another Salesforce organization. Usernames must be unique across
    //    all Salesforce organizations. To resolve, use a different username (it doesn't need to match the user's
    //    email address).

    // Basically, during a SandboxPostCopy event, the user table is temporarily out of whack--and we must wait to run some functions.

    if (!isSandbox && !Test.isRunningTest()) {
        throw new IllegalArgumentException('Unauthorized environment: ' + environment);
    }

    Integer minute = Datetime.now().addMinutes(10).minute();
    String cronString = '0 ' + minute + ' * * * ?'; // Starting 10m from now, run hourly.  Job will self-cancel once successful.
    SandboxSetupSchedulable job = new SandboxSetupSchedulable(...);
    System.schedule('Sandbox Setup', cronString, job);
}

Next, the scheduled job should do something roughly like this:

public with sharing class SandboxSetupSchedulable implements Schedulable {

    public void execute(SchedulableContext schedulableContext) {
        SandboxSetupService sandboxService = new SandboxSetupService();
        SandboxSetupService.setSysAdmins();
        sandboxService.doOtherManipulations();
        finish();
    }

    private void finish() {
        SandboxSetupService sandboxService = new SandboxSetupService();
        if (sandboxService.looksLikeSchedulerFinished(/*implementation not shown*/)) {
            sandboxService.cleanupCronJobsForSetup();
        }
    }

The sandbox setup service looks about like this (you could possibly combine it with the schedulable class -- just watch the with/without sharing declarations....)

public without sharing class SandboxSetupService {
    public void cleanupCronJobsForSetup() {
        List<CronTrigger> triggerList = [
                SELECT Id
                FROM CronTrigger
                WHERE CronJobDetail.Name LIKE 'Sandbox Setup%'
        ];
        for (CronTrigger t : triggerList) {
            System.abortJob(t.Id);
        }
    }

    //Needs to be in an @Future to avoid a "mixed dml operation" error, unless you update NOTHING but user objects.
    @Future
    public static void setSysAdmins(){
        ensureSandboxEnvironment();
        doSomeSecurityChecksHere_IMPORTANT();
        ...
        Profile sysAdminProfile = [SELECT Id FROM Profile WHERE Name = 'System Administrator'];

        List<User> usersToMakeSysAdmins = [SELECT Id, ProfileId, Sandbox_System_Administrator__c, Alias FROM User WHERE Sandbox_System_Administrator__c = TRUE]; // your query will be different!!!!!
        if (userList != null && !userList.isEmpty()) {
            for (User user : userList) {
                user.ProfileId = sysAdminProfile.Id;
            }
            try {
                update userList;
            } catch (Exception toSummarize) {
                // when running a scheduled/@Future method as 'Automated Process' aka autproc, only the first 255 chars of logs are available in AsyncApexJob.ExtendedStatus -- nowhere else.
                String originalMessage = toSummarize.getMessage();
                String mostRelevantPart = originalMessage;
                Integer i = originalMessage.length();
                if (i >= 255) mostRelevantPart = originalMessage.substring(i - 255);
                throw new SandboxSetupException(mostRelevantPart, toSummarize);
                // Note: because of mixed DML operation restrictions, you can't save the full exception message anywhere.
            }
        }
    }

    public class SandboxSetupException extends Exception {}
2
  • 1
    +1 seems like if the schedulable's jobname were made to be unique each time (using TOD), you wouldn't need to clean up the jobs with abortJob as this is only a sandbox. Also, not sure why last statement of schedulable's execute() can't abort itself as that is a pattern I use all the time. – cropredy Nov 29 '18 at 0:36
  • True, that will work for most people. In my case there are functions that won't succeed until a data load finishes. That's why I let it keep running and cancel jobs by name. – Kimball Robinson Dec 14 '18 at 1:14

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.