Skip to content

Commit da7b5b7

Browse files
Refactor Cosmetology License schema to remove NPI / require license number (#1291)
The Cosmetology compact does not collect npi in their license records, and they intend to require all license records to include a license number as they will use this to search for particular practitioners in the system. This updates the schemas in the Cosmetology API to remove reference to npi and enforce the inclusion of the licenseNumber field with every license upload The Cosmetology API will be called by the same UI as the original compact connect app used by JCC. As such, we needed to set the explicit ui domain in the env context so we set the correct callback urls, CORS allowed origins, and email template links. This adds that needed change so that CSV license uploads through the app will work correctly. This PR also removes API endpoints for features that will not be applicable to the Cosmetology compact. This includes endpoints for programmatically reading privileges from the system, as well as the search endpoint for exporting privilege CSV reports. ### Requirements List - The CDK environment contexts in SSM parameter store need to be updated to include the new optional 'ui_domain_name_override' field (Complete) ### Description List - Remove NPI from Cosmetology license schema - Enforce 'licenseNumber' field - Remove unneeded State API endpoints for programmatically reading data from the system - Remove irrelevant endpoint for exporting privilege records through search API - Add 'ui_domain_name_override' field to SSM environment contexts ### Testing List - For API configuration changes: CDK tests added/updated in `backend/compact-connect/tests/unit/test_api.py` - For API endpoint changes: OpenAPI spec updated to show latest endpoint configuration `run compact-connect/bin/download_oas30.py` - Tests updated to remove reference to NPI and expect licenseNumber - Code review Closes #1290 #1283 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Per-environment UI domain override; custom UI domains now require TLS 1.2. Pipeline stages disable the public execute-api endpoint. * **Breaking Changes** * Privilege export/history/deactivation features removed across APIs, handlers, tests, and docs. * National Provider Identifier (NPI) removed from inputs/responses/tests. * licenseNumber is now required for license submissions; privilegeJurisdictions and related fields removed. * **Documentation** * Added UI domain override guidance; removed signature-auth and privilege-export documentation and examples. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent c4be201 commit da7b5b7

134 files changed

Lines changed: 6206 additions & 26990 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/common-cdk/common_constructs/stack.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ def __init__(self, *args, environment_context: dict, environment_name: str, **kw
9797

9898
self.environment_context = environment_context
9999
self.environment_name = environment_name
100+
101+
# Guard: all pipeline environments (test, beta, prod) MUST have a domain_name configured
102+
if environment_name in ('test', 'beta', 'prod') and not environment_context.get('domain_name'):
103+
raise ValueError(
104+
f"Pipeline environments (test, beta, prod) require 'domain_name' to be configured. "
105+
f"Environment '{environment_name}' is missing this required configuration."
106+
)
107+
100108
# We only set the API_BASE_URL common env var if the API_DOMAIN_NAME is set
101109
# The API_BASE_URL is used by the feature flag client to call the flag check endpoint
102110
if self.api_domain_name:
@@ -130,14 +138,20 @@ def search_api_domain_name(self) -> str | None:
130138

131139
@property
132140
def ui_domain_name(self) -> str | None:
141+
# Allow explicit override via environment context for cases where the UI is hosted
142+
# on a different domain than the backend's hosted zone (e.g. cosmetology backend uses
143+
# cosmetology.compactconnect.org but the UI is at app.compactconnect.org)
144+
override = self.environment_context.get('ui_domain_name_override')
145+
if override is not None:
146+
return override
133147
if self.hosted_zone is not None:
134148
return f'app.{self.hosted_zone.zone_name}'
135149
return None
136150

137151
@property
138152
def allowed_origins(self) -> list[str]:
139153
allowed_origins = []
140-
if self.hosted_zone is not None:
154+
if self.ui_domain_name is not None:
141155
allowed_origins.append(f'https://{self.ui_domain_name}')
142156

143157
if self.environment_context.get('allow_local_ui', False):

backend/compact-connect/common_constructs/cc_api.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
MethodLoggingLevel,
1818
ResponseType,
1919
RestApi,
20+
SecurityPolicy,
2021
StageOptions,
2122
)
2223
from aws_cdk.aws_certificatemanager import Certificate, CertificateValidation
@@ -89,7 +90,14 @@ def __init__(
8990
validation=CertificateValidation.from_dns(hosted_zone=stack.hosted_zone),
9091
subject_alternative_names=[stack.hosted_zone.zone_name],
9192
)
92-
domain_kwargs = {'domain_name': DomainNameOptions(certificate=certificate, domain_name=domain_name)}
93+
domain_kwargs = {
94+
'domain_name': DomainNameOptions(
95+
certificate=certificate,
96+
domain_name=domain_name,
97+
# this resource defaults to TLS_1_2, but we will explicitly set this anyway
98+
security_policy=SecurityPolicy.TLS_1_2,
99+
)
100+
}
93101

94102
access_log_group = LogGroup(scope, 'ApiAccessLogGroup', retention=RetentionDays.ONE_MONTH)
95103
NagSuppressions.add_resource_suppressions(
@@ -103,10 +111,14 @@ def __init__(
103111
],
104112
)
105113

114+
# Disable the default execute-api endpoint for all pipeline environments so traffic must use the custom domain.
115+
disable_execute_api_endpoint = environment_name in ('test', 'beta', 'prod')
116+
106117
super().__init__(
107118
scope,
108119
construct_id,
109120
cloud_watch_role=True,
121+
disable_execute_api_endpoint=disable_execute_api_endpoint,
110122
deploy_options=StageOptions(
111123
# NOTE: If we are ever updating our pipeline architecture which requires a change to the pipeline stack
112124
# name, the domain base path mapping for the API will fail to deploy unless we change the name of the

backend/compact-connect/lambdas/nodejs/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"version": "1.0.0",
44
"type": "commonjs",
55
"description": "NodeJS lambdas for Compact Connect",
6+
"resolutions": {
7+
"fast-xml-parser": "5.3.6"
8+
},
69
"scripts": {
710
"build": "tsc",
811
"watch": "tsc -w",

backend/compact-connect/lambdas/nodejs/yarn.lock

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3453,12 +3453,12 @@ fast-levenshtein@^2.0.6:
34533453
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
34543454
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
34553455

3456-
fast-xml-parser@5.3.4:
3457-
version "5.3.4"
3458-
resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz#06f39aafffdbc97bef0321e626c7ddd06a043ecf"
3459-
integrity sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==
3456+
fast-xml-parser@5.3.4, fast-xml-parser@5.3.6:
3457+
version "5.3.6"
3458+
resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz#85a69117ca156b1b3c52e426495b6de266cb6a4b"
3459+
integrity sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==
34603460
dependencies:
3461-
strnum "^2.1.0"
3461+
strnum "^2.1.2"
34623462

34633463
fb-watchman@^2.0.0:
34643464
version "2.0.2"
@@ -5116,7 +5116,7 @@ strip-json-comments@^3.1.1:
51165116
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
51175117
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
51185118

5119-
strnum@^2.1.0:
5119+
strnum@^2.1.2:
51205120
version "2.1.2"
51215121
resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.1.2.tgz#a5e00ba66ab25f9cafa3726b567ce7a49170937a"
51225122
integrity sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==

backend/compact-connect/tests/app/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,15 @@ def _inspect_api_stack(self, api_stack: ApiStack):
552552
},
553553
)
554554

555+
# When a custom domain is configured, verify the API Gateway domain uses TLS 1.2
556+
if api_stack.hosted_zone is not None:
557+
api_template.has_resource_properties(
558+
'AWS::ApiGateway::DomainName',
559+
{
560+
'SecurityPolicy': 'TLS_1_2',
561+
},
562+
)
563+
555564
def _check_no_stack_annotations(self, stack: Stack):
556565
with self.subTest(f'Security Rules: {stack.stack_name}'):
557566
errors = Annotations.from_stack(stack).find_error('*', Match.string_like_regexp('.*'))

backend/compact-connect/tests/common_constructs/test_cognito_user_backup.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ class TestCognitoUserBackup(TestCase):
3131
def setUpClass(cls):
3232
"""Set up test infrastructure."""
3333
cls.app = App()
34-
# The persistent stack and layer are required for CognitoUserBackup, as an internal lambda depends on it
34+
# The persistent stack and layer are required for CognitoUserBackup, as an internal lambda depends on it.
35+
# Use a non-pipeline environment name so domain_name is not required (avoids HostedZone.from_lookup in tests).
3536
common_stack = AppStack(
3637
cls.app,
3738
'CommonStack',
3839
environment_context={},
39-
environment_name='test',
40+
environment_name='sandbox',
4041
standard_tags=StandardTags(project='compact-connect', service='compact-connect', environment='test'),
4142
)
4243
# Create common lambda layers

backend/compact-connect/tests/common_constructs/test_data_migration.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@ def test_data_migration_synthesizes(self):
1717
from common_constructs.python_common_layer_versions import PythonCommonLayerVersions
1818

1919
app = App()
20-
# The persistent stack and layer are required for DataMigration, as an internal lambda depends on it
20+
# The persistent stack and layer are required for DataMigration, as an internal lambda depends on it.
21+
# Use a non-pipeline environment name so domain_name is not required (avoids HostedZone.from_lookup in tests).
2122
common_stack = AppStack(
2223
app,
2324
'CommonStack',
2425
environment_context={},
25-
environment_name='test',
26+
environment_name='sandbox',
2627
standard_tags=StandardTags(project='compact-connect', service='compact-connect', environment='test'),
2728
)
2829
# Create common lambda layers

backend/cosmetology-app/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ The `cdk.json` file tells the CDK Toolkit how to execute your app, including con
4444
deployment. You can add local configuration that will be merged into the `cdk.json['context']` values with a
4545
`cdk.context.json` file that you will not check in.
4646

47+
### `ui_domain_name_override`
48+
49+
**Important:** Because the cosmetology backend is hosted on a different domain than the shared frontend UI application (e.g. the
50+
backend hosted zone is `cosmetology.compactconnect.org` but the UI lives at `app.compactconnect.org`), each
51+
environment's context must include a `ui_domain_name_override` field that specifies the correct UI domain name. Without
52+
this override, the UI domain would be incorrectly derived from the backend's hosted zone (e.g.
53+
`app.cosmetology.compactconnect.org` instead of `app.compactconnect.org`). This value is used for CORS allowed origins,
54+
Cognito callback/logout URLs, and email template links.
55+
56+
Example:
57+
```json
58+
{
59+
"domain_name": "cosmetology.compactconnect.org",
60+
"ui_domain_name_override": "app.compactconnect.org"
61+
}
62+
```
63+
4764
This project is set up like a standard Python project. To use it, create and activate a python virtual environment
4865
using the tools of your choice (`pyenv` and `venv` are common).
4966

backend/cosmetology-app/app_clients/README.md

Lines changed: 1 addition & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,7 @@ The following scopes are available at the jurisdiction level:
5656
```
5757

5858
Currently, the most common scope needed by app clients is `{jurisdiction}/{compact}.write`, which allows uploading
59-
license data for a jurisdiction/compact combination. Scopes that expose PII (e.g., `.readSSN`, `.readPrivate`) should
60-
be granted sparingly and will require valid request signatures once a signing public key is configured for the
61-
jurisdiction.
59+
license data for a jurisdiction/compact combination.
6260

6361
### 3. Create App Client Using Interactive Python Script
6462

@@ -108,143 +106,6 @@ link that you'll generate separately.
108106
As part of the email message sent to the consuming team, be sure to include the onboarding instructions document from
109107
the `it_staff_onboarding_instructions/` directory.
110108

111-
## Managing API Signing Public Keys
112-
113-
### Overview
114-
115-
Signature-based authentication provides an additional layer of security for API access to sensitive licensure data. Each
116-
compact/state combination can have multiple SIGNATURE public keys configured to support key rotation and zero-downtime
117-
deployments.
118-
119-
### Authorization Requirements
120-
121-
**⚠️ CRITICAL SECURITY NOTICE:** Due to the sensitivity of the data protected by SIGNATURE authentication (including
122-
partial Social Security Numbers, personal addresses, and professional license details), configuration of new SIGNATURE
123-
public keys in production environments **MUST** include explicit authorization from the state board executive director.
124-
125-
126-
### Creating SIGNATURE Public Keys
127-
128-
Once a state configures a public key, they will be able to access the SIGNATURE-required API endpoints. API endpoints with
129-
_optional_ SIGNATURE support will also begin to enforce SIGNATURE signatures for that combination of compact and state. **This
130-
means that, once a compact/state has a public key configured, they will be denied access to SIGNATURE-Optional endpoints,
131-
such as the `POST license` endpoint, unless they have also implemented SIGNATURE signatures there as well.** Be sure that
132-
the representative is advised that they should begin signing those requests _before_ CompactConnect has a configured
133-
public key.
134-
135-
#### 1. Prerequisites
136-
137-
Before creating a new SIGNATURE public key, ensure you have:
138-
- **Production Authorization**: Explicit approval from the state board executive director for production environments
139-
- Validated the identity of the individual providing the public key to you
140-
- Jurisdiction and compact information confirmed
141-
- Contact information for the state IT representative
142-
- The public key file (`.pub` format) from the state IT representative (copy it to the same directory you are running the script from). The name of the file must match the key id.
143-
- AWS credentials configured with permissions to write to the compact configuration table
144-
- Python 3.10+ installed with boto3 dependency (`pip install boto3`)
145-
146-
#### 2. Key ID Naming Convention
147-
148-
The state IT department should provide an identifier; however, you can recommend a descriptive key ID that includes:
149-
- Environment indicator (if applicable)
150-
- Version or date suffix
151-
152-
Examples:
153-
- `prod-key-001`
154-
- `beta-key-2024-01`
155-
156-
#### 3. Create SIGNATURE Public Key Using Interactive Python Script
157-
158-
**Use the provided Python script in the bin directory for streamlined SIGNATURE key management:**
159-
160-
```bash
161-
python3 bin/manage_signature_keys.py create -t <compact_configuration_table_name>
162-
```
163-
164-
**Interactive Process:**
165-
The script will prompt you for:
166-
- Compact (cosm)
167-
- State postal abbreviation (e.g., "ky", "la")
168-
- Key ID (e.g., "client-org-prod-key-001")
169-
170-
**File Reading:**
171-
The script will:
172-
- Notify you that it will read the public key from `<key-id>.pub`
173-
- Validate the PEM format of the public key
174-
- Check for existing keys with the same ID
175-
- Write the key to the compact configuration database
176-
177-
**⚠️NOTICE:** Once the public key has been successfully stored, remove the `.pub` file from the directory to ensure it
178-
is never accidentally checked into the project.
179-
180-
#### 4. Database Schema
181-
182-
SIGNATURE keys are stored in the compact configuration table with the following schema:
183-
- **Primary Key (pk)**: `{compact}#SIGNATURE_KEYS#{state}`
184-
- **Sort Key (sk)**: `{compact}#JURISDICTION#{jurisdiction}#{key_id}`
185-
- **Additional Fields**:
186-
- `publicKey`: PEM-encoded public key content
187-
- `compact`: Compact abbreviation
188-
- `jurisdiction`: Jurisdiction abbreviation
189-
- `keyId`: Key identifier
190-
- `createdAt`: Creation timestamp
191-
192-
### Deleting SIGNATURE Public Keys
193-
194-
#### 1. Prerequisites
195-
196-
Before deleting a SIGNATURE public key, ensure you have:
197-
- Confirmation that the key is no longer in use by the state IT department
198-
- Confirmation of the key id to be deleted
199-
- Understanding of the impact on API access for the compact/state combination
200-
201-
#### 2. Delete SIGNATURE Public Key Using Interactive Python Script
202-
203-
```bash
204-
python3 bin/manage_signature_keys.py delete -t <table_name>
205-
```
206-
207-
**Interactive Process:**
208-
The script will:
209-
- Prompt for compact and state
210-
- List all existing keys for the compact/state combination
211-
- Allow you to select the specific key ID to delete
212-
- Require typing "DELETE" to confirm the deletion
213-
- Remove the key from the compact configuration database
214-
215-
### Key Rotation Best Practices
216-
217-
#### 1. Planning
218-
219-
- Coordinate with the State IT representative well in advance
220-
- Plan for zero-downtime deployment
221-
222-
#### 2. Implementation
223-
224-
- Create new keys before removing old ones
225-
- Allow both keys to be active during the transition period
226-
- Monitor API access and authentication success rates
227-
- Remove old keys only after confirming new keys are working correctly
228-
229-
#### 3. Documentation
230-
231-
- Document key rotation dates and reasons
232-
- Maintain audit trail of all key management activities
233-
234-
### Security Considerations
235-
236-
#### 1. Key Storage
237-
238-
- Public keys are stored in DynamoDB with appropriate access controls
239-
- Private keys should never be stored in CompactConnect systems
240-
- State IT departments are responsible for secure private key management
241-
242-
#### 2. Access Control
243-
244-
- Only authorized technical staff should have access to key management resources
245-
- All key management activities should be logged and audited
246-
- Production key creation requires executive director approval
247-
248109
## Rotating App Client Credentials
249110

250111
Unfortunately, AWS Cognito does not support rotating app client credentials for an existing app client. The only way

backend/cosmetology-app/bin/generate_mock_license_csv_upload_file.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444

4545
FIELDS = (
4646
'ssn',
47-
'npi',
4847
'licenseNumber',
4948
'licenseType',
5049
'licenseStatus',
@@ -146,10 +145,8 @@ def get_mock_license(
146145
license_data = {
147146
# |Zero padded 4 digit int|
148147
'ssn': f'{ssn_prefix}-{(i // 10_000) % 100:02}-{(i % 10_000):04}',
149-
# Some have NPI, some don't
150-
'npi': str(randint(1_000_000_000, 9_999_999_999)) if choice([True, False]) else None,
151-
# Some have License number, some don't
152-
'licenseNumber': generate_mock_license_number() if choice([True, False]) else None,
148+
# licenseNumber is required
149+
'licenseNumber': generate_mock_license_number(),
153150
'licenseType': choice(LICENSE_TYPES[compact]),
154151
'givenName': name_faker.first_name(),
155152
'middleName': name_faker.first_name(),

0 commit comments

Comments
 (0)