Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"openapi": "3.0.1",
Comment thread
landonshumway-ia marked this conversation as resolved.
"info": {
"title": "LicenseApi",
"version": "2026-01-14T16:16:32Z"
"version": "2026-05-26T15:59:12Z"
},
"servers": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -113,6 +113,7 @@ def public_query_providers(event: dict, context: LambdaContext): # noqa: ARG001


@api_handler
@delayed_function(delay_seconds=1.0)
Comment thread
landonshumway-ia marked this conversation as resolved.
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
Expand All @@ -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)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
from datetime import datetime
from functools import wraps
from unittest.mock import patch
from urllib.parse import quote

Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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']))
22 changes: 16 additions & 6 deletions backend/compact-connect/stacks/api_stack/v1_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
13 changes: 13 additions & 0 deletions backend/compact-connect/tests/app/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 16 additions & 6 deletions backend/cosmetology-app/stacks/api_stack/v1_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
13 changes: 13 additions & 0 deletions backend/cosmetology-app/tests/app/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 16 additions & 6 deletions backend/social-work-app/stacks/api_stack/v1_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
13 changes: 13 additions & 0 deletions backend/social-work-app/tests/app/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading