Desktop and Application Streaming

Automate provisioning of Amazon WorkSpaces using AWS Lambda

Many companies that have adopted Amazon WorkSpaces seek out ways to provision desktops for their users efficiently. In this blog, I will show you how to build a serverless solution that uses directory group membership to automate WorkSpaces provisioning and de-provisioning using AWS Lambda. This allows companies to use their existing directory group approval workflows to provision WorkSpaces.

Alternatively, you can create self-service portals or use ServiceNow integration to enable users to request WorkSpaces for themselves.

Overview

By creating a Lambda function with VPC access, we can use the Python library LDAP3 to connect to directory services. Using a simple LDAPS search, we can get a list of members in a directory group. Then using the Python Boto3 library to access the WorkSpaces API, we can compare the directory group members to current WorkSpaces users. Finally, we create WorkSpaces for group members who do not have one, and terminate WorkSpaces for users no longer in the group.

Solution Overview Flowchart: Amazon CloudWatch Events, triggers AWS Lambda Function, which connects to AWS Secrects Manager, a Directory over LDAPS, and Amazon WorkSpace

In this walkthrough you will complete the following tasks:

  1. Store domain service account password in AWS Secrets Manager.
  2. Create an IAM Policy and Role for the AWS Lambda function.
  3. Create an AWS Security Group to allow the Lambda function to connect to LDAPS.
  4. Create zip file with Lambda code and dependencies.
  5. Create a Lambda function.
  6. Create CloudWatch Events Rule to run the Lambda function on a schedule.

Prerequisites

For this walk-through, you should have the following prerequisites:

  • An AWS account
  • A domain service account
  • Access to a workstation with Python and Pip installed
  • A VPC with access to secure LDAP, and the internet
  • An existing Amazon WorkSpaces environment

You can read more about setting up Amazon WorkSpaces in the getting started guide.

Step 1. Store domain service account password in Secrets Manager

In this step, we store the password for the domain service account in Secrets Manager. This allows the password to be retrieved by the Lambda function securely each time it is run. Lambda will use this service account to perform the LDAPS bind and retrieve the group members. In most cases, elevated permissions beyond domain user are not required, but ensure that the service account has access to read group membership.

  1. Navigate to the Secrets Manager console.
  2. Under Get Started, choose Store a new secret.
  3. For Select secret type, select Other type of secrets.
  4. Select Plaintext, remove the JSON text, and enter the domain service account password.
  5. Choose Next.
  6. Enter a unique name for the secret, making a note of it for a later step, and choose Next.
  7. Leave automatic rotation disabled, and choose Next.
  8. Choose Store.
  9. Finally, from the Secrets, click the name of the newly created secret, and note the ARN.

Step 2. Create the IAM Policy and IAM Role

In this step, we create an IAM Policy, and attach it to an IAM Role that the Lambda function can assume.

  1. Navigate to the IAM console.
  2. In the navigation pane, choose Policies.
  3. Choose Create policy.
  4. Choose the JSON tab.
  5. Copy and paste the JSON policy below.
  6. Replace <Secret ARN> with ARN noted in the previous step.
  7. When you’re done, choose Review policy.
  8. Enter a name of your choosing.
  9. Choose Create policy.

IAM Policy document example:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "workspaces:TerminateWorkspaces",
                "secretsmanager:GetSecretValue",
                "workspaces:CreateWorkspaces"
            ],
            "Resource": [
                "arn:aws:workspaces:*:*:workspace/*",
                "arn:aws:workspaces:*:*:workspacebundle/*",
                "arn:aws:workspaces:*:*:directory/*",
                "<Secret ARN>"
            ]
       },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:CreateLogGroup",
                "logs:PutLogEvents",
                "logs:CreateLogStream",
                "ec2:CreateNetworkInterface",
                "ec2:DescribeNetworkInterfaces",
                "ec2:DeleteNetworkInterface",
                "workspaces:CreateTags",
                "workspaces:DescribeTags",
                "workspaces:DescribeWorkspaces"
            ],
            "Resource": "*"
     }
    ]
}

Now that the IAM Policy has been created, we create the IAM Role for Lambda to assume with the policy you just created.

  1. Open the IAM console.
  2. In the navigation pane, choose Roles.
  3. Choose Create role.
  4. For Select type of trusted entity, keep AWS service selected.
  5. Choose Lambda, and then choose Next: Permissions.
  6. In the filter policies search box, type name of the policy created in the previous step. When the policy appears in the list, select the check box next to the policy name.
  7. Choose Next: Tags. Although you can specify a tag for the policy, a tag is not required.
  8. Choose Next: Review.
  9. Enter a name for your Role to help you identify it.
  10. Choose Create role.

Step 3. Create an AWS Security Group

In this step, we create a Security group to allow the Lambda function network interface access to the appropriate destinations.

  1. Open the Amazon EC2 console.
  2. In the navigation pane, choose Security Groups.
  3. Choose Create security group.
  4. Specify a Security group name and Description for the security group.
  5. For VPC, choose the ID of the existing VPC.
  6. Add outbound rules TCP port 443 to 0.0.0.0/0 and TCP port 636 to your domain controllers. If the VPC DHCP options set specific alternative DNS providers, all add outbound DNS rules to accommodate this traffic.
  7. Select Create security group.

If necessary, update the security group for the domain controllers to allow inbound TCP port 636 traffic from the newly created security group.

Step 4. Create zip file with Lambda code and dependencies

In this step, we package the Python function, and required dependencies on a local workstation for upload to Lambda in the next step.

  1. Using the workstation with Python and Pip installed, create a new folder to store the required files.
  2. Open the command line or terminal window, and navigate to the newly created folder
  3. Run the following command to install the required libraries to the current directory. “Python –m pip install ldap3 –t .” Replace Python with Python3 if required by the workstation you are using.
  4. Next open a text editor and copy the script below into the document.
  5. Save the file to the directory created in step 1 using the name lambda_function.py.
  6. Navigate to the directory and create a zip file from the contents, ensuring the file lambda_function.py in the root.

Note: LDAP3 is a third-party software. Review any applicable license terms before downloading or using the software.

Lambda function example:

import logging
import boto3
from ldap3 import Server, Connection, Tls, NTLM, ALL

logger = logging.getLogger()
logger.setLevel(logging.INFO)

secrets = boto3.client('secretsmanager')
workspaces = boto3.client('workspaces')

def loadMoreResults(NextToken, Directory_Id):
    workspace_users = {}
    while NextToken:
        response = workspaces.describe_workspaces(DirectoryId=Directory_Id, NextToken=NextToken)
        for workspace in response['Workspaces']:
            if not workspace['State'] == 'TERMINATING':
                workspace_users[workspace['UserName']] = workspace['WorkspaceId']
        NextToken = response.get('NextToken')
    return workspace_users
        
def lambda_handler(event, context):
    #Setup variables
    LDAP_SERVER = event['LDAP_SERVER']
    LDAP_USER = event['LDAP_USER']
    GROUP_FILTER = event['GROUP_FILTER']
    USER_FILTER = event['USER_FILTER']
    SECRET_NAME = event['SECRET_NAME']
    WORKSPACE_GROUP_FRIENDLY_NAME = event['WORKSPACE_GROUP_FRIENDLY_NAME']
    WORKSPACE_GROUP_DN = event['WORKSPACE_GROUP_DN']
    Directory_Id = event['Directory_Id']
    Bundle_Id = event['Bundle_Id']
    #Get LDAP bind password from Secrect Store
    LDAP_PASSWORD = (secrets.get_secret_value(SecretId=SECRET_NAME)).get('SecretString')
    
    #Create LDAP Bind
    server = Server(LDAP_SERVER, port=636, use_ssl=True, tls=Tls(), get_info=ALL)
    conn = Connection(server, user=LDAP_USER, password=LDAP_PASSWORD, authentication=NTLM, auto_bind=True)
    
    #Get defined group members and store 
    conn.search(WORKSPACE_GROUP_DN, GROUP_FILTER, attributes=['member'])
    group_members = conn.entries[0].member
    
    #Look up each member of defined group and add a dictionary to the list for each user with an email address
    workspace_group_users = {}
    for user_DN in group_members:
        conn.search(user_DN, USER_FILTER, attributes=['mail', 'sAMAccountName'])
        if conn.entries[0].mail[:7]:
            workspace_group_users[conn.entries[0].sAMAccountName[:17][0]] = conn.entries[0].mail[:7][0]
            
    #Get current list of WorkSpaces in the defined directory
    all_workspaces = workspaces.describe_workspaces(DirectoryId=Directory_Id)
    
    #Build dictionary of sAMAccountNames and WorkspaceIds
    current_workspace_users = {}
    for workspace in all_workspaces['Workspaces']:
        if not workspace['State'] == 'TERMINATING':
            current_workspace_users[workspace['UserName']] = workspace['WorkspaceId']
    if all_workspaces.get('NextToken'): 
        loadmore_Workspaces_List = loadMoreResults(all_workspaces['NextToken'], Directory_Id)
        current_workspace_users.update(loadmore_Workspaces_List)
        
    #Check that every user in defined group as a WorkSpace, else add them to the provision list
    provision_sAMAccountName = []
    for groupuser in workspace_group_users.keys():
        if not groupuser in current_workspace_users.keys():
            provision_sAMAccountName.append(groupuser)
            
    #check that every WorkSpace user is in the defined group, else add them to the termination list
    termination_sAMAccountName = []
    for workspaceuser in current_workspace_users.keys():
        if not workspaceuser in workspace_group_users.keys():
            termination_sAMAccountName.append(workspaceuser)
            
    #create provisioning task list
    provision = []
    provision_count = 1
    for provision_user in provision_sAMAccountName:
        appendData = {
                'DirectoryId': Directory_Id,
                'UserName': provision_user,
                'BundleId': Bundle_Id,
                'UserVolumeEncryptionEnabled': False,
                'RootVolumeEncryptionEnabled': False,
                'WorkspaceProperties': event['WorkSpace_Properties'],
                'Tags': [
                    {
                        'Key': 'Automation',
                        'Value': 'Managed'
                    },
                    {
                        'Key': 'DirectoryGroup',
                        'Value': WORKSPACE_GROUP_FRIENDLY_NAME
                    }
                ]
            }
        provision.append(dict(appendData))
        if provision_count > 24:
            break
        else:
            provision_count = provision_count + 1

    #create termination task list
    terminate = []
    terminate_count = 1
    for terminate_user in termination_sAMAccountName:
        tags = (workspaces.describe_tags(ResourceId=current_workspace_users[terminate_user]))['TagList']
        if {'Key': 'Automation', 'Value': 'Managed'} in tags:
            if {'Key': 'DirectoryGroup', 'Value': WORKSPACE_GROUP_FRIENDLY_NAME} in tags:
                appendData = {'WorkspaceId': current_workspace_users[terminate_user]}
                terminate.append(dict(appendData))
                if terminate_count > 24:
                    break
                else:
                    terminate_count = terminate_count + 1
    #terminate WorkSpaces for non-group members
    if terminate:
        termination = workspaces.terminate_workspaces(TerminateWorkspaceRequests=terminate)
        print('Created termination task for: ' + str(terminate))
        if termination['FailedRequests']:
            print('Failed to terminate: ' + termination['FailedRequests'])
    else:
        print('No WorkSpaces to terminate at this time.')
        
    #Create WorkSpaces for members without existing WorkSpaces
    if provision:
        creation = workspaces.create_workspaces(Workspaces=provision)
        print('Successfully created provisioning requests for: ' + str(creation['PendingRequests']))
        if creation['FailedRequests']:
            print('Failed to create provisioning requests for: ' + str(creation['FailedRequests']))
    else:
        print('No WorkSpaces to create at this time.')

Step 5. Create a Lambda function

In this step, we upload the package created in the previous step to Lambda, and set up the runtime environment.

  1. Open the Lambda console
  2. Choose Create function.
  3. Enter a meaningful name in Function name.
  4. Select Python 3.8 as the Runtime.
  5. Expand the permissions section, select Use an existing role, and from the list pick the role created in step 2.
  6. Choose Create function.
  7. Under the Function code section, select Upload a .zip file from the Code entry type dropdown.
  8. Select Upload, and choose the zip file created in the previous step.
  9. Under Basic settings, choose Edit.
  10. Set Timeout to 5 minutes, and choose Save. This may need to be adjusted depending on the size of your deployment.
  11. Under the VPC section, choose Edit.
  12. Choose Custom VPC.
  13. Select a VPC, and at least one subnet with access to the domain controllers.
  14. Select the security group created in step 3.
  15. Choose Save.
  16. Provisioning the network interfaces in the VPC for the Lambda function can take a few minutes. Once Save becomes available again, choose Save to complete the Lambda function configuration.

Step 6. Create CloudWatch Events Rule

In this step, we create a CloudWatch Events Rule to schedule the execution of the Lambda function we just created. The Events Rule also sends the configuration information to Lambda at runtime. This allows the Lambda function to be used for multiple configurations. Simply repeat this step to create another Events Rule, with a different configuration input to automate another directory group.

  1. Open the CloudWatch console.
  2. In the navigation pane, choose Events, Create rule.
  3. Choose Schedule, for this example leave fixed rate at 5 minutes. This may need to be adjusted depending on the size of your deployment.
  4. For Targets, choose Add target, leave Lambda function, and select the function created in the step 5.
  5. Expand Configure input, select Constant (JSON text).
  6. Copy the example below, replacing the details with the appropriate values.
    •  <domain FQDN> for example amazon.com
    • <DomainName\\ServiceAccountName> for example amazon\\username
    • <SecretName> for example SecretPassword
    • <FriendlyName> for example WorkSpaces
    • <DomainGroupDN> for example CN=WorkSpaces,OU=Users,OU=blog, DC=amazon,DC=com
    • <WorkSpacesDirectoryId> for example d-1234567890
    •  <WorkSpacesBundleId> for example wsb-123456789
  7. Choose Configure details, enter a meaningful Name, then choose Create rule.

CloudWatch Events Rule JSON text example:

{
  "LDAP_SERVER": "<domain FQDN>",
  "LDAP_USER": "<DomainName\\ServiceAccountName>",
  "GROUP_FILTER": "(objectclass=group)",
  "USER_FILTER": "(objectclass=user)",
  "SECRET_NAME": "<SecretName>",
  "WORKSPACE_GROUP_FRIENDLY_NAME": "<FriendlyName>",
  "WORKSPACE_GROUP_DN": "<DomainGroupDN>",
  "Directory_Id": "<WorkSpacesDirectoryId>",
  "Bundle_Id": "<WorkSpacesBundleId>",
  "WorkSpace_Properties": {
    "RunningMode": "AUTO_STOP",
    "RunningModeAutoStopTimeoutInMinutes": 60,
    "RootVolumeSizeGib": 80,
    "UserVolumeSizeGib": 50,
    "ComputeTypeName": "STANDARD"
  }
}

Conclusion

The CloudWatch Events Rule we created runs the Lambda function every 5 minutes. The function retrieves the service account password from Secrets Manager to connect to the directory. Then compare group membership to the existing WorkSpaces. The process of creating WorkSpaces for new group members and terminating WorkSpaces of members removed from the group is fully automated. More information about the AWS SDK for Python WorkSpaces client can be found in the Boto3 documentation.

Clean up

To avoid incurring future charges, remove the resources that were created. Delete the CloudWatch Events Rule, Lambda Function, Security Group, IAM Policy and Role, stored secret, and any WorkSpaces created by the automation.