From 8cb6f60f04311e58564a7b510314bc0f4462eeb4 Mon Sep 17 00:00:00 2001 From: jimmoffet Date: Mon, 3 Oct 2022 09:05:34 -0700 Subject: [PATCH] modify inbound notif processing --- .venv/bin/Activate.ps1 | 247 ++++++++++++++++++ .venv/bin/activate | 69 +++++ .venv/bin/activate.csh | 26 ++ .venv/bin/activate.fish | 66 +++++ .venv/bin/pip | 8 + .venv/bin/pip-compile | 8 + .venv/bin/pip-sync | 8 + .venv/bin/pip3 | 8 + .venv/bin/pip3.10 | 8 + .venv/bin/pyproject-build | 8 + .venv/bin/python | 1 + .venv/bin/python3 | 1 + .venv/bin/python3.10 | 1 + .venv/bin/wheel | 8 + .venv/pyvenv.cfg | 3 + app/celery/process_ses_receipts_tasks.py | 44 +--- .../{validate_sns.py => validate_sns_cert.py} | 3 +- app/celery/validate_sns_message.py | 66 +++++ app/config.py | 4 +- app/inbound_sms/rest.py | 7 +- app/notifications/receive_notifications.py | 63 ++++- .../versions/0377_add_inbound_sms_number.py | 52 ++++ 22 files changed, 665 insertions(+), 44 deletions(-) create mode 100644 .venv/bin/Activate.ps1 create mode 100644 .venv/bin/activate create mode 100644 .venv/bin/activate.csh create mode 100644 .venv/bin/activate.fish create mode 100755 .venv/bin/pip create mode 100755 .venv/bin/pip-compile create mode 100755 .venv/bin/pip-sync create mode 100755 .venv/bin/pip3 create mode 100755 .venv/bin/pip3.10 create mode 100755 .venv/bin/pyproject-build create mode 120000 .venv/bin/python create mode 120000 .venv/bin/python3 create mode 120000 .venv/bin/python3.10 create mode 100755 .venv/bin/wheel create mode 100644 .venv/pyvenv.cfg rename app/celery/{validate_sns.py => validate_sns_cert.py} (97%) create mode 100644 app/celery/validate_sns_message.py create mode 100644 migrations/versions/0377_add_inbound_sms_number.py diff --git a/.venv/bin/Activate.ps1 b/.venv/bin/Activate.ps1 new file mode 100644 index 000000000..b49d77ba4 --- /dev/null +++ b/.venv/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/.venv/bin/activate b/.venv/bin/activate new file mode 100644 index 000000000..d8aaaac7e --- /dev/null +++ b/.venv/bin/activate @@ -0,0 +1,69 @@ +# This file must be used with "source bin/activate" *from bash* +# you cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # This should detect bash and zsh, which have a hash command that must + # be called to get it to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then + hash -r 2> /dev/null + fi + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +VIRTUAL_ENV="/Users/jamesdmoffet/notifications-api/.venv" +export VIRTUAL_ENV + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/bin:$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1="(.venv) ${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT="(.venv) " + export VIRTUAL_ENV_PROMPT +fi + +# This should detect bash and zsh, which have a hash command that must +# be called to get it to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then + hash -r 2> /dev/null +fi diff --git a/.venv/bin/activate.csh b/.venv/bin/activate.csh new file mode 100644 index 000000000..de77f1e28 --- /dev/null +++ b/.venv/bin/activate.csh @@ -0,0 +1,26 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. +# Created by Davide Di Blasi . +# Ported to Python 3.3 venv by Andrew Svetlov + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV "/Users/jamesdmoffet/notifications-api/.venv" + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/bin:$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = "(.venv) $prompt" + setenv VIRTUAL_ENV_PROMPT "(.venv) " +endif + +alias pydoc python -m pydoc + +rehash diff --git a/.venv/bin/activate.fish b/.venv/bin/activate.fish new file mode 100644 index 000000000..579f1fae1 --- /dev/null +++ b/.venv/bin/activate.fish @@ -0,0 +1,66 @@ +# This file must be used with "source /bin/activate.fish" *from fish* +# (https://fishshell.com/); you cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + functions -e fish_prompt + set -e _OLD_FISH_PROMPT_OVERRIDE + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV "/Users/jamesdmoffet/notifications-api/.venv" + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/bin" $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) "(.venv) " (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT "(.venv) " +end diff --git a/.venv/bin/pip b/.venv/bin/pip new file mode 100755 index 000000000..d9be3c77d --- /dev/null +++ b/.venv/bin/pip @@ -0,0 +1,8 @@ +#!/Users/jamesdmoffet/notifications-api/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pip-compile b/.venv/bin/pip-compile new file mode 100755 index 000000000..a5e7ad8af --- /dev/null +++ b/.venv/bin/pip-compile @@ -0,0 +1,8 @@ +#!/Users/jamesdmoffet/notifications-api/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from piptools.scripts.compile import cli +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli()) diff --git a/.venv/bin/pip-sync b/.venv/bin/pip-sync new file mode 100755 index 000000000..07dcdc6d6 --- /dev/null +++ b/.venv/bin/pip-sync @@ -0,0 +1,8 @@ +#!/Users/jamesdmoffet/notifications-api/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from piptools.scripts.sync import cli +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli()) diff --git a/.venv/bin/pip3 b/.venv/bin/pip3 new file mode 100755 index 000000000..d9be3c77d --- /dev/null +++ b/.venv/bin/pip3 @@ -0,0 +1,8 @@ +#!/Users/jamesdmoffet/notifications-api/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pip3.10 b/.venv/bin/pip3.10 new file mode 100755 index 000000000..d9be3c77d --- /dev/null +++ b/.venv/bin/pip3.10 @@ -0,0 +1,8 @@ +#!/Users/jamesdmoffet/notifications-api/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pyproject-build b/.venv/bin/pyproject-build new file mode 100755 index 000000000..d045f4ee0 --- /dev/null +++ b/.venv/bin/pyproject-build @@ -0,0 +1,8 @@ +#!/Users/jamesdmoffet/notifications-api/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from build.__main__ import entrypoint +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(entrypoint()) diff --git a/.venv/bin/python b/.venv/bin/python new file mode 120000 index 000000000..15b49b769 --- /dev/null +++ b/.venv/bin/python @@ -0,0 +1 @@ +/Users/jamesdmoffet/.pyenv/versions/3.10.1/bin/python \ No newline at end of file diff --git a/.venv/bin/python3 b/.venv/bin/python3 new file mode 120000 index 000000000..d8654aa0e --- /dev/null +++ b/.venv/bin/python3 @@ -0,0 +1 @@ +python \ No newline at end of file diff --git a/.venv/bin/python3.10 b/.venv/bin/python3.10 new file mode 120000 index 000000000..d8654aa0e --- /dev/null +++ b/.venv/bin/python3.10 @@ -0,0 +1 @@ +python \ No newline at end of file diff --git a/.venv/bin/wheel b/.venv/bin/wheel new file mode 100755 index 000000000..5dc0d7448 --- /dev/null +++ b/.venv/bin/wheel @@ -0,0 +1,8 @@ +#!/Users/jamesdmoffet/notifications-api/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from wheel.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/pyvenv.cfg b/.venv/pyvenv.cfg new file mode 100644 index 000000000..728fbf7d7 --- /dev/null +++ b/.venv/pyvenv.cfg @@ -0,0 +1,3 @@ +home = /Users/jamesdmoffet/.pyenv/versions/3.10.1/bin +include-system-site-packages = false +version = 3.10.1 diff --git a/app/celery/process_ses_receipts_tasks.py b/app/celery/process_ses_receipts_tasks.py index 0b7290593..fa90ae141 100644 --- a/app/celery/process_ses_receipts_tasks.py +++ b/app/celery/process_ses_receipts_tasks.py @@ -9,8 +9,8 @@ from celery.exceptions import Retry from flask import Blueprint, current_app, json, jsonify, request from sqlalchemy.orm.exc import NoResultFound -from app import notify_celery, statsd_client, redis_store -from app.celery.validate_sns import validate_sns_message +from app import notify_celery, statsd_client +from app.celery.validate_sns_message import sns_notification_handler from app.config import QueueNames from app.dao import notifications_dao from app.errors import InvalidRequest, register_errors @@ -51,43 +51,15 @@ def verify_message_type(message_type: str): # got refactored into a task, which is fine, but it created a circular dependency. Will need # to investigate why GDS extracted this into a lambda @ses_callback_blueprint.route('/notifications/email/ses', methods=['POST']) -def sns_callback_handler(): - message_type = request.headers.get('x-amz-sns-message-type') +def email_ses_callback_handler(): try: - verify_message_type(message_type) - except InvalidMessageTypeException: - current_app.logger.exception(f"Response headers: {request.headers}\nResponse data: {request.data}") + data = sns_notification_handler(request.data, request.headers) + except Exception as e: raise InvalidRequest("SES-SNS callback failed: invalid message type", 400) - - try: - message = json.loads(request.data.decode('utf-8')) - except decoder.JSONDecodeError: - current_app.logger.exception(f"Response headers: {request.headers}\nResponse data: {request.data}") - raise InvalidRequest("SES-SNS callback failed: invalid JSON given", 400) - - try: - validate_sns_message(message) - except Exception as err: - current_app.logger.error(f"SES-SNS callback failed: validation failed! Response headers: {request.headers}\nResponse data: {request.data}\nError: Signature validation failed with error {err}") - raise InvalidRequest("SES-SNS callback failed: validation failed", 400) - - if message.get('Type') == 'SubscriptionConfirmation': - url = message.get('SubscribeUrl') if 'SubscribeUrl' in message else message.get('SubscribeURL') - response = requests.get(url) - try: - response.raise_for_status() - except Exception as e: - current_app.logger.warning(f"Attempt to raise_for_status()SubscriptionConfirmation Type message files for response: {response.text} with error {e}") - raise e - - return jsonify( - result="success", message="SES-SNS auto-confirm callback succeeded" - ), 200 - - # TODO remove after smoke testing on prod is implemented - current_app.logger.info(f"SNS message: {message} is a valid delivery status message. Attempting to process it now.") - process_ses_results.apply_async([{"Message": message.get("Message")}], queue=QueueNames.NOTIFY) + message = data.get("Message") + if "mail" in message: + process_ses_results.apply_async([{"Message": message}], queue=QueueNames.NOTIFY) return jsonify( result="success", message="SES-SNS callback succeeded" diff --git a/app/celery/validate_sns.py b/app/celery/validate_sns_cert.py similarity index 97% rename from app/celery/validate_sns.py rename to app/celery/validate_sns_cert.py index e639b17a1..28549d521 100644 --- a/app/celery/validate_sns.py +++ b/app/celery/validate_sns_cert.py @@ -69,10 +69,11 @@ def get_string_to_sign(sns_payload): return string_to_sign -def validate_sns_message(sns_payload): +def validate_sns_cert(sns_payload): """ Adapted from the solution posted at https://github.com/boto/boto3/issues/2508#issuecomment-992931814 + Modified to swap m2crypto for oscrypto """ if not isinstance(sns_payload, dict): raise ValidationError("Unexpected message type {!r}".format(type(sns_payload).__name__)) diff --git a/app/celery/validate_sns_message.py b/app/celery/validate_sns_message.py new file mode 100644 index 000000000..37295dc7d --- /dev/null +++ b/app/celery/validate_sns_message.py @@ -0,0 +1,66 @@ +import enum +from datetime import timedelta +from json import decoder + +import requests +from flask import current_app, json + +from app.celery.validate_sns_cert import validate_sns_cert +from app.errors import InvalidRequest + + +DEFAULT_MAX_AGE = timedelta(days=10000) + + +class SNSMessageType(enum.Enum): + SubscriptionConfirmation = 'SubscriptionConfirmation' + Notification = 'Notification' + UnsubscribeConfirmation = 'UnsubscribeConfirmation' + + +class InvalidMessageTypeException(Exception): + pass + + +def verify_message_type(message_type: str): + try: + SNSMessageType(message_type) + except ValueError: + raise InvalidRequest("SES-SNS callback failed: invalid message type", 400) + + +def sns_notification_handler(data, headers): + message_type = headers.get('x-amz-sns-message-type') + try: + verify_message_type(message_type) + except InvalidMessageTypeException: + current_app.logger.exception(f"Response headers: {headers}\nResponse data: {data}") + raise InvalidRequest("SES-SNS callback failed: invalid message type", 400) + + try: + message = json.loads(data.decode('utf-8')) + except decoder.JSONDecodeError: + current_app.logger.exception(f"Response headers: {headers}\nResponse data: {data}") + raise InvalidRequest("SES-SNS callback failed: invalid JSON given", 400) + + try: + validate_sns_cert(message) + except Exception as e: + current_app.logger.error(f"SES-SNS callback failed: validation failed with error: Signature validation failed with error {e}") + raise InvalidRequest("SES-SNS callback failed: validation failed", 400) + + if message.get('Type') == 'SubscriptionConfirmation': + url = message.get('SubscribeUrl') if 'SubscribeUrl' in message else message.get('SubscribeURL') + response = requests.get(url) + try: + response.raise_for_status() + except Exception as e: + current_app.logger.warning(f"Attempt to raise_for_status()SubscriptionConfirmation Type message files for response: {response.text} with error {e}") + raise InvalidRequest("SES-SNS callback failed: attempt to raise_for_status()SubscriptionConfirmation Type message failed", 400) + current_app.logger.info("SES-SNS auto-confirm subscription callback succeeded") + return message + + # TODO remove after smoke testing on prod is implemented + current_app.logger.info(f"SNS message: {message} is a valid message. Attempting to process it now.") + + return message diff --git a/app/config.py b/app/config.py index 7aaf81ae9..07e4a0fd8 100644 --- a/app/config.py +++ b/app/config.py @@ -121,7 +121,7 @@ class Config(object): NOTIFY_EMAIL_DOMAIN = 'notify.sandbox.10x.gsa.gov' # AWS SNS topics for delivery receipts - VALID_SNS_TOPICS = ['notify_test_bounce', 'notify_test_success', 'notify_test_complaint'] + VALID_SNS_TOPICS = ['notify_test_bounce', 'notify_test_success', 'notify_test_complaint', 'notify_test_sms_inbound'] # URL of redis instance REDIS_URL = os.environ.get('REDIS_URL') @@ -196,7 +196,7 @@ class Config(object): MOU_SIGNER_RECEIPT_TEMPLATE_ID = '4fd2e43c-309b-4e50-8fb8-1955852d9d71' MOU_SIGNED_ON_BEHALF_SIGNER_RECEIPT_TEMPLATE_ID = 'c20206d5-bf03-4002-9a90-37d5032d9e84' MOU_SIGNED_ON_BEHALF_ON_BEHALF_RECEIPT_TEMPLATE_ID = '522b6657-5ca5-4368-a294-6b527703bd0b' - NOTIFY_INTERNATIONAL_SMS_SENDER = '07984404008' + NOTIFY_INTERNATIONAL_SMS_SENDER = '18446120782' LETTERS_VOLUME_EMAIL_TEMPLATE_ID = '11fad854-fd38-4a7c-bd17-805fb13dfc12' NHS_EMAIL_BRANDING_ID = 'a7dc4e56-660b-4db7-8cff-12c37b12b5ea' # we only need real email in Live environment (production) diff --git a/app/inbound_sms/rest.py b/app/inbound_sms/rest.py index bf34fc553..9afc90425 100644 --- a/app/inbound_sms/rest.py +++ b/app/inbound_sms/rest.py @@ -30,9 +30,10 @@ def post_inbound_sms_for_service(service_id): form = validate(request.get_json(), get_inbound_sms_for_service_schema) user_number = form.get('phone_number') - if user_number: - # we use this to normalise to an international phone number - but this may fail if it's an alphanumeric - user_number = try_validate_and_format_phone_number(user_number, international=True) + # TODO update this for US formatting + # if user_number: + # # we use this to normalise to an international phone number - but this may fail if it's an alphanumeric + # user_number = try_validate_and_format_phone_number(user_number, international=True) inbound_data_retention = fetch_service_data_retention_by_notification_type(service_id, 'sms') limit_days = inbound_data_retention.days_of_retention if inbound_data_retention else 7 diff --git a/app/notifications/receive_notifications.py b/app/notifications/receive_notifications.py index 72103c20f..d7f94e755 100644 --- a/app/notifications/receive_notifications.py +++ b/app/notifications/receive_notifications.py @@ -2,15 +2,16 @@ from datetime import datetime from urllib.parse import unquote import iso8601 -from flask import Blueprint, abort, current_app, jsonify, request +from flask import Blueprint, abort, current_app, jsonify, request, json from gds_metrics.metrics import Counter from notifications_utils.recipients import try_validate_and_format_phone_number from app.celery import tasks +from app.celery.validate_sns_message import sns_notification_handler from app.config import QueueNames from app.dao.inbound_sms_dao import dao_create_inbound_sms from app.dao.services_dao import dao_fetch_service_by_inbound_number -from app.errors import register_errors +from app.errors import register_errors, InvalidRequest from app.models import INBOUND_SMS_TYPE, SMS_TYPE, InboundSms receive_notifications_blueprint = Blueprint('receive_notifications', __name__) @@ -23,6 +24,64 @@ INBOUND_SMS_COUNTER = Counter( ['provider'] ) +@receive_notifications_blueprint.route('/notifications/sms/receive/sns', methods=['POST']) +def receive_sns_sms(): + """ + { + "originationNumber":"+14255550182", + "destinationNumber":"+12125550101", + "messageKeyword":"JOIN", # unique to our sending number + "messageBody":"EXAMPLE", + "inboundMessageId":"cae173d2-66b9-564c-8309-21f858e9fb84", + "previousPublishedMessageId":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + """ + + try: + post_data = sns_notification_handler(request.data, request.headers) + except Exception as e: + raise InvalidRequest(f"SMS-SNS callback failed with error: {e}", 400) + + message = json.loads(post_data.get("Message")) + # TODO wrap this up + if "inboundMessageId" in message: + # TODO use standard formatting we use for all US numbers + inbound_number = message['destinationNumber'].replace('+','') + + service = fetch_potential_service(inbound_number, 'sns') + if not service: + # since this is an issue with our service <-> number mapping, or no inbound_sms service permission + # we should still tell SNS that we received it successfully + current_app.logger.warning(f"Mapping between service and inbound number: {inbound_number} is broken, or service does not have permission to receive inbound sms") + return jsonify( + result="success", message="SMS-SNS callback succeeded" + ), 200 + + INBOUND_SMS_COUNTER.labels("sns").inc() + + content = message.get("messageBody") + from_number = message.get('originationNumber') + provider_ref = message.get('inboundMessageId') + date_received = post_data.get('Timestamp') + provider_name = "sns" + + inbound = create_inbound_sms_object(service, + content=content, + from_number=from_number, + provider_ref=provider_ref, + date_received=date_received, + provider_name=provider_name) + + # TODO ensure inbound sms callback endpoints are accessible and functioning for notify api users, then uncomment the task below + tasks.send_inbound_sms_to_service.apply_async([str(inbound.id), str(service.id)], queue=QueueNames.NOTIFY) + + current_app.logger.debug( + '{} received inbound SMS with reference {} from SNS'.format(service.id, inbound.provider_reference)) + + return jsonify( + result="success", message="SMS-SNS callback succeeded" + ), 200 + @receive_notifications_blueprint.route('/notifications/sms/receive/mmg', methods=['POST']) def receive_mmg_sms(): diff --git a/migrations/versions/0377_add_inbound_sms_number.py b/migrations/versions/0377_add_inbound_sms_number.py new file mode 100644 index 000000000..b3641d843 --- /dev/null +++ b/migrations/versions/0377_add_inbound_sms_number.py @@ -0,0 +1,52 @@ +"""empty message + +Revision ID: 0377_add_inbound_sms_number +Revises: 0376_add_provider_response +Create Date: 2022-09-30 11:04:15.888017 + +""" +import uuid + +from alembic import op +from flask import current_app + + +revision = '0377_add_inbound_sms_number' +down_revision = '0376_add_provider_response' + +INBOUND_NUMBER_ID = '9b5bc009-b847-4b1f-8a54-f3b5f95cff18' +INBOUND_NUMBER = current_app.config['NOTIFY_INTERNATIONAL_SMS_SENDER'] +DEFAULT_SERVICE_ID = current_app.config['NOTIFY_SERVICE_ID'] + +def upgrade(): + op.get_bind() + + # add the inbound number for the default service to inbound_numbers + table_name = 'inbound_numbers' + provider = 'sns' + active = 'true' + op.execute(f"insert into {table_name} (id, number, provider, service_id, active, created_at) VALUES('{INBOUND_NUMBER_ID}', '{INBOUND_NUMBER}', '{provider}','{DEFAULT_SERVICE_ID}', '{active}', 'now()')") + + # add the inbound number for the default service to service_sms_senders + table_name = 'service_sms_senders' + id = '286d6176-adbe-7ea7-ba26-b7606ee5e2a4' + is_default = 'true' + sms_sender = INBOUND_NUMBER + inbound_number_id = INBOUND_NUMBER_ID + archived = 'false' + op.execute(f"insert into {table_name} (id, sms_sender, service_id, is_default, inbound_number_id, created_at, archived) VALUES('{id}', '{INBOUND_NUMBER}', '{DEFAULT_SERVICE_ID}', '{is_default}', '{INBOUND_NUMBER_ID}', 'now()','{archived}')") + + # add the inbound number for the default service to inbound_numbers + table_name = 'service_permissions' + permission = 'inbound_sms' + active = 'true' + op.execute(f"insert into {table_name} (service_id, permission, created_at) VALUES('{DEFAULT_SERVICE_ID}', '{permission}', 'now()')") + + +def downgrade(): + delete_sms_sender = f"delete from service_sms_senders where inbound_number_id = '{INBOUND_NUMBER_ID}'" + delete_inbound_number = f"delete from inbound_numbers where number = '{INBOUND_NUMBER}'" + delete_service_inbound_permission = f"delete from service_permissions where service_id = '{DEFAULT_SERVICE_ID}' and permission = 'inbound_sms'" + op.execute(delete_sms_sender) + op.execute(delete_inbound_number) + op.execute(delete_service_inbound_permission)