diff --git a/backend/cosmetology-app/common_constructs/resource_scope_mixin.py b/backend/cosmetology-app/common_constructs/resource_scope_mixin.py index be20a536e..cd8f0dfdf 100644 --- a/backend/cosmetology-app/common_constructs/resource_scope_mixin.py +++ b/backend/cosmetology-app/common_constructs/resource_scope_mixin.py @@ -32,10 +32,6 @@ def _add_resource_servers(self, stack: ps.PersistentStack): scope_name='readGeneral', scope_description='Read access for generally available data (not private) in the compact', ) - self.compact_read_ssn_scope = ResourceServerScope( - scope_name='readSSN', - scope_description='Read access for SSNs in the compact', - ) active_compacts = stack.get_list_of_compact_abbreviations() self.compact_resource_servers = {} @@ -49,7 +45,6 @@ def _add_resource_servers(self, stack: ps.PersistentStack): self.compact_admin_scope, self.compact_write_scope, self.compact_read_scope, - self.compact_read_ssn_scope, ], ) # we define the jurisdiction level scopes, which will be used by every @@ -90,8 +85,4 @@ def _generate_resource_server_scopes_list_for_compact(self, compact: str): scope_name=f'{compact}.readPrivate', scope_description=f'Read access for SSNs in the {compact} compact within the jurisdiction', ), - ResourceServerScope( - scope_name=f'{compact}.readSSN', - scope_description=f'Read access for SSNs in the {compact} compact within the jurisdiction', - ), ] diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/common.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/common.py index 957b2ae88..8baf20d0a 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/common.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/common.py @@ -231,7 +231,6 @@ class CCPermissionsAction(StrEnum): ADMIN = 'admin' READ_GENERAL = 'readGeneral' READ_PRIVATE = 'readPrivate' - READ_SSN = 'readSSN' class S3PresignedPostSchema(Schema): diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py index 52dbdcc2a..67835a436 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py @@ -10,7 +10,6 @@ Compact, CompactEligibility, Jurisdiction, - SocialSecurityNumber, ) from cc_common.data_model.schema.license.api import ( LicenseGeneralResponseSchema, @@ -56,20 +55,6 @@ def _validate_no_cross_index_keys(obj, path: str = 'query') -> None: # Scalar values (str, int, bool, None) are safe - we only check keys -class ProviderSSNResponseSchema(ForgivingSchema): - """ - Schema for provider SSN API responses. - - This schema validates the response from the provider SSN endpoint, - ensuring the SSN is properly formatted. - - Serialization direction: - Python -> load() -> API - """ - - ssn = SocialSecurityNumber(required=True, allow_none=False) - - class ProviderReadPrivateResponseSchema(ForgivingSchema): """ Provider object fields that are sanitized for users with the 'readPrivate' permission. diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/utils.py b/backend/cosmetology-app/lambdas/python/common/cc_common/utils.py index 5ed590aa9..c1d73184e 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/utils.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/utils.py @@ -758,12 +758,6 @@ def _user_has_read_private_access_for_provider(compact: str, provider_informatio ) -def user_has_read_ssn_access_for_provider(compact: str, provider_information: dict, scopes: set[str]) -> bool: - return _user_has_permission_for_action_on_user( - action=CCPermissionsAction.READ_SSN, compact=compact, provider_information=provider_information, scopes=scopes - ) - - def _user_has_permission_for_action_on_user( action: str, compact: str, provider_information: dict, scopes: set[str] ) -> bool: diff --git a/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/providers.py b/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/providers.py index 18c1e83e1..35ec7b3f7 100644 --- a/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/providers.py +++ b/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/providers.py @@ -1,33 +1,21 @@ -from datetime import timedelta - from aws_lambda_powertools.utilities.typing import LambdaContext -from botocore.exceptions import ClientError -from cc_common.config import config, logger, metrics +from cc_common.config import config, logger from cc_common.data_model.schema.common import CCPermissionsAction from cc_common.data_model.schema.provider.api import ( ProviderGeneralResponseSchema, - ProviderSSNResponseSchema, QueryProvidersRequestSchema, ) -from cc_common.exceptions import ( - CCAccessDeniedException, - CCAwsServiceException, - CCInvalidRequestException, - CCRateLimitingException, -) +from cc_common.exceptions import CCInvalidRequestException from cc_common.utils import ( api_handler, authorize_compact, get_event_scopes, sanitize_provider_data_based_on_caller_scopes, - user_has_read_ssn_access_for_provider, ) from marshmallow import ValidationError from . import get_provider_information -SSN_RATE_LIMITING_PK = 'READ_SSN_REQUESTS' - @api_handler @authorize_compact(action=CCPermissionsAction.READ_GENERAL) @@ -136,162 +124,3 @@ def get_provider(event: dict, context: LambdaContext): # noqa: ARG001 unused-ar return sanitize_provider_data_based_on_caller_scopes( compact=compact, provider=provider_information, scopes=get_event_scopes(event) ) - - -@api_handler -@authorize_compact(action=CCPermissionsAction.READ_SSN) -def get_provider_ssn(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument - """ - Return one provider's SSN - :param event: Standard API Gateway event, API schema documented in the CDK ApiStack - :param LambdaContext context: - """ - compact = event['pathParameters']['compact'] - provider_id = event['pathParameters']['providerId'] - user_id = event['requestContext']['authorizer']['claims']['sub'] - - with logger.append_context_keys(compact=compact, provider_id=provider_id, user_id=user_id): - logger.info('Processing provider SSN request') - - # Check if the user has exceeded the rate limit - if _ssn_rate_limit_exceeded(context=context, user_id=user_id, provider_id=provider_id, compact=compact): - metrics.add_metric(name='rate-limited-ssn-access', value=1, unit='Count') - logger.warning('Rate limited SSN access attempt') - raise CCRateLimitingException( - 'Rate limit exceeded. You have reached the maximum number of SSN requests allowed in a 24-hour period.' - ) - - provider_information = get_provider_information(compact=compact, provider_id=provider_id) - - # Inspect the caller's scopes to determine if they have readSSN permission for this provider - if not user_has_read_ssn_access_for_provider( - compact=compact, - provider_information=provider_information, - scopes=get_event_scopes(event), - ): - metrics.add_metric(name='unauthorized-ssn-access', value=1, unit='Count') - logger.warning('Unauthorized SSN access attempt') - raise CCAccessDeniedException( - f'User does not have {CCPermissionsAction.READ_SSN} permission for this provider' - ) - - # Query the provider's SSN from the database - ssn = config.data_client.get_ssn_by_provider_id(compact=compact, provider_id=provider_id) - - metrics.add_metric(name='read-ssn', value=1, unit='Count') - - # Apply schema validation - response_schema = ProviderSSNResponseSchema() - return response_schema.load({'ssn': ssn}) - - -def _ssn_rate_limit_exceeded(context: LambdaContext, user_id: str, provider_id: str, compact: str) -> bool: - """Check if the user has exceeded the SSN rate limit. - - :param context: The lambda context, used to get the unique request id and lambda name - :param user_id: The Cognito user ID of the staff user - :param provider_id: The provider ID being accessed - :param compact: The compact being accessed - :return: True if rate limit is exceeded, False otherwise - """ - now = config.current_standard_datetime - window_start = now - timedelta(hours=24) - window_start_timestamp = window_start.timestamp() - now_timestamp = now.timestamp() - - # Append the unique AWS request id for this request - # This ensures every request is recorded, even for - # requests within the same second - request_sk = f'TIME#{now_timestamp}#REQUEST#{context.aws_request_id}' - - logger.info('Recording request in rate limiting table', request_sk=request_sk) - - try: - # First, record this request in the rate limiting table - config.rate_limiting_table.put_item( - Item={ - 'pk': SSN_RATE_LIMITING_PK, - 'sk': request_sk, - 'compact': compact, - 'provider_id': provider_id, - 'staffUserId': user_id, - 'ttl': int(now_timestamp) + 86400, # 24 hours in seconds - } - ) - - # Check if the global rate limit has been exceeded (more than 15 requests in 24 hours) - all_requests = config.rate_limiting_table.query( - KeyConditionExpression='pk = :pk AND sk BETWEEN :start_sk AND :end_sk', - ExpressionAttributeValues={ - ':pk': SSN_RATE_LIMITING_PK, - ':start_sk': f'TIME#{window_start_timestamp}', - # Add 1 second to ensure we include all records at the current timestamp - ':end_sk': f'TIME#{now_timestamp + 1}', - }, - ConsistentRead=True, - ) - - global_request_count = len(all_requests['Items']) - logger.info(f'Global SSN request count in last 24 hours: {global_request_count}') - - # If there are more than 15 requests globally in the last 24 hours, throttle the entire endpoint - if global_request_count > 15: - logger.critical( - 'Global SSN rate limit exceeded, throttling endpoint', - global_request_count=global_request_count, - current_request_user_id=user_id, - current_request_compact=compact, - ) - - # Set the lambda's reserved concurrency to 0 to throttle the endpoint - try: - config.lambda_client.put_function_concurrency( - FunctionName=context.function_name, ReservedConcurrentExecutions=0 - ) - logger.critical('Lambda concurrency set to 0 due to excessive SSN requests') - metrics.add_metric(name='ssn-endpoint-disabled', value=1, unit='Count') - except ClientError as e: - logger.error('Failed to set lambda concurrency', error=str(e)) - - return True - - # Count how many requests were made by this user - user_request_count = 0 - for item in all_requests.get('Items', []): - if item.get('staffUserId') == user_id: - user_request_count += 1 - - logger.info(f'User SSN request count: {user_request_count}', user_id=user_id) - - # If there are more than 7 requests by this user in the window, deactivate the user's account - if user_request_count >= 7: - logger.warning( - 'User exceeded SSN rate limit multiple times, deactivating account', - user_id=user_id, - request_count=user_request_count, - ) - - # Deactivate the user's account - try: - config.cognito_client.admin_disable_user(UserPoolId=config.user_pool_id, Username=user_id) - logger.warning('User account deactivated due to excessive SSN requests', user_id=user_id) - except ClientError as e: - logger.error('Failed to deactivate user account', error=str(e), user_id=user_id) - - return True - - # If there are 5 or more requests by this user in the window, rate limit is exceeded - if user_request_count >= 6: - logger.warning('SSN rate limit exceeded for user', user_id=user_id, request_count=user_request_count) - return True - - logger.info( - 'Rate limit has not been exceeded, proceeding with request', - user_request_count=user_request_count, - staff_user_id=user_id, - provider_id=provider_id, - ) - return False - except ClientError as e: - logger.error('Failed to check SSN rate limit', error=str(e)) - raise CCAwsServiceException('Failed to check SSN rate limit') from e diff --git a/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py b/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py index 3dc84cb82..c9625b944 100644 --- a/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py +++ b/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py @@ -1,6 +1,5 @@ import json -import uuid -from datetime import datetime, timedelta +from datetime import datetime from unittest.mock import patch from urllib.parse import quote @@ -349,163 +348,3 @@ def test_get_provider_returns_expected_general_response_when_caller_does_not_hav self._when_testing_get_provider_response_based_on_read_access( scopes='openid email cosm/readGeneral', expected_provider=expected_provider ) - - -@mock_aws -class TestGetProviderSSN(TstFunction): - def _when_testing_rate_limiting(self, previous_attempt_count: int, provider_id: str): - test_email = 'test@example.com' - # create the test user and get their user id - resp = self.config.cognito_client.admin_create_user( - UserPoolId=self.config.user_pool_id, - Username=test_email, - UserAttributes=[ - {'Name': 'email', 'Value': test_email}, - {'Name': 'email_verified', 'Value': 'true'}, - ], - ) - user_id = resp['User']['Username'] - - now_datetime = self.config.current_standard_datetime - for attempt in range(previous_attempt_count): - self.config.rate_limiting_table.put_item( - Item={ - 'pk': 'READ_SSN_REQUESTS', - # separate each attempt by one minute - 'sk': f'TIME#{(now_datetime - timedelta(minutes=attempt)).timestamp()}#REQUEST#{uuid.uuid4()}', - 'compact': 'cosm', - 'providerId': provider_id, - 'staffUserId': user_id, - } - ) - - return user_id - - def _make_ssn_request_with_unique_request_id(self, event: dict): - from handlers.providers import get_provider_ssn - - self.mock_context.aws_request_id = uuid.uuid4() - return get_provider_ssn(event, self.mock_context) - - def test_get_provider_ssn_returns_ssn_if_caller_has_read_ssn_compact_level_scope(self): - self._load_provider_data() - - from handlers.providers import get_provider_ssn - - with open('../common/tests/resources/api-event.json') as f: - event = json.load(f) - - # The user has read permission for cosm - event['requestContext']['authorizer']['claims']['scope'] = 'openid email cosm/readGeneral cosm/readSSN' - event['pathParameters'] = {'compact': 'cosm', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} - - resp = get_provider_ssn(event, self.mock_context) - - self.assertEqual(200, resp['statusCode']) - self.assertEqual({'ssn': '123-12-1234'}, json.loads(resp['body'])) - - def test_get_provider_ssn_returns_ssn_if_caller_has_read_ssn_license_jurisdiction_scope(self): - """ - The provider has a license in oh, and the caller has readSSN permission for oh. - """ - self._load_provider_data() - - from handlers.providers import get_provider_ssn - - with open('../common/tests/resources/api-event.json') as f: - event = json.load(f) - - # The user has read permission for cosm - event['requestContext']['authorizer']['claims']['scope'] = 'openid email cosm/readGeneral oh/cosm.readSSN' - event['pathParameters'] = {'compact': 'cosm', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} - - resp = get_provider_ssn(event, self.mock_context) - - self.assertEqual(200, resp['statusCode']) - self.assertEqual({'ssn': '123-12-1234'}, json.loads(resp['body'])) - - def test_get_provider_ssn_forbidden_without_correct_jurisdiction_level_scope(self): - """ - The provider has no license in ky, and the caller has readSSN permission for ky. - """ - self._load_provider_data() - - from handlers.providers import get_provider_ssn - - with open('../common/tests/resources/api-event.json') as f: - event = json.load(f) - - # The user has read permission for cosm - event['requestContext']['authorizer']['claims']['scope'] = 'openid email ky/cosm.readSSN' - event['pathParameters'] = {'compact': 'cosm', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} - - resp = get_provider_ssn(event, self.mock_context) - - self.assertEqual(403, resp['statusCode']) - - def test_get_provider_ssn_throttled_and_deactivated_if_staff_user_goes_beyond_rate_limit(self): - """ - The staff user has called this endpoint more than the set limit, so the endpoint throttles the user (which will - cause an alert to trigger from CloudWatch) and, after one more request, their account is deactivated. - """ - self._load_provider_data() - - test_provider_id = '89a6377e-c3a5-40e5-bca5-317ec854c570' - # add 4 previous calls to the endpoint - staff_user_id = self._when_testing_rate_limiting(previous_attempt_count=4, provider_id=test_provider_id) - - with open('../common/tests/resources/api-event.json') as f: - event = json.load(f) - event['requestContext']['authorizer']['claims']['sub'] = staff_user_id - - # The user has read permission for cosm - event['requestContext']['authorizer']['claims']['scope'] = 'openid email cosm/readGeneral cosm/readSSN' - event['pathParameters'] = {'compact': 'cosm', 'providerId': test_provider_id} - - # the fifth request should succeed, being right at the limit - resp = self._make_ssn_request_with_unique_request_id(event) - self.assertEqual(200, resp['statusCode']) - # next request should be throttled, but should not deactivate their account - resp = self._make_ssn_request_with_unique_request_id(event) - self.assertEqual(429, resp['statusCode']) - # assert that the user's account has not been deactivated yet. - user = self.config.cognito_client.admin_get_user(UserPoolId=self.config.user_pool_id, Username=staff_user_id) - self.assertEqual(user['Enabled'], True) - - # make another request to trigger deactivation - resp = self._make_ssn_request_with_unique_request_id(event) - self.assertEqual(429, resp['statusCode']) - - # assert that the user's account has been deactivated. - user = self.config.cognito_client.admin_get_user(UserPoolId=self.config.user_pool_id, Username=staff_user_id) - self.assertEqual(user['Enabled'], False) - - @patch('handlers.providers.config.lambda_client', autospec=True) - def test_get_provider_ssn_endpoint_throttled_if_endpoint_calls_exceed_global_rate_limit(self, mock_lambda_client): - """ - If this endpoint is invoked more than the set global limit within a 24-hour period, we throttle this endpoint - by setting its reserved concurrency to 0, to prevent a concentrated attack. - """ - self._load_provider_data() - - test_provider_id = '89a6377e-c3a5-40e5-bca5-317ec854c570' - # add 15 previous calls to the endpoint - staff_user_id = self._when_testing_rate_limiting(previous_attempt_count=15, provider_id=test_provider_id) - - with open('../common/tests/resources/api-event.json') as f: - event = json.load(f) - event['requestContext']['authorizer']['claims']['sub'] = staff_user_id - - # The user has read permission for cosm - event['requestContext']['authorizer']['claims']['scope'] = 'openid email cosm/readGeneral cosm/readSSN' - event['pathParameters'] = {'compact': 'cosm', 'providerId': test_provider_id} - - # request should be throttled - self.mock_context.function_name = 'testLambdaName' - resp = self._make_ssn_request_with_unique_request_id(event) - self.assertEqual(429, resp['statusCode']) - - # assert that the lambda client was called with expected parameters - mock_lambda_client.put_function_concurrency.assert_called_once_with( - FunctionName='testLambdaName', ReservedConcurrentExecutions=0 - ) diff --git a/backend/cosmetology-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py b/backend/cosmetology-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py index 69404c91f..655104374 100644 --- a/backend/cosmetology-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py +++ b/backend/cosmetology-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py @@ -20,7 +20,7 @@ def test_compact_ed_user(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#cosm', 'compact': 'cosm', - 'permissions': {'actions': {'read', 'admin', 'readPrivate', 'readSSN'}, 'jurisdictions': {}}, + 'permissions': {'actions': {'read', 'admin', 'readPrivate'}, 'jurisdictions': {}}, } ) @@ -31,7 +31,6 @@ def test_compact_ed_user(self): 'profile', 'cosm/admin', 'cosm/readGeneral', - 'cosm/readSSN', 'cosm/readPrivate', }, user_data.scopes, @@ -46,7 +45,7 @@ def test_board_ed_user(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#cosm', 'compact': 'cosm', - 'permissions': {'jurisdictions': {'al': {'write', 'admin', 'readPrivate', 'readSSN'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin', 'readPrivate'}}}, } ) @@ -58,8 +57,7 @@ def test_board_ed_user(self): 'cosm/readGeneral', 'al/cosm.admin', 'al/cosm.write', - 'al/cosm.readPrivate', - 'al/cosm.readSSN', + 'al/cosm.readPrivate' }, user_data.scopes, ) diff --git a/backend/cosmetology-app/lambdas/python/staff-user-pre-token/user_data.py b/backend/cosmetology-app/lambdas/python/staff-user-pre-token/user_data.py index 1da02add9..46a3b4b72 100644 --- a/backend/cosmetology-app/lambdas/python/staff-user-pre-token/user_data.py +++ b/backend/cosmetology-app/lambdas/python/staff-user-pre-token/user_data.py @@ -53,8 +53,7 @@ def _process_compact_permissions(self, compact_abbr, compact_permissions): disallowed_actions = compact_actions - { CCPermissionsAction.READ, CCPermissionsAction.ADMIN, - CCPermissionsAction.READ_PRIVATE, - CCPermissionsAction.READ_SSN, + CCPermissionsAction.READ_PRIVATE } if disallowed_actions: raise ValueError(f'User {compact_abbr} permissions include disallowed actions: {disallowed_actions}') @@ -65,9 +64,6 @@ def _process_compact_permissions(self, compact_abbr, compact_permissions): if CCPermissionsAction.READ_PRIVATE in compact_actions: self.scopes.add(f'{compact_abbr}/{CCPermissionsAction.READ_PRIVATE}') - if CCPermissionsAction.READ_SSN in compact_actions: - self.scopes.add(f'{compact_abbr}/{CCPermissionsAction.READ_SSN}') - if CCPermissionsAction.ADMIN in compact_actions: self.scopes.add(f'{compact_abbr}/{CCPermissionsAction.ADMIN}') @@ -87,8 +83,7 @@ def _process_jurisdiction_permissions(self, compact_abbr, jurisdiction_name, jur disallowed_actions = jurisdiction_actions - { CCPermissionsAction.WRITE, CCPermissionsAction.ADMIN, - CCPermissionsAction.READ_PRIVATE, - CCPermissionsAction.READ_SSN, + CCPermissionsAction.READ_PRIVATE } if disallowed_actions: raise ValueError( diff --git a/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py b/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py index cb9749020..d2e0fad74 100644 --- a/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py +++ b/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py @@ -2,13 +2,12 @@ import os -from aws_cdk import Duration -from aws_cdk.aws_cloudwatch import Alarm, CfnAlarm, ComparisonOperator, Metric, TreatMissingData -from aws_cdk.aws_cloudwatch_actions import SnsAction from aws_cdk.aws_events import EventBus -from aws_cdk.aws_iam import Policy, PolicyStatement +from aws_cdk.aws_lambda import Code, Function, Runtime +from aws_cdk.aws_logs import RetentionDays from cdk_nag import NagSuppressions from common_constructs.stack import Stack +from constructs import Construct from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als @@ -34,8 +33,6 @@ def __init__( 'EVENT_BUS_NAME': data_event_bus.event_bus_name, 'PROV_FAM_GIV_MID_INDEX_NAME': persistent_stack.provider_table.provider_fam_giv_mid_index_name, 'PROV_DATE_OF_UPDATE_INDEX_NAME': persistent_stack.provider_table.provider_date_of_update_index_name, - 'SSN_TABLE_NAME': persistent_stack.ssn_table.table_name, - 'SSN_INDEX_NAME': persistent_stack.ssn_table.ssn_index_name, 'RATE_LIMITING_TABLE_NAME': persistent_stack.rate_limiting_table.table_name, 'USER_POOL_ID': persistent_stack.staff_users.user_pool_id, 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': persistent_stack.email_notification_service_lambda.function_name, @@ -51,11 +48,79 @@ def __init__( api_lambda_stack.log_groups.append(self.get_provider_handler.log_group) self.query_providers_handler = self._query_providers_handler(lambda_environment) api_lambda_stack.log_groups.append(self.query_providers_handler.log_group) - self.get_provider_ssn_handler = self._get_provider_ssn_handler(lambda_environment) - api_lambda_stack.log_groups.append(self.get_provider_ssn_handler.log_group) self.provider_encumbrance_handler = self._add_provider_encumbrance_handler(lambda_environment) api_lambda_stack.log_groups.append(self.provider_encumbrance_handler.log_group) + # TODO: Remove this dummy once ApiStack no longer imports this lambda. # noqa: FIX002 + self._create_dummy_get_provider_ssn_handler(scope) + + def _create_dummy_get_provider_ssn_handler(self, scope: Construct) -> None: + """ + Keep a no-op Lambda with the original construct id so ApiStack cross-stack imports (export of ARN and log + group name) remain valid until phase 1 removes those references from the API template. + """ + stack = Stack.of(scope) + dummy_function = Function( + scope, + 'GetProviderSSNHandler', # Must match original + description='Get provider SSN handler dummy function', + handler='handler', + code=Code.from_inline('def handler(*args, **kwargs):\n return'), + runtime=Runtime.PYTHON_3_14, + log_retention=RetentionDays.ONE_DAY, # Triggers creation of the LogRetention custom resource + ) + stack.export_value(dummy_function.log_group.log_group_name) + stack.export_value(dummy_function.function_arn) + + NagSuppressions.add_resource_suppressions( + dummy_function, + suppressions=[ + { + 'id': 'HIPAA.Security-LambdaDLQ', + 'reason': 'This function is a dummy function to get past a deadly embrace with cross-stack ' + 'dependencies. It will be removed in a future update. It does not need a DLQ.', + }, + { + 'id': 'HIPAA.Security-LambdaInsideVPC', + 'reason': 'This function is a dummy function to get past a deadly embrace with cross-stack ' + 'dependencies. It will be removed in a future update. It does not need to be in a VPC.', + }, + ], + ) + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{dummy_function.node.path}/ServiceRole/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'reason': 'The AWSBasicExecutionPolicy is suitable for this lambda', + }, + ], + ) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{stack.node.path}/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'reason': 'The actions in this policy are specifically what this lambda needs ' + 'and is scoped to one table, user pool, and one secret.', + }, + ], + ) + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{stack.node.path}/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are scoped specifically to what this lambda needs to manage' + ' log groups.', + }, + ], + ) + def _create_provider_investigation_handler(self, lambda_environment: dict) -> PythonFunction: """Create and configure the Lambda handler for investigating a provider's privilege or license.""" handler = PythonFunction( @@ -154,155 +219,6 @@ def _query_providers_handler( ) return handler - def _get_provider_ssn_handler( - self, - lambda_environment: dict, - ) -> PythonFunction: - """Create and configure the Lambda handler for retrieving a provider's SSN.""" - handler = PythonFunction( - self.scope, - 'GetProviderSSNHandler', - description='Get provider SSN handler', - lambda_dir='provider-data-v1', - index=os.path.join('handlers', 'providers.py'), - handler='get_provider_ssn', - role=self.persistent_stack.ssn_table.api_query_role, - environment=lambda_environment, - alarm_topic=self.persistent_stack.alarm_topic, - ) - # The lambda needs to read providers from the provider table and the SSN from the ssn table - # Though, ssn table access is granted via resource policies on the table and key so `.grant()` - # calls are not needed here. - - # Grant permissions for rate limiting, provider table access, compact config, and staff user pool access - self.persistent_stack.rate_limiting_table.grant_read_write_data(handler) - self.persistent_stack.provider_table.grant_read_data(handler) - self.persistent_stack.compact_configuration_table.grant_read_data(handler) - self.persistent_stack.staff_users.grant(handler, 'cognito-idp:AdminDisableUser') - - # Add permission for the lambda to update its own concurrency setting - function_arn = handler.function_arn - handler.role.attach_inline_policy( - Policy( - self.scope, - 'PutFunctionConcurrency', - statements=[ - PolicyStatement( - actions=['lambda:PutFunctionConcurrency'], - resources=[function_arn], - ) - ], - ) - ) - - NagSuppressions.add_resource_suppressions_by_path( - Stack.of(handler.role), - path=f'{handler.role.node.path}/DefaultPolicy/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM5', - 'reason': 'The wildcard actions in this policy are scoped to the rate-limiting table and ' - 'the provider data table.', - }, - ], - ) - - # Create a metric to track how many times this endpoint has been invoked with a day - daily_read_ssn_count_metric = Metric( - namespace='compact-connect', - metric_name='read-ssn', - statistic='SampleCount', - period=Duration.days(1), - dimensions_map={'service': 'common'}, - ) - - # We'll monitor longer access patterns to detect anomalies, over time - # The L2 construct, Alarm, doesn't yet support Anomaly Detection as a configuration - # so we're using the L1 construct, CfnAlarm - # This anomaly detector scans the count of requests to the ssn endpoint by - # the daily_read_ssn_count_metric and uses machine-learning and pattern recognition to - # establish baselines of typical usage. - # See https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/LogsAnomalyDetection.html - self.ssn_anomaly_detection_alarm = CfnAlarm( - handler, - 'ReadSSNAnomalyAlarm', - alarm_description=f'{handler.node.path} read-ssn anomaly detection. The GET provider SSN endpoint has been' - f'called an irregular number of times. Investigation required to ensure ssn endpoint is ' - f'not being abused.', - comparison_operator='GreaterThanUpperThreshold', - evaluation_periods=1, - treat_missing_data='notBreaching', - actions_enabled=True, - alarm_actions=[self.persistent_stack.alarm_topic.node.default_child.ref], - metrics=[ - CfnAlarm.MetricDataQueryProperty(id='ad1', expression='ANOMALY_DETECTION_BAND(m1, 2)'), - CfnAlarm.MetricDataQueryProperty( - id='m1', - metric_stat=CfnAlarm.MetricStatProperty( - metric=CfnAlarm.MetricProperty( - metric_name=daily_read_ssn_count_metric.metric_name, - namespace=daily_read_ssn_count_metric.namespace, - dimensions=[CfnAlarm.DimensionProperty(name='service', value='common')], - ), - period=3600, - stat='SampleCount', - ), - ), - ], - threshold_metric_id='ad1', - ) - - # Create a metric to track if any user is rate-limited while calling this endpoint - ssn_rate_limited_count_metric = Metric( - namespace='compact-connect', - metric_name='rate-limited-ssn-access', - statistic='SampleCount', - period=Duration.minutes(5), - dimensions_map={'service': 'common'}, - ) - - # This alarm will fire if any user is rate-limited by this endpoint - # This will help us determine if the limit needs to be raised or detect early abuse - self.ssn_rate_limited_alarm = Alarm( - handler, - 'SSNReadsRateLimitedAlarm', - metric=ssn_rate_limited_count_metric, - threshold=1, - evaluation_periods=1, - comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=TreatMissingData.NOT_BREACHING, - alarm_description=f'{handler.node.path} ssn reads rate-limited alarm. The GET provider SSN endpoint has ' - f'been invoked more than an expected threshold within a 24 hour period. Investigation is ' - f'required to ensure access is not the result of abuse.', - ) - self.ssn_rate_limited_alarm.add_alarm_action(SnsAction(self.persistent_stack.alarm_topic)) - - # Create a metric to track if ssn endpoint has been disabled due to excessive requests - ssn_endpoint_disabled_count_metric = Metric( - namespace='compact-connect', - metric_name='ssn-endpoint-disabled', - statistic='SampleCount', - period=Duration.minutes(5), - dimensions_map={'service': 'common'}, - ) - # This alarm will fire if the ssn endpoint hits our global threshold and is disabled (concurrency set to 0) - self.ssn_endpoint_disabled_alarm = Alarm( - handler, - 'SSNEndpointDisabledAlarm', - metric=ssn_endpoint_disabled_count_metric, - threshold=1, - evaluation_periods=1, - comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=TreatMissingData.NOT_BREACHING, - alarm_description=f'{handler.node.path} SECURITY ALERT: SSN ENDPOINT DISABLED. The GET provider SSN ' - 'endpoint has been disabled due to excessive requests. Immediate investigation required. ' - 'Endpoint will need to be manually reactivated before any further requests can be ' - 'processed.', - ) - self.ssn_endpoint_disabled_alarm.add_alarm_action(SnsAction(self.persistent_stack.alarm_topic)) - - return handler - def _add_provider_encumbrance_handler( self, lambda_environment: dict, diff --git a/backend/cosmetology-app/stacks/api_stack/v1_api/api.py b/backend/cosmetology-app/stacks/api_stack/v1_api/api.py index dfd68c188..60b90fa6e 100644 --- a/backend/cosmetology-app/stacks/api_stack/v1_api/api.py +++ b/backend/cosmetology-app/stacks/api_stack/v1_api/api.py @@ -44,7 +44,6 @@ def __init__( read_scopes = [] write_scopes = [] admin_scopes = [] - read_ssn_scopes = [] # set the compact level scopes for compact in _active_compacts: # We only set the readGeneral permission scope at the compact level, since users with any permissions @@ -52,7 +51,6 @@ def __init__( read_scopes.append(f'{compact}/readGeneral') write_scopes.append(f'{compact}/write') admin_scopes.append(f'{compact}/admin') - read_ssn_scopes.append(f'{compact}/readSSN') _active_compact_jurisdictions = persistent_stack.get_list_of_active_jurisdictions_for_compact_environment( compact=compact @@ -64,7 +62,6 @@ def __init__( for jurisdiction in _active_compact_jurisdictions: write_scopes.append(f'{jurisdiction}/{compact}.write') admin_scopes.append(f'{jurisdiction}/{compact}.admin') - read_ssn_scopes.append(f'{jurisdiction}/{compact}.readSSN') read_auth_method_options = MethodOptions( authorization_type=AuthorizationType.COGNITO, @@ -83,12 +80,6 @@ def __init__( authorization_scopes=admin_scopes, ) - read_ssn_auth_method_options = MethodOptions( - authorization_type=AuthorizationType.COGNITO, - authorizer=self.api.staff_users_authorizer, - authorization_scopes=read_ssn_scopes, - ) - # /v1/flags self.flags_resource = self.resource.add_resource('flags') self.feature_flags = FeatureFlagsApi( @@ -129,7 +120,6 @@ def __init__( resource=providers_resource, method_options=read_auth_method_options, admin_method_options=admin_auth_method_options, - ssn_method_options=read_ssn_auth_method_options, api_model=self.api_model, api_lambda_stack=api_lambda_stack, ) diff --git a/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py b/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py index 29d27519b..99724322d 100644 --- a/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py +++ b/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py @@ -315,7 +315,6 @@ def _staff_user_permissions_schema(self): properties={ 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN), 'admin': JsonSchema(type=JsonSchemaType.BOOLEAN), - 'readSSN': JsonSchema(type=JsonSchemaType.BOOLEAN), }, ), 'jurisdictions': JsonSchema( @@ -329,8 +328,7 @@ def _staff_user_permissions_schema(self): properties={ 'write': JsonSchema(type=JsonSchemaType.BOOLEAN), 'admin': JsonSchema(type=JsonSchemaType.BOOLEAN), - 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN), - 'readSSN': JsonSchema(type=JsonSchemaType.BOOLEAN), + 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN) }, ), }, @@ -1265,29 +1263,6 @@ def put_jurisdiction_request_model(self) -> Model: ) return self.api._v1_put_jurisdiction_request_model - @property - def get_provider_ssn_response_model(self) -> Model: - """Return the provider SSN response model, which should only be created once per API""" - if hasattr(self.api, '_v1_get_provider_ssn_response_model'): - return self.api._v1_get_provider_ssn_response_model - - self.api._v1_get_provider_ssn_response_model = self.api.add_model( - 'V1GetProviderSSNResponseModel', - description='Get provider SSN response model', - schema=JsonSchema( - type=JsonSchemaType.OBJECT, - required=['ssn'], - properties={ - 'ssn': JsonSchema( - type=JsonSchemaType.STRING, - description="The provider's social security number", - pattern=cc_api.SSN_FORMAT, - ), - }, - ), - ) - return self.api._v1_get_provider_ssn_response_model - @property def public_query_providers_response_model(self) -> Model: """Return the public query providers response model, which should only be created once per API""" diff --git a/backend/cosmetology-app/stacks/api_stack/v1_api/provider_management.py b/backend/cosmetology-app/stacks/api_stack/v1_api/provider_management.py index ee13a71d0..073d37b85 100644 --- a/backend/cosmetology-app/stacks/api_stack/v1_api/provider_management.py +++ b/backend/cosmetology-app/stacks/api_stack/v1_api/provider_management.py @@ -2,13 +2,6 @@ from aws_cdk import Duration from aws_cdk.aws_apigateway import LambdaIntegration, MethodOptions, MethodResponse, Resource -from aws_cdk.aws_cloudwatch import ( - Alarm, - ComparisonOperator, - Metric, - TreatMissingData, -) -from aws_cdk.aws_cloudwatch_actions import SnsAction from common_constructs.cc_api import CCApi from common_constructs.python_function import PythonFunction @@ -28,7 +21,6 @@ def __init__( resource: Resource, method_options: MethodOptions, admin_method_options: MethodOptions, - ssn_method_options: MethodOptions, api_model: ApiModel, api_lambda_stack: ApiLambdaStack, ): @@ -64,10 +56,6 @@ def __init__( method_options=method_options, get_provider_handler=api_lambda_stack.provider_management_lambdas.get_provider_handler, ) - self._add_get_provider_ssn( - method_options=ssn_method_options, - get_provider_ssn_handler=api_lambda_stack.provider_management_lambdas.get_provider_ssn_handler, - ) self._add_encumber_privilege( method_options=admin_method_options, @@ -134,56 +122,6 @@ def _add_query_providers( authorization_scopes=method_options.authorization_scopes, ) - def _add_get_provider_ssn( - self, - method_options: MethodOptions, - get_provider_ssn_handler: PythonFunction, - ): - """Add GET /providers/{providerId}/ssn endpoint to retrieve a provider's SSN.""" - # Add the SSN endpoint as a sub-resource of the provider - self.ssn_resource = self.provider_resource.add_resource('ssn') - self.ssn_resource.add_method( - 'GET', - request_validator=self.api.parameter_body_validator, - method_responses=[ - MethodResponse( - status_code='200', - response_models={'application/json': self.api_model.get_provider_ssn_response_model}, - ), - ], - integration=LambdaIntegration(get_provider_ssn_handler, timeout=Duration.seconds(29)), - request_parameters={'method.request.header.Authorization': True}, - authorization_type=method_options.authorization_type, - authorizer=method_options.authorizer, - authorization_scopes=method_options.authorization_scopes, - ) - - # Add an alarm for 4xx responses from the SSN endpoint - self.ssn_api_throttling_alarm = Alarm( - self.api, - 'SSNApi4XXAlarm', - alarm_description=f'{self.api.node.path} SECURITY ALERT: Potential abuse detected - ' - 'Excessive 4xx errors triggered on GET provider SSN endpoint. ' - 'Immediate investigation required.', - metric=Metric( - namespace='AWS/ApiGateway', - metric_name='4XXError', - dimensions_map={ - 'ApiName': self.api.rest_api_name, - 'Stage': self.api.deployment_stage.stage_name, - 'Resource': self.ssn_resource.path, - 'Method': 'GET', - }, - statistic='Sum', - period=Duration.minutes(5), - ), - evaluation_periods=1, - threshold=100, - comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=TreatMissingData.NOT_BREACHING, - ) - self.ssn_api_throttling_alarm.add_alarm_action(SnsAction(self.api.alarm_topic)) - def _add_encumber_privilege( self, method_options: MethodOptions, diff --git a/backend/cosmetology-app/stacks/persistent_stack/__init__.py b/backend/cosmetology-app/stacks/persistent_stack/__init__.py index 2afd938fa..f1bba166c 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/__init__.py +++ b/backend/cosmetology-app/stacks/persistent_stack/__init__.py @@ -241,22 +241,6 @@ def _add_data_resources( environment_context=self.environment_context, ) - # The api query role needs access to the provider table to associate a provider with - # its jurisdictions, so it can make authorization decisions for the requester. - self.provider_table.grant_read_data(self.ssn_table.api_query_role) - NagSuppressions.add_resource_suppressions_by_path( - self, - f'{self.ssn_table.api_query_role.node.path}/DefaultPolicy/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM5', - 'reason': """This policy contains wild-carded actions and resources but they are scoped to the - specific actions, Table, and KMS Key that this lambda specifically needs access to. - """, - }, - ], - ) - self.data_event_table = DataEventTable( scope=self, construct_id='DataEventTable', diff --git a/backend/cosmetology-app/stacks/persistent_stack/ssn_table.py b/backend/cosmetology-app/stacks/persistent_stack/ssn_table.py index 5fc11514e..19beaff30 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/ssn_table.py +++ b/backend/cosmetology-app/stacks/persistent_stack/ssn_table.py @@ -240,17 +240,39 @@ def _configure_access(self): self.key.grant_encrypt_decrypt(self.license_upload_role) self._role_suppressions(self.license_upload_role) + # TODO: Remove this role in a follow-up deployment. # noqa: FIX002 + # This role is retained temporarily to avoid a CloudFormation cross-stack export + # deletion conflict with the API Lambda stack during the first deployment that removes + # the GET provider SSN endpoint. It has been stripped of all effective permissions + # (no table/KMS grants, not in the KMS deny-exception allowlist) and will be removed + # entirely in a follow-up deployment. self.api_query_role = Role( self, 'ProviderQueryRole', assumed_by=ServicePrincipal('lambda.amazonaws.com'), - description='Dedicated role for API provider queries, with access to full SSNs', + description='Deprecated inert role retained only to preserve the cross-stack export ' + 'during SSN endpoint removal. Has no effective permissions.', managed_policies=[ManagedPolicy.from_aws_managed_policy_name('service-role/AWSLambdaBasicExecutionRole')], ) - self.grant_read_data(self.api_query_role) - self._role_suppressions(self.api_query_role) - + # Keep exporting these values during phase 1 so dependent stacks can continue to resolve + # cross-stack references while endpoint resources are removed in stages. stack = Stack.of(self) + stack.export_value(self.api_query_role.role_arn) + stack.export_value(self.api_query_role.role_name) + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(self.api_query_role), + f'{self.api_query_role.node.path}/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], + 'reason': 'This role is inert (no grants attached) and retained only to preserve a ' + 'cross-stack export during staged removal of the SSN endpoint.', + }, + ], + ) # Configure permissions for the Lambda role # Add scan permissions for restored tables (needed by copy_records Lambda) @@ -420,7 +442,6 @@ def _configure_access(self): allowed_principal_arns = [ self.ingest_role.role_arn, self.license_upload_role.role_arn, - self.api_query_role.role_arn, self.disaster_recovery_lambda_role.role_arn, self.disaster_recovery_step_function_role.role_arn, ] @@ -442,7 +463,6 @@ def _configure_access(self): }, ) ) - self.key.grant_decrypt(self.api_query_role) self.key.grant_encrypt_decrypt(self.ingest_role) def _setup_license_preprocessor_queue(self, data_event_bus: EventBus, alarm_topic: ITopic): diff --git a/backend/cosmetology-app/tests/app/base.py b/backend/cosmetology-app/tests/app/base.py index 60f9be1e3..e4d0b3eea 100644 --- a/backend/cosmetology-app/tests/app/base.py +++ b/backend/cosmetology-app/tests/app/base.py @@ -188,9 +188,6 @@ def _inspect_ssn_table(self, persistent_stack: PersistentStack, persistent_stack license_upload_role_logical_id = persistent_stack.get_logical_id( persistent_stack.ssn_table.license_upload_role.node.default_child ) - api_query_role_logical_id = persistent_stack.get_logical_id( - persistent_stack.ssn_table.api_query_role.node.default_child - ) disaster_recovery_lambda_role_logical_id = persistent_stack.get_logical_id( persistent_stack.ssn_table.disaster_recovery_lambda_role.node.default_child ) @@ -198,12 +195,11 @@ def _inspect_ssn_table(self, persistent_stack: PersistentStack, persistent_stack persistent_stack.ssn_table.disaster_recovery_step_function_role.node.default_child ) - # Build the expected PrincipalArn array - always includes 5 roles, plus optional backup role + # Build the expected PrincipalArn array - always includes 4 roles, plus optional backup role # Note: SSN backup role reference may be a nested stack output, so we use Match.any_value() for flexibility principal_arn_array = [ {'Fn::GetAtt': [ingest_role_logical_id, 'Arn']}, {'Fn::GetAtt': [license_upload_role_logical_id, 'Arn']}, - {'Fn::GetAtt': [api_query_role_logical_id, 'Arn']}, {'Fn::GetAtt': [disaster_recovery_lambda_role_logical_id, 'Arn']}, {'Fn::GetAtt': [disaster_recovery_step_function_role_logical_id, 'Arn']}, ] diff --git a/backend/cosmetology-app/tests/app/test_api/test_provider_management_api.py b/backend/cosmetology-app/tests/app/test_api/test_provider_management_api.py index a3c2ddcba..0caf3c6fe 100644 --- a/backend/cosmetology-app/tests/app/test_api/test_provider_management_api.py +++ b/backend/cosmetology-app/tests/app/test_api/test_provider_management_api.py @@ -1,6 +1,5 @@ from aws_cdk.assertions import Capture, Template from aws_cdk.aws_apigateway import CfnMethod, CfnModel, CfnResource -from aws_cdk.aws_cloudwatch import CfnAlarm from aws_cdk.aws_lambda import CfnFunction from tests.app.test_api import TestApi @@ -334,145 +333,6 @@ def test_synth_generates_query_providers_endpoint(self): overwrite_snapshot=False, ) - def test_synth_generates_get_provider_ssn_endpoint(self): - """Test that the GET /providers/{providerId}/ssn endpoint is configured correctly.""" - api_stack = self.app.sandbox_backend_stage.api_stack - api_stack_template = Template.from_stack(api_stack) - api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack - api_lambda_stack_template = Template.from_stack(api_lambda_stack) - - # Ensure the resource is created with expected path - api_stack_template.has_resource_properties( - type=CfnResource.CFN_RESOURCE_TYPE_NAME, - props={ - 'ParentId': { - 'Ref': api_stack.get_logical_id( - api_stack.api.v1_api.provider_management.provider_resource.node.default_child - ), - }, - 'PathPart': 'ssn', - }, - ) - - # Ensure the lambda is created with expected code path - api_lambda_stack_template.has_resource_properties( - type=CfnFunction.CFN_RESOURCE_TYPE_NAME, - props={'Handler': 'handlers.providers.get_provider_ssn'}, - ) - - # Capture model logical ID for verification - response_model_logical_id_capture = Capture() - - # Ensure the GET method is configured correctly - api_stack_template.has_resource_properties( - type=CfnMethod.CFN_RESOURCE_TYPE_NAME, - props={ - 'HttpMethod': 'GET', - 'AuthorizerId': { - 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), - }, - 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( - api_lambda_stack, - api_lambda_stack_template, - api_lambda_stack.provider_management_lambdas.get_provider_ssn_handler, - ), - 'MethodResponses': [ - { - 'ResponseModels': {'application/json': {'Ref': response_model_logical_id_capture}}, - 'StatusCode': '200', - }, - ], - }, - ) - - # Verify response model schema - response_model = TestApi.get_resource_properties_by_logical_id( - response_model_logical_id_capture.as_string(), - api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), - ) - self.compare_snapshot( - response_model['Schema'], - 'GET_PROVIDER_SSN_RESPONSE_SCHEMA', - overwrite_snapshot=False, - ) - - def test_synth_generates_get_provider_ssn_alarms(self): - """Test that the GET /providers/{providerId}/ssn alarms are configured correctly.""" - api_stack = self.app.sandbox_backend_stage.api_stack - api_stack_template = Template.from_stack(api_stack) - api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack - api_lambda_stack_template = Template.from_stack(api_lambda_stack) - - # Ensure the anomaly detection alarm is created - alarms = api_lambda_stack_template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME) - anomaly_alarm = TestApi.get_resource_properties_by_logical_id( - api_lambda_stack.get_logical_id(api_lambda_stack.provider_management_lambdas.ssn_anomaly_detection_alarm), - alarms, - ) - - # The alarm actions ref change depending on sandbox vs pipeline configuration, so we'll just - # make sure there is one action and remove it from the comparison - actions = anomaly_alarm.pop('AlarmActions', []) - self.assertEqual(len(actions), 1) - - self.compare_snapshot( - anomaly_alarm, - 'GET_PROVIDER_SSN_ANOMALY_DETECTION_ALARM_SCHEMA', - overwrite_snapshot=False, - ) - - # Ensure the ssn read rate-limited alarm is created - ssn_read_rate_limited_alarm = TestApi.get_resource_properties_by_logical_id( - api_lambda_stack.get_logical_id( - api_lambda_stack.provider_management_lambdas.ssn_rate_limited_alarm.node.default_child - ), - alarms, - ) - - actions = ssn_read_rate_limited_alarm.pop('AlarmActions', []) - self.assertEqual(len(actions), 1) - - self.compare_snapshot( - ssn_read_rate_limited_alarm, - 'GET_PROVIDER_SSN_READS_RATE_LIMITED_ALARM_SCHEMA', - overwrite_snapshot=False, - ) - - # Ensure the ssn endpoint disabled alarm is created - ssn_endpoint_disabled_alarm = TestApi.get_resource_properties_by_logical_id( - api_lambda_stack.get_logical_id( - api_lambda_stack.provider_management_lambdas.ssn_endpoint_disabled_alarm.node.default_child - ), - alarms, - ) - - actions = ssn_endpoint_disabled_alarm.pop('AlarmActions', []) - self.assertEqual(len(actions), 1) - - self.compare_snapshot( - ssn_endpoint_disabled_alarm, - 'GET_PROVIDER_SSN_ENDPOINT_DISABLED_ALARM_SCHEMA', - overwrite_snapshot=False, - ) - - # Ensure the 4xx API alarm is created (still in api_stack) - api_stack_alarms = api_stack_template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME) - throttling_alarm = TestApi.get_resource_properties_by_logical_id( - api_stack.get_logical_id( - api_stack.api.v1_api.provider_management.ssn_api_throttling_alarm.node.default_child - ), - api_stack_alarms, - ) - - actions = throttling_alarm.pop('AlarmActions', []) - self.assertEqual(len(actions), 1) - - self.compare_snapshot( - throttling_alarm, - 'GET_PROVIDER_SSN_4XX_ALARM_SCHEMA', - overwrite_snapshot=False, - ) - def test_synth_generates_privilege_encumbrance_endpoint(self): """Test that the POST /providers/{providerId}/privileges/jurisdiction/{jurisdiction} /licenseType/{licenseType}/encumbrance endpoint is configured correctly.""" diff --git a/backend/cosmetology-app/tests/app/test_pipeline.py b/backend/cosmetology-app/tests/app/test_pipeline.py index 2bb22c1ae..64785376f 100644 --- a/backend/cosmetology-app/tests/app/test_pipeline.py +++ b/backend/cosmetology-app/tests/app/test_pipeline.py @@ -101,7 +101,7 @@ def _when_testing_compact_resource_servers(self, persistent_stack): ) # Ensure the compact resource servers are created with the expected scopes self.assertEqual( - ['admin', 'write', 'readGeneral', 'readSSN'], + ['admin', 'write', 'readGeneral'], [scope['ScopeName'] for scope in resource_server_properties['Scopes']], msg=f'Expected scopes for compact {compact} not found', ) diff --git a/backend/cosmetology-app/tests/resources/snapshots/JURISDICTION_RESOURCE_SERVER_CONFIGURATION.json b/backend/cosmetology-app/tests/resources/snapshots/JURISDICTION_RESOURCE_SERVER_CONFIGURATION.json index ebc86505b..d7481291d 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/JURISDICTION_RESOURCE_SERVER_CONFIGURATION.json +++ b/backend/cosmetology-app/tests/resources/snapshots/JURISDICTION_RESOURCE_SERVER_CONFIGURATION.json @@ -11,10 +11,6 @@ "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", "ScopeName": "cosm.readPrivate" }, - { - "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", - "ScopeName": "cosm.readSSN" - }, { "ScopeDescription": "Write access for the cosm compact within the jurisdiction", "ScopeName": "cosm.write" @@ -33,10 +29,6 @@ "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", "ScopeName": "cosm.readPrivate" }, - { - "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", - "ScopeName": "cosm.readSSN" - }, { "ScopeDescription": "Write access for the cosm compact within the jurisdiction", "ScopeName": "cosm.write" @@ -55,10 +47,6 @@ "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", "ScopeName": "cosm.readPrivate" }, - { - "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", - "ScopeName": "cosm.readSSN" - }, { "ScopeDescription": "Write access for the cosm compact within the jurisdiction", "ScopeName": "cosm.write" @@ -77,10 +65,6 @@ "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", "ScopeName": "cosm.readPrivate" }, - { - "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", - "ScopeName": "cosm.readSSN" - }, { "ScopeDescription": "Write access for the cosm compact within the jurisdiction", "ScopeName": "cosm.write" @@ -99,10 +83,6 @@ "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", "ScopeName": "cosm.readPrivate" }, - { - "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", - "ScopeName": "cosm.readSSN" - }, { "ScopeDescription": "Write access for the cosm compact within the jurisdiction", "ScopeName": "cosm.write" @@ -121,10 +101,6 @@ "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", "ScopeName": "cosm.readPrivate" }, - { - "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", - "ScopeName": "cosm.readSSN" - }, { "ScopeDescription": "Write access for the cosm compact within the jurisdiction", "ScopeName": "cosm.write" @@ -143,10 +119,6 @@ "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", "ScopeName": "cosm.readPrivate" }, - { - "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", - "ScopeName": "cosm.readSSN" - }, { "ScopeDescription": "Write access for the cosm compact within the jurisdiction", "ScopeName": "cosm.write" @@ -165,10 +137,6 @@ "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", "ScopeName": "cosm.readPrivate" }, - { - "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", - "ScopeName": "cosm.readSSN" - }, { "ScopeDescription": "Write access for the cosm compact within the jurisdiction", "ScopeName": "cosm.write" @@ -187,10 +155,6 @@ "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", "ScopeName": "cosm.readPrivate" }, - { - "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", - "ScopeName": "cosm.readSSN" - }, { "ScopeDescription": "Write access for the cosm compact within the jurisdiction", "ScopeName": "cosm.write" @@ -209,10 +173,6 @@ "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", "ScopeName": "cosm.readPrivate" }, - { - "ScopeDescription": "Read access for SSNs in the cosm compact within the jurisdiction", - "ScopeName": "cosm.readSSN" - }, { "ScopeDescription": "Write access for the cosm compact within the jurisdiction", "ScopeName": "cosm.write" diff --git a/backend/cosmetology-app/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json index 10c369425..d3bf1d6d2 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json @@ -12,9 +12,6 @@ }, "admin": { "type": "boolean" - }, - "readSSN": { - "type": "boolean" } }, "type": "object" @@ -33,9 +30,6 @@ }, "readPrivate": { "type": "boolean" - }, - "readSSN": { - "type": "boolean" } }, "type": "object" diff --git a/backend/cosmetology-app/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json index 4b509b83c..fed034d2b 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json @@ -48,9 +48,6 @@ }, "admin": { "type": "boolean" - }, - "readSSN": { - "type": "boolean" } }, "type": "object" @@ -69,9 +66,6 @@ }, "readPrivate": { "type": "boolean" - }, - "readSSN": { - "type": "boolean" } }, "type": "object" diff --git a/backend/cosmetology-app/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json index fee25163e..7809782f7 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json @@ -38,9 +38,6 @@ }, "admin": { "type": "boolean" - }, - "readSSN": { - "type": "boolean" } }, "type": "object" @@ -59,9 +56,6 @@ }, "readPrivate": { "type": "boolean" - }, - "readSSN": { - "type": "boolean" } }, "type": "object" diff --git a/backend/cosmetology-app/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json b/backend/cosmetology-app/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json index 4b509b83c..fed034d2b 100644 --- a/backend/cosmetology-app/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json +++ b/backend/cosmetology-app/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json @@ -48,9 +48,6 @@ }, "admin": { "type": "boolean" - }, - "readSSN": { - "type": "boolean" } }, "type": "object" @@ -69,9 +66,6 @@ }, "readPrivate": { "type": "boolean" - }, - "readSSN": { - "type": "boolean" } }, "type": "object" diff --git a/backend/cosmetology-app/tests/smoke/README.md b/backend/cosmetology-app/tests/smoke/README.md index 3d81bae30..2cad90ee6 100644 --- a/backend/cosmetology-app/tests/smoke/README.md +++ b/backend/cosmetology-app/tests/smoke/README.md @@ -79,7 +79,6 @@ You must have a test license record uploaded in your sandbox environment to gene - `CC_TEST_ROLLBACK_STEP_FUNCTION_ARN`: Step function ARN for rollback tests - `CC_TEST_RATE_LIMITING_DYNAMO_TABLE_NAME`: DynamoDB table name for rate limiting - `CC_TEST_SSN_DYNAMO_TABLE_NAME`: DynamoDB table name for SSN data - - `CC_TEST_GET_PROVIDER_SSN_LAMBDA_NAME`: Lambda function name for SSN retrieval 3. **Important:** Never commit `smoke_tests_env.json` to version control. It contains sensitive credentials and should be in `.gitignore`. diff --git a/backend/cosmetology-app/tests/smoke/smoke_common.py b/backend/cosmetology-app/tests/smoke/smoke_common.py index 1601a895b..7d54fc0a8 100644 --- a/backend/cosmetology-app/tests/smoke/smoke_common.py +++ b/backend/cosmetology-app/tests/smoke/smoke_common.py @@ -201,10 +201,6 @@ def get_data_events_dynamodb_table(): return boto3.resource('dynamodb').Table(os.environ['CC_TEST_DATA_EVENT_DYNAMO_TABLE_NAME']) -def get_provider_ssn_lambda_name(): - return os.environ['CC_TEST_GET_PROVIDER_SSN_LAMBDA_NAME'] - - def get_lambda_client(): return boto3.client('lambda') diff --git a/backend/cosmetology-app/tests/smoke/smoke_tests_env_example.json b/backend/cosmetology-app/tests/smoke/smoke_tests_env_example.json index 98b5c0dd5..b2416e868 100644 --- a/backend/cosmetology-app/tests/smoke/smoke_tests_env_example.json +++ b/backend/cosmetology-app/tests/smoke/smoke_tests_env_example.json @@ -6,7 +6,6 @@ "CC_TEST_PROVIDER_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-ProviderTable12345", "CC_TEST_COMPACT_CONFIGURATION_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-CompactConfigTable12345", "CC_TEST_RATE_LIMITING_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-RateLimitingTable12345", - "CC_TEST_GET_PROVIDER_SSN_LAMBDA_NAME": "Sandbox-APIStack-LicenseApiv1compactscompactprovid-1234", "CC_TEST_SSN_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-SSNTable12345", "CC_TEST_DATA_EVENT_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-DataEventTable1234", "CC_TEST_STAFF_USER_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-StaffUserTable1234", diff --git a/backend/cosmetology-app/tests/smoke/ssn_read_throttling_smoke_tests.py b/backend/cosmetology-app/tests/smoke/ssn_read_throttling_smoke_tests.py deleted file mode 100644 index c98e44544..000000000 --- a/backend/cosmetology-app/tests/smoke/ssn_read_throttling_smoke_tests.py +++ /dev/null @@ -1,223 +0,0 @@ -# ruff: noqa: T201 we use print statements for smoke testing -#!/usr/bin/env python3 -import time -import uuid - -import requests -from config import logger -from smoke_common import ( - SmokeTestFailureException, - create_test_staff_user, - delete_test_staff_user, - get_api_base_url, - get_lambda_client, - get_provider_ssn_lambda_name, - get_rate_limiting_dynamodb_table, - get_staff_user_auth_headers, - load_smoke_test_env, -) - -COMPACT = 'cosm' -JURISDICTION = 'az' -TEST_PROVIDER_GIVEN_NAME = 'Joe' -TEST_PROVIDER_FAMILY_NAME = 'Dokes' - -# This script can be run locally against a sandbox environment to test the throttling functionality of the -# GET provider SSN endpoint of the Compact Connect API. -# Your sandbox account must be deployed with the "security_profile": "VULNERABLE" setting in your cdk.context.json -# To run this script, create a smoke_tests_env.json file in the same directory as this script using the -# 'smoke_tests_env_example.json' file as a template. - -# By design, this sensitive endpoint should throttle users that make more than 5 requests within a 24 period, and the -# endpoint should deactivate itself after 15 requests within 24 hours. This is to limit risk of compromised admin -# credentials resulting in large numbers of SSNs being leaked. -# This test spins up three test staff users and calls the endpoint 6 times each for each user. - - -def _cleanup_test_generated_records(): - """ - Cleanup all test records from the rate limiting table, so that this test can be run again if needed - """ - # Now clean up the records we added - # First, get all provider records to delete - rate_limiting_dynamo_table = get_rate_limiting_dynamodb_table() - read_ssn_requests_query_response = rate_limiting_dynamo_table.query( - KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': 'READ_SSN_REQUESTS'} - ) - - items = read_ssn_requests_query_response.get('Items', []) - - logger.info('Read SSN request count', request_count=len(items)) - - # Delete all read records - for record in items: - rate_limiting_dynamo_table.delete_item(Key={'pk': record['pk'], 'sk': record['sk']}) - logger.info('Successfully deleted read ssn request records from rate limiting table') - - -def _make_ssn_request(email, provider_id): - """ - Makes a request to the SSN endpoint and returns the response - """ - headers = get_staff_user_auth_headers(email) - return requests.get( - url=get_api_base_url() + f'/v1/compacts/{COMPACT}/providers/{provider_id}/ssn', - headers=headers, - timeout=10, - ) - - -def _staff_user_is_enabled(email): - """ - Checks if a user is enabled or disabled in Cognito - Returns True if enabled, False if disabled - """ - from config import config - - user_data = config.cognito_client.admin_get_user(UserPoolId=config.cognito_staff_user_pool_id, Username=email) - return user_data.get('Enabled', True) - - -def trigger_get_provider_ssn_endpoint_throttling(): - """ - Verifies that the GET provider SSN endpoint will throttle and deactivate users that call the - endpoint too frequently. - - Step 1: Create three test staff users with the cosm/readSSN scope. - Step 2: Have each user call the endpoint until throttled after 16 requests (7 requests from first two users, - 2 requests from the third). The first two should be disabled (asserted with the AdminGetUser api), and the last - one should cause the lambda to throttle itself with a set reserved concurrency limit of 0 (asserted using the boto3 - lambda client) - Step 3: Ensure that all test staff users are cleaned up and all request record in the rate limiting table - are cleared. - """ - # Generate a random provider ID - we don't need a real one since we'll get 404s or 429s - # but the endpoint will still record the access attempt - test_provider_id = str(uuid.uuid4()) - - # Create three test staff users - test_emails = [ - f'test-staff-user-1-{uuid.uuid4()}@example.com', - f'test-staff-user-2-{uuid.uuid4()}@example.com', - f'test-staff-user-3-{uuid.uuid4()}@example.com', - ] - - test_user_subs = [] - - # Create staff users with permission to read SSNs - for email in test_emails: - user_sub = create_test_staff_user( - email=email, - compact=COMPACT, - jurisdiction=JURISDICTION, - permissions={'actions': {'readSSN'}}, - ) - test_user_subs.append(user_sub) - logger.info(f'Created test staff user: {email}') - - try: - # Test the first two users - each should be able to make 5 requests successfully, - # get throttled on the 6th, and disabled on the 7th - for i, email in enumerate(test_emails[:2]): - logger.info(f'Testing user {i + 1}: {email}') - - # Make 5 successful requests - for j in range(5): - response = _make_ssn_request(email, test_provider_id) - # We expect a 404 since the provider ID doesn't exist, but that's fine - # The important thing is that it's not a 429 - if response.status_code == 429: - raise SmokeTestFailureException( - f'User {email} was throttled on request {j + 1}, expected to succeed' - ) - logger.info(f'Request {j + 1} successful with status code {response.status_code}') - - # 6th request should be throttled but user should still be enabled - response = _make_ssn_request(email, test_provider_id) - if response.status_code != 429: - raise SmokeTestFailureException( - f'Expected 429 on 6th request for user {email}, got {response.status_code}' - ) - logger.info('Request 6 correctly throttled with 429 status code') - - # Check that user is still enabled - if not _staff_user_is_enabled(email): - raise SmokeTestFailureException( - f'User {email} was disabled after 6th request, expected to still be enabled' - ) - logger.info(f'User {email} still enabled after 6th request as expected') - - # 7th request should be throttled and user should be disabled - response = _make_ssn_request(email, test_provider_id) - if response.status_code != 429: - raise SmokeTestFailureException( - f'Expected 429 on 7th request for user {email}, got {response.status_code}' - ) - logger.info('Request 7 correctly throttled with 429 status code') - - # Check that user is now disabled - if _staff_user_is_enabled(email): - raise SmokeTestFailureException(f'User {email} was not disabled after 7th request as expected') - logger.info(f'User {email} correctly disabled after 7th request') - - # Test the third user - this should trigger the global throttling after 16 total requests - # (14 from first two users, plus 2 from this user) - logger.info(f'Testing user 3: {test_emails[2]}') - - # First request should succeed - response = _make_ssn_request(test_emails[2], test_provider_id) - if response.status_code == 429: - raise SmokeTestFailureException( - f'User {test_emails[2]} was throttled on first request, expected to succeed' - ) - logger.info(f'First request for user 3 successful with status code {response.status_code}') - - # Second request should trigger global throttling (16th request overall) - response = _make_ssn_request(test_emails[2], test_provider_id) - if response.status_code != 429: - raise SmokeTestFailureException( - f'Expected 429 on 2nd request for user {test_emails[2]}, got {response.status_code}' - ) - logger.info('Second request correctly throttled with 429 status code') - - # Verify that lambda's reserved concurrency is set to 0 - # Give the lambda a moment to update its concurrency - time.sleep(2) - - lambda_concurrency_resp = get_lambda_client().get_function_concurrency( - FunctionName=get_provider_ssn_lambda_name() - ) - - reserved_concurrency = lambda_concurrency_resp.get('ReservedConcurrentExecutions') - - if reserved_concurrency != 0: - raise SmokeTestFailureException(f'Lambda reserved concurrency not set to zero. {reserved_concurrency}') - - logger.info('Lambda reserved concurrency correctly set to 0') - # Reset lambda concurrency - get_lambda_client().delete_function_concurrency(FunctionName=get_provider_ssn_lambda_name()) - logger.info('Reset lambda concurrency') - - except Exception as e: - logger.error('Smoke test failure', failure=str(e)) - raise - finally: - # Step 3: delete test staff users and cleanup items from the rate limiting table - logger.info('Cleaning up resources...') - - # Delete test staff users - for i, email in enumerate(test_emails): - try: - delete_test_staff_user(email, test_user_subs[i], COMPACT) - logger.info(f'Deleted test staff user: {email}') - except Exception as e: # noqa: BLE001 - logger.error(f'Failed to delete test staff user {email}: {str(e)}') - - # Clean up rate limiting records - _cleanup_test_generated_records() - logger.info('Cleanup complete') - - -if __name__ == '__main__': - load_smoke_test_env() - trigger_get_provider_ssn_endpoint_throttling()