Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0f28b7a
Refactor Cosm License schema to remove NPI / require license number
landonshumway-ia Feb 11, 2026
49dc835
Remove incompatible endpoints for retrieving privilege data
landonshumway-ia Feb 12, 2026
b8ab99f
Remove reference to state data retrieval
landonshumway-ia Feb 12, 2026
dcb704c
Remove documentation for client signature auth
landonshumway-ia Feb 12, 2026
43f8531
Set optional value in env context to set UI domain
landonshumway-ia Feb 12, 2026
2cb870a
Update openAPI spec to latest
landonshumway-ia Feb 12, 2026
17524b2
AI PR feedback
landonshumway-ia Feb 12, 2026
744c3c2
PR feedback - set explicit check for domain name in prod and beta
landonshumway-ia Feb 13, 2026
c1d92cc
Remove unneeded logic related to privileges
landonshumway-ia Feb 13, 2026
2245790
Enforce domain name and disable execute API url in all pipeline envir…
landonshumway-ia Feb 13, 2026
96a14ff
Update tests to account for domain_name requirement in pipeline envs
landonshumway-ia Feb 13, 2026
1aa1543
Remove privilege history logic/endpoints
landonshumway-ia Feb 13, 2026
7bcb0b6
Remove privilege logic from DR license upload rollback process
landonshumway-ia Feb 13, 2026
f763a1a
Remove privilegeJurisdictions field from top level provider records
landonshumway-ia Feb 13, 2026
23af47a
Remove claim privilege number method
landonshumway-ia Feb 13, 2026
e294532
Formatting/linter
landonshumway-ia Feb 13, 2026
f8e9fc0
PR feedback
landonshumway-ia Feb 13, 2026
7817028
Remove irrelevant smoke tests
landonshumway-ia Feb 16, 2026
d5c5b74
Update Cosmetology API specs to latest
landonshumway-ia Feb 16, 2026
3526232
remove outdated postman config
landonshumway-ia Feb 16, 2026
137fac0
update snapshots
landonshumway-ia Feb 16, 2026
9c23505
update another snapshot
landonshumway-ia Feb 16, 2026
02e9f1e
linter for smoke test cleanup
landonshumway-ia Feb 18, 2026
e869a56
Add missing field to test setup
landonshumway-ia Feb 18, 2026
44449c5
apply patch for node xml dependency
landonshumway-ia Feb 18, 2026
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
16 changes: 15 additions & 1 deletion backend/common-cdk/common_constructs/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ def __init__(self, *args, environment_context: dict, environment_name: str, **kw

self.environment_context = environment_context
self.environment_name = environment_name

# Guard: all pipeline environments (test, beta, prod) MUST have a domain_name configured
if environment_name in ('test', 'beta', 'prod') and not environment_context.get('domain_name'):
raise ValueError(
f"Pipeline environments (test, beta, prod) require 'domain_name' to be configured. "
f"Environment '{environment_name}' is missing this required configuration."
)

# 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:
Expand Down Expand Up @@ -130,14 +138,20 @@ def search_api_domain_name(self) -> str | None:

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

@property
def allowed_origins(self) -> list[str]:
allowed_origins = []
if self.hosted_zone is not None:
if self.ui_domain_name is not None:
allowed_origins.append(f'https://{self.ui_domain_name}')

if self.environment_context.get('allow_local_ui', False):
Expand Down
14 changes: 13 additions & 1 deletion backend/compact-connect/common_constructs/cc_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
MethodLoggingLevel,
ResponseType,
RestApi,
SecurityPolicy,
StageOptions,
)
from aws_cdk.aws_certificatemanager import Certificate, CertificateValidation
Expand Down Expand Up @@ -89,7 +90,14 @@ def __init__(
validation=CertificateValidation.from_dns(hosted_zone=stack.hosted_zone),
subject_alternative_names=[stack.hosted_zone.zone_name],
)
domain_kwargs = {'domain_name': DomainNameOptions(certificate=certificate, domain_name=domain_name)}
domain_kwargs = {
'domain_name': DomainNameOptions(
certificate=certificate,
domain_name=domain_name,
# this resource defaults to TLS_1_2, but we will explicitly set this anyway
security_policy=SecurityPolicy.TLS_1_2,
)
}

access_log_group = LogGroup(scope, 'ApiAccessLogGroup', retention=RetentionDays.ONE_MONTH)
NagSuppressions.add_resource_suppressions(
Expand All @@ -103,10 +111,14 @@ def __init__(
],
)

# Disable the default execute-api endpoint for all pipeline environments so traffic must use the custom domain.
disable_execute_api_endpoint = environment_name in ('test', 'beta', 'prod')

super().__init__(
scope,
construct_id,
cloud_watch_role=True,
disable_execute_api_endpoint=disable_execute_api_endpoint,
deploy_options=StageOptions(
# NOTE: If we are ever updating our pipeline architecture which requires a change to the pipeline stack
# name, the domain base path mapping for the API will fail to deploy unless we change the name of the
Expand Down
3 changes: 3 additions & 0 deletions backend/compact-connect/lambdas/nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "1.0.0",
"type": "commonjs",
"description": "NodeJS lambdas for Compact Connect",
"resolutions": {
"fast-xml-parser": "5.3.6"
},
"scripts": {
"build": "tsc",
"watch": "tsc -w",
Expand Down
12 changes: 6 additions & 6 deletions backend/compact-connect/lambdas/nodejs/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3453,12 +3453,12 @@ fast-levenshtein@^2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==

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

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

strnum@^2.1.0:
strnum@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.1.2.tgz#a5e00ba66ab25f9cafa3726b567ce7a49170937a"
integrity sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==
Expand Down
9 changes: 9 additions & 0 deletions backend/compact-connect/tests/app/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,15 @@ def _inspect_api_stack(self, api_stack: ApiStack):
},
)

# When a custom domain is configured, verify the API Gateway domain uses TLS 1.2
if api_stack.hosted_zone is not None:
api_template.has_resource_properties(
'AWS::ApiGateway::DomainName',
{
'SecurityPolicy': 'TLS_1_2',
},
)

def _check_no_stack_annotations(self, stack: Stack):
with self.subTest(f'Security Rules: {stack.stack_name}'):
errors = Annotations.from_stack(stack).find_error('*', Match.string_like_regexp('.*'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ class TestCognitoUserBackup(TestCase):
def setUpClass(cls):
"""Set up test infrastructure."""
cls.app = App()
# The persistent stack and layer are required for CognitoUserBackup, as an internal lambda depends on it
# The persistent stack and layer are required for CognitoUserBackup, as an internal lambda depends on it.
# Use a non-pipeline environment name so domain_name is not required (avoids HostedZone.from_lookup in tests).
common_stack = AppStack(
cls.app,
'CommonStack',
environment_context={},
environment_name='test',
environment_name='sandbox',
standard_tags=StandardTags(project='compact-connect', service='compact-connect', environment='test'),
)
# Create common lambda layers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ def test_data_migration_synthesizes(self):
from common_constructs.python_common_layer_versions import PythonCommonLayerVersions

app = App()
# The persistent stack and layer are required for DataMigration, as an internal lambda depends on it
# The persistent stack and layer are required for DataMigration, as an internal lambda depends on it.
# Use a non-pipeline environment name so domain_name is not required (avoids HostedZone.from_lookup in tests).
common_stack = AppStack(
app,
'CommonStack',
environment_context={},
environment_name='test',
environment_name='sandbox',
standard_tags=StandardTags(project='compact-connect', service='compact-connect', environment='test'),
)
# Create common lambda layers
Expand Down
17 changes: 17 additions & 0 deletions backend/cosmetology-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ The `cdk.json` file tells the CDK Toolkit how to execute your app, including con
deployment. You can add local configuration that will be merged into the `cdk.json['context']` values with a
`cdk.context.json` file that you will not check in.

### `ui_domain_name_override`

**Important:** Because the cosmetology backend is hosted on a different domain than the shared frontend UI application (e.g. the
backend hosted zone is `cosmetology.compactconnect.org` but the UI lives at `app.compactconnect.org`), each
environment's context must include a `ui_domain_name_override` field that specifies the correct UI domain name. Without
this override, the UI domain would be incorrectly derived from the backend's hosted zone (e.g.
`app.cosmetology.compactconnect.org` instead of `app.compactconnect.org`). This value is used for CORS allowed origins,
Cognito callback/logout URLs, and email template links.
Comment thread
jlkravitz marked this conversation as resolved.

Example:
```json
{
"domain_name": "cosmetology.compactconnect.org",
"ui_domain_name_override": "app.compactconnect.org"
}
```

This project is set up like a standard Python project. To use it, create and activate a python virtual environment
using the tools of your choice (`pyenv` and `venv` are common).

Expand Down
141 changes: 1 addition & 140 deletions backend/cosmetology-app/app_clients/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,7 @@ The following scopes are available at the jurisdiction level:
```

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

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

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

## Managing API Signing Public Keys

### Overview

Signature-based authentication provides an additional layer of security for API access to sensitive licensure data. Each
compact/state combination can have multiple SIGNATURE public keys configured to support key rotation and zero-downtime
deployments.

### Authorization Requirements

**⚠️ CRITICAL SECURITY NOTICE:** Due to the sensitivity of the data protected by SIGNATURE authentication (including
partial Social Security Numbers, personal addresses, and professional license details), configuration of new SIGNATURE
public keys in production environments **MUST** include explicit authorization from the state board executive director.


### Creating SIGNATURE Public Keys

Once a state configures a public key, they will be able to access the SIGNATURE-required API endpoints. API endpoints with
_optional_ SIGNATURE support will also begin to enforce SIGNATURE signatures for that combination of compact and state. **This
means that, once a compact/state has a public key configured, they will be denied access to SIGNATURE-Optional endpoints,
such as the `POST license` endpoint, unless they have also implemented SIGNATURE signatures there as well.** Be sure that
the representative is advised that they should begin signing those requests _before_ CompactConnect has a configured
public key.

#### 1. Prerequisites

Before creating a new SIGNATURE public key, ensure you have:
- **Production Authorization**: Explicit approval from the state board executive director for production environments
- Validated the identity of the individual providing the public key to you
- Jurisdiction and compact information confirmed
- Contact information for the state IT representative
- 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.
- AWS credentials configured with permissions to write to the compact configuration table
- Python 3.10+ installed with boto3 dependency (`pip install boto3`)

#### 2. Key ID Naming Convention

The state IT department should provide an identifier; however, you can recommend a descriptive key ID that includes:
- Environment indicator (if applicable)
- Version or date suffix

Examples:
- `prod-key-001`
- `beta-key-2024-01`

#### 3. Create SIGNATURE Public Key Using Interactive Python Script

**Use the provided Python script in the bin directory for streamlined SIGNATURE key management:**

```bash
python3 bin/manage_signature_keys.py create -t <compact_configuration_table_name>
```

**Interactive Process:**
The script will prompt you for:
- Compact (cosm)
- State postal abbreviation (e.g., "ky", "la")
- Key ID (e.g., "client-org-prod-key-001")

**File Reading:**
The script will:
- Notify you that it will read the public key from `<key-id>.pub`
- Validate the PEM format of the public key
- Check for existing keys with the same ID
- Write the key to the compact configuration database

**⚠️NOTICE:** Once the public key has been successfully stored, remove the `.pub` file from the directory to ensure it
is never accidentally checked into the project.

#### 4. Database Schema

SIGNATURE keys are stored in the compact configuration table with the following schema:
- **Primary Key (pk)**: `{compact}#SIGNATURE_KEYS#{state}`
- **Sort Key (sk)**: `{compact}#JURISDICTION#{jurisdiction}#{key_id}`
- **Additional Fields**:
- `publicKey`: PEM-encoded public key content
- `compact`: Compact abbreviation
- `jurisdiction`: Jurisdiction abbreviation
- `keyId`: Key identifier
- `createdAt`: Creation timestamp

### Deleting SIGNATURE Public Keys

#### 1. Prerequisites

Before deleting a SIGNATURE public key, ensure you have:
- Confirmation that the key is no longer in use by the state IT department
- Confirmation of the key id to be deleted
- Understanding of the impact on API access for the compact/state combination

#### 2. Delete SIGNATURE Public Key Using Interactive Python Script

```bash
python3 bin/manage_signature_keys.py delete -t <table_name>
```

**Interactive Process:**
The script will:
- Prompt for compact and state
- List all existing keys for the compact/state combination
- Allow you to select the specific key ID to delete
- Require typing "DELETE" to confirm the deletion
- Remove the key from the compact configuration database

### Key Rotation Best Practices

#### 1. Planning

- Coordinate with the State IT representative well in advance
- Plan for zero-downtime deployment

#### 2. Implementation

- Create new keys before removing old ones
- Allow both keys to be active during the transition period
- Monitor API access and authentication success rates
- Remove old keys only after confirming new keys are working correctly

#### 3. Documentation

- Document key rotation dates and reasons
- Maintain audit trail of all key management activities

### Security Considerations

#### 1. Key Storage

- Public keys are stored in DynamoDB with appropriate access controls
- Private keys should never be stored in CompactConnect systems
- State IT departments are responsible for secure private key management

#### 2. Access Control

- Only authorized technical staff should have access to key management resources
- All key management activities should be logged and audited
- Production key creation requires executive director approval

## Rotating App Client Credentials

Unfortunately, AWS Cognito does not support rotating app client credentials for an existing app client. The only way
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@

FIELDS = (
'ssn',
'npi',
'licenseNumber',
'licenseType',
'licenseStatus',
Expand Down Expand Up @@ -146,10 +145,8 @@ def get_mock_license(
license_data = {
# |Zero padded 4 digit int|
'ssn': f'{ssn_prefix}-{(i // 10_000) % 100:02}-{(i % 10_000):04}',
# Some have NPI, some don't
'npi': str(randint(1_000_000_000, 9_999_999_999)) if choice([True, False]) else None,
# Some have License number, some don't
'licenseNumber': generate_mock_license_number() if choice([True, False]) else None,
# licenseNumber is required
'licenseNumber': generate_mock_license_number(),
'licenseType': choice(LICENSE_TYPES[compact]),
'givenName': name_faker.first_name(),
'middleName': name_faker.first_name(),
Expand Down
Loading
Loading