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
6 changes: 4 additions & 2 deletions .github/workflows/check-compact-connect.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ jobs:

- name: Install dev dependencies
run: "pip install -r backend/compact-connect/requirements-dev.txt"

# Note we are currently pinning the pip version to deal with compatibility issues released with pip 25.3
# see https://stackoverflow.com/a/79802727 If this issues is addressed in a later version, we can remove the
# extra pip install command so we stop pinning the pip version
- name: Install all Python dependencies
run: "cd backend/compact-connect; bin/sync_deps.sh"
run: "cd backend/compact-connect; pip install -U 'pip<25.3'; bin/sync_deps.sh"

- name: Test backend
run: "cd backend/compact-connect; bin/run_tests.sh -l all -no"
4 changes: 4 additions & 0 deletions backend/common-cdk/common_constructs/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ def __init__(self, *args, environment_context: dict, environment_name: str, **kw

self.environment_context = environment_context
self.environment_name = environment_name
# We only set the API_BASE_URL common env var if the API_DOMAIN_NAME is set
# The API_BASE_URL is used by the feature flag client to call the flag check endpoint
if self.api_domain_name:
self.common_env_vars.update({'API_BASE_URL': f'https://{self.api_domain_name}'})

@cached_property
def hosted_zone(self) -> IHostedZone | None:
Expand Down
1 change: 1 addition & 0 deletions backend/compact-connect/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Export your license data to a CSV file, formatted as follows:
- String lengths are enforced - exceeding them will cause validation errors
- Some fields have a set list of allowed values. For those fields, make sure to enter the value exactly, including
spacing and capitalization
- SSNs must be unique within a single CSV upload file. Do not include multiple rows with the same `ssn` in one file. If duplicate SSNs are sent within the same file, the first row will be processed, but all other duplicate rows will be rejected.

#### Field Descriptions

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ For your convenience, use of this feature is included in the [Postman Collection
- `licenseType` must match exactly with one of the valid types for the specified compact
- All date fields must use the `YYYY-MM-DD` format
- The API does not accept `null` values. For optional fields with no value, omit the field or leave it empty in CSV.
- For CSV uploads, SSNs must be unique within a single file. Do not include multiple rows with the same `ssn` in one upload. If duplicate SSNs are sent within the same file, the first row will be processed, but all other duplicate rows will be rejected.
- For JSON uploads, SSNs must be unique within a single request payload (array). Do not include duplicate `ssn` values in the same batch. Attempting to do so will cause the entire request to be rejected.

## Common Upload Strategies: JSON vs CSV

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1428,9 +1428,9 @@ def encumber_privilege(self, adverse_action: AdverseActionData) -> None:

now = config.current_standard_datetime
# TODO - remove the flag as part of https://github.com/csg-org/CompactConnect/issues/1136 # noqa: FIX002
from cc_common.feature_flag_client import is_feature_enabled
from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled

if is_feature_enabled('encumbrance-multi-category-flag'):
if is_feature_enabled(FeatureFlagEnum.ENCUMBRANCE_MULTI_CATEGORY_FLAG):
encumbrance_details = {
'clinicalPrivilegeActionCategories': adverse_action.clinicalPrivilegeActionCategories,
'adverseActionId': adverse_action.adverseActionId,
Expand Down Expand Up @@ -2674,9 +2674,9 @@ def encumber_home_jurisdiction_license_privileges(
'Found privileges to encumber', privilege_count=len(unencumbered_privileges_associated_with_license)
)
# TODO - remove the flag as part of https://github.com/csg-org/CompactConnect/issues/1136 # noqa: FIX002
from cc_common.feature_flag_client import is_feature_enabled
from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled

if is_feature_enabled('encumbrance-multi-category-flag'):
if is_feature_enabled(FeatureFlagEnum.ENCUMBRANCE_MULTI_CATEGORY_FLAG):
encumbrance_details = {
'clinicalPrivilegeActionCategories': adverse_action.clinicalPrivilegeActionCategories,
'licenseJurisdiction': jurisdiction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,9 +375,9 @@ def construct_simplified_privilege_history_object(
):
# TODO - remove the flag as part of https://github.com/csg-org/CompactConnect/issues/1136 # noqa: FIX002
# as well as check for deprecated field
from cc_common.feature_flag_client import is_feature_enabled
from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled

if is_feature_enabled('encumbrance-multi-category-flag'):
if is_feature_enabled(FeatureFlagEnum.ENCUMBRANCE_MULTI_CATEGORY_FLAG):
if 'clinicalPrivilegeActionCategory' in event['encumbranceDetails']:
event['note'] = event['encumbranceDetails'].get('clinicalPrivilegeActionCategory')
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,6 @@ class UpdateCategory(CCEnum):
DEACTIVATION = 'deactivation'
EXPIRATION = 'expiration'
ISSUANCE = 'issuance'
OTHER = 'other'
RENEWAL = 'renewal'
ENCUMBRANCE = 'encumbrance'
HOME_JURISDICTION_CHANGE = 'homeJurisdictionChange'
Expand All @@ -297,6 +296,9 @@ class UpdateCategory(CCEnum):
# this is specific to privileges that are deactivated due to a state license deactivation
LICENSE_DEACTIVATION = 'licenseDeactivation'
EMAIL_CHANGE = 'emailChange'
# NOTE: this value should explicitly be used for license upload updates, not anywhere else
# it is referenced in the event that an invalid license upload needs to be reverted.
LICENSE_UPLOAD_UPDATE_OTHER = 'other'


class ActiveInactiveStatus(CCEnum):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import requests

from cc_common.config import config, logger
from cc_common.feature_flag_enum import FeatureFlagEnum


@dataclass
Expand Down Expand Up @@ -43,14 +44,16 @@ def to_dict(self) -> dict[str, Any]:
return result


def is_feature_enabled(flag_name: str, context: FeatureFlagContext | None = None, fail_default: bool = False) -> bool:
def is_feature_enabled(
flag_name: FeatureFlagEnum, context: FeatureFlagContext | None = None, fail_default: bool = False
) -> bool:
"""
Check if a feature flag is enabled.

This function calls the internal feature flag API endpoint to determine
if a feature flag is enabled for the given context.

:param flag_name: The name of the feature flag to check
:param flag_name: The name of the feature flag to check.
:param context: Optional FeatureFlagContext for feature flag evaluation
:param fail_default: If True, return True on errors; if False, return False on errors (default: False)
:return: True if the feature flag is enabled, False otherwise (or fail_default value on error)
Expand All @@ -76,6 +79,7 @@ def is_feature_enabled(flag_name: str, context: FeatureFlagContext | None = None
):
"""
try:
logger.info("checking status of feature flag", flag_name=flag_name)
api_base_url = _get_api_base_url()
endpoint_url = f'{api_base_url}/v1/flags/{flag_name}/check'

Expand Down Expand Up @@ -103,7 +107,8 @@ def is_feature_enabled(flag_name: str, context: FeatureFlagContext | None = None
# Invalid response format - return fail_default value
return fail_default

return bool(response_data['enabled'])
logger.info('Checked flag status successfully', flag_name=flag_name, enabled=response_data['enabled'])
return response_data['enabled']

# We catch all exceptions to prevent a feature flag issue causing the system from operating
except Exception as e: # noqa: BLE001
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from enum import StrEnum


class FeatureFlagEnum(StrEnum):
"""
Central source for all feature flags currently referenced in the python code of the project.
Flags should be defined here when first added, and removed when the flag
is no longer in use.
"""

# flag used by internal testing
TEST_FLAG = 'test-flag'
# runtime flags
ENCUMBRANCE_MULTI_CATEGORY_FLAG = 'encumbrance-multi-category-flag'
DUPLICATE_SSN_UPLOAD_CHECK_FLAG = 'duplicate-ssn-upload-check-flag'
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from unittest.mock import MagicMock, patch

from cc_common.feature_flag_enum import FeatureFlagEnum

from tests import TstLambdas


Expand All @@ -13,7 +15,7 @@ def test_is_feature_enabled_returns_true_when_flag_enabled(self):
mock_response.json.return_value = {'enabled': True}

with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response) as mock_post:
result = is_feature_enabled('test-flag')
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG)

# Verify the result
self.assertTrue(result)
Expand All @@ -35,7 +37,7 @@ def test_is_feature_enabled_returns_false_when_flag_disabled(self):
mock_response.json.return_value = {'enabled': False}

with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response):
result = is_feature_enabled('test-flag')
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG)

# Verify the result
self.assertFalse(result)
Expand All @@ -51,7 +53,7 @@ def test_is_feature_enabled_with_context(self):
context = FeatureFlagContext(user_id='user123', custom_attributes={'licenseType': 'lpc'})

with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response) as mock_post:
result = is_feature_enabled('test-flag', context=context)
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, context=context)

# Verify the result
self.assertTrue(result)
Expand All @@ -71,7 +73,7 @@ def test_is_feature_enabled_fail_closed_on_timeout(self):
from cc_common.feature_flag_client import is_feature_enabled

with patch('cc_common.feature_flag_client.requests.post', side_effect=Exception('Timeout')):
result = is_feature_enabled('test-flag', fail_default=False)
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=False)

# Verify it fails closed (returns False)
self.assertFalse(result)
Expand All @@ -81,7 +83,7 @@ def test_is_feature_enabled_fail_open_on_timeout(self):
from cc_common.feature_flag_client import is_feature_enabled

with patch('cc_common.feature_flag_client.requests.post', side_effect=Exception('Timeout')):
result = is_feature_enabled('test-flag', fail_default=True)
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=True)

# Verify it fails open (returns True)
self.assertTrue(result)
Expand All @@ -95,7 +97,7 @@ def test_is_feature_enabled_fail_closed_on_http_error(self):
mock_response.raise_for_status.side_effect = Exception('500 Server Error')

with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response):
result = is_feature_enabled('test-flag', fail_default=False)
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=False)

# Verify it fails closed (returns False)
self.assertFalse(result)
Expand All @@ -109,7 +111,7 @@ def test_is_feature_enabled_fail_open_on_http_error(self):
mock_response.raise_for_status.side_effect = Exception('500 Server Error')

with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response):
result = is_feature_enabled('test-flag', fail_default=True)
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=True)

# Verify it fails open (returns True)
self.assertTrue(result)
Expand All @@ -124,7 +126,7 @@ def test_is_feature_enabled_fail_closed_on_invalid_response(self):
mock_response.raise_for_status = MagicMock()

with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response):
result = is_feature_enabled('test-flag', fail_default=False)
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=False)

# Verify it fails closed (returns False)
self.assertFalse(result)
Expand All @@ -139,7 +141,7 @@ def test_is_feature_enabled_fail_open_on_invalid_response(self):
mock_response.raise_for_status = MagicMock()

with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response):
result = is_feature_enabled('test-flag', fail_default=True)
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=True)

# Verify it fails open (returns True)
self.assertTrue(result)
Expand All @@ -154,7 +156,7 @@ def test_is_feature_enabled_fail_closed_on_json_parse_error(self):
mock_response.raise_for_status = MagicMock()

with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response):
result = is_feature_enabled('test-flag', fail_default=False)
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=False)

# Verify it fails closed (returns False)
self.assertFalse(result)
Expand All @@ -168,7 +170,7 @@ def test_is_feature_enabled_fail_open_on_json_parse_error(self):
mock_response.json.side_effect = ValueError('Invalid JSON')

with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response):
result = is_feature_enabled('test-flag', fail_default=True)
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=True)

# Verify it fails open (returns True)
self.assertTrue(result)
Expand Down Expand Up @@ -220,7 +222,7 @@ def test_is_feature_enabled_with_context_user_id_only(self):
context = FeatureFlagContext(user_id='user789')

with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response) as mock_post:
result = is_feature_enabled('test-flag', context=context)
result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, context=context)

# Verify the result
self.assertTrue(result)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
)
from cc_common.event_batch_writer import EventBatchWriter
from cc_common.exceptions import CCInternalException

# initialize flag outside of handler so the flag is cached for the lifecycle of the lambda execution environment
from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled # noqa: E402
from cc_common.utils import (
ResponseEncoder,
api_handler,
Expand All @@ -22,6 +25,10 @@
from license_csv_reader import LicenseCSVReader
from marshmallow import ValidationError

duplicate_ssn_check_flag_enabled = is_feature_enabled(
FeatureFlagEnum.DUPLICATE_SSN_UPLOAD_CHECK_FLAG, fail_default=True
)


@api_handler
@authorize_compact_jurisdiction(action='write')
Expand Down Expand Up @@ -139,6 +146,9 @@ def process_bulk_upload_file(
current_batch = []
total_processed = 0
failed_validation_count = 0
# track which ssns were included in this file to detect duplicates,
# which are not allowed within the same file upload
ssns_in_file_upload = {}

with EventBatchWriter(config.events_client) as event_writer:
for i, raw_license in enumerate(reader.licenses(stream)):
Expand All @@ -148,6 +158,17 @@ def process_bulk_upload_file(
# dict() here, because it prevents `compact` and `jurisdiction` from being allowed in the
# raw_license
validated_license = schema.load(dict(compact=compact, jurisdiction=jurisdiction, **raw_license))
# verify that this ssn has not been used previously in the same batch
license_ssn = validated_license['ssn']
if duplicate_ssn_check_flag_enabled:
matched_ssn_index = ssns_in_file_upload.get(license_ssn)
if matched_ssn_index:
raise ValidationError(
message=f'Duplicate License SSN detected. SSN matches with record '
f'{matched_ssn_index}. Every record must have a unique SSN within the same '
f'file.'
)
ssns_in_file_upload.update({license_ssn: i + 1})
except TypeError as e:
# This will be raised, if `raw_license` includes compact and/or jurisdiction fields
logger.error('License contains unsupported fields', fields=list(raw_license.keys()), exc_info=e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ def _generate_adverse_action_for_record_type(
adverse_action.actionAgainst = adverse_action_against_record_type
adverse_action.encumbranceType = EncumbranceType(adverse_action_request['encumbranceType'])
# TODO - remove the flag conditions as part of https://github.com/csg-org/CompactConnect/issues/1136 # noqa: FIX002
from cc_common.feature_flag_client import is_feature_enabled
from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled

if is_feature_enabled('encumbrance-multi-category-flag'):
if is_feature_enabled(FeatureFlagEnum.ENCUMBRANCE_MULTI_CATEGORY_FLAG):
if 'clinicalPrivilegeActionCategory' in adverse_action_request:
# replicate data to both the deprecated and new fields
adverse_action.clinicalPrivilegeActionCategory = ClinicalPrivilegeActionCategory(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ def _populate_update_record(*, existing_license: dict, updated_values: dict, rem
update_type = UpdateCategory.DEACTIVATION
logger.info('License deactivation detected')
if update_type is None:
update_type = UpdateCategory.OTHER
update_type = UpdateCategory.LICENSE_UPLOAD_UPDATE_OTHER
logger.info('License update detected')

now = config.current_standard_datetime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@

schema = LicensePostRequestSchema()

# initialize flag outside of handler so the flag is cached for the lifecycle of the execution environment
from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled # noqa: E402

# low risk flag, so we default to enabled if failure detected
duplicate_ssn_check_flag_enabled = is_feature_enabled(
FeatureFlagEnum.DUPLICATE_SSN_UPLOAD_CHECK_FLAG, fail_default=True
)


@api_handler
@optional_signature_auth
Expand Down Expand Up @@ -63,6 +71,19 @@ def post_licenses(event: dict, context: LambdaContext): # noqa: ARG001 unused-a
'errors': invalid_records,
}
)
if duplicate_ssn_check_flag_enabled:
# verify that none of the SSNs are repeats within the same batch
license_ssns = [license_record['ssn'] for license_record in licenses]
if len(set(license_ssns)) < len(license_ssns):
raise CCInvalidRequestCustomResponseException(
response_body={
'message': 'Invalid license records in request. See errors for more detail.',
'errors': {
'SSN': 'Same SSN detected on multiple rows. '
'Every record must have a unique SSN within the same request.'
},
}
)

event_time = config.current_standard_datetime

Expand Down
Loading