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