diff --git a/.talismanrc b/.talismanrc index 9603c69b61..be28b91ffb 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,30 +1,34 @@ fileignoreconfig: -- filename: README.md - checksum: b2cbbb8508af49abccae8b35b317f7ce09215f3508430ed31440add78f450e5a -- filename: app/celery/contact_information_tasks.py - checksum: 80d0acf88bafb1358583016b9e143f4523ef1160d6eacdc9754ca68859b90eae -- filename: app/notifications/process_notifications.py - checksum: ae4e31c6eb56d91ec80ae09d13baf4558cf461c65f08893b93fee43f036a17a7 -- filename: app/service/rest.py - checksum: b42aefd1ae0e6ea76e75db4cf14d425facd0941943b17f7ba2e41f850ad1ec23 -- filename: app/template/rest.py - checksum: 1e5bdac8bc694d50f8f656dec127dd036b7b1b5b6156e3282d3411956c71ba0b -- filename: app/va/va_profile/va_profile_client.py - checksum: 6f4a0f7b8bb1fee23ae53cbcc8de6f23ca51a341a087e4910fde07ab599e5fde -- filename: ci/docker-compose-test.yml - checksum: e3efec2749e8c19e60f5bfc68eafabe24eba647530a482ceccfc4e0e62cff424 -- filename: lambda_functions/pinpoint_callback/pinpoint_callback_lambda.py - checksum: 7bd4900e14b1fa789bbb2568b8a8d7a400e3c8350ba32fb44cc0b5b66a2df037 -- filename: lambda_functions/ses_callback/ses_callback_lambda.py - checksum: b20c36921290a9609f158784e2a3278c36190887e6054ea548004a67675fd79b -- filename: poetry.lock - checksum: 34d12acdf749363555c31add4e7e7afa9e2a27afd792bd98c85f331b87bd7112 -- filename: scripts/trigger_task.py - checksum: 0e9d244dbe285de23fc84bb643407963dacf7d25a3358373f01f6272fb217778 -- filename: tests/app/celery/test_process_ga4_measurement_task.py - checksum: d33a6911258922f4bd3d149c90c2ee16c021a8e59e462594e4b1cd972902d689 -- filename: tests/app/conftest.py - checksum: a80aa727586db82ed1b50bdb81ddfe1379e649a9dfc1ece2c36047486b41b83d -- filename: tests/app/notifications/test_process_notifications_for_profile_v3.py - checksum: 4e15e63d349635131173ffdd7aebcd547621db08de877ef926d3a41fde72d065 + - filename: README.md + checksum: b2cbbb8508af49abccae8b35b317f7ce09215f3508430ed31440add78f450e5a + - filename: app/celery/contact_information_tasks.py + checksum: 80d0acf88bafb1358583016b9e143f4523ef1160d6eacdc9754ca68859b90eae + - filename: app/notifications/process_notifications.py + checksum: ae4e31c6eb56d91ec80ae09d13baf4558cf461c65f08893b93fee43f036a17a7 + - filename: app/service/rest.py + checksum: b42aefd1ae0e6ea76e75db4cf14d425facd0941943b17f7ba2e41f850ad1ec23 + - filename: app/template/rest.py + checksum: 1e5bdac8bc694d50f8f656dec127dd036b7b1b5b6156e3282d3411956c71ba0b + - filename: lambda_functions/pinpoint_callback/pinpoint_callback_lambda.py + checksum: 7bd4900e14b1fa789bbb2568b8a8d7a400e3c8350ba32fb44cc0b5b66a2df037 + - filename: lambda_functions/ses_callback/ses_callback_lambda.py + checksum: b20c36921290a9609f158784e2a3278c36190887e6054ea548004a67675fd79b + - filename: poetry.lock + checksum: 34d12acdf749363555c31add4e7e7afa9e2a27afd792bd98c85f331b87bd7112 + - filename: scripts/trigger_task.py + checksum: 0e9d244dbe285de23fc84bb643407963dacf7d25a3358373f01f6272fb217778 + - filename: tests/app/celery/test_process_ga4_measurement_task.py + checksum: d33a6911258922f4bd3d149c90c2ee16c021a8e59e462594e4b1cd972902d689 + - filename: tests/app/conftest.py + checksum: a80aa727586db82ed1b50bdb81ddfe1379e649a9dfc1ece2c36047486b41b83d + - filename: tests/app/notifications/test_process_notifications_for_profile_v3.py + checksum: 4e15e63d349635131173ffdd7aebcd547621db08de877ef926d3a41fde72d065 + - filename: tests/app/callback/test_webhook_callback_strategy.py + checksum: 288841d3209dc3ca885cd0bb08591221f7f15e5b3406fb7140505096db212554 + - filename: app/callback/webhook_callback_strategy.py + checksum: 47846ab651c27512d3ac7864c08cb25d647f63bb84321953f907551fd9d2e85f + - filename: app/dao/api_key_dao.py + checksum: ab93313f306c8a3f6576141e8f32d9fc99b0de7da8d44a1ddbe6ea55d167dcdb + - filename: ci/docker-compose-test.yml + checksum: e3efec2749e8c19e60f5bfc68eafabe24eba647530a482ceccfc4e0e62cff424 version: "1.0" diff --git a/app/callback/webhook_callback_strategy.py b/app/callback/webhook_callback_strategy.py index fce44e11d8..78caee839d 100644 --- a/app/callback/webhook_callback_strategy.py +++ b/app/callback/webhook_callback_strategy.py @@ -1,14 +1,17 @@ -from app.callback.service_callback_strategy_interface import ServiceCallbackStrategyInterface - +import hashlib import json +from hmac import HMAC +from urllib.parse import urlencode +from uuid import UUID from flask import current_app - from requests.api import request -from requests.exceptions import RequestException, HTTPError +from requests.exceptions import HTTPError, RequestException from app import statsd_client -from app.celery.exceptions import RetryableException, NonRetryableException +from app.callback.service_callback_strategy_interface import ServiceCallbackStrategyInterface +from app.celery.exceptions import NonRetryableException, RetryableException +from app.dao.api_key_dao import get_unsigned_secret from app.models import ServiceCallback @@ -43,3 +46,26 @@ def send_callback( raise NonRetryableException(e) else: statsd_client.incr(f'callback.webhook.{callback.callback_type}.success') + + +def generate_callback_signature( + api_key_id: UUID, + callback_params: dict[str, str], +) -> str: + """Generate a signature based on key and params + + Args: + api_key_id (UUID): ID of the key to generate the signature + callback_params (dict[str, str]): Parameters being sent to the client + + Returns: + str: The signature for this callback + """ + signature = HMAC( + get_unsigned_secret(api_key_id).encode(), + urlencode(callback_params).encode(), + digestmod=hashlib.sha256, + ).hexdigest() + + current_app.logger.debug('Generated signature: %s with params: %s', signature, callback_params) + return signature diff --git a/app/celery/service_callback_tasks.py b/app/celery/service_callback_tasks.py index 6afef8367c..fe1ecdaa11 100644 --- a/app/celery/service_callback_tasks.py +++ b/app/celery/service_callback_tasks.py @@ -278,6 +278,36 @@ def create_delivery_status_callback_data( return encryption.encrypt(data) +def create_delivery_status_callback_data_v3(notification: Notification) -> dict[str, str]: + """Create all data that will be sent to a callback url specified in the Notification object. + + Args: + notification (Notification): Notification object + + Returns: + dict[str, str]: Data for callbacks + """ + + from app import DATETIME_FORMAT # Circular import + + data = { + 'id': str(notification.id), + 'reference': notification.client_reference, + 'to': notification.to, + 'status': notification.status, + 'created_at': notification.created_at.strftime(DATETIME_FORMAT), + 'completed_at': notification.updated_at.strftime(DATETIME_FORMAT) if notification.updated_at else None, + 'sent_at': notification.sent_at.strftime(DATETIME_FORMAT) if notification.sent_at else None, + 'notification_type': notification.notification_type, + 'status_reason': notification.status_reason, + 'provider': notification.sent_by, + 'provider_payload': None, + } + + current_app.logger.debug('Created notification callback data: %s', data) + return data + + def create_complaint_callback_data( complaint, notification, diff --git a/app/dao/api_key_dao.py b/app/dao/api_key_dao.py index 6cc6178ce6..87b0aaba7b 100644 --- a/app/dao/api_key_dao.py +++ b/app/dao/api_key_dao.py @@ -58,9 +58,14 @@ def get_unsigned_secrets(service_id): return keys -def get_unsigned_secret(key_id): - """ - This method can only be exposed to the Authentication of the api calls. +def get_unsigned_secret(key_id: uuid.UUID) -> str: + """Retrieve the secret for a given key. + + Args: + key_id (uuid.UUID): The id related to the secret being looked up + + Returns: + str: The secret """ stmt = select(ApiKey).where(ApiKey.id == key_id, ApiKey.expiry_date.is_(None)) api_key = db.session.scalars(stmt).one() diff --git a/tests/app/callback/test_webhook_callback_strategy.py b/tests/app/callback/test_webhook_callback_strategy.py index 91fb09ecdf..a6340c8258 100644 --- a/tests/app/callback/test_webhook_callback_strategy.py +++ b/tests/app/callback/test_webhook_callback_strategy.py @@ -4,9 +4,26 @@ import requests_mock from requests import RequestException -from app.callback.webhook_callback_strategy import WebhookCallbackStrategy -from app.celery.exceptions import RetryableException, NonRetryableException -from app.models import ServiceCallback +from app.callback.webhook_callback_strategy import WebhookCallbackStrategy, generate_callback_signature +from app.celery.exceptions import NonRetryableException, RetryableException +from app.models import ApiKey, ServiceCallback + + +@pytest.fixture +def sample_callback_data_v3(): + return { + 'notification_id': '342d2432-6a79-4e18-afef-8c254751969b', + 'reference': 'some client reference', + 'to': '+16502532222', + 'status': 'created', + 'created_at': '2024-10-01T00:00:00.000000Z', + 'updated_at': None, + 'sent_at': None, + 'notification_type': 'sms', + 'provider': 'pinpoint', + 'status_reason': None, + 'provider_payload': None, + } @pytest.fixture @@ -111,3 +128,31 @@ def test_send_callback_increments_statsd_client_with_non_retryable_error_for_sta ) mock_statsd_client.incr.assert_called_with(f'callback.webhook.{mock_callback.callback_type}.non_retryable_error') + + +def test_generate_callback_signature( + sample_callback_data_v3, + sample_api_key, + mocker, +) -> None: + mocker.patch( + 'app.callback.webhook_callback_strategy.get_unsigned_secret', + return_value='test_generate_callback_signature', + ) + api_key: ApiKey = sample_api_key() + + signature = generate_callback_signature( + api_key.id, + sample_callback_data_v3, + ) + assert signature == '18689cf9fb9c6a9dc1e0840245d48c666d97499d3894deb0e4cf3a5ba82f3d6e' + + +def test_callback_signature_length( + sample_api_key, +) -> None: + signature = generate_callback_signature( + sample_api_key().id, + {'data': 'test'}, + ) + assert len(signature) == 64 # Expected length from HMAC-SHA256 diff --git a/tests/app/celery/test_service_callback_tasks.py b/tests/app/celery/test_service_callback_tasks.py index fa3d34a8e6..f058df905c 100644 --- a/tests/app/celery/test_service_callback_tasks.py +++ b/tests/app/celery/test_service_callback_tasks.py @@ -18,6 +18,7 @@ publish_complaint, send_inbound_sms_to_service, create_delivery_status_callback_data, + create_delivery_status_callback_data_v3, ) from app.config import QueueNames @@ -536,3 +537,22 @@ def test_create_delivery_status_callback_data( ] else: assert 'provider_payload' not in decrypted_message + + +def test_create_delivery_status_callback_data_v3( + sample_notification, +): + notification: Notification = sample_notification() + data = create_delivery_status_callback_data_v3(notification) + + assert data['id'] == str(notification.id) + assert data['reference'] == notification.client_reference + assert data['to'] == notification.to + assert data['status'] == notification.status + assert data['created_at'] == notification.created_at.strftime(DATETIME_FORMAT) + assert data['completed_at'] is None + assert data['sent_at'] is None + assert data['notification_type'] == notification.notification_type + assert data['status_reason'] == notification.status_reason + assert data['provider'] == notification.sent_by + assert data['provider_payload'] is None