From 727e75f10d1c329f6ac64d5af6b3c1ddf99d3360 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 26 May 2026 09:02:53 -0500 Subject: [PATCH 1/5] Disable public endpoints in beta environment --- .../compact-connect/stacks/api_stack/v1_api/api.py | 14 ++++++++------ backend/compact-connect/tests/app/test_pipeline.py | 13 +++++++++++++ .../cosmetology-app/stacks/api_stack/v1_api/api.py | 14 ++++++++------ backend/cosmetology-app/tests/app/test_pipeline.py | 13 +++++++++++++ 4 files changed, 42 insertions(+), 12 deletions(-) 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..3b579fab94 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,7 @@ def __init__( self.api: LicenseApi = root.api self.api_model = ApiModel(api=self.api) stack: Stack = Stack.of(self.resource) + 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 +116,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..f4e9647953 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,7 @@ def __init__( self.api: LicenseApi = root.api self.api_model = ApiModel(api=self.api) stack: Stack = Stack.of(self.resource) + 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 +102,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) From 0bf1e98a9b2fea360a015976ed2a73c879318bd9 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 26 May 2026 14:38:46 -0500 Subject: [PATCH 2/5] Check for privileges on public get providers endpoint --- .../handlers/public_lookup.py | 8 +++-- .../test_handlers/test_public_lookup.py | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) 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'])) From 7972092f5805e01d0ee17876d73cb05db5591e26 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 26 May 2026 16:00:03 -0500 Subject: [PATCH 3/5] update spec --- .../docs/internal/api-specification/latest-oas30.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": [ { From 11a10f30ef15e93d36981c0e22d3b69c21eb2b1b Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Fri, 29 May 2026 10:27:14 -0500 Subject: [PATCH 4/5] PR feedback - add docs to explain reason for disabling beta public endpoints Also added change to social-work API for consistency --- .../stacks/api_stack/v1_api/api.py | 8 +++++++ .../stacks/api_stack/v1_api/api.py | 7 +++++++ .../stacks/api_stack/v1_api/api.py | 21 +++++++++++++------ .../tests/app/test_pipeline.py | 13 ++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) 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 3b579fab94..1dd3e38a54 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,14 @@ 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() 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 f4e9647953..d204d4a082 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,13 @@ 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 least privilege. deploy_public_lookup_api = stack.environment_name != 'beta' _active_compacts = persistent_stack.get_list_of_compact_abbreviations() 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..d204d4a082 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,14 @@ 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 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 +109,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) From e7cdce1f6017261fef77cbb4ab0f4ef1c1478179 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Fri, 29 May 2026 10:29:56 -0500 Subject: [PATCH 5/5] formatting/wording --- backend/compact-connect/stacks/api_stack/v1_api/api.py | 2 +- backend/cosmetology-app/stacks/api_stack/v1_api/api.py | 3 ++- backend/social-work-app/stacks/api_stack/v1_api/api.py | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) 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 1dd3e38a54..6a19dcc53a 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/api.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/api.py @@ -42,7 +42,7 @@ def __init__( # 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 + # 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() 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 d204d4a082..56efd19d2c 100644 --- a/backend/cosmetology-app/stacks/api_stack/v1_api/api.py +++ b/backend/cosmetology-app/stacks/api_stack/v1_api/api.py @@ -40,7 +40,8 @@ def __init__( # 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 least privilege. + # 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() 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 d204d4a082..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 @@ -40,7 +40,8 @@ def __init__( # 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 least privilege. + # 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()