diff --git a/human-in-the-loop/README.md b/human-in-the-loop/README.md index ea71d74b..440c6c41 100644 --- a/human-in-the-loop/README.md +++ b/human-in-the-loop/README.md @@ -1,6 +1,6 @@ # Human in the Loop -This pattern allows you to integrate an human review or approval process into your workflows. Each task sends a message to a SNS topic which sends a notification to a human reviewer or approver by email for example. The workflow then waits until the approver completes their review. Depending on the review outcome a different Lambda function can be invoked. +This pattern allows you to integrate a human review or approval process into your workflows with **one-click email approval**. An AWS Lambda function sends an approval request via Amazon SNS email containing clickable approve/reject links. The task token is URL-encoded to ensure special characters don't break the Amazon API Gateway callback URL. The workflow pauses until the reviewer clicks a link, which triggers an API Gateway endpoint to resume the AWS Step Functions execution. Learn more about this workflow at Step Functions workflows collection: [Human in the Loop](https://serverlessland.com/workflows/human-in-the-loop) @@ -12,6 +12,7 @@ Important: this application uses various AWS services and there are costs associ * [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured * [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) * [AWS Serverless Application Model](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) (AWS SAM) installed +* Python 3.13 or later ## Deployment Instructions @@ -41,24 +42,37 @@ Important: this application uses various AWS services and there are costs associ ![image](./resources/statemachine.png) -1. Data that should be reviewed by a human is passed to the workflow. A message is send to a [Amazon Simple Notification Service (SNS)](https://aws.amazon.com/sns/) topic which sends out a notification via Email. The notification contains a [task token](https://docs.aws.amazon.com/step-functions/latest/dg/connect-to-resource.html#connect-wait-token) which is automatically generated by AWS Step Functions. -2. After approving or denying, the reviewer calls the `SendTaskSuccess` API and passes the task token as well as the review result. -3. The result is evaluated by Step Functions and the corresponding AWS Lambda function is invoked. +1. Data that should be reviewed by a human is passed to the workflow. The state machine invokes a Lambda function using the `.waitForTaskToken` integration pattern. The Lambda function URL-encodes the task token and constructs approve/reject links pointing to an API Gateway endpoint. +2. The Lambda function publishes an email via [Amazon Simple Notification Service (SNS)](https://aws.amazon.com/sns/) containing the clickable approve/reject links. +3. The reviewer clicks one of the links in the email. This triggers a GET request to API Gateway. +4. API Gateway invokes a second Lambda function that decodes the task token and calls `SendTaskSuccess` (approve) or `SendTaskFailure` (reject) on the Step Functions execution. +5. The workflow resumes and routes to the appropriate processing Lambda based on the approval outcome. + ## Testing 1. After deployment you receive an email titled `AWS Notification - Subscription Confirmation`. Click on the link in the email to confirm your subscription. This will allow SNS to send you emails. 2. Navigate to the AWS Step Functions console and select the `human-in-the-loop` workflow. 3. Select `Start Execution` and use any valid JSON data as input. -4. Select `Start Execution` and wait until you receive the email from SNS. -5. Copy the task token from the email. -6. Use the AWS CLI to complete the task by calling the `SendTaskSuccess` API. Replace the task token with the value you copied earlier. - ``` - aws stepfunctions send-task-success --task-token --task-output '{"result":true}' - ``` - Make sure to use that the cli uses the same region as the one you used to deploy your state machine. -5. Observe the task in the Step Functions console. Because response states that the approval was granted, the task transitioned to the `Process Approval` step. -6. If you trigger a new execution and replace `{"result":true}` with `{"result":false}` in step 6, the workflow transitions to `Process Rejection` respectively. +4. Select `Start Execution` and wait until you receive the approval request email from SNS. +5. Click the **Approve** or **Reject** link in the email. +6. You will see a confirmation page in your browser indicating the workflow was approved or rejected. +7. Observe the execution in the Step Functions console — the workflow transitions to `Handle approval` or `Handle rejection` based on your response. + +### Testing via CLI (alternative) + +You can also test the API Gateway endpoint directly: + +```bash +# Get the API endpoint from stack outputs +export API_ENDPOINT=$(aws cloudformation describe-stacks \ + --stack-name \ + --query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \ + --output text) + +# Approve (replace with the URL-encoded token from the email link) +curl "$API_ENDPOINT?taskToken=&decision=approve" +``` ## Cleanup diff --git a/human-in-the-loop/example-workflow.json b/human-in-the-loop/example-workflow.json index ff00b245..52213f51 100644 --- a/human-in-the-loop/example-workflow.json +++ b/human-in-the-loop/example-workflow.json @@ -1,22 +1,23 @@ { "title": "Human in the Loop", - "description": "Wait for an approval from a human reviewer before continuing", - "language": "", + "description": "Wait for an approval from a human reviewer with one-click email approve/reject links", + "language": "Python", "simplicity": "2 - Pattern", "usecase": "", "type": "Standard", "diagram":"/resources/statemachine.png", "videoId": "", - "level": "100", + "level": "200", "framework": "SAM", - "services": ["sns","lambda"], + "services": ["sns","lambda","apigateway"], "introBox": { "headline": "How it works", "text": [ - "This pattern allows you to integrate an human review or approval process into your workflows.", - "The task sends a message to a SNS topic which sends a notification to a human reviewer or approver by email for example. The workflow then waits until the approver completes their review. Depending on the review outcome a different Lambda function can be invoked.", - "Waiting for completion of the review is done using the .waitForTaskToken service integration. The payload of the SNS message contains a task token, which is automatically generated by AWS Step Functions.", - "The task will pause until it receives that task token back with a SendTaskSuccess or SendTaskFailure API call." + "This pattern allows you to integrate a human review or approval process into your workflows with one-click email approval.", + "A Lambda function sends an approval request via SNS email containing clickable approve/reject links. The task token is URL-encoded to avoid issues with special characters in API Gateway URLs.", + "The workflow pauses using the .waitForTaskToken service integration until the reviewer clicks an approve or reject link.", + "The link triggers an API Gateway endpoint backed by a Lambda function that calls SendTaskSuccess or SendTaskFailure to resume the workflow.", + "Depending on the review outcome, a different processing path is followed." ] }, "testing": { @@ -56,6 +57,14 @@ { "text": "The AWS Step Functions Workshop", "link": "https://catalog.workshops.aws/stepfunctions/en-US" + }, + { + "text": "AWS Step Functions Task Tokens", + "link": "https://docs.aws.amazon.com/step-functions/latest/dg/connect-to-resource.html#connect-wait-token" + }, + { + "text": "Step Functions Service Integration Patterns", + "link": "https://docs.aws.amazon.com/step-functions/latest/dg/connect-to-resource.html" } ] }, @@ -66,7 +75,12 @@ "bio": "Ben is a Senior Solutions Architect at Amazon Web Services (AWS) based in Frankfurt, Germany.", "linkedin": "benfreiberg", "twitter": "" + }, + { + "name": "Yogesh Nain", + "image": "link-to-your-photo.jpg", + "bio": "Yogesh is a Cloud Support Engineer and SME of Lambda, API Gateway at Amazon Web Services.", + "linkedin": "https://www.linkedin.com/in/yogesh-nain-a54133170/" } ] } - \ No newline at end of file diff --git a/human-in-the-loop/lambda/handle_approval.py b/human-in-the-loop/lambda/handle_approval.py new file mode 100644 index 00000000..02f9266f --- /dev/null +++ b/human-in-the-loop/lambda/handle_approval.py @@ -0,0 +1,59 @@ +""" + MIT No Attribution + + Copyright 2022 Amazon Web Services + + Permission is hereby granted, free of charge, to any person obtaining a copy of this + software and associated documentation files (the "Software"), to deal in the Software + without restriction, including without limitation the rights to use, copy, modify, + merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +""" +import boto3 +import json +from urllib.parse import unquote + + +def lambda_handler(event, context): + query_params = event.get('queryStringParameters', {}) + task_token = unquote(query_params.get('taskToken', '')) + decision = query_params.get('decision') + + print(f"Received request - decision: {decision}, taskToken (first 50 chars): {task_token[:50]}...") + + sfn = boto3.client('stepfunctions') + + try: + if decision == 'approve': + sfn.send_task_success( + taskToken=task_token, + output=json.dumps({'result': True}) + ) + message = 'Workflow approved successfully!' + else: + sfn.send_task_success( + taskToken=task_token, + output=json.dumps({'result': False}) + ) + message = 'Workflow rejected.' + + print(f"Successfully processed decision: {decision}") + return { + 'statusCode': 200, + 'headers': {'Content-Type': 'text/html'}, + 'body': f'

{message}

' + } + except Exception as e: + print(f"Error processing decision '{decision}': {str(e)}") + return { + 'statusCode': 500, + 'headers': {'Content-Type': 'text/html'}, + 'body': f'

Error processing request

{str(e)}

' + } diff --git a/human-in-the-loop/lambda/send_approval_email.py b/human-in-the-loop/lambda/send_approval_email.py new file mode 100644 index 00000000..d18d689f --- /dev/null +++ b/human-in-the-loop/lambda/send_approval_email.py @@ -0,0 +1,61 @@ +""" + MIT No Attribution + + Copyright 2022 Amazon Web Services + + Permission is hereby granted, free of charge, to any person obtaining a copy of this + software and associated documentation files (the "Software"), to deal in the Software + without restriction, including without limitation the rights to use, copy, modify, + merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +""" +import boto3 +import json +import os +from urllib.parse import quote + + +def lambda_handler(event, context): + task_token = quote(event['taskToken'], safe='') + execution_id = event['execution'] + + print(f"Sending approval email for execution: {execution_id}") + print(f"Task token (first 50 chars): {event['taskToken'][:50]}...") + + api_endpoint = os.environ['API_ENDPOINT'] + approve_url = f"{api_endpoint}?taskToken={task_token}&decision=approve" + reject_url = f"{api_endpoint}?taskToken={task_token}&decision=reject" + + message_data = { + 'default': 'Workflow Approval Required', + 'email': ( + f"Workflow Approval Required\n" + f"{'=' * 40}\n\n" + f"A new approval is required for workflow execution:\n" + f"{execution_id}\n\n" + f"To APPROVE, click the link below:\n" + f"{approve_url}\n\n" + f"To REJECT, click the link below:\n" + f"{reject_url}\n" + ) + } + + sns = boto3.client('sns') + sns.publish( + TopicArn=os.environ['SNS_TOPIC_ARN'], + Subject='Step Functions Workflow - Approval Required', + Message=json.dumps(message_data), + MessageStructure='json' + ) + + return { + 'statusCode': 200, + 'body': 'Approval notification sent successfully' + } diff --git a/human-in-the-loop/resources/architecture.svg b/human-in-the-loop/resources/architecture.svg new file mode 100644 index 00000000..6fda17f0 --- /dev/null +++ b/human-in-the-loop/resources/architecture.svg @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + Human in the Loop - One-Click Email Approval Workflow + + + + + + User + + + + + Start Execution + + + + + AWS Step Functions State Machine + + + + + Notify Approver + lambda:invoke.waitForTaskToken + + + + Task token returned + + + + Approved? + Choice + + + + result = true + + + + result = false + + + + Default + + + + Handle Approval + + + + Handle Rejection + + + + Handle Failure + + + + + + SendApprovalEmailFunction + URL-encodes task token + + + + Invokes Lambda + + + + + Amazon SNS + Sends email notification + + + + + + + + Reviewer Email + Approve / Reject links + + + + + + + + API Gateway + GET /respond + + + + Click link + + + + + HandleApprovalFunction + Decodes token, calls SendTaskSuccess + + + + + + + SendTaskSuccess (token) + + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + + Flow: + 1. State machine invokes Lambda with waitForTaskToken + 2. Lambda URL-encodes token, publishes email via SNS + 3. SNS delivers email with clickable links + 4. Reviewer clicks Approve/Reject link + 5. API Gateway triggers HandleApproval Lambda + 6. Lambda decodes token and calls SendTaskSuccess to resume workflow + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/human-in-the-loop/statemachine/statemachine.asl.json b/human-in-the-loop/statemachine/statemachine.asl.json index de30aa66..d3ca8a72 100644 --- a/human-in-the-loop/statemachine/statemachine.asl.json +++ b/human-in-the-loop/statemachine/statemachine.asl.json @@ -1,17 +1,19 @@ { - "Comment": "A state machine to integrate a human approver", + "Comment": "A state machine to integrate a human approver with one-click email approval", "StartAt": "Notify Approver", "States": { "Notify Approver": { "Type": "Task", - "Resource": "arn:aws:states:::sns:publish.waitForTaskToken", + "Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken", "Parameters": { - "TopicArn": "${TopicName}", - "Message": { - "token.$": "$$.Task.Token", - "message.$": "$" + "FunctionName": "${SendApprovalEmailFunction}", + "Payload": { + "execution.$": "$$.Execution.Id", + "taskToken.$": "$$.Task.Token", + "input.$": "$" } }, + "TimeoutSeconds": 3600, "Next": "Approved?", "ResultPath": "$.approvalStatus" }, diff --git a/human-in-the-loop/template.yaml b/human-in-the-loop/template.yaml index 931a2c15..91f57f04 100644 --- a/human-in-the-loop/template.yaml +++ b/human-in-the-loop/template.yaml @@ -1,6 +1,6 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 -Description: Step Functions Workflow Pattern - Human in the loop +Description: Step Functions Workflow Pattern - Human in the loop with one-click email approval Parameters: ModeratorEmailAddress: @@ -15,15 +15,15 @@ Resources: Name: human-in-the-loop DefinitionUri: statemachine/statemachine.asl.json DefinitionSubstitutions: + SendApprovalEmailFunction: !GetAtt SendApprovalEmailFunction.Arn ProcessingLambda: !Ref ProcessingLambda - TopicName: !Ref NotificationTopic - Policies: # Find out more about SAM policy templates: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-policy-templates.html - - SNSPublishMessagePolicy: - TopicName: !GetAtt NotificationTopic.TopicName + Policies: + - LambdaInvokePolicy: + FunctionName: !Ref SendApprovalEmailFunction - LambdaInvokePolicy: FunctionName: !Ref ProcessingLambda - # Amazon SNS topic and subscription, triggers notification email when content needs to be reviewed. + # Amazon SNS topic for sending approval email notifications. NotificationTopic: Type: AWS::SNS::Topic Properties: @@ -31,8 +31,45 @@ Resources: - Endpoint: !Ref ModeratorEmailAddress Protocol: "email" + # Lambda function that sends the approval email with clickable approve/reject links. + # Uses waitForTaskToken — the task token is URL-encoded before embedding in the links. + SendApprovalEmailFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: SendApprovalEmailFunction + CodeUri: lambda/ + Handler: send_approval_email.lambda_handler + Runtime: python3.13 + Timeout: 10 + Environment: + Variables: + SNS_TOPIC_ARN: !Ref NotificationTopic + API_ENDPOINT: !Sub "https://${ApprovalApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/respond" + Policies: + - AWSLambdaBasicExecutionRole + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName + + # Lambda function that handles the approve/reject API Gateway callback. + # Decodes the task token and calls SendTaskSuccess or SendTaskFailure. + HandleApprovalFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: HandleApprovalFunction + CodeUri: lambda/ + Handler: handle_approval.lambda_handler + Runtime: python3.13 + Timeout: 10 + Policies: + - AWSLambdaBasicExecutionRole + - Statement: + - Effect: Allow + Action: + - states:SendTaskSuccess + - states:SendTaskFailure + Resource: '*' + # AWS Lambda function processes the review result. - # Consider using different functions when your logic gets more complex. ProcessingLambda: Type: AWS::Serverless::Function Properties: @@ -44,9 +81,60 @@ Resources: Policies: - AWSLambdaBasicExecutionRole + # API Gateway for handling approve/reject callbacks from email links. + ApprovalApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: HumanApprovalApi + + ApprovalApiResource: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: !GetAtt ApprovalApi.RootResourceId + PathPart: respond + RestApiId: !Ref ApprovalApi + + ApprovalApiMethod: + Type: AWS::ApiGateway::Method + Properties: + HttpMethod: GET + ResourceId: !Ref ApprovalApiResource + RestApiId: !Ref ApprovalApi + AuthorizationType: NONE + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub + - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaArn}/invocations + - LambdaArn: !GetAtt HandleApprovalFunction.Arn + + ApprovalApiDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: ApprovalApiMethod + Properties: + RestApiId: !Ref ApprovalApi + + ApprovalApiStage: + Type: AWS::ApiGateway::Stage + Properties: + DeploymentId: !Ref ApprovalApiDeployment + RestApiId: !Ref ApprovalApi + StageName: Prod + + ApiGatewayPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref HandleApprovalFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApprovalApi}/*/GET/respond + Outputs: StepFunctionConsoleUrl: Description: AWS Console URL of the created StepFunction Value: !Sub - "https://${AWS::Region}.console.aws.amazon.com/states/home?region=${AWS::Region}#/statemachines/view/${statemachine}" - statemachine: !Ref StateMachine + ApiEndpoint: + Description: API Gateway endpoint URL for approval callbacks + Value: !Sub "https://${ApprovalApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/respond"