Desktop and Application Streaming

Automate Amazon WorkSpaces with a Self-Service Portal

Amazon WorkSpaces is a secure, and managed cloud desktop as a service. With Amazon WorkSpaces, you can provision either a Windows or Linux desktop for your users in minutes and allow them to access to desktops from any supported devices from any location.

The WorkSpaces self-service portal helps customers streamline the process to deploy WorkSpaces on a large scale. Using this portal, you can enable your workforce to provision their own WorkSpaces with an integrated approval workflow that doesn’t require IT intervention for each request. This reduces IT operational costs while helping end-users get started even faster with WorkSpaces. The additional built-in approval workflow simplifies the desktop approval process for businesses.

The portal integrates WorkSpaces with AWS application services to offer an automated tool for provisioning Windows or Linux cloud desktops. In this post, we will show you how to build a self-service portal for your organization for WorkSpaces provisioning requests.

 

Solution overview

The following section describes the Serverless architectural design for the portal.

  1. The portal front end website is hosted in an Amazon S3 bucket that has the static website hosting feature enabled. A WorkSpaces requester visits the portal from a web browser to initiate provisioning by submitting a web form. Amazon API Gateway accepts WorkSpaces provisioning requests from the requester. The WorkSpaces requester specifies the WorkSpaces request details:
  2. An Amazon DynamoDB table is used to record WorkSpaces provisioning requests and their status. When a provisioning request is submitted by a user, an AWS Lambda function inserts or updates a record in the DynamoDB table to track the request status. A Lambda function initiates the approval workflow by calling a Step Function to start a Step Function execution. The following is an example of a Lambda function inserting a WorkSpaces request record into a DynamoDB table and running the Step Function:
    def write_new_request(email, bundle, table):
      status = "WAITING_FOR_APPROVAL"
      success = False
      try:
        resp = dynamodb_client.put_item(
          ReturnConsumedCapacity='TOTAL',
          TableName=table,
          Item={
            'requester_email': {'S': email}, 
            'request_status': {'S': status},
            'bundle_id': {'S': bundle}
          }
        )
        logger.debug("writing request item: " + json.dumps(resp))
        success = True
      except Exception, err:
        logger.error("Error: failed writing requests item. Cause: " + err.message)
      return success
    
    def write_new_request(email, bundle, table):
      status = "WAITING_FOR_APPROVAL"
      success = False
      try:
        resp = dynamodb_client.put_item(
          ReturnConsumedCapacity='TOTAL',
          TableName=table,
          Item={
            'requester_email': {'S': email}, 
            'request_status': {'S': status},
            'bundle_id': {'S': bundle}
          }
        )
        logger.debug("writing request item: " + json.dumps(resp))
        success = True
      except Exception, err:
        logger.error("Error: failed writing requests item. Cause: " + err.message)
      return success
    
    # Log Request into DynamoDB
      requested = request_exists(email=requester_email, table=requests_table)
      if requested == True:
        # Request has been submitted before, return provision status
        request_status = get_request_status(email=requester_email, table=requests_table)
        result = "Request is submitted. Status: " + request_status
      elif requested == None:
        result = "Error: Failed to check duplications. Please see system administrator."
      else:
        success = write_new_request(email=requester_email, bundle=bundle_id, table=requests_table)
    
        # Kick off Request Step Function
        function_input = {
                "ApproverEmail": approver_email,
                "RequesterEmail": requester_email,  
                "BundleId": bundle_id, 
                "Message": "Workspace Provision Request from " + requester_email + " for Workspace bundle " + bundle_id
                }
        resp = sfn_client.start_execution(stateMachineArn=request_sfn_arn, input=json.dumps(function_input))
    
        logger.debug(resp)
    
        result="Request submitted, please wait for manager's approval."
    
      return {
          "isBase64Encoded": "false",
          "statusCode": 200,
          "body": result
      }

    3. By this time, the execution reaches a state that requires manual approval. A unique task token is generated by the Step Function for a call back. The activity state in the Step Function is paused. The Step Function waits until CreateWorkSpace or SendRejectionEmail is called with the token. Here is the Step Function workflow:

    4. At the start of the Step Functions execution, a Lambda function that runs the SendOutApprovalRequest step acquires the token associated with the ManualApproval step. It then sends an email with two embedded hyperlinks for approval and rejection. While the email is sent, the Step Function pauses and waits for a manual approval response. When the approver receives the email and chooses the “approve” hyperlink, it signals the Step Functions to continue the provisioning request. Likewise, when the approver chooses the “reject” hyperlink, it signals the Step Functions to terminate the provisioning workflow. Here is an example approval email:

    The following is an example of a Lambda function that emails the approval email with the Step Function token:

    def handler(event, context):
      logger.debug("got event: " + json.dumps(event))
      result = "OK"
      requester_email = ""
      approver_email = ""
    
      # Get Task Arn
      task_arn = os.getenv("APPROVAL_TASK_ARN")
      task_response_url = os.getenv("APPROVAL_TASK_URL")
    
      logger.debug("Task Arn: " + task_arn)
      logger.debug("Task Response URL: " + task_response_url)
    
      if not task_arn: 
          raise Exception("APPROVAL_TASK_NOT_FOUND", "Provision Approval Task Arn is not found")
      # Get Task Activity
      resp = sfn_client.get_activity_task(activityArn=task_arn)
    
      logger.debug("Got activity task respond" + json.dumps(resp))
    
      task_input = json.loads(resp['input'])
      task_token = resp['taskToken']
    
      logger.debug("Got request input " + json.dumps(task_input))
      logger.debug("Got request token " + task_token)
    
      # System Email
      system_email = os.getenv('DEFAULT_SYSTEM_EMAIL')
    
      # Approver Email
      if 'ApproverEmail' in task_input:
          approver_email = event['ApproverEmail']
      elif os.getenv('DEFAULT_APPROVER_EMAIL'):
          approver_email = os.getenv('DEFAULT_APPROVER_EMAIL')
      else:
          raise Exception("MISSING_APPROVER_EMAIL", "Approver Email and Default Approver Email are not found")
    
      # Get Input RequesterEmail
      requester_email = task_input['RequesterEmail']
    
      if 'Message' in task_input:
          message = task_input['Message']
      else:
          raise Exception("MISSING_REQUEST_MESSAGE", "Request Message is not found")
    
      send_email(from_email=system_email, to_email=approver_email, task_token=task_token, task_response_url=task_response_url, message=message)
    
    # Email to Manager
    def send_email(from_email, to_email, task_token, task_response_url, message):
        params_for_url = urllib.urlencode({"task_token": task_token})
        resp = ses_client.send_email(
          Source=from_email, 
          Destination={ 'ToAddresses': [to_email] }, 
          Message={
              "Body": {
                  "Html":{
                    "Charset": 'UTF-8',
                    "Data": "Hi!<br />" + 
                      message + "<br />" +
                      "Can you please approve:<br />" +
                      task_response_url + "approve?" + params_for_url + "<br />" +
                      "Or reject:<br />" +
                      task_response_url + "reject?" + params_for_url
                  }
              }, 
              "Subject": {
                  'Charset': 'UTF-8',
                  'Data': '[Request] WorkSpace Provisioning Request,
              }
          })
        logger.info("got ses resp: " + json.dumps(resp))
    

    5. The two hyperlinks are linked to API Gateway and the API Gateway routes the requests to a Lambda function that can relay the result back to the Step Function. Upon receiving the result, the Step Function decides whether to trigger CreateWorkSpace or SendRejectionEmail. The following code snippet shows how the Lambda function relayed the approval response to the Step Function along with a task token:

    # Send answer to task
        if answer == "approve":
            resp = sfn_client.send_task_success(taskToken=task_token, output='{"answer": "approved"}')
        elif answer == "reject": 
            resp = sfn_client.send_task_success(taskToken=task_token, output='{"answer": "rejected"}')
        else:
            raise Exception("UNKNOWN_ANSWER", "unable to process answer " + answer)
    

    6. If the approver’s response is to approve the provisioning, the Step Function triggers a Lambda function to start the WorkSpaces provisioning process. The following Lambda function snippet shows how to provision a WorkSpace:

    def handler(event, context):
      logger.debug("got event: " + json.dumps(event))
      logger.debug("got context: " + json.dumps(dir(context)))
      #logger.debug("got context: " + json.dumps(getattr(context)))
    
      # Need Directory Id, UserName, BundleId, Requester Email Address
      directory_id = os.getenv('DIRECTORY_ID')
      request_table = os.getenv('REQUEST_TABLE_NAME')
      workspace_table  = os.getenv('WORKSPACE_INSTANCE_TABLE')        
      requester_email = event['RequesterEmail']
      bundle_id = event['BundleId']
      user_name = requester_email
      
      # Send WS Create Request
      resp = create_workspace(DirectoryId=directory_id, UserName=user_name, BundleId=bundle_id)
    
      # Need to record workspaces_status, WorkspaceId, DirectoryId, UserName
      request = read_workspace_request_info(table=request_table, email=requester_email)
      request['workspace_id'] = {"S": resp['PendingRequests'][0]['WorkspaceId']}
      request['bundle_id'] = {"S": bundle_id}
      write_workspace_instance_info(table=workspace_table, request=request)
      event["workspace_id"] = resp['PendingRequests'][0]['WorkspaceId']
      return event
    
    def create_workspace(DirectoryId, UserName, BundleId):
      resp = ws_client.create_workspaces(Workspaces=[{
            'DirectoryId': DirectoryId,
            'UserName': UserName,
            'BundleId': BundleId,
            'WorkspaceProperties': {
              'RunningMode': 'AUTO_STOP',
              'RunningModeAutoStopTimeoutInMinutes': 60
            },
        }])
      if len(resp['FailedRequests']) > 0:
        logger.error(resp['FailedRequests'][0]['ErrorCode'] + " " + resp['FailedRequests'][0]['ErrorMessage'])
        raise Exception(resp['FailedRequests'][0]['ErrorCode'], resp['FailedRequests'][0]['ErrorMessage'])
      return resp
    

    At this point, the Step Function completes the execution:

    7. If the approver’s response is to reject the provisioning request, the Step Function sends an email to the requester and notifies that the request has been rejected. The following code snippet shows how to use SES to send a notification to the requesters:

    def handler(event, context):
      logger.debug("got event: " + json.dumps(event))
      requester_email = ""
    
      # System Email
      system_email = os.getenv('DEFAULT_SYSTEM_EMAIL')
    
      # Get Input RequesterEmail
      requester_email = event['RequesterEmail']
      bundle_id = event['BundleId']
    
      # Rejection Message
      message = "Request for WorkSpace " + bundle_id + " from " + requester_email + " has been rejected"
    
      send_email(from_email=system_email, to_email=requester_email, message=message)
    
    # Send Email to Manager on behalf of manager
    def send_email(from_email, to_email, message):
        resp = ses_client.send_email(
          Source=from_email, 
          Destination={ 'ToAddresses': [to_email] }, 
          Message={
              "Body": {
                  "Html":{
                    "Charset": 'UTF-8',
                    "Data": "Hi!<br />" + message
                  }
              }, 
              "Subject": {
                  'Charset': 'UTF-8',
                  'Data': '[Request] WorkSpace Provisioning From Employee',
              }
          })
        logger.debug("got ses resp: " + json.dumps(resp))
    

    Conclusion

    In this blog post, we show you how to build a WorkSpaces self-service provisioning portal using S3 static web hosting, API Gateway, Lambda Functions, Step Functions, DynamoDB, and Simple Email Services. You can deploy this solution to enable your employee to provision their own WorkSpaces with an embedded approval workflow. This helps reduce the operational burden on IT for deploying WorkSpaces at a large scale. These same concepts could be extended to build additional self-service capabilities for common WorkSpaces management tasks such as reboot, rebuild, or decommissioning. By integrating your WorkSpaces with other AWS Application Services you can start to use automation and customization to simplify WorkSpaces administration and deliver a great experience to your end users.

    – Vickie Hsu, Senior Infrastructure Architect & Kevin Yung, Cloud Architect