"""Lambda function module.""" import base64 import hashlib import boto3 import config from constants.constants import ENCODING import requests from simplejson.errors import JSONDecodeError # Based on AWS Secrets Manager rotation Lambdas generic template: # https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/blob/master/SecretsManagerRotationTemplate/lambda_function.py # noqa # # For more information on how AWS secret rotation works: # https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotate-secrets_how.html # noqa logger = config.app_logger def handler(event, context): """Secrets Manager Rotation Template. This is a template for creating an AWS Secrets Manager rotation lambda Args: event (dict): Lambda dictionary of event parameters. These keys must include the following: - SecretId: The secret ARN or identifier - ClientRequestToken: The ClientRequestToken of the secret version - Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret) context (LambdaContext): The Lambda runtime information Raises: ResourceNotFoundException: If the secret with the specified arn and stage does not exist ValueError: If the secret is not properly configured for rotation KeyError: If the event parameters do not contain the expected keys """ logger.info('Triggered Rotate Payoneer Auth Token Lambda.') logger.info(f'Event: {event}') logger.info(f'Context: {context}') arn = event['SecretId'] token = event['ClientRequestToken'] step = event['Step'] service_client = boto3.client( service_name='secretsmanager', region_name=config.AWS_REGION, ) # Make sure the version is staged correctly metadata = service_client.describe_secret(SecretId=arn) if not metadata['RotationEnabled']: logger.error('Secret %s is not enabled for rotation' % arn) raise ValueError('Secret %s is not enabled for rotation' % arn) versions = metadata['VersionIdsToStages'] if token not in versions: logger.error( 'Secret version %s has no stage for rotation of secret %s.' % (token, arn)) raise ValueError( 'Secret version %s has no stage for rotation of secret %s.' % (token, arn)) if 'AWSCURRENT' in versions[token]: logger.info( 'Secret version %s already set as AWSCURRENT for secret %s.' % (token, arn)) return elif 'AWSPENDING' not in versions[token]: logger.error( 'Secret version %s not set as AWSPENDING ' 'for rotation of secret %s.' % (token, arn)) raise ValueError( 'Secret version %s not set as AWSPENDING ' 'for rotation of secret %s.' % (token, arn)) if step == 'createSecret': create_secret(service_client, arn, token) elif step == 'setSecret': set_secret(service_client, arn, token) elif step == 'testSecret': test_secret(service_client, arn, token) elif step == 'finishSecret': finish_secret(service_client, arn, token) else: raise ValueError('Invalid step parameter') def create_secret(service_client, arn, token): """Create the secret. This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a new secret and put it with the passed in token. Args: service_client (client): The secrets manager service client arn (string): The secret ARN or other identifier token (string): The ClientRequestToken associated with the secret version Raises: ResourceNotFoundException: If the secret with the specified arn and stage does not exist """ # Make sure the current secret exists service_client.get_secret_value(SecretId=arn, VersionStage='AWSCURRENT') # Try to get the secret version, if that fails, create a new secret version # with stage "pending" that will be set to "current" after it's populated try: service_client.get_secret_value( SecretId=arn, VersionId=token, VersionStage='AWSPENDING') logger.info( 'createSecret: Successfully retrieved secret for %s.' % arn) except service_client.exceptions.ResourceNotFoundException: logger.info('createSecret: Could not find secret for %s.' % arn) auth_token = get_payoneer_auth_token() # Put the secret service_client.put_secret_value( SecretId=arn, ClientRequestToken=token, SecretString=auth_token, VersionStages=['AWSPENDING']) logger.info( 'createSecret: Successfully put secret ' 'for ARN %s and version %s.' % (arn, token)) def set_secret(service_client, arn, token): """Set the secret. This method should set the AWSPENDING secret in the service that the secret belongs to. For example, if the secret is a database credential, this method should take the value of the AWSPENDING secret and set the user's password to this value in the database. Args: service_client (client): The secrets manager service client arn (string): The secret ARN or other identifier token (string): The ClientRequestToken associated with the secret version """ pass def test_secret(service_client, arn, token): """Test the secret. This method should validate that the AWSPENDING secret works in the service that the secret belongs to. For example, if the secret is a database credential, this method should validate that the user can login with the password in AWSPENDING and that the user has all of the expected permissions against the database. Args: service_client (client): The secrets manager service client arn (string): The secret ARN or other identifier token (string): The ClientRequestToken associated with the secret version """ if not config.PAYONEER_PROGRAM_ID: raise ValueError('Missing Payoneer program id.') # Try to get the pending secret to test if it works with the Payoneer API try: access_token = service_client.get_secret_value( SecretId=arn, VersionId=token, VersionStage='AWSPENDING')['SecretString'] logger.info( 'testSecret: Successfully retrieved secret for %s.' % arn) except service_client.exceptions.ResourceNotFoundException as exc: logger.info('testSecret: Could not find secret for %s.' % arn) raise exc if not access_token: raise ValueError('Payoneer auth token to test was not found.') # Log token hash to make sure we test the one generated previously token_hash = hashlib.sha256(access_token.encode(ENCODING)).hexdigest()[:6] logger.info(f'Token hash: {token_hash} Token length: {len(access_token)}') headers = { 'content-type': 'application/json', 'Authorization': f'Bearer {access_token}', } # Hash to hide Payoneer program id url_hash = token_hash = hashlib.sha256( config.PAYONEER_API_TEST_URL.encode(ENCODING)).hexdigest()[:6] logger.info(f'Payoneer API URL hash: {url_hash}') logger.info(f'Payoneer API base URL: {config.PAYONEER_API_URL}') try: response = requests.get( config.PAYONEER_API_TEST_URL, headers=headers ) response_json = response.json() except JSONDecodeError: logger.info( f'testSecret failed to parse JSON for response {response.text}') raise ValueError( 'testSecret failed to parse JSON from Payoneer response') logger.info(f'Payoneer API test request response: {response_json.keys()}') if response_json.get('error'): raise ValueError( f'The Payoneer API test request returned errors: {response_json}') if not response_json.get('result'): raise ValueError('The Payoneer API test request returned no result') def finish_secret(service_client, arn, token): """Finish the secret. This method finalizes the rotation process by marking the secret version passed in as the AWSCURRENT secret. Args: service_client (client): The secrets manager service client arn (string): The secret ARN or other identifier token (string): The ClientRequestToken associated with the secret version Raises: ResourceNotFoundException: If the secret with the specified arn does not exist """ # First describe the secret to get the current version metadata = service_client.describe_secret(SecretId=arn) current_version = None for version in metadata['VersionIdsToStages']: if 'AWSCURRENT' in metadata['VersionIdsToStages'][version]: if version == token: # The correct version is already marked as current, return logger.info( 'finishSecret: Version %s already marked ' 'as AWSCURRENT for %s' % (version, arn)) return current_version = version break # Finalize by staging the secret version current service_client.update_secret_version_stage( SecretId=arn, VersionStage='AWSCURRENT', MoveToVersionId=token, RemoveFromVersionId=current_version) logger.info( 'finishSecret: Successfully set AWSCURRENT stage to ' 'version %s for secret %s.' % (token, arn)) def get_payoneer_auth_token(): """Retrieve Payoneer auth token. This method retrieves a new auth token from Payoneer using the Client's secret and id. Returns: token (string): The Payoneer auth token Raises: ValueError: If the token is not present in the Payoneer response """ # Based on Payoneer documentation "Mass Payouts V4 - Requesting an Application Token" # noqa # https://developer.payoneer.com/docs/mass-payouts-v4.html#/ZG9jOjM1Njc2ODY4-requesting-an-application-token # noqa logger.info('Started the process to retrieve a new Payoneer auth token.') if not config.PAYONEER_CLIENT_ID or not config.PAYONEER_CLIENT_SECRET: raise ValueError('Missing Payoneer client id or client secret.') client_string =\ f'{config.PAYONEER_CLIENT_ID}:{config.PAYONEER_CLIENT_SECRET}' client_string_bytes = client_string.encode(ENCODING) base64_client_string = base64.b64encode(client_string_bytes) decoded_client_string = base64_client_string.decode(ENCODING) header_token = f'Basic {decoded_client_string}' headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': header_token, } body = { 'grant_type': 'client_credentials', 'scope': 'read write', } logger.info( f'Sending auth request to Payoneer API on: {config.PAYONEER_AUTH_URL}') response = requests.post( config.PAYONEER_AUTH_URL, data=body, headers=headers) response_json = response.json() access_token = response_json.get('access_token') # Don't log the values to avoid exposing secrets logger.info(f'Payoneer response keys: {response_json.keys()}') if not access_token: # It's ok to log errors as they don't contain sensitive information logger.info(f'Payoneer response errors: {response_json.get("Errors")}') raise ValueError('Payoneer did not return an access token') # Log token hash to make sure we use the right one later token_hash = hashlib.sha256(access_token.encode(ENCODING)).hexdigest()[:6] logger.info(f'Token hash: {token_hash} Token length: {len(access_token)}') return access_token