diff --git a/backend/compact-connect/docs/internal/api-specification/latest-oas30.json b/backend/compact-connect/docs/internal/api-specification/latest-oas30.json index d140640c93..c51f87bd74 100644 --- a/backend/compact-connect/docs/internal/api-specification/latest-oas30.json +++ b/backend/compact-connect/docs/internal/api-specification/latest-oas30.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "LicenseApi", - "version": "2026-01-14T16:16:32Z" + "version": "2026-05-26T15:59:12Z" }, "servers": [ { diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/public_lookup.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/public_lookup.py index 085b7063a5..9700508833 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/public_lookup.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/public_lookup.py @@ -1,8 +1,8 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from cc_common.config import config, logger from cc_common.data_model.schema.provider.api import ProviderPublicResponseSchema, QueryProvidersRequestSchema -from cc_common.exceptions import CCInvalidRequestException -from cc_common.utils import api_handler +from cc_common.exceptions import CCInvalidRequestException, CCNotFoundException +from cc_common.utils import api_handler, delayed_function from marshmallow import ValidationError from . import get_provider_information @@ -113,6 +113,7 @@ def public_query_providers(event: dict, context: LambdaContext): # noqa: ARG001 @api_handler +@delayed_function(delay_seconds=1.0) def public_get_provider(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument """Return one provider's data :param event: Standard API Gateway event, API schema documented in the CDK ApiStack @@ -128,6 +129,9 @@ def public_get_provider(event: dict, context: LambdaContext): # noqa: ARG001 un with logger.append_context_keys(compact=compact, provider_id=provider_id): provider_information = get_provider_information(compact=compact, provider_id=provider_id) + if not provider_information.get('privileges'): + # Only return providers with privileges to the public endpoint + raise CCNotFoundException('Provider not found') public_schema = ProviderPublicResponseSchema() return public_schema.load(provider_information) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_public_lookup.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_public_lookup.py index 8b33a31825..65e3fe3db1 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_public_lookup.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_public_lookup.py @@ -1,5 +1,6 @@ import json from datetime import datetime +from functools import wraps from unittest.mock import patch from urllib.parse import quote @@ -8,6 +9,17 @@ from .. import TstFunction +def mock_delay_decorator(*args, **kwargs): # noqa: ARG001 unused-argument + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + return f(*args, **kwargs) + + return decorated_function + + return decorator + + @mock_aws @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) class TestPublicQueryProviders(TstFunction): @@ -413,6 +425,7 @@ def test_public_query_providers_strips_whitespace_from_query_fields(self): @mock_aws +@patch('cc_common.utils.delayed_function', mock_delay_decorator) @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) class TestPublicGetProvider(TstFunction): @staticmethod @@ -481,3 +494,26 @@ def test_public_get_provider_missing_provider_id(self): resp = public_get_provider(event, self.mock_context) self.assertEqual(400, resp['statusCode']) + + def test_public_get_provider_does_not_return_provider_if_they_do_not_have_privileges(self): + from handlers.public_lookup import public_get_provider + + # add provider without any privileges + test_provider = self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'privilegeJurisdictions': set()} + ) + self.test_data_generator.put_default_license_record_in_provider_table() + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # public endpoint does not have authorizer + del event['requestContext']['authorizer'] + # Searching for provider by id + event['pathParameters'] = {'compact': 'aslp', 'providerId': test_provider.providerId} + event['queryStringParameters'] = None + + resp = public_get_provider(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) + self.assertEqual({'message': 'Provider not found'}, json.loads(resp['body'])) diff --git a/backend/compact-connect/stacks/api_stack/v1_api/api.py b/backend/compact-connect/stacks/api_stack/v1_api/api.py index f356ff6729..6a19dcc53a 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/api.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/api.py @@ -36,6 +36,15 @@ def __init__( self.api: LicenseApi = root.api self.api_model = ApiModel(api=self.api) stack: Stack = Stack.of(self.resource) + # Beta intentionally omits unauthenticated public provider lookup routes + # (GET/POST under /v1/public/compacts/{compact}/providers/...). Test and prod deploy them. + # + # We normally keep beta and prod behavior aligned, but beta is used for state onboarding: IT staff + # may upload full license datasets or create mock privileges for API testing that they do not intend + # to be publicly searchable. In prod, states upload records they intend to expose via CompactConnect. + # There is no valid use case for public search in beta; omitting these routes follows the principle + # of least privilege. + deploy_public_lookup_api = stack.environment_name != 'beta' _active_compacts = persistent_stack.get_list_of_compact_abbreviations() # we only pass the API_BASE_URL env var if the API_DOMAIN_NAME is set @@ -115,12 +124,13 @@ def __init__( self.public_compacts_compact_providers_resource = self.public_compacts_compact_resource.add_resource( 'providers' ) - self.public_lookup_api = PublicLookupApi( - resource=self.public_compacts_compact_providers_resource, - api_model=self.api_model, - api_lambda_stack=api_lambda_stack, - privilege_history_function=privilege_history_handler, - ) + if deploy_public_lookup_api: + self.public_lookup_api = PublicLookupApi( + resource=self.public_compacts_compact_providers_resource, + api_model=self.api_model, + api_lambda_stack=api_lambda_stack, + privilege_history_function=privilege_history_handler, + ) # /v1/provider-users self.provider_users_resource = self.resource.add_resource('provider-users') diff --git a/backend/compact-connect/tests/app/test_pipeline.py b/backend/compact-connect/tests/app/test_pipeline.py index 9004d069aa..917ba5f754 100644 --- a/backend/compact-connect/tests/app/test_pipeline.py +++ b/backend/compact-connect/tests/app/test_pipeline.py @@ -122,6 +122,19 @@ def _when_testing_compact_resource_servers(self, persistent_stack): msg=f'Expected scopes for compact {compact} not found', ) + def test_beta_api_stack_omits_public_lookup_api(self): + """Beta must not expose unauthenticated public provider lookup routes.""" + api_stack = self.app.beta_backend_pipeline_stack.beta_backend_stage.api_stack + self.assertFalse(hasattr(api_stack.api.v1_api, 'public_lookup_api')) + + def test_non_beta_api_stacks_include_public_lookup_api(self): + for api_stack in ( + self.app.test_backend_pipeline_stack.test_stage.api_stack, + self.app.prod_backend_pipeline_stack.prod_stage.api_stack, + ): + with self.subTest(api_stack.stack_name): + self.assertTrue(hasattr(api_stack.api.v1_api, 'public_lookup_api')) + def test_synth_generates_compact_resource_servers_with_expected_scopes_for_staff_users_beta_stage(self): persistent_stack = self.app.beta_backend_pipeline_stack.beta_backend_stage.persistent_stack self._when_testing_compact_resource_servers(persistent_stack) 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 60b90fa6e5..56efd19d2c 100644 --- a/backend/cosmetology-app/stacks/api_stack/v1_api/api.py +++ b/backend/cosmetology-app/stacks/api_stack/v1_api/api.py @@ -34,6 +34,15 @@ def __init__( self.api: LicenseApi = root.api self.api_model = ApiModel(api=self.api) stack: Stack = Stack.of(self.resource) + # Beta intentionally omits unauthenticated public provider lookup routes + # (GET/POST under /v1/public/compacts/{compact}/providers/...). Test and prod deploy them. + # + # We normally keep beta and prod behavior aligned, but beta is used for state onboarding: IT staff + # may upload full license datasets or create mock privileges for API testing that they do not intend + # to be publicly searchable. In prod, states upload records they intend to expose via CompactConnect. + # There is no valid use case for public search in beta; omitting these routes follows the principle + # of least privilege. + deploy_public_lookup_api = stack.environment_name != 'beta' _active_compacts = persistent_stack.get_list_of_compact_abbreviations() # we only pass the API_BASE_URL env var if the API_DOMAIN_NAME is set @@ -101,12 +110,13 @@ def __init__( self.public_compacts_compact_providers_resource = self.public_compacts_compact_resource.add_resource( 'providers' ) - self.public_lookup_api = PublicLookupApi( - resource=self.public_compacts_compact_providers_resource, - api_model=self.api_model, - api_lambda_stack=api_lambda_stack, - search_persistent_stack=search_persistent_stack, - ) + if deploy_public_lookup_api: + self.public_lookup_api = PublicLookupApi( + resource=self.public_compacts_compact_providers_resource, + api_model=self.api_model, + api_lambda_stack=api_lambda_stack, + search_persistent_stack=search_persistent_stack, + ) # /v1/compacts self.compacts_resource = self.resource.add_resource('compacts') diff --git a/backend/cosmetology-app/tests/app/test_pipeline.py b/backend/cosmetology-app/tests/app/test_pipeline.py index 64785376ff..d12985f303 100644 --- a/backend/cosmetology-app/tests/app/test_pipeline.py +++ b/backend/cosmetology-app/tests/app/test_pipeline.py @@ -106,6 +106,19 @@ def _when_testing_compact_resource_servers(self, persistent_stack): msg=f'Expected scopes for compact {compact} not found', ) + def test_beta_api_stack_omits_public_lookup_api(self): + """Beta must not expose unauthenticated public provider lookup routes.""" + api_stack = self.app.beta_backend_pipeline_stack.beta_backend_stage.api_stack + self.assertFalse(hasattr(api_stack.api.v1_api, 'public_lookup_api')) + + def test_non_beta_api_stacks_include_public_lookup_api(self): + for api_stack in ( + self.app.test_backend_pipeline_stack.test_stage.api_stack, + self.app.prod_backend_pipeline_stack.prod_stage.api_stack, + ): + with self.subTest(api_stack.stack_name): + self.assertTrue(hasattr(api_stack.api.v1_api, 'public_lookup_api')) + def test_synth_generates_compact_resource_servers_with_expected_scopes_for_staff_users_beta_stage(self): persistent_stack = self.app.beta_backend_pipeline_stack.beta_backend_stage.persistent_stack self._when_testing_compact_resource_servers(persistent_stack) diff --git a/backend/social-work-app/stacks/api_stack/v1_api/api.py b/backend/social-work-app/stacks/api_stack/v1_api/api.py index 60b90fa6e5..56efd19d2c 100644 --- a/backend/social-work-app/stacks/api_stack/v1_api/api.py +++ b/backend/social-work-app/stacks/api_stack/v1_api/api.py @@ -34,6 +34,15 @@ def __init__( self.api: LicenseApi = root.api self.api_model = ApiModel(api=self.api) stack: Stack = Stack.of(self.resource) + # Beta intentionally omits unauthenticated public provider lookup routes + # (GET/POST under /v1/public/compacts/{compact}/providers/...). Test and prod deploy them. + # + # We normally keep beta and prod behavior aligned, but beta is used for state onboarding: IT staff + # may upload full license datasets or create mock privileges for API testing that they do not intend + # to be publicly searchable. In prod, states upload records they intend to expose via CompactConnect. + # There is no valid use case for public search in beta; omitting these routes follows the principle + # of least privilege. + deploy_public_lookup_api = stack.environment_name != 'beta' _active_compacts = persistent_stack.get_list_of_compact_abbreviations() # we only pass the API_BASE_URL env var if the API_DOMAIN_NAME is set @@ -101,12 +110,13 @@ def __init__( self.public_compacts_compact_providers_resource = self.public_compacts_compact_resource.add_resource( 'providers' ) - self.public_lookup_api = PublicLookupApi( - resource=self.public_compacts_compact_providers_resource, - api_model=self.api_model, - api_lambda_stack=api_lambda_stack, - search_persistent_stack=search_persistent_stack, - ) + if deploy_public_lookup_api: + self.public_lookup_api = PublicLookupApi( + resource=self.public_compacts_compact_providers_resource, + api_model=self.api_model, + api_lambda_stack=api_lambda_stack, + search_persistent_stack=search_persistent_stack, + ) # /v1/compacts self.compacts_resource = self.resource.add_resource('compacts') diff --git a/backend/social-work-app/tests/app/test_pipeline.py b/backend/social-work-app/tests/app/test_pipeline.py index d98d2ed965..b23612026f 100644 --- a/backend/social-work-app/tests/app/test_pipeline.py +++ b/backend/social-work-app/tests/app/test_pipeline.py @@ -106,6 +106,19 @@ def _when_testing_compact_resource_servers(self, persistent_stack): msg=f'Expected scopes for compact {compact} not found', ) + def test_beta_api_stack_omits_public_lookup_api(self): + """Beta must not expose unauthenticated public provider lookup routes.""" + api_stack = self.app.beta_backend_pipeline_stack.beta_backend_stage.api_stack + self.assertFalse(hasattr(api_stack.api.v1_api, 'public_lookup_api')) + + def test_non_beta_api_stacks_include_public_lookup_api(self): + for api_stack in ( + self.app.test_backend_pipeline_stack.test_stage.api_stack, + self.app.prod_backend_pipeline_stack.prod_stage.api_stack, + ): + with self.subTest(api_stack.stack_name): + self.assertTrue(hasattr(api_stack.api.v1_api, 'public_lookup_api')) + def test_synth_generates_compact_resource_servers_with_expected_scopes_for_staff_users_beta_stage(self): persistent_stack = self.app.beta_backend_pipeline_stack.beta_backend_stage.persistent_stack self._when_testing_compact_resource_servers(persistent_stack)