From 6e7e5b7b51ff5d292f9450bf7ab980f0504d848a Mon Sep 17 00:00:00 2001 From: suhussai Date: Wed, 27 Mar 2024 16:34:34 -0600 Subject: [PATCH] feat: add github action for running tests (#15) * feat: add placeholder github action for running tests * add missing copyright for script * add helper scripts for testing * add ability to specify stack names for integ stacks * fix nag suppressions errors from stack name change * add support for multiple deployments and update tests - Add UserPoolName parameter to CognitoAuth construct - Use stack-specific log group for EventBusWatcher in control plane and core app plane - Improve credentials generation in sbt-aws.sh script - Update test-sbt.sh to use stack-specific DynamoDB table name - Remove unnecessary cdk-nag suppressions * add parameter checking for script * rename test file and update response check for deleting a non-existent tenant * update test-sbt-aws to return error code based on tests * Add check for test result output from Step Functions execution * fix sfn output parsing and comment out test for deleting non-existent tenant * update json parse logic and remove commented test. (test will be added later) * execute run-tests nightly on a schedule --- .gitattributes | 1 + .github/workflows/run-tests.yml | 25 + .gitignore | 1 + .projen/files.json | 1 + .projenrc.ts | 2 + projenrc/run-tests-workflow.ts | 48 ++ .../cognito_identity_provider_management.py | 11 +- .../functions/auth-custom-resource/index.py | 5 +- scripts/github-actions-run-tests-script.sh | 77 ++++ scripts/parse-password.py | 70 +++ scripts/sbt-aws.sh | 431 ++++++++++++++++++ scripts/test-sbt-aws.sh | 168 +++++++ src/control-plane/auth/cognito-auth.ts | 3 +- src/control-plane/integ.default.ts | 61 +-- src/core-app-plane/integ.default.ts | 87 +--- 15 files changed, 876 insertions(+), 115 deletions(-) create mode 100644 .github/workflows/run-tests.yml create mode 100644 projenrc/run-tests-workflow.ts create mode 100644 scripts/github-actions-run-tests-script.sh create mode 100644 scripts/parse-password.py create mode 100755 scripts/sbt-aws.sh create mode 100755 scripts/test-sbt-aws.sh diff --git a/.gitattributes b/.gitattributes index 361cce3..4db2526 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,7 @@ /.github/workflows/build.yml linguist-generated /.github/workflows/pull-request-lint.yml linguist-generated /.github/workflows/release.yml linguist-generated +/.github/workflows/run-tests.yml linguist-generated /.github/workflows/upgrade-main.yml linguist-generated /.gitignore linguist-generated /.mergify.yml linguist-generated diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..175b613 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,25 @@ +# ~~ Generated by projen. To modify, edit .projenrc.ts and run "npx projen". + +name: run-tests +on: + schedule: + - cron: 0 6 * * * +jobs: + run-tests: + runs-on: ubuntu-22.04 + permissions: + id-token: write + contents: read + steps: + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.IAM_ROLE_GITHUB }} + aws-region: ${{ secrets.AWS_REGION }} + - name: checkout source + uses: actions/checkout@v4 + - name: run tests + env: + STEP_FUNCTION_ARN: ${{ secrets.STEP_FUNCTION_ARN }} + LOG_GROUP_NAME: ${{ secrets.LOG_GROUP_NAME }} + run: bash -e scripts/github-actions-run-tests-script.sh diff --git a/.gitignore b/.gitignore index fd4db23..8a371cd 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,5 @@ junit.xml .jsii tsconfig.json !/API.md +!/.github/workflows/run-tests.yml !/.projenrc.ts diff --git a/.projen/files.json b/.projen/files.json index 2d4ccfa..5db4f55 100644 --- a/.projen/files.json +++ b/.projen/files.json @@ -6,6 +6,7 @@ ".github/workflows/build.yml", ".github/workflows/pull-request-lint.yml", ".github/workflows/release.yml", + ".github/workflows/run-tests.yml", ".github/workflows/upgrade-main.yml", ".gitignore", ".mergify.yml", diff --git a/.projenrc.ts b/.projenrc.ts index 017bd87..509a619 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -4,6 +4,7 @@ import { awscdk, javascript } from 'projen'; import { GithubCredentials } from 'projen/lib/github'; import { NpmAccess } from 'projen/lib/javascript'; +import { runTestsWorkflow } from './projenrc/run-tests-workflow'; const GITHUB_USER: string = 'awslabs'; const PUBLICATION_NAMESPACE: string = 'cdklabs'; @@ -142,4 +143,5 @@ project.eslint?.addRules({ ], }); +runTestsWorkflow(project); project.synth(); diff --git a/projenrc/run-tests-workflow.ts b/projenrc/run-tests-workflow.ts new file mode 100644 index 0000000..e1a53c6 --- /dev/null +++ b/projenrc/run-tests-workflow.ts @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AwsCdkConstructLibrary } from 'projen/lib/awscdk'; +import { JobPermission } from 'projen/lib/github/workflows-model'; + +export function runTestsWorkflow(project: AwsCdkConstructLibrary) { + const runTests = project.github?.addWorkflow('run-tests'); + if (runTests) { + runTests.on({ + schedule: [ + { cron: '0 6 * * *' }, // Runs at midnight Mountain Time (UTC-6) every day + ], + }); + + runTests.addJobs({ + 'run-tests': { + runsOn: ['ubuntu-22.04'], + permissions: { + idToken: JobPermission.WRITE, + contents: JobPermission.READ, + }, + steps: [ + { + name: 'configure aws credentials', + uses: 'aws-actions/configure-aws-credentials@v4', + with: { + 'role-to-assume': '${{ secrets.IAM_ROLE_GITHUB }}', + 'aws-region': '${{ secrets.AWS_REGION }}', + }, + }, + { + name: 'checkout source', + uses: 'actions/checkout@v4', + }, + { + name: 'run tests', + run: 'bash -e scripts/github-actions-run-tests-script.sh', + env: { + STEP_FUNCTION_ARN: '${{ secrets.STEP_FUNCTION_ARN }}', + LOG_GROUP_NAME: '${{ secrets.LOG_GROUP_NAME }}', + }, + }, + ], + }, + }); + } +} diff --git a/resources/functions/auth-custom-resource/cognito_identity_provider_management.py b/resources/functions/auth-custom-resource/cognito_identity_provider_management.py index 43d721a..9351024 100644 --- a/resources/functions/auth-custom-resource/cognito_identity_provider_management.py +++ b/resources/functions/auth-custom-resource/cognito_identity_provider_management.py @@ -11,13 +11,14 @@ cognito = boto3.client('cognito-idp') region = os.environ['AWS_REGION'] + class CognitoIdentityProviderManagement(): - def delete_control_plane_idp(self, userPoolId): + def delete_control_plane_idp(self, userPoolId): response = cognito.describe_user_pool( UserPoolId=userPoolId ) domain = response['UserPool']['Domain'] - + cognito.delete_user_pool_domain( UserPoolId=userPoolId, Domain=domain @@ -29,12 +30,13 @@ def create_control_plane_idp(self, event): idp_response['idp'] = {} user_details = {} control_plane_callback_url = event['ControlPlaneCallbackURL'] + user_pool_name = event['UserPoolName'] user_details['email'] = event['SystemAdminEmail'] user_details['userRole'] = event['SystemAdminRoleName'] user_details['userName'] = 'admin' user_pool_response = self.__create_user_pool( - 'SaaSControlPlaneUserPool', control_plane_callback_url) + user_pool_name, control_plane_callback_url) logger.info(user_pool_response) user_pool_id = user_pool_response['UserPool']['Id'] @@ -45,7 +47,8 @@ def create_control_plane_idp(self, event): self.__create_user_pool_domain(user_pool_id, user_pool_domain) tenant_user_group_response = user_management_util.create_user_group(user_pool_id, user_details['userRole']) user_management_util.create_user(user_pool_id, user_details) - user_management_util.add_user_to_group(user_pool_id, user_details['userName'], tenant_user_group_response['Group']['GroupName']) + user_management_util.add_user_to_group( + user_pool_id, user_details['userName'], tenant_user_group_response['Group']['GroupName']) idp_response['idp']['name'] = 'Cognito' idp_response['idp']['userPoolId'] = user_pool_id diff --git a/resources/functions/auth-custom-resource/index.py b/resources/functions/auth-custom-resource/index.py index f401d7e..703e9ff 100644 --- a/resources/functions/auth-custom-resource/index.py +++ b/resources/functions/auth-custom-resource/index.py @@ -25,6 +25,7 @@ def do_action(event, _): idp_input['ControlPlaneCallbackURL'] = event['ResourceProperties']['ControlPlaneCallbackURL'] idp_input['SystemAdminRoleName'] = event['ResourceProperties']['SystemAdminRoleName'] idp_input['SystemAdminEmail'] = event['ResourceProperties']['SystemAdminEmail'] + idp_input['UserPoolName'] = event['ResourceProperties']['UserPoolName'] idpDetails = idp_mgmt_service.create_control_plane_idp(idp_input) response = json.dumps(idpDetails) @@ -40,13 +41,15 @@ def do_action(event, _): except Exception as e: raise e + @helper.delete def do_delete(event, _): - try: + try: userPoolId = event['PhysicalResourceId'] idp_mgmt_service.delete_control_plane_idp(userPoolId) except Exception as e: raise e + def handler(event, context): helper(event, context) diff --git a/scripts/github-actions-run-tests-script.sh b/scripts/github-actions-run-tests-script.sh new file mode 100644 index 0000000..15f61a4 --- /dev/null +++ b/scripts/github-actions-run-tests-script.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Required env variables +# - $STEP_FUNCTION_ARN - ARN of the Step Function to trigger +# - $LOG_GROUP_NAME - Name of the CloudWatch Log Group to tail logs from + +# Check if required environment variables are set +if [ -z "$STEP_FUNCTION_ARN" ]; then + echo "Error: STEP_FUNCTION_ARN is not set" + exit 1 +fi + +if [ -z "$LOG_GROUP_NAME" ]; then + echo "Error: LOG_GROUP_NAME is not set" + exit 1 +fi + +# Get the current timestamp in UTC format +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Start the Step Functions execution +# The --query option extracts the executionArn from the response and assigns it to EXECUTION_ARN +EXECUTION_ARN=$(aws stepfunctions start-execution \ + --state-machine-arn "$STEP_FUNCTION_ARN" \ + --query 'executionArn' \ + --output text) + +# Get the execution name from the execution ARN +EXECUTION_NAME=$(aws stepfunctions describe-execution \ + --execution-arn "$EXECUTION_ARN" \ + --query 'name' \ + --output text) + +# Loop until the Step Function execution is complete +while true; do + # Get the current status of the Step Function execution + STATUS=$(aws stepfunctions describe-execution \ + --execution-arn "$EXECUTION_ARN" \ + --query 'status' \ + --output text) + + # Tail the logs for the current execution from the specified log group + # The --log-stream-name-prefix option filters logs by the execution name + # The --format short option prints logs in a compact format + # The --since option specifies the start time for the log stream + aws logs tail "$LOG_GROUP_NAME" --log-stream-name-prefix "$EXECUTION_NAME" --format short --since "$TIMESTAMP" + + # Update the timestamp for the next iteration + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # If the execution is still running, wait for 5 seconds before checking again + if [ "$STATUS" == "RUNNING" ]; then + sleep 5 + else + # Exit the loop if the execution is not running + break + fi +done + +# Get the final status and test result of the Step Function execution +FINAL_STATUS=$(aws stepfunctions describe-execution \ + --execution-arn "$EXECUTION_ARN" \ + --query 'status' \ + --output text) + +TEST_RESULT=$(aws stepfunctions describe-execution \ + --execution-arn "$EXECUTION_ARN" \ + --query 'output' | jq -rc '. as $my_json | try (fromjson) catch $my_json | .testResult') + +# Exit with a success (0) or failure (1) code based on the final status and test result +if [ "$FINAL_STATUS" == "SUCCEEDED" ] && [ "$TEST_RESULT" == "0" ]; then + exit 0 +else + exit 1 +fi diff --git a/scripts/parse-password.py b/scripts/parse-password.py new file mode 100644 index 0000000..3251eec --- /dev/null +++ b/scripts/parse-password.py @@ -0,0 +1,70 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import argparse +import boto3 +import re +import time +from botocore.exceptions import ClientError + +# Parse command line arguments +parser = argparse.ArgumentParser(description='Wait for an email message and extract password') +parser.add_argument('email', type=str, help='The email address to search for') +parser.add_argument('--bucket', type=str, required=True, help='The S3 bucket name') +parser.add_argument('--prefix', type=str, default='emails/', help='The prefix for email files in the S3 bucket') +parser.add_argument('--max_attempts', type=int, default=30, help='The maximum number of attempts to check for the email') +parser.add_argument('--debug', type=bool, default=False, help='Set debug mode for more verbose logs.') +args = parser.parse_args() + +# Create an S3 client +s3 = boto3.client('s3') + +# Function to check for the email message +def check_for_email(): + try: + # List objects in the S3 bucket with the specified prefix + response = s3.list_objects_v2(Bucket=args.bucket, Prefix=args.prefix) + # Iterate over the objects + for obj in response.get('Contents', []): + # Download the object content + s3_object = s3.get_object(Bucket=args.bucket, Key=obj['Key']) + file_contents = s3_object['Body'].read() + try: + obj_body = file_contents.decode('utf-8') + except UnicodeDecodeError: + if args.debug: + print(f"Unable to decode file with name: {obj['Key']}") + continue + + # Check if the email is for the specified recipient + if args.email in obj_body: + if args.debug: + print(obj_body) + # Search for the password pattern + password_match = re.search(r'with username admin and temporary password (.+)', obj_body) + if password_match: + password = password_match.group(1) + if args.debug: + print(f"Found password '{password}' for {args.email} in {obj['Key']}") + return password + + except ClientError as e: + print(f"Error: {e}") + + return None + +# Loop to check for the email message with a maximum number of attempts +password = None +attempts = 0 +while attempts < args.max_attempts: + password = check_for_email() + if password: + break + time.sleep(10) + attempts += 1 + +# Print the password if found, or a message indicating the maximum attempts reached +if password: + print(password.strip()) +else: + print(f"Maximum attempts ({args.max_attempts}) reached: Email message not found.") diff --git a/scripts/sbt-aws.sh b/scripts/sbt-aws.sh new file mode 100755 index 0000000..f245078 --- /dev/null +++ b/scripts/sbt-aws.sh @@ -0,0 +1,431 @@ +#!/bin/bash +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -e + +# Config values +CONFIG_FILE="${HOME}/.sbt-aws-config" + +# Functions +help() { + echo "Usage: $0 [--debug] [additional args]" + echo "Operations:" + echo " configure " + echo " refresh-tokens" + echo " create-tenant" + echo " get-tenant " + echo " get-all-tenants" + echo " delete-tenant " + echo " update-tenant " + echo " create-user" + echo " get-user " + echo " delete-user " + echo " help" +} + +generate_credentials() { + if $DEBUG; then + echo "Generating credentials..." + fi + + USER="admin" + PASSWORD="$1" + CONTROL_PLANE_STACK_NAME="$2" + + CONTROL_PLANE_IDP_DETAILS=$(aws cloudformation describe-stacks \ + --stack-name "$CONTROL_PLANE_STACK_NAME" \ + --query "Stacks[0].Outputs[?OutputKey=='ControlPlaneIdpDetails'].OutputValue" \ + --output text) + CLIENT_ID=$(echo "$CONTROL_PLANE_IDP_DETAILS" | jq -r '.idp.clientId') + USER_POOL_ID=$(echo "$CONTROL_PLANE_IDP_DETAILS" | jq -r '.idp.userPoolId') + + if $DEBUG; then + echo "CLIENT_ID: $CLIENT_ID" + echo "USER_POOL_ID: $USER_POOL_ID" + echo "USER: $USER" + fi + + # required in order to initiate-auth + aws cognito-idp update-user-pool-client \ + --user-pool-id "$USER_POOL_ID" \ + --client-id "$CLIENT_ID" \ + --explicit-auth-flows USER_PASSWORD_AUTH \ + --output text >/dev/null + + if $DEBUG; then + echo "Updated user pool client for USER_PASSWORD_AUTH" + fi + + # remove need for password reset + aws cognito-idp admin-set-user-password \ + --user-pool-id "$USER_POOL_ID" \ + --username "$USER" \ + --password "$PASSWORD" \ + --permanent \ + --output text >/dev/null + + if $DEBUG; then + echo "Set user password for $USER" + fi + + # get credentials for user + AUTHENTICATION_RESULT=$(aws cognito-idp initiate-auth \ + --auth-flow USER_PASSWORD_AUTH \ + --client-id "${CLIENT_ID}" \ + --auth-parameters "USERNAME='${USER}',PASSWORD='${PASSWORD}'" \ + --query 'AuthenticationResult') + + ACCESS_TOKEN=$(echo "$AUTHENTICATION_RESULT" | jq -r '.AccessToken') + ID_TOKEN=$(echo "$AUTHENTICATION_RESULT" | jq -r '.IdToken') + + if $DEBUG; then + echo "ACCESS_TOKEN: $ACCESS_TOKEN" + echo "ID_TOKEN: $ID_TOKEN" + fi + + export ACCESS_TOKEN + export ID_TOKEN +} + +configure() { + CONTROL_PLANE_STACK_NAME="$1" + EMAIL_USERNAME="$2" + EMAIL_DOMAIN="$3" + + if $DEBUG; then + echo "Configuring with:" + echo "CONTROL_PLANE_STACK_NAME: $CONTROL_PLANE_STACK_NAME" + echo "EMAIL_USERNAME: $EMAIL_USERNAME" + echo "EMAIL_DOMAIN: $EMAIL_DOMAIN" + fi + + read -r -s -p "Enter admin password: " ADMIN_USER_PASSWORD + echo + + generate_credentials "$ADMIN_USER_PASSWORD" "$CONTROL_PLANE_STACK_NAME" + CONTROL_PLANE_API_ENDPOINT=$(aws cloudformation describe-stacks \ + --stack-name "$CONTROL_PLANE_STACK_NAME" \ + --query "Stacks[0].Outputs[?contains(OutputKey,'controlPlaneAPIEndpoint')].OutputValue" \ + --output text) + + if $DEBUG; then + echo "CONTROL_PLANE_API_ENDPOINT: $CONTROL_PLANE_API_ENDPOINT" + fi + + printf "CONTROL_PLANE_STACK_NAME=%s\nCONTROL_PLANE_API_ENDPOINT=%s\nADMIN_USER_PASSWORD=\'%s\'\nEMAIL_USERNAME=%s\nEMAIL_DOMAIN=%s\nACCESS_TOKEN=%s\nID_TOKEN=%s\n" \ + "$CONTROL_PLANE_STACK_NAME" "$CONTROL_PLANE_API_ENDPOINT" "$ADMIN_USER_PASSWORD" "$EMAIL_USERNAME" "$EMAIL_DOMAIN" "$ACCESS_TOKEN" "$ID_TOKEN" > "$CONFIG_FILE" + + if $DEBUG; then + echo "Configuration saved to $CONFIG_FILE" + fi +} + +refresh_tokens() { + source_config + + if $DEBUG; then + echo "Refreshing tokens..." + fi + + generate_credentials "$ADMIN_USER_PASSWORD" + CONTROL_PLANE_API_ENDPOINT=$(aws cloudformation describe-stacks \ + --stack-name "$CONTROL_PLANE_STACK_NAME" \ + --query "Stacks[0].Outputs[?contains(OutputKey,'controlPlaneAPIEndpoint')].OutputValue" \ + --output text) + + printf "CONTROL_PLANE_STACK_NAME=%s\nCONTROL_PLANE_API_ENDPOINT=%s\nADMIN_USER_PASSWORD=\'%s\'\nEMAIL_USERNAME=%s\nEMAIL_DOMAIN=%s\nACCESS_TOKEN=%s\nID_TOKEN=%s\n" \ + "$CONTROL_PLANE_STACK_NAME" "$CONTROL_PLANE_API_ENDPOINT" "$ADMIN_USER_PASSWORD" "$EMAIL_USERNAME" "$EMAIL_DOMAIN" "$ACCESS_TOKEN" "$ID_TOKEN" >"$CONFIG_FILE" + + if $DEBUG; then + echo "Tokens refreshed and saved to $CONFIG_FILE" + fi +} + +source_config() { + source "$CONFIG_FILE" +} + +create_tenant() { + source_config + TENANT_NAME="tenant$RANDOM" + TENANT_EMAIL="${EMAIL_USERNAME}+${TENANT_NAME}@${EMAIL_DOMAIN}" + + if $DEBUG; then + echo "Creating tenant with:" + echo "TENANT_NAME: $TENANT_NAME" + echo "TENANT_EMAIL: $TENANT_EMAIL" + fi + + DATA=$(jq --null-input \ + --arg tenantName "$TENANT_NAME" \ + --arg tenantEmail "$TENANT_EMAIL" \ + '{ + "tenantName": $tenantName, + "email": $tenantEmail, + "tier": "basic", + "tenantStatus": "In progress", + "prices": [ + { + "id": "price_123456789Example", + "metricName": "productsSold" + }, + { + "id": "price_123456789AnotherExample", + "metricName": "plusProductsSold" + } + ] + }') + + RESPONSE=$(curl --request POST \ + --url "${CONTROL_PLANE_API_ENDPOINT}tenants" \ + --header "Authorization: Bearer ${ID_TOKEN}" \ + --header 'content-type: application/json' \ + --data "$DATA" \ + --silent) + + if $DEBUG; then + echo "Response: $RESPONSE" + else + echo "$RESPONSE" + fi +} + +get_tenant() { + source_config + TENANT_ID="$1" + + if $DEBUG; then + echo "Getting tenant with ID: $TENANT_ID" + fi + + RESPONSE=$(curl --request GET \ + --url "${CONTROL_PLANE_API_ENDPOINT}tenants/$TENANT_ID" \ + --header "Authorization: Bearer $ID_TOKEN" \ + --silent) + + if $DEBUG; then + echo "Response: $RESPONSE" + else + echo "$RESPONSE" + fi +} + +get_all_tenants() { + source_config + + if $DEBUG; then + echo "Getting all tenants" + fi + + RESPONSE=$(curl --request GET \ + --url "${CONTROL_PLANE_API_ENDPOINT}tenants" \ + --header "Authorization: Bearer $ID_TOKEN" \ + --silent) + + if $DEBUG; then + echo "Response: $RESPONSE" + else + echo "$RESPONSE" + fi +} + +delete_tenant() { + source_config + TENANT_ID="$1" + + if $DEBUG; then + echo "Deleting tenant with ID: $TENANT_ID" + fi + + RESPONSE=$(curl --request DELETE \ + --url "${CONTROL_PLANE_API_ENDPOINT}tenants/$TENANT_ID" \ + --header "Authorization: Bearer $ID_TOKEN" \ + --header 'content-type: application/json' \ + --silent) + + if $DEBUG; then + echo "Response: $RESPONSE" + else + echo "$RESPONSE" + fi +} + +create_user() { + source_config + USER_NAME="user$RANDOM" + USER_EMAIL="${EMAIL_USERNAME}+${USER_NAME}@${EMAIL_DOMAIN}" + + if $DEBUG; then + echo "Creating user with:" + echo "USER_NAME: $USER_NAME" + echo "USER_EMAIL: $USER_EMAIL" + fi + + DATA=$(jq --null-input \ + --arg userName "$USER_NAME" \ + --arg email "$USER_EMAIL" \ + '{ + "userName": $userName, + "email": $email, + "userRole": "basicUser" + }') + + RESPONSE=$(curl --request POST \ + --url "${CONTROL_PLANE_API_ENDPOINT}users" \ + --header "Authorization: Bearer $ID_TOKEN" \ + --header 'content-type: application/json' \ + --data "$DATA" \ + --silent) + + if $DEBUG; then + echo "Response: $RESPONSE" + else + echo "$RESPONSE" + fi +} + +get_user() { + source_config + USER_NAME="$1" + + if $DEBUG; then + echo "Getting user with name: $USER_NAME" + fi + + RESPONSE=$(curl --request GET \ + --url "${CONTROL_PLANE_API_ENDPOINT}users/$USER_NAME" \ + --header "Authorization: Bearer $ID_TOKEN" \ + --silent) + + if $DEBUG; then + echo "Response: $RESPONSE" + else + echo "$RESPONSE" + fi +} + +delete_user() { + source_config + USER_NAME="$1" + + if $DEBUG; then + echo "Deleting user with name: $USER_NAME" + fi + + RESPONSE=$(curl --request DELETE \ + --url "${CONTROL_PLANE_API_ENDPOINT}users/$USER_NAME" \ + --header "Authorization: Bearer $ID_TOKEN" \ + --silent) + + if $DEBUG; then + echo "Response: $RESPONSE" + else + echo "$RESPONSE" + fi +} + +update_tenant() { + echo "PUT ${CONTROL_PLANE_API_ENDPOINT}tenants/$TENANT_ID only supports AWS_IAM auth" + # source_config + # TENANT_ID="$1" + # KEY="$2" + # VALUE="$3" + + # DATA=$(jq --null-input \ + # --arg key "$KEY" \ + # --arg value "$VALUE" \ + # '{($key): $value}') + + # curl --request PUT \ + # --url "${CONTROL_PLANE_API_ENDPOINT}tenants/$TENANT_ID" \ + # --header "Authorization: Bearer $ID_TOKEN" \ + # --header 'content-type: application/json' \ + # --data "$DATA" \ + # --silent +} + +# Main +DEBUG=false +if [ "$1" = "--debug" ]; then + DEBUG=true + shift +fi + +if [ $# -eq 0 ]; then + help + exit 1 +fi + +case "$1" in +"configure") + shift + configure "$@" + ;; + +"refresh-tokens") + refresh_tokens + ;; + +"create-tenant") + create_tenant + ;; + +"get-tenant") + if [ $# -ne 2 ]; then + echo "Error: delete-tenant requires tenant id" + exit 1 + fi + get_tenant "$2" + ;; + +"get-all-tenants") + get_all_tenants + ;; + +"delete-tenant") + if [ $# -ne 2 ]; then + echo "Error: delete-tenant requires tenant id" + exit 1 + fi + delete_tenant "$2" + ;; + +"update-tenant") + if [ $# -ne 4 ]; then + echo "Error: update-tenant requires tenant id, key, and value" + exit 1 + fi + update_tenant "$2" "$3" "$4" + ;; + +"create-user") + create_user + ;; + +"get-user") + if [ $# -ne 2 ]; then + echo "Error: get-user requires user name" + exit 1 + fi + get_user "$2" + ;; + +"delete-user") + if [ $# -ne 2 ]; then + echo "Error: delete-user requires user name" + exit 1 + fi + delete_user "$2" + ;; + +"help") + help + ;; + +*) + echo "Invalid operation: $1" + help + exit 1 + ;; +esac diff --git a/scripts/test-sbt-aws.sh b/scripts/test-sbt-aws.sh new file mode 100755 index 0000000..4aa70d9 --- /dev/null +++ b/scripts/test-sbt-aws.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +CONTROL_PLANE_STACK_NAME="$1" + +# Colors for logging +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +# Variable to track overall test status +TEST_PASSED=true + +# Function to log test status +log_test() { + local status=$1 + local message=$2 + + if [ "$status" = "pass" ]; then + echo -e "${GREEN}[PASS] $message${NC}" + else + echo -e "${RED}[FAIL] $message${NC}" + TEST_PASSED=false + fi +} + +# Function to wait for CloudFormation stack creation +wait_for_stack_creation() { + local stack_name_pattern=$1 + local max_attempts=60 + local attempt=0 + + while true; do + stack_status=$(aws cloudformation describe-stacks --query 'Stacks[?contains(StackName, `'"$stack_name_pattern"'`)].StackStatus' --output text) + if [ "$stack_status" = "CREATE_COMPLETE" ]; then + break + elif [ "$stack_status" = "CREATE_FAILED" ]; then + log_test "fail" "CloudFormation stack creation failed" + return 1 + fi + + attempt=$((attempt + 1)) + if [ "$attempt" -gt "$max_attempts" ]; then + log_test "fail" "Timeout waiting for CloudFormation stack creation" + return 1 + fi + + sleep 10 + done +} + +check_dynamodb_table_entry() { + local table_name=$1 + local tenant_id=$2 + local expected_status=$3 + + entry=$(aws dynamodb get-item --table-name "$table_name" --key '{"tenantId":{"S":"'$tenant_id'"}}' --query 'Item.tenantStatus.S' --output text) + if [ "$entry" = "$expected_status" ]; then + return 0 + else + return 1 + fi +} + +# Test create-tenant +echo "Testing create-tenant..." +tenant_id=$(./sbt-aws.sh create-tenant | jq -r '.data.tenantId') +# check to make sure tenant_id is NOT empty and is NOT null +if [ -n "$tenant_id" ] && [ "$tenant_id" != "null" ]; then + log_test "pass" "Tenant created successfully with ID: $tenant_id" + + # Check DynamoDB table entry + table_name=$(aws dynamodb list-tables --query "TableNames[?contains(@, 'TenantDetails') && contains(@, '$CONTROL_PLANE_STACK_NAME')]" | jq -r '.[0]') + check_dynamodb_table_entry "$table_name" "$tenant_id" "In progress" + if [ $? -eq 0 ]; then + log_test "pass" "Tenant status set to 'In progress' in DynamoDB table" + else + log_test "fail" "Failed to set tenant status to 'In progress' in DynamoDB table" + fi + + # Wait for CloudFormation stack creation + stack_name="$tenant_id" + wait_for_stack_creation "$stack_name" + + # Wait for tenant status to change to 'created' + max_attempts=60 + attempt=0 + while true; do + check_dynamodb_table_entry "$table_name" "$tenant_id" "created" + if [ $? -eq 0 ]; then + log_test "pass" "Tenant status changed to 'created' in DynamoDB table" + break + fi + + attempt=$((attempt + 1)) + if [ "$attempt" -gt "$max_attempts" ]; then + log_test "fail" "Timeout waiting for tenant status to change to 'created'" + break + fi + + sleep 5 + done +else + log_test "fail" "Failed to create tenant" + exit 1 +fi + +# Test get-all-tenants +echo "Testing get-all-tenants..." +tenants=$(./sbt-aws.sh get-all-tenants) +if echo "$tenants" | grep -q "$tenant_id"; then + log_test "pass" "Tenant found in get-all-tenants" +else + log_test "fail" "Tenant not found in get-all-tenants" +fi + +# Test get-tenant +echo "Testing get-tenant..." +tenant_details=$(./sbt-aws.sh get-tenant "$tenant_id") +if [ -n "$tenant_details" ]; then + log_test "pass" "Tenant details retrieved successfully" +else + log_test "fail" "Failed to retrieve tenant details" +fi + +# Test delete-tenant +echo "Testing delete-tenant..." +./sbt-aws.sh delete-tenant "$tenant_id" >/dev/null +if [ $? -eq 0 ]; then + log_test "pass" "Tenant deletion initiated successfully" + + # Check DynamoDB table entry + check_dynamodb_table_entry "$table_name" "$tenant_id" "Deleting" + if [ $? -eq 0 ]; then + log_test "pass" "Tenant status set to 'Deleting' in DynamoDB table" + else + log_test "fail" "Failed to set tenant status to 'Deleting' in DynamoDB table" + fi + + # Wait for tenant status to change to 'deleted' + max_attempts=60 + attempt=0 + while true; do + check_dynamodb_table_entry "$table_name" "$tenant_id" "deleted" + if [ $? -eq 0 ]; then + log_test "pass" "Tenant status changed to 'deleted' in DynamoDB table" + break + fi + + attempt=$((attempt + 1)) + if [ "$attempt" -gt "$max_attempts" ]; then + log_test "fail" "Timeout waiting for tenant status to change to 'deleted'" + break + fi + + sleep 5 + done +else + log_test "fail" "Failed to delete tenant" +fi + +# Set the exit code based on the overall test status +if [ "$TEST_PASSED" = true ]; then + exit 0 +else + exit 1 +fi diff --git a/src/control-plane/auth/cognito-auth.ts b/src/control-plane/auth/cognito-auth.ts index 3f6739a..cb9dfcb 100644 --- a/src/control-plane/auth/cognito-auth.ts +++ b/src/control-plane/auth/cognito-auth.ts @@ -129,6 +129,7 @@ export class CognitoAuth extends Construct implements IAuth { ControlPlaneCallbackURL: props.controlPlaneCallbackURL || defaultControlPlaneCallbackURL, SystemAdminRoleName: props.systemAdminRoleName, SystemAdminEmail: props.systemAdminEmail, + UserPoolName: `SaaSControlPlaneUserPool-${this.node.addr}`, }, } ); @@ -142,7 +143,7 @@ export class CognitoAuth extends Construct implements IAuth { new CfnOutput(this, 'ControlPlaneIdpDetails', { value: this.controlPlaneIdpDetails, - exportName: 'ControlPlaneIdpDetails', + key: 'ControlPlaneIdpDetails', }); const customAuthorizerFunction = new PythonFunction(this, 'CustomAuthorizerFunction', { entry: path.join(__dirname, '../../../resources/functions/authorizer'), diff --git a/src/control-plane/integ.default.ts b/src/control-plane/integ.default.ts index a9f5867..9f81e3f 100644 --- a/src/control-plane/integ.default.ts +++ b/src/control-plane/integ.default.ts @@ -2,10 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import * as cdk from 'aws-cdk-lib'; -import { EventBus, Rule } from 'aws-cdk-lib/aws-events'; -import * as targets from 'aws-cdk-lib/aws-events-targets'; +import { CfnRule, EventBus, Rule } from 'aws-cdk-lib/aws-events'; import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; -import { AwsSolutionsChecks, NagSuppressions } from 'cdk-nag'; +import { AwsSolutionsChecks } from 'cdk-nag'; import { CognitoAuth, ControlPlane } from '.'; import { DestroyPolicySetter } from '../cdk-aspect/destroy-policy-setter'; @@ -25,8 +24,6 @@ export class IntegStack extends cdk.Stack { idpName: idpName, systemAdminRoleName: systemAdminRoleName, systemAdminEmail: props.systemAdminEmail, - // optional parameter possibly populated by another construct or an argument - // controlPlaneCallbackURL: 'https://example.com', }); const controlPlane = new ControlPlane(this, 'ControlPlane', { @@ -51,14 +48,22 @@ export class IntegStack extends cdk.Stack { }, }); - eventBusWatcherRule.addTarget( - new targets.CloudWatchLogGroup( - new LogGroup(this, 'EventBusWatcherLogGroup', { - removalPolicy: cdk.RemovalPolicy.DESTROY, - retention: RetentionDays.ONE_WEEK, - }) - ) - ); + const eventBusWatcherLogGroup = new LogGroup(this, 'EventBusWatcherLogGroup', { + logGroupName: `/aws/events/EventBusWatcher-${this.node.addr}`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + retention: RetentionDays.ONE_WEEK, + }); + + // use escape-hatch instead of native addTarget functionality to avoid + // unpredictable resource names that emit cdk-nag errors + // https://github.com/aws/aws-cdk/issues/17002#issuecomment-1144066244 + const cfnRule = eventBusWatcherRule.node.defaultChild as CfnRule; + cfnRule.targets = [ + { + arn: eventBusWatcherLogGroup.logGroupArn, + id: this.node.addr, + }, + ]; } } @@ -67,37 +72,11 @@ if (!process.env.CDK_PARAM_SYSTEM_ADMIN_EMAIL) { } const app = new cdk.App(); -const integStack = new IntegStack(app, 'ControlPlane-integ', { +const integStack = new IntegStack(app, process.env.CDK_PARAM_STACK_ID ?? 'ControlPlane-integ', { systemAdminEmail: process.env.CDK_PARAM_SYSTEM_ADMIN_EMAIL, + stackName: process.env.CDK_PARAM_STACK_NAME, }); -NagSuppressions.addResourceSuppressionsByPath( - integStack, - [ - `/${integStack.stackName}/AWS679f53fac002430cb0da5b7982bd2287/Resource`, - `/${integStack.stackName}/AWS679f53fac002430cb0da5b7982bd2287/ServiceRole/Resource`, - `/${integStack.stackName}/EventsLogGroupPolicyControlPlaneintegEventBusWatcherRule79DEBEE7/CustomResourcePolicy/Resource`, - ], - [ - { - id: 'AwsSolutions-IAM4', - reason: 'Suppress error from resource created for testing.', - appliesTo: [ - 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', - ], - }, - { - id: 'AwsSolutions-IAM5', - reason: 'Suppress error from resource created for testing.', - appliesTo: ['Resource::*'], - }, - { - id: 'AwsSolutions-L1', - reason: 'Suppress error from resource created for testing.', - }, - ] -); - // Ensure that we remove all resources (like DDB tables, s3 buckets) when deleting the stack. cdk.Aspects.of(integStack).add(new DestroyPolicySetter()); cdk.Aspects.of(integStack).add(new AwsSolutionsChecks({ verbose: true })); diff --git a/src/core-app-plane/integ.default.ts b/src/core-app-plane/integ.default.ts index 70b00ab..cc4874f 100644 --- a/src/core-app-plane/integ.default.ts +++ b/src/core-app-plane/integ.default.ts @@ -2,8 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import * as cdk from 'aws-cdk-lib'; -import { EventBus, Rule } from 'aws-cdk-lib/aws-events'; -import * as targets from 'aws-cdk-lib/aws-events-targets'; +import { CfnRule, EventBus, Rule } from 'aws-cdk-lib/aws-events'; import { Effect, PolicyDocument, PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; import { AwsSolutionsChecks, NagSuppressions } from 'cdk-nag'; @@ -150,33 +149,22 @@ echo "done!" }, }); - NagSuppressions.addResourceSuppressions( - eventBusWatcherRule, - [ - { - id: 'AwsSolutions-IAM4', - reason: 'Suppress error from resource created for testing.', - appliesTo: [ - 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', - ], - }, - { - id: 'AwsSolutions-IAM5', - reason: 'Suppress error from resource created for testing.', - appliesTo: ['Resource::*'], - }, - ], - true // applyToChildren = true, so that it applies to resources created by the rule. Ex. lambda role. - ); + const eventBusWatcherLogGroup = new LogGroup(this, 'EventBusWatcherLogGroup', { + logGroupName: `/aws/events/EventBusWatcher-${this.node.addr}`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + retention: RetentionDays.ONE_WEEK, + }); - eventBusWatcherRule.addTarget( - new targets.CloudWatchLogGroup( - new LogGroup(this, 'EventBusWatcherLogGroup', { - removalPolicy: cdk.RemovalPolicy.DESTROY, - retention: RetentionDays.ONE_WEEK, - }) - ) - ); + // use escape-hatch instead of native addTarget functionality to avoid + // unpredictable resource names that emit cdk-nag errors + // https://github.com/aws/aws-cdk/issues/17002#issuecomment-1144066244 + const cfnRule = eventBusWatcherRule.node.defaultChild as CfnRule; + cfnRule.targets = [ + { + arn: eventBusWatcherLogGroup.logGroupArn, + id: this.node.addr, + }, + ]; } } @@ -189,36 +177,13 @@ if (!process.env.CDK_PARAM_EVENT_BUS_ARN) { } const app = new cdk.App(); -const integStack = new IntegStack(app, 'CoreAppPlane-integ', { +const integStack = new IntegStack(app, process.env.CDK_PARAM_STACK_ID ?? 'CoreAppPlane-integ', { eventBusArn: process.env.CDK_PARAM_EVENT_BUS_ARN, }); NagSuppressions.addResourceSuppressionsByPath( integStack, - `/${integStack.stackName}/AWS679f53fac002430cb0da5b7982bd2287/Resource`, - [ - { - id: 'AwsSolutions-L1', - reason: 'Suppress error from resource created for testing.', - }, - ] -); - -NagSuppressions.addResourceSuppressionsByPath( - integStack, - `/${integStack.stackName}/EventsLogGroupPolicyCoreAppPlaneintegEventBusWatcherRule0F03BA2B/CustomResourcePolicy/Resource`, - [ - { - id: 'AwsSolutions-IAM5', - reason: 'Suppress error from resource created for testing.', - appliesTo: ['Resource::*'], - }, - ] -); - -NagSuppressions.addResourceSuppressionsByPath( - integStack, - `/${integStack.stackName}/CoreApplicationPlane/deprovisioning-codeBuildProvisionProjectRole/Resource`, + `/${integStack.artifactId}/CoreApplicationPlane/deprovisioning-codeBuildProvisionProjectRole/Resource`, [ { id: 'AwsSolutions-IAM5', @@ -230,7 +195,7 @@ NagSuppressions.addResourceSuppressionsByPath( NagSuppressions.addResourceSuppressionsByPath( integStack, - `/${integStack.stackName}/CoreApplicationPlane/provisioning-codeBuildProvisionProjectRole/Resource`, + `/${integStack.artifactId}/CoreApplicationPlane/provisioning-codeBuildProvisionProjectRole/Resource`, [ { id: 'AwsSolutions-IAM5', @@ -240,19 +205,5 @@ NagSuppressions.addResourceSuppressionsByPath( ] ); -NagSuppressions.addResourceSuppressionsByPath( - integStack, - `/${integStack.stackName}/AWS679f53fac002430cb0da5b7982bd2287/ServiceRole/Resource`, - [ - { - id: 'AwsSolutions-IAM4', - reason: 'Suppress error from resource created for testing.', - appliesTo: [ - 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', - ], - }, - ] -); - cdk.Aspects.of(integStack).add(new DestroyPolicySetter()); cdk.Aspects.of(integStack).add(new AwsSolutionsChecks({ verbose: true }));