diff --git a/.github/workflows/check-python.yml b/.github/workflows/check-python.yml index f27f1bc1a..d34d7ef92 100644 --- a/.github/workflows/check-python.yml +++ b/.github/workflows/check-python.yml @@ -49,5 +49,8 @@ jobs: - name: Install all dependencies run: "cd backend; bin/sync_deps.sh" + - name: Check Dependencies + run: "pip-audit" + - name: Test backend run: "cd backend; bin/run_tests.sh no-report" diff --git a/backend/bin/compile_requirements.sh b/backend/bin/compile_requirements.sh index b90171a99..37d67c3fa 100755 --- a/backend/bin/compile_requirements.sh +++ b/backend/bin/compile_requirements.sh @@ -8,6 +8,8 @@ pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/license-data/r pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/license-data/requirements-dev.in pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/board-user-pre-token/requirements.in pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/board-user-pre-token/requirements-dev.in +pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/staff-user-pre-token/requirements.in +pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/staff-user-pre-token/requirements-dev.in pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/delete-objects/requirements.in pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/delete-objects/requirements-dev.in bin/sync_deps.sh diff --git a/backend/bin/run_tests.sh b/backend/bin/run_tests.sh index 868578a62..47548e325 100755 --- a/backend/bin/run_tests.sh +++ b/backend/bin/run_tests.sh @@ -9,6 +9,7 @@ REPORT="$1" for dir in \ compact-connect/lambdas/license-data \ compact-connect/lambdas/board-user-pre-token \ + compact-connect/lambdas/staff-user-pre-token \ compact-connect/lambdas/delete-objects \ multi-account do diff --git a/backend/bin/sync_deps.sh b/backend/bin/sync_deps.sh index b05d2d2cc..400a19206 100755 --- a/backend/bin/sync_deps.sh +++ b/backend/bin/sync_deps.sh @@ -7,5 +7,7 @@ pip-sync \ compact-connect/lambdas/license-data/requirements-dev.txt \ compact-connect/lambdas/board-user-pre-token/requirements.txt \ compact-connect/lambdas/board-user-pre-token/requirements-dev.txt \ + compact-connect/lambdas/staff-user-pre-token/requirements.txt \ + compact-connect/lambdas/staff-user-pre-token/requirements-dev.txt \ compact-connect/lambdas/delete-objects/requirements.txt \ compact-connect/lambdas/delete-objects/requirements-dev.txt diff --git a/backend/compact-connect/README.md b/backend/compact-connect/README.md index 0da60e6cb..f65180347 100644 --- a/backend/compact-connect/README.md +++ b/backend/compact-connect/README.md @@ -99,7 +99,10 @@ To execute the tests, simply run `bin/run_tests.sh` from the `backend` directory The very first deploy to a new environment (like your personal sandbox account) requires a few steps to fully set up its environment: 1) *Optional:* Create a new Route53 HostedZone in your AWS sandbox account for the DNS domain name you want to use for - your app. See [About Route53 Hosted Zones](#about-route53-hosted-zones) for more. + your app. See [About Route53 Hosted Zones](#about-route53-hosted-zones) for more. Note: Without this step, you will + not be able to log in to the UI hosted in CloudFront. The Oauth2 authentication process requires a predictable + callback url to be pre-configured, which the domain name provides. You can still run a local UI against this app, + so long as you leave the `allow_local_ui` context value set to `true` in your environment's context. 2) Copy [cdk.context.sandbox-example.json](./cdk.context.sandbox-example.json) to `cdk.context.json`. 3) At the top level of the JSON structure update the `"environment_name"` field to your own name. 4) Update the environment entry under `ssm_context.environments` to your own name and your own AWS sandbox account id, diff --git a/backend/compact-connect/bin/create_staff_user.py b/backend/compact-connect/bin/create_staff_user.py new file mode 100755 index 000000000..63a47380d --- /dev/null +++ b/backend/compact-connect/bin/create_staff_user.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +Staff user generation helper script. Run from `backend/compact-connect`. + +Note: This script requires the boto3 library and two environment variables: +USER_POOL_ID=us-east-1_7zzexample +USER_TABLE_NAME=Sandbox-PersistentStack-StaffUsersUsersTableB4F6C7C8-example + +The CLI must also be configured with AWS credentials that have appropriate access to Cognito and DynamoDB +""" +import os + +import boto3 +from botocore.exceptions import ClientError + +USER_POOL_ID = os.environ['USER_POOL_ID'] +USER_TABLE_NAME = os.environ['USER_TABLE_NAME'] + + +cognito_client = boto3.client('cognito-idp') +user_table = boto3.resource('dynamodb').Table(USER_TABLE_NAME) + + +def create_compact_ed_user(*, username: str, email: str, compact: str): + print(f"Creating Compact ED user, '{username}', in {compact}") + sub = create_cognito_user(username=username, email=email) + user_table.put_item( + Item={ + 'pk': sub, + 'createdCompactJurisdiction': f'{compact}/{compact}', + 'permissions': { + compact: { + 'actions': {'read', 'admin'}, + 'jurisdictions': {} + } + } + } + ) + + +def create_board_ed_user(*, username: str, email: str, compact: str, jurisdiction: str): + print(f"Creating Board ED user, '{username}', in {compact}/{jurisdiction}") + sub = create_cognito_user(username=username, email=email) + user_table.put_item( + Item={ + 'pk': sub, + 'createdCompactJurisdiction': f'{compact}/{jurisdiction}', + 'permissions': { + compact: { + 'actions': {'read'}, + 'jurisdictions': { + jurisdiction: {'actions': {'write', 'admin'}} + } + } + } + } + ) + + +def create_cognito_user(*, username: str, email: str): + def get_sub_from_attributes(attributes: list): + for attribute in attributes: + if attribute['Name'] == 'sub': + return attribute['Value'] + raise ValueError('Failed to find user sub!') + + try: + user_data = cognito_client.admin_create_user( + UserPoolId=USER_POOL_ID, + Username=username, + UserAttributes=[ + { + 'Name': 'email', + 'Value': email + } + ], + DesiredDeliveryMediums=[ + 'EMAIL' + ] + ) + return get_sub_from_attributes(user_data['User']['Attributes']) + + except ClientError as e: + if e.response['Error']['Code'] == 'UsernameExistsException': + user_data = cognito_client.admin_get_user( + UserPoolId=USER_POOL_ID, + Username=username + ) + return get_sub_from_attributes(user_data['UserAttributes']) + + +if __name__ == '__main__': + import json + import sys + from argparse import ArgumentParser + + # Pull compacts and jurisdictions from cdk.json + with open('cdk.json', 'r') as f: + context = json.load(f)['context'] + jurisdictions = context['jurisdictions'] + compacts = context['compacts'] + + parser = ArgumentParser( + description='Create a staff user' + ) + parser.add_argument('username') + parser.add_argument('-e', '--email', help="The new user's email address", required=True) + parser.add_argument( + '-t', '--type', + help="The new user's type", + required=True, + choices=['compact-ed', 'board-ed'] + ) + parser.add_argument( + '-c', '--compact', + help="The new user's compact", + required=True, + choices=compacts + ) + parser.add_argument( + '-j', '--jurisdiction', + help="The new user's jurisdiction, required for board users", + required=False, + choices=jurisdictions + ) + + args = parser.parse_args() + + match args.type: + case 'compact-ed': + create_compact_ed_user( + username=args.username, + email=args.email, + compact=args.compact + ) + case 'board-ed': + if not args.jurisdiction: + print('jurisdiction is required for board-ed users.') + sys.exit(2) + create_board_ed_user( + username=args.username, + email=args.email, + compact=args.compact, + jurisdiction=args.jurisdiction + ) + case _: + print(f'Unsupported user type: {args.type}') + sys.exit(2) diff --git a/backend/compact-connect/cdk.context.production-example.json b/backend/compact-connect/cdk.context.production-example.json index 31050fa47..f0a53148a 100644 --- a/backend/compact-connect/cdk.context.production-example.json +++ b/backend/compact-connect/cdk.context.production-example.json @@ -16,7 +16,8 @@ "test": { "account_id": "111122223333", "region": "us-east-1", - "domain_name": "test.app.compactconnect.org" + "domain_name": "test.app.compactconnect.org", + "allow_local_ui": true } } } diff --git a/backend/compact-connect/cdk.context.sandbox-example.json b/backend/compact-connect/cdk.context.sandbox-example.json index aa9c2a1d5..bcf9760b5 100644 --- a/backend/compact-connect/cdk.context.sandbox-example.json +++ b/backend/compact-connect/cdk.context.sandbox-example.json @@ -8,7 +8,8 @@ "justin": { "account_id": "111122223333", "region": "us-east-1", - "domain_name": "justin.compactconnect.org" + "domain_name": "justin.compactconnect.org", + "allow_local_ui": true } } } diff --git a/backend/compact-connect/cdk.json b/backend/compact-connect/cdk.json index e10c5aa9a..20e65595e 100644 --- a/backend/compact-connect/cdk.json +++ b/backend/compact-connect/cdk.json @@ -46,8 +46,8 @@ }, "compacts": [ "aslp", - "ot", - "counseling" + "octp", + "coun" ], "@aws-cdk/aws-lambda:recognizeLayerVersion": true, "@aws-cdk/core:checkSecretUsage": true, diff --git a/backend/compact-connect/common_constructs/stack.py b/backend/compact-connect/common_constructs/stack.py index 1f31c09ec..b26fa3e3a 100644 --- a/backend/compact-connect/common_constructs/stack.py +++ b/backend/compact-connect/common_constructs/stack.py @@ -3,6 +3,7 @@ from textwrap import dedent from aws_cdk import Stack as CdkStack, Aspects +from aws_cdk.aws_route53 import HostedZone, IHostedZone from cdk_nag import AwsSolutionsChecks, HIPAASecurityChecks, NagSuppressions @@ -86,3 +87,35 @@ def common_env_vars(self): 'JURISDICTIONS': json.dumps(self.node.get_context('jurisdictions')), 'LICENSE_TYPES': json.dumps(self.node.get_context('license_types')) } + + +class AppStack(Stack): + """ + A stack that is part of the main app deployment + """ + def __init__(self, *args, environment_context: dict, **kwargs): + super().__init__(*args, **kwargs) + self.environment_context = environment_context + + @cached_property + def hosted_zone(self) -> IHostedZone | None: + hosted_zone = None + domain_name = self.environment_context.get('domain_name') + if domain_name is not None: + hosted_zone = HostedZone.from_lookup( + self, 'HostedZone', + domain_name=domain_name + ) + return hosted_zone + + @property + def api_domain_name(self) -> str | None: + if self.hosted_zone is not None: + return f'api.{self.hosted_zone.zone_name}' + return None + + @property + def ui_domain_name(self) -> str | None: + if self.hosted_zone is not None: + return f'app.{self.hosted_zone.zone_name}' + return None diff --git a/backend/compact-connect/common_constructs/user_pool.py b/backend/compact-connect/common_constructs/user_pool.py index 598cda4df..6960c751a 100644 --- a/backend/compact-connect/common_constructs/user_pool.py +++ b/backend/compact-connect/common_constructs/user_pool.py @@ -84,7 +84,7 @@ def __init__( ] ) - def add_ui_client(self, ui_scopes: List[OAuthScope] = None): + def add_ui_client(self, callback_urls: List[str], ui_scopes: List[OAuthScope] = None): return self.add_client( 'UIClient', auth_flows=AuthFlow( @@ -94,9 +94,7 @@ def add_ui_client(self, ui_scopes: List[OAuthScope] = None): user_password=False ), o_auth=OAuthSettings( - callback_urls=[ - 'http://localhost:8000/auth' - ], + callback_urls=callback_urls, flows=OAuthFlows( authorization_code_grant=True, implicit_code_grant=False diff --git a/backend/compact-connect/docs/design/README.md b/backend/compact-connect/docs/design/README.md index 42bc9ff87..f84ac77d1 100644 --- a/backend/compact-connect/docs/design/README.md +++ b/backend/compact-connect/docs/design/README.md @@ -2,7 +2,33 @@ Look here for continued documentation of the back-end design, as it progresses. +## Table of Contents +- **[Compacts and Jurisdictions](#compacts-and-jurisdictions)** +- **[License Ingest](#license-ingest)** +- **[User Architecture](#user-architecture)** + +## Compacts and Jurisdictions + +The CompactConnect system supports multiple licensure compacts and, within each compact, multiple jurisdictions. The +jurisdictions it supports within each compact is all 50 states, Washington D.C., Puerto Rico, and the Virgin Islands. + +### Adding a compact to CompactConnect + +When a new compact joins CompactConnect, some configuration has to be done to add them to the system. First, a new +entry has to be added to the list of supported compacts, found in [cdk.json](../../cdk.json). Each compact is +represented there with an abbreviation, which determines how the compact will be represented in the API as well as +in its corresponding Oauth2 access scopes. Because of the way that the scopes are represented, the compact abbreviation +must not overlap with any jurisdiction abbreviations (which correspond to the jurisdictions' USPS postal abbreviations). +**Since postal abbreviations are all two letters, make a point to choose a compact abbreviation that is at least four +letters for clarity and to avoid naming conflicts.** + +Once the supported compacts have been updated and the configuration change deployed, a CompactConnect admin can create +a user for the compact's executive director, who then will be allowed to start creating users for the boards of each +jurisdiction within the compact. + ## License Ingest +[Back to top](#backend-design) + To facilitate sharing of license data across states, compact member jurisdictions will periodically upload data for eligible licensees to CompactConnect. See [license-ingest-digram.pdf](./license-ingest-diagram.pdf) for an illustration of the ingest chain architecture. Board admins and/or information systems have two primary methods of upload: @@ -31,6 +57,82 @@ efficient processing. A lambda receives messages from the SQS queue. Each messag ingested. The lambda receives the data and creates or updates a corresponding license record in the DynamoDB license data table. - ### Asynchronous validation feedback Asynchronous validation feedback for boards to review is not yet implemented. + +## User Architecture +[Back to top](#backend-design) + +Authentication with the CompactConnect backend will be controlled through Oauth2 via +[AWS Cognito User Pools](https://github.com/csg-org/CompactConnect). Clients will be divided into two groups, each +represented by an independent User Pool: [Staff Users](#staff-users) and [Licensee Users](#licensee-users). See +the accompanying [architecture diagram](./users-arch-diagram.pdf) for an illustration. + +### Staff Users + +Staff users come with a variety of different permissions, depending on their role. There are Compact Executive +Directors, Compact ED Staff, Board Executive Directors, Board ED Staff, and CSG Admins, each with different levels +of ability to read and write data, and to administrate users. Read permissions are granted to a user for an entire +compact or not at all. Data writing and user administration permissions can each be granted to a user per +compact/jurisdiction combination. All of a compact user's permissions are stored in a DynamoDB record that is associated +with their own Cognito user id. That record will be used to generate scopes in the Oauth2 token issued to them on login. +See [Implementation of scopes](#implementation-of-scopes) for a detailed explanation of the design for exactly how +permissions will be represented by scopes in an access token. See +[Implementation of permissions](#implementation-of-permissions) for a detailed explanation of the design for exactly +how permissions are stored and translated into scopes. + +#### Compact Executive Directors and Staff + +Compact ED level staff can have permission to read all compact data as well as to create and manage users and their +permissions. They can grant other users the ability to write data for a particular jurisdiction and to create more +users associated with a particular jurisdiction. They can also delete any user within their compact, so long as that +user does not have permissions associated with a different compact. + +#### Board Executive Directors and Staff + +Board ED level staff can have permission to read all compact data, write data to for their own jurisdiction, and to +create more users that have permissions within their own jurisdiction. They can also delete any user within their +jurisdiction, so long as that user does not have permissions associated with a different compact or jurisdiction. + +#### Implementation of Scopes + +AWS Cognito integrates with API Gateway to provide +[authorizers](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-integrate-with-cognito.html) on an +API that can verify the tokens issued by a given User Pool and to protect access based on scopes belonging to +[Resource Servers](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html) +associated with that User Pool. In the Staff Users user pool, we represent each compact as its own Resource Server, with +associated scopes. Unfortunately, because resource servers support only up to 100 scopes each, and we would like to +control permission to write to or administrate each of more than 50 jurisdictions independently, the combinations would +require more than 100 scopes per resource server. + +To design around the 100 scope limit, we will have to split authorization into two layers: coarse- and fine-grained. +We can rely on the Cognito authorizers to protect our API endpoints based on fewer coarse-grained scopes, then +protect the more fine-grained access within the API endpoint logic. The Staff User pool resource servers will are +configured with `read`, `write`, and `admin` scopes. `read` scopes indicate that the user is allowed to read the +entire compact's licensee data. `write` and `admin` scopes, however, indicate only that the user is allowed to write +or administrate _something_ in the compact respectively, thus giving them access to the write or administrative +API endpoints. We will then rely on the API endpoint logic to refine their access based on the more fine-grained +access scopes. + +To compliment each of the `write` and `admin` scopes, there will be at least one, more specific, scope, to indicate +_what_ within the compact they are allowed to write or administrate, respectively. In the case of `write` scopes, +a jurisdiction-specific scope will control what jurisdiction they are able to write data for (i.e. `al.write` grants +permission to write data for the Alabama jurisdiction). Similarly, `admin` scopes can have a jurisdiction-specific +scope like `al.admin` and can also have a compact-wide scope like `aslp.admin`, which grants permission for a compact +executive director to perform the administrative functions for the Audiology and Speech Language Pathology compact. + +#### Implementation of Permissions + +Staff user permissions will be stored in a dedicated DynamoDB table, which will have a single record for each user +and include a data structure that details that user's particular permissions. Cognito allows for a lambda to be [invoked +just before it issues a token](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html). +We will use that feature to retrieve the database record for each user, parse the permissions data and translate those +into scopes, which will be added to the Cognito token. The lambda will generate both the coarse- and fine-grained +scopes to be added to the token, thus being the single control point for access control on the token-issuing side. + +### Licensee Users + +Licensee users permissions are much simpler as compared to Compact Users. Their access is entirely based on identity. +Once their identity has been verified and associated with a licensee in the license data system, they will only have +permission to view system data that is specific to them. They will be able to apply for and renew privileges to practice +across jurisdictions, subject to their eligibility. diff --git a/backend/compact-connect/docs/design/users-arch-diagram.pdf b/backend/compact-connect/docs/design/users-arch-diagram.pdf new file mode 100644 index 000000000..09b0e2a23 Binary files /dev/null and b/backend/compact-connect/docs/design/users-arch-diagram.pdf differ diff --git a/backend/compact-connect/docs/postman/postman-collection.json b/backend/compact-connect/docs/postman/postman-collection.json index 5d34c9ac6..c0e82c6a5 100644 --- a/backend/compact-connect/docs/postman/postman-collection.json +++ b/backend/compact-connect/docs/postman/postman-collection.json @@ -7,7 +7,7 @@ }, "item": [ { - "name": "Board-Auth", + "name": "Staff-Auth", "item": [ { "name": "client-credentials-grant", @@ -225,160 +225,6 @@ } ] }, - { - "name": "Admin-Auth", - "item": [ - { - "name": "authorization-code-grant-token", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Access token returned', () => {", - " var access_token = pm.response.json().access_token;", - " pm.expect(access_token).not.to.be.empty;", - " pm.environment.set(\"accessToken\", access_token);", - " console.log('Access token: ' + access_token);", - "});", - "", - "pm.test('Identity token returned', () => {", - " var id_token = pm.response.json().id_token;", - " pm.expect(id_token).not.to.be.empty;", - " pm.environment.set(\"idToken\", id_token);", - " console.log('id token: ' + id_token);", - "});", - "", - "pm.test('Refresh token returned', () => {", - " var refresh_token = pm.response.json().refresh_token;", - " pm.expect(refresh_token).not.to.be.empty;", - " pm.environment.set(\"refreshToken\", refresh_token);", - " console.log('refresh token: ' + refresh_token);", - "});" - ], - "type": "text/javascript", - "packages": {} - } - } - ], - "request": { - "auth": { - "type": "noauth" - }, - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded" - }, - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{adminUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=d3222758-3d1f-4392-8359-0f9f4fc0f29e&client_id={{clientId}}&scope=openid email admin/*&redirect_uri=http://localhost:8000/auth", - "host": [ - "{{adminUserPoolUrl}}" - ], - "path": [ - "oauth2", - "token" - ], - "query": [ - { - "key": "grant_type", - "value": "authorization_code" - }, - { - "key": "code", - "value": "d3222758-3d1f-4392-8359-0f9f4fc0f29e" - }, - { - "key": "client_id", - "value": "{{clientId}}" - }, - { - "key": "scope", - "value": "openid email admin/*" - }, - { - "key": "redirect_uri", - "value": "http://localhost:8000/auth" - } - ] - } - }, - "response": [] - }, - { - "name": "refresh-token", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Access token returned', () => {", - " var access_token = pm.response.json().access_token;", - " pm.environment.set(\"accessToken\", access_token);", - " console.log('Access token: ' + access_token);", - "});", - "", - "pm.test('Identity token returned', () => {", - " var id_token = pm.response.json().id_token;", - " pm.environment.set(\"idToken\", id_token);", - " console.log('id token: ' + id_token);", - "});" - ], - "type": "text/javascript", - "packages": {} - } - } - ], - "request": { - "auth": { - "type": "noauth" - }, - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/x-www-form-urlencoded" - }, - { - "key": "Accept", - "value": "application/json" - } - ], - "url": { - "raw": "{{adminUserPoolUrl}}/oauth2/token?grant_type=refresh_token&client_id={{clientId}}&refresh_token={{refreshToken}}", - "host": [ - "{{adminUserPoolUrl}}" - ], - "path": [ - "oauth2", - "token" - ], - "query": [ - { - "key": "grant_type", - "value": "refresh_token" - }, - { - "key": "client_id", - "value": "{{clientId}}" - }, - { - "key": "refresh_token", - "value": "{{refreshToken}}" - } - ] - } - }, - "response": [] - } - ] - }, { "name": "mock", "item": [ @@ -454,12 +300,12 @@ "variable": [ { "key": "compact", - "value": "", + "value": "aslp", "description": "(Required) " }, { "key": "jurisdiction", - "value": "", + "value": "CO", "description": "(Required) " } ] @@ -1499,12 +1345,12 @@ "variable": [ { "key": "compact", - "value": "", + "value": "aslp", "description": "(Required) " }, { "key": "jurisdiction", - "value": "", + "value": "co", "description": "(Required) " } ] @@ -1673,7 +1519,7 @@ ], "body": { "mode": "raw", - "raw": "[\n {\n \"dateOfBirth\": \"1963-07-30\",\n \"dateOfExpiration\": \"2025-08-11\",\n \"dateOfIssuance\": \"2020-12-30\",\n \"dateOfRenewal\": \"2022-12-31\",\n \"familyName\": \"Guðmundsdóttir\",\n \"givenName\": \"Björk\",\n \"homeStateCity\": \"Birmingham\",\n \"homeStatePostalCode\": \"35004\",\n \"homeStateStreet1\": \"123 A St.\",\n \"status\": \"inactive\",\n \"licenseType\": \"audiologist\",\n \"ssn\": \"337-66-6786\",\n \"npi\": \"5045836020\",\n \"middleName\": \"Gunnar\"\n },\n {\n \"dateOfBirth\": \"2950-07-15\",\n \"dateOfExpiration\": \"2198-06-13\",\n \"dateOfIssuance\": \"1625-17-11\",\n \"dateOfRenewal\": \"1912-02-28\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeStateCity\": \"\",\n \"homeStatePostalCode\": \"\",\n \"homeStateStreet1\": \"\",\n \"licenseType\": \"speech and language pathologist\",\n \"ssn\": \"023-09-1574\",\n \"status\": \"active\",\n \"homeStateStreet2\": \"\",\n \"npi\": \"9588895602\",\n \"middleName\": \"\"\n }\n]", + "raw": "[\n {\n \"dateOfBirth\": \"1963-07-30\",\n \"dateOfExpiration\": \"2025-08-11\",\n \"dateOfIssuance\": \"2020-12-30\",\n \"dateOfRenewal\": \"2022-12-31\",\n \"familyName\": \"Guðmundsdóttir\",\n \"givenName\": \"Björk\",\n \"homeStateCity\": \"Birmingham\",\n \"homeStatePostalCode\": \"35004\",\n \"homeStateStreet1\": \"123 A St.\",\n \"status\": \"inactive\",\n \"licenseType\": \"audiologist\",\n \"ssn\": \"337-66-6786\",\n \"npi\": \"5045836020\",\n \"middleName\": \"Gunnar\"\n }\n]", "options": { "raw": { "headerFamily": "json", @@ -1700,7 +1546,7 @@ }, { "key": "jurisdiction", - "value": "al", + "value": "co", "description": "(Required) " } ] diff --git a/backend/compact-connect/docs/postman/postman-environment.json b/backend/compact-connect/docs/postman/postman-environment.json index 3a1a2950a..4f26fec65 100644 --- a/backend/compact-connect/docs/postman/postman-environment.json +++ b/backend/compact-connect/docs/postman/postman-environment.json @@ -1,34 +1,22 @@ { "id": "65234e00-5ac9-4819-8620-d1b9076dcbce", - "name": "License Data - sandbox", + "name": "JCC License Data - sandbox", "values": [ { "key": "boardUserPoolUrl", - "value": "https://licensure-compact.auth.us-east-1.amazoncognito.com", - "type": "default", - "enabled": true - }, - { - "key": "adminUserPoolUrl", - "value": "https://jcc-admin.auth.us-east-1.amazoncognito.com", + "value": "https://ia-cc-staff-justin.auth.us-east-1.amazoncognito.com", "type": "default", "enabled": true }, { "key": "baseUrl", - "value": "https://z8esfh6iu2.execute-api.us-east-1.amazonaws.com/sandbox", + "value": "https://api.justin.jcc.iaapi.io", "type": "default", "enabled": true }, { "key": "uiUrl", - "value": "", - "type": "default", - "enabled": true - }, - { - "key": "jurisdiction", - "value": "al", + "value": "https://app.justin.jcc.iaapi.io", "type": "default", "enabled": true }, @@ -37,15 +25,9 @@ "value": "", "type": "default", "enabled": true - }, - { - "key": "clientSecret", - "value": "", - "type": "default", - "enabled": true } ], "_postman_variable_scope": "environment", - "_postman_exported_at": "2024-06-10T22:31:46.225Z", - "_postman_exported_using": "Postman/11.1.28-240604-0452" + "_postman_exported_at": "2024-08-02T19:23:16.240Z", + "_postman_exported_using": "Postman/11.6.2-240731-0006" } diff --git a/backend/compact-connect/lambdas/board-user-pre-token/requirements-dev.txt b/backend/compact-connect/lambdas/board-user-pre-token/requirements-dev.txt index e13a140dd..55b0acc73 100644 --- a/backend/compact-connect/lambdas/board-user-pre-token/requirements-dev.txt +++ b/backend/compact-connect/lambdas/board-user-pre-token/requirements-dev.txt @@ -4,9 +4,9 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/board-user-pre-token/requirements-dev.in # -boto3==1.34.141 +boto3==1.34.151 # via -r compact-connect/lambdas/board-user-pre-token/requirements-dev.in -botocore==1.34.141 +botocore==1.34.151 # via # boto3 # s3transfer diff --git a/backend/compact-connect/lambdas/board-user-pre-token/requirements.txt b/backend/compact-connect/lambdas/board-user-pre-token/requirements.txt index 2a7afc588..9a0646ec5 100644 --- a/backend/compact-connect/lambdas/board-user-pre-token/requirements.txt +++ b/backend/compact-connect/lambdas/board-user-pre-token/requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/board-user-pre-token/requirements.in # -aws-lambda-powertools==2.40.1 +aws-lambda-powertools==2.42.0 # via -r compact-connect/lambdas/board-user-pre-token/requirements.in jmespath==1.0.1 # via aws-lambda-powertools diff --git a/backend/compact-connect/lambdas/delete-objects/requirements-dev.txt b/backend/compact-connect/lambdas/delete-objects/requirements-dev.txt index 7e3fd22e7..cdaa87b9e 100644 --- a/backend/compact-connect/lambdas/delete-objects/requirements-dev.txt +++ b/backend/compact-connect/lambdas/delete-objects/requirements-dev.txt @@ -16,11 +16,11 @@ aws-sam-translator==1.89.0 # via cfn-lint aws-xray-sdk==2.14.0 # via moto -boto3==1.34.141 +boto3==1.34.151 # via # aws-sam-translator # moto -botocore==1.34.141 +botocore==1.34.151 # via # aws-xray-sdk # boto3 @@ -30,11 +30,11 @@ certifi==2024.7.4 # via requests cffi==1.16.0 # via cryptography -cfn-lint==1.5.2 +cfn-lint==1.9.2 # via moto charset-normalizer==3.3.2 # via requests -cryptography==42.0.8 +cryptography==43.0.0 # via # joserfc # moto @@ -50,9 +50,9 @@ jmespath==1.0.1 # via # boto3 # botocore -joserfc==0.12.0 +joserfc==1.0.0 # via moto -jsondiff==2.1.1 +jsondiff==2.2.0 # via moto jsonpatch==1.33 # via cfn-lint @@ -119,7 +119,7 @@ referencing==0.35.1 # jsonschema # jsonschema-path # jsonschema-specifications -regex==2024.5.15 +regex==2024.7.24 # via cfn-lint requests==2.32.3 # via @@ -131,7 +131,7 @@ responses==0.25.3 # via moto rfc3339-validator==0.1.4 # via openapi-schema-validator -rpds-py==0.19.0 +rpds-py==0.19.1 # via # jsonschema # referencing @@ -141,7 +141,7 @@ six==1.16.0 # via # python-dateutil # rfc3339-validator -sympy==1.13.0 +sympy==1.13.1 # via cfn-lint typing-extensions==4.12.2 # via diff --git a/backend/compact-connect/lambdas/delete-objects/requirements.txt b/backend/compact-connect/lambdas/delete-objects/requirements.txt index af175daff..3b2c01d3a 100644 --- a/backend/compact-connect/lambdas/delete-objects/requirements.txt +++ b/backend/compact-connect/lambdas/delete-objects/requirements.txt @@ -4,11 +4,11 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/delete-objects/requirements.in # -aws-lambda-powertools==2.40.1 +aws-lambda-powertools==2.42.0 # via -r compact-connect/lambdas/delete-objects/requirements.in -boto3==1.34.141 +boto3==1.34.151 # via -r compact-connect/lambdas/delete-objects/requirements.in -botocore==1.34.141 +botocore==1.34.151 # via # boto3 # s3transfer diff --git a/backend/compact-connect/lambdas/license-data/handlers/bulk_upload.py b/backend/compact-connect/lambdas/license-data/handlers/bulk_upload.py index 1da5ef9c0..63cd8d3fe 100644 --- a/backend/compact-connect/lambdas/license-data/handlers/bulk_upload.py +++ b/backend/compact-connect/lambdas/license-data/handlers/bulk_upload.py @@ -15,7 +15,7 @@ from license_csv_reader import LicenseCSVReader -@scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact') +@scope_by_path(resource_parameter='compact', scope_parameter='jurisdiction', action='write') @api_handler def bulk_upload_url_handler(event: dict, context: LambdaContext): """ diff --git a/backend/compact-connect/lambdas/license-data/handlers/licenses.py b/backend/compact-connect/lambdas/license-data/handlers/licenses.py index a600f4a63..407d52f98 100644 --- a/backend/compact-connect/lambdas/license-data/handlers/licenses.py +++ b/backend/compact-connect/lambdas/license-data/handlers/licenses.py @@ -13,7 +13,7 @@ schema = LicensePostSchema() -@scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact') +@scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact', action='write') @api_handler def post_licenses(event: dict, context: LambdaContext): # pylint: disable=unused-argument """ diff --git a/backend/compact-connect/lambdas/license-data/handlers/utils.py b/backend/compact-connect/lambdas/license-data/handlers/utils.py index ccbf52ebb..4c019f2e6 100644 --- a/backend/compact-connect/lambdas/license-data/handlers/utils.py +++ b/backend/compact-connect/lambdas/license-data/handlers/utils.py @@ -91,19 +91,33 @@ def caught_handler(event, context: LambdaContext): class scope_by_path: # pylint: disable=invalid-name - """ - Decorator to wrap scope-based authorization - """ - def __init__(self, *, scope_parameter: str, resource_parameter: str): - self.scope_parameter = scope_parameter + def __init__(self, *, resource_parameter: str, scope_parameter: str, action: str): + """ + Decorator to wrap scope-based authorization, for a scope like '{resource_server}/{scope}.{action}'. + + For a URL path like: + ``` + /foo/{resource_parameter}/bar/{scope_parameter} + ``` + + decorating an api handler with `@scope_by_path('resource_parameter', 'scope_parameter', 'write')` will create + an authorization that expects a request like `/foo/zig/bar/zag` to have a scope called `zig/zag.write`. + + :param str resource_parameter: The path parameter to use for the resource server portion of a resource/scope + requirement. + :param str scope_parameter: The path parameter to use for the scope portion of a resource/scope requirement + :param str action: The additional 'action' portion of the resource/scope requirement. + """ self.resource_parameter = resource_parameter + self.scope_parameter = scope_parameter + self.action = action def __call__(self, fn: Callable): @wraps(fn) @logger.inject_lambda_context def authorized(event: dict, context: LambdaContext): try: - path_value = event['pathParameters'][self.scope_parameter] + scope_value = event['pathParameters'][self.scope_parameter] resource_value = event['pathParameters'][self.resource_parameter] except KeyError: # If we raise this exact exception, API Gateway returns a 401 instead of 403 for a DENY statement @@ -121,7 +135,7 @@ def authorized(event: dict, context: LambdaContext): logger.error('Unauthorized access attempt!') return {'statusCode': 401} - required_scope = f'{resource_value}/{path_value}' + required_scope = f'{resource_value}/{scope_value}.{self.action}' if required_scope not in scopes: logger.warning('Forbidden access attempt!') return {'statusCode': 403} diff --git a/backend/compact-connect/lambdas/license-data/requirements-dev.txt b/backend/compact-connect/lambdas/license-data/requirements-dev.txt index 7428f739f..6f17f77b0 100644 --- a/backend/compact-connect/lambdas/license-data/requirements-dev.txt +++ b/backend/compact-connect/lambdas/license-data/requirements-dev.txt @@ -4,9 +4,9 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/license-data/requirements-dev.in # -boto3==1.34.141 +boto3==1.34.151 # via moto -botocore==1.34.141 +botocore==1.34.151 # via # boto3 # moto @@ -17,7 +17,7 @@ cffi==1.16.0 # via cryptography charset-normalizer==3.3.2 # via requests -cryptography==42.0.8 +cryptography==43.0.0 # via moto docker==7.1.0 # via moto diff --git a/backend/compact-connect/lambdas/license-data/requirements.txt b/backend/compact-connect/lambdas/license-data/requirements.txt index a4b7b6407..84b32815d 100644 --- a/backend/compact-connect/lambdas/license-data/requirements.txt +++ b/backend/compact-connect/lambdas/license-data/requirements.txt @@ -4,11 +4,11 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/license-data/requirements.in # -aws-lambda-powertools==2.40.1 +aws-lambda-powertools==2.42.0 # via -r compact-connect/lambdas/license-data/requirements.in -boto3==1.34.141 +boto3==1.34.151 # via -r compact-connect/lambdas/license-data/requirements.in -botocore==1.34.141 +botocore==1.34.151 # via # boto3 # s3transfer diff --git a/backend/compact-connect/lambdas/license-data/tests/__init__.py b/backend/compact-connect/lambdas/license-data/tests/__init__.py index 05d419885..4c2b54b55 100644 --- a/backend/compact-connect/lambdas/license-data/tests/__init__.py +++ b/backend/compact-connect/lambdas/license-data/tests/__init__.py @@ -19,7 +19,7 @@ def setUpClass(cls): 'SSN_INDEX_NAME': 'ssn', 'CJ_NAME_INDEX_NAME': 'cj_name', 'CJ_UPDATED_INDEX_NAME': 'cj_updated', - 'COMPACTS': '["aslp", "ot", "counseling"]', + 'COMPACTS': '["aslp", "octp", "coun"]', 'JURISDICTIONS': '["al", "co"]', 'LICENSE_TYPES': json.dumps({ 'aslp': [ diff --git a/backend/compact-connect/lambdas/license-data/tests/bin/generate_mock_data.py b/backend/compact-connect/lambdas/license-data/tests/bin/generate_mock_data.py index 916bc1e90..ce3f04d2e 100755 --- a/backend/compact-connect/lambdas/license-data/tests/bin/generate_mock_data.py +++ b/backend/compact-connect/lambdas/license-data/tests/bin/generate_mock_data.py @@ -5,7 +5,7 @@ # python -m tests.bin.generate_mock_data # Required environment variables: # export LICENSE_TABLE_NAME='Sandbox-PersistentStack-MockLicenseTable12345-ETC' -# export COMPACTS='["aslp", "ot", "counseling"]' +# export COMPACTS='["aslp", "octp", "coun"]' # export JURISDICTIONS='["al", "co"]' # export LICENSE_TYPES='{"aslp": ["audiologist", "speech-language pathologist", "speech and language pathologist"]}' diff --git a/backend/compact-connect/lambdas/license-data/tests/function/test_data_model/test_license_transformations.py b/backend/compact-connect/lambdas/license-data/tests/function/test_data_model/test_license_transformations.py index 9a3c6ade0..6bec654e7 100644 --- a/backend/compact-connect/lambdas/license-data/tests/function/test_data_model/test_license_transformations.py +++ b/backend/compact-connect/lambdas/license-data/tests/function/test_data_model/test_license_transformations.py @@ -32,7 +32,7 @@ def test_transformations(self): 'jurisdiction': 'co' } # Authorize ourselves to write an aslp/co license - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/co' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/co.write' from handlers.licenses import post_licenses diff --git a/backend/compact-connect/lambdas/license-data/tests/resources/api-event.json b/backend/compact-connect/lambdas/license-data/tests/resources/api-event.json index d1a5eadd2..01ee7fcff 100644 --- a/backend/compact-connect/lambdas/license-data/tests/resources/api-event.json +++ b/backend/compact-connect/lambdas/license-data/tests/resources/api-event.json @@ -99,7 +99,7 @@ "claims": { "sub": "4kq74be0isgt7shnhhfgi5dk9k", "token_use": "access", - "scope": "openid email aslp/al", + "scope": "openid email aslp/al.write", "auth_time": "1717782706", "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xS47lbscb", "exp": "Fri Jun 07 18:51:46 UTC 2024", diff --git a/backend/compact-connect/lambdas/license-data/tests/unit/test_scope_by_path.py b/backend/compact-connect/lambdas/license-data/tests/unit/test_scope_by_path.py index 1cbbf867f..4da8d027f 100644 --- a/backend/compact-connect/lambdas/license-data/tests/unit/test_scope_by_path.py +++ b/backend/compact-connect/lambdas/license-data/tests/unit/test_scope_by_path.py @@ -9,7 +9,7 @@ class TestScopeByPath(TstLambdas): def test_scope_by_path(self): from handlers.utils import scope_by_path - @scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact') + @scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact', action='write') def example_entrypoint(event: dict, context: LambdaContext): # pylint: disable=unused-argument return { 'body': 'Hurray!' @@ -23,7 +23,7 @@ def example_entrypoint(event: dict, context: LambdaContext): # pylint: disable= def test_no_path_param(self): from handlers.utils import scope_by_path - @scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact') + @scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact', action='write') def example_entrypoint(event: dict, context: LambdaContext): # pylint: disable=unused-argument return { 'body': 'Hurray!' @@ -39,7 +39,7 @@ def example_entrypoint(event: dict, context: LambdaContext): # pylint: disable= def test_no_authorizer(self): from handlers.utils import scope_by_path - @scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact') + @scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact', action='write') def example_entrypoint(event: dict, context: LambdaContext): # pylint: disable=unused-argument return { 'body': 'Hurray!' @@ -55,7 +55,7 @@ def example_entrypoint(event: dict, context: LambdaContext): # pylint: disable= def test_missing_scope(self): from handlers.utils import scope_by_path - @scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact') + @scope_by_path(scope_parameter='jurisdiction', resource_parameter='compact', action='write') def example_entrypoint(event: dict, context: LambdaContext): # pylint: disable=unused-argument return { 'body': 'Hurray!' diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/.coveragerc b/backend/compact-connect/lambdas/staff-user-pre-token/.coveragerc new file mode 100644 index 000000000..99c409d65 --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/.coveragerc @@ -0,0 +1,10 @@ +[run] +data_file = ../../../.coverage + +omit = + */cdk.out/* + */smoke-test/* + */tests/* + +[report] +skip_empty = true diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/config.py b/backend/compact-connect/lambdas/staff-user-pre-token/config.py new file mode 100644 index 000000000..0f32651fc --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/config.py @@ -0,0 +1,33 @@ +import json +import logging +import os +from functools import cached_property + +import boto3 +from aws_lambda_powertools.logging import Logger + + +logging.basicConfig() +logger = Logger() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false').lower() == 'true' else logging.INFO) + + +class _Config: + @cached_property + def users_table(self): + return boto3.resource('dynamodb').Table(self.users_table_name) + + @property + def compacts(self): + return set(json.loads(os.environ['COMPACTS'])) + + @property + def jurisdictions(self): + return set(json.loads(os.environ['JURISDICTIONS'])) + + @property + def users_table_name(self): + return os.environ['USERS_TABLE_NAME'] + + +config = _Config() diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/main.py b/backend/compact-connect/lambdas/staff-user-pre-token/main.py new file mode 100644 index 000000000..554fcaa6c --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/main.py @@ -0,0 +1,45 @@ + +import logging +import os + +from aws_lambda_powertools import Logger + +from user_scopes import UserScopes + +logger = Logger() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false').lower() == 'true' else logging.INFO) + + +@logger.inject_lambda_context() +def customize_scopes(event, context): # pylint: disable=unused-argument + """ + Customize the scopes in the access token before AWS generates and issues it + """ + logger.info('Received event', event=event) + + try: + sub = event['request']['userAttributes']['sub'] + except KeyError as e: + # This logic will only ever trigger in the event of a misconfiguration. + # The Cognito Authorizer will validate all JWTs before ever calling this function. + logger.error('Unauthenticated user access attempted!', exc_info=e) + # Explicitly set this, to avoid future bugs + event['response']['claimsAndScopeOverrideDetails'] = None + return event + + try: + scopes_to_add = UserScopes(sub) + logger.debug('Adding scopes', scopes=scopes_to_add) + # We want to catch almost any exception here, so we can gracefully return execution back to AWS + except Exception as e: # pylint: disable=broad-exception-caught + logger.error('Error while getting user scopes!', exc_info=e) + event['response']['claimsAndScopeOverrideDetails'] = None + return event + + event['response']['claimsAndScopeOverrideDetails'] = { + 'accessTokenGeneration': { + 'scopesToAdd': list(scopes_to_add) + } + } + + return event diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/requirements-dev.in b/backend/compact-connect/lambdas/staff-user-pre-token/requirements-dev.in new file mode 100644 index 000000000..4a6bc56d0 --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/requirements-dev.in @@ -0,0 +1 @@ +moto[dynamodb, s3]>=5.0.0, <6 diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/requirements-dev.txt b/backend/compact-connect/lambdas/staff-user-pre-token/requirements-dev.txt new file mode 100644 index 000000000..7d2d54002 --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/requirements-dev.txt @@ -0,0 +1,70 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --no-emit-index-url compact-connect/lambdas/staff-user-pre-token/requirements-dev.in +# +boto3==1.34.151 + # via moto +botocore==1.34.151 + # via + # boto3 + # moto + # s3transfer +certifi==2024.7.4 + # via requests +cffi==1.16.0 + # via cryptography +charset-normalizer==3.3.2 + # via requests +cryptography==43.0.0 + # via moto +docker==7.1.0 + # via moto +idna==3.7 + # via requests +jinja2==3.1.4 + # via moto +jmespath==1.0.1 + # via + # boto3 + # botocore +markupsafe==2.1.5 + # via + # jinja2 + # werkzeug +moto[dynamodb,s3]==5.0.11 + # via -r compact-connect/lambdas/staff-user-pre-token/requirements-dev.in +py-partiql-parser==0.5.5 + # via moto +pycparser==2.22 + # via cffi +python-dateutil==2.9.0.post0 + # via + # botocore + # moto +pyyaml==6.0.1 + # via + # moto + # responses +requests==2.32.3 + # via + # docker + # moto + # responses +responses==0.25.3 + # via moto +s3transfer==0.10.2 + # via boto3 +six==1.16.0 + # via python-dateutil +urllib3==2.2.2 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.0.3 + # via moto +xmltodict==0.13.0 + # via moto diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/requirements.in b/backend/compact-connect/lambdas/staff-user-pre-token/requirements.in new file mode 100644 index 000000000..1d8667d40 --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/requirements.in @@ -0,0 +1,2 @@ +aws-lambda-powertools>=2.29.1, <3 +boto3>=1.34.33, <2 diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/requirements.txt b/backend/compact-connect/lambdas/staff-user-pre-token/requirements.txt new file mode 100644 index 000000000..bb43d109e --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/requirements.txt @@ -0,0 +1,29 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --no-emit-index-url compact-connect/lambdas/staff-user-pre-token/requirements.in +# +aws-lambda-powertools==2.42.0 + # via -r compact-connect/lambdas/staff-user-pre-token/requirements.in +boto3==1.34.151 + # via -r compact-connect/lambdas/staff-user-pre-token/requirements.in +botocore==1.34.151 + # via + # boto3 + # s3transfer +jmespath==1.0.1 + # via + # aws-lambda-powertools + # boto3 + # botocore +python-dateutil==2.9.0.post0 + # via botocore +s3transfer==0.10.2 + # via boto3 +six==1.16.0 + # via python-dateutil +typing-extensions==4.12.2 + # via aws-lambda-powertools +urllib3==2.2.2 + # via botocore diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/tests/__init__.py b/backend/compact-connect/lambdas/staff-user-pre-token/tests/__init__.py new file mode 100644 index 000000000..98876556f --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/tests/__init__.py @@ -0,0 +1,54 @@ +import os +from unittest import TestCase +from unittest.mock import MagicMock + +import boto3 +from aws_lambda_powertools.utilities.typing import LambdaContext +from moto import mock_aws + + +@mock_aws +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update({ + # Set to 'true' to enable debug logging in tests + 'DEBUG': 'true', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'USERS_TABLE_NAME': 'users-table', + 'COMPACTS': '["aslp", "octp", "coun"]', + 'JURISDICTIONS': '["al", "co"]' + }) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + import config + cls.config = config._Config() # pylint: disable=protected-access + config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) + + def setUp(self): + super().setUp() + + self.build_resources() + self.addCleanup(self.delete_resources) + + def build_resources(self): + self._table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + { + 'AttributeName': 'pk', + 'AttributeType': 'S' + } + ], + TableName=os.environ['USERS_TABLE_NAME'], + KeySchema=[ + { + 'AttributeName': 'pk', + 'KeyType': 'HASH' + } + ], + BillingMode='PAY_PER_REQUEST' + ) + + def delete_resources(self): + self._table.delete() diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/tests/resources/pre-token-event.json b/backend/compact-connect/lambdas/staff-user-pre-token/tests/resources/pre-token-event.json new file mode 100644 index 000000000..e28f30cd7 --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/tests/resources/pre-token-event.json @@ -0,0 +1,33 @@ +{ + "version": "2", + "triggerSource": "TokenGeneration_HostedAuth", + "region": "us-east-1", + "userPoolId": "us-east-1_abcdefghi", + "userName": "justin", + "callerContext": { + "awsSdkVersion": "aws-sdk-unknown-unknown", + "clientId": "1234567890abcdefghijkl" + }, + "request": { + "userAttributes": { + "sub": "a4182428-d061-701c-82e5-a3d1d547d797", + "cognito:user_status": "CONFIRMED", + "email": "joe@example.com" + }, + "groupConfiguration": { + "groupsToOverride": [], + "iamRolesToOverride": [], + "preferredRole": null + }, + "scopes": [ + "aws.cognito.signin.user.admin", + "phone", + "openid", + "profile", + "email" + ] + }, + "response": { + "claimsAndScopeOverrideDetails": null + } +} diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/tests/test_main.py b/backend/compact-connect/lambdas/staff-user-pre-token/tests/test_main.py new file mode 100644 index 000000000..2cfdaed0b --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/tests/test_main.py @@ -0,0 +1,79 @@ +import json +from unittest.mock import patch + +from moto import mock_aws + +from tests import TstLambdas + + +@mock_aws +class TestCustomizeScopes(TstLambdas): + + def test_happy_path(self): + from main import customize_scopes + + with open('tests/resources/pre-token-event.json', 'r') as f: + event = json.load(f) + sub = event['request']['userAttributes']['sub'] + + # Create a DB record for this user's permissions + self._table.put_item( + Item={ + 'pk': sub, + 'createdCompactJurisdiction': 'aslp/al', + 'permissions': { + 'aslp': { + 'actions': {'read'}, + 'jurisdictions': { + # should correspond to the 'aslp/write' and 'aslp/al.write' scopes + 'al': {'actions': {'write'}} + } + } + } + } + ) + + resp = customize_scopes(event, self.mock_context) + + self.assertEqual( + sorted(['aslp/read', 'aslp/write', 'aslp/al.write']), + sorted(resp['response']['claimsAndScopeOverrideDetails']['accessTokenGeneration']['scopesToAdd']) + ) + + def test_unauthenticated(self): + """ + We should never actually receive an authenticated request, but if that happens somehow, + we'll not add any scopes. + """ + from main import customize_scopes + + with open('tests/resources/pre-token-event.json', 'r') as f: + event = json.load(f) + + del event['request']['userAttributes'] + + resp = customize_scopes(event, self.mock_context) + + self.assertEqual( + None, + resp['response']['claimsAndScopeOverrideDetails'] + ) + + @patch('main.UserScopes', autospec=True) + def test_error_getting_scopes(self, mock_get_scopes): + """ + If something goes wrong calculating scopes, we will return none. + """ + mock_get_scopes.side_effect = RuntimeError('Oh noes!') + + from main import customize_scopes + + with open('tests/resources/pre-token-event.json', 'r') as f: + event = json.load(f) + + resp = customize_scopes(event, self.mock_context) + + self.assertEqual( + None, + resp['response']['claimsAndScopeOverrideDetails'] + ) diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/tests/test_user_scopes.py b/backend/compact-connect/lambdas/staff-user-pre-token/tests/test_user_scopes.py new file mode 100644 index 000000000..890c0cc95 --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/tests/test_user_scopes.py @@ -0,0 +1,246 @@ +from uuid import uuid4 + +from moto import mock_aws + +from tests import TstLambdas + + +@mock_aws +class TestGetUserScopesFromDB(TstLambdas): + + def setUp(self): # pylint: disable=invalid-name + super().setUp() + self._user_sub = str(uuid4()) + + def test_compact_ed_user(self): + from user_scopes import UserScopes + + # Create a DB record for a typical compact executive director's permissions + self._table.put_item( + Item={ + 'pk': self._user_sub, + 'createdCompactJurisdiction': 'aslp/aslp', + 'permissions': { + 'aslp': { + 'actions': {'read', 'admin'}, + 'jurisdictions': {} + } + } + } + ) + + scopes = UserScopes(self._user_sub) + + self.assertEqual( + {'aslp/read', 'aslp/admin', 'aslp/aslp.admin'}, + scopes + ) + + def test_board_ed_user(self): + from user_scopes import UserScopes + + # Create a DB record for a typical board executive director's permissions + self._table.put_item( + Item={ + 'pk': self._user_sub, + 'createdCompactJurisdiction': 'aslp/al', + 'permissions': { + 'aslp': { + 'actions': {'read'}, + 'jurisdictions': { + 'al': {'actions': {'write', 'admin'}} + } + } + } + } + ) + + scopes = UserScopes(self._user_sub) + + self.assertEqual( + {'aslp/read', 'aslp/admin', 'aslp/write', 'aslp/al.admin', 'aslp/al.write'}, + scopes + ) + + def test_board_ed_user_multi_compact(self): + """ + There is a small number of expected users who will represent multiple compacts within a state. + We'll specifically verify handling of what their permissions may look like. + """ + from user_scopes import UserScopes + + # Create a DB record for a board executive director's permissions + self._table.put_item( + Item={ + 'pk': self._user_sub, + 'createdCompactJurisdiction': 'aslp/al', + 'permissions': { + 'aslp': { + 'actions': {'read'}, + 'jurisdictions': { + 'al': {'actions': {'write', 'admin'}} + } + }, + 'octp': { + 'actions': {'read'}, + 'jurisdictions': { + 'al': {'actions': {'write', 'admin'}} + } + } + } + } + ) + + scopes = UserScopes(self._user_sub) + + self.assertEqual( + { + 'aslp/read', 'aslp/admin', 'aslp/write', 'aslp/al.admin', 'aslp/al.write', + 'octp/read', 'octp/admin', 'octp/write', 'octp/al.admin', 'octp/al.write' + }, + scopes + ) + + def test_board_staff(self): + from user_scopes import UserScopes + + # Create a DB record for a typical board staff user's permissions + self._table.put_item( + Item={ + 'pk': self._user_sub, + 'createdCompactJurisdiction': 'aslp/al', + 'permissions': { + 'aslp': { + 'actions': {'read'}, + 'jurisdictions': { + 'al': {'actions': {'write'}} # should correspond to the 'aslp/al.write' scope + } + } + } + } + ) + + scopes = UserScopes(self._user_sub) + + self.assertEqual( + {'aslp/read', 'aslp/write', 'aslp/al.write'}, + scopes + ) + + def test_missing_user(self): + from user_scopes import UserScopes + + # We didn't specifically add a user for this test, so they will be missing + with self.assertRaises(RuntimeError): + UserScopes(self._user_sub) + + def test_disallowed_compact(self): + """ + If a user's permissions list an invalid compact, we will refuse to give them + any scopes at all. + """ + from user_scopes import UserScopes + + # Create a DB record with permissions for an unsupported compact + self._table.put_item( + Item={ + 'pk': self._user_sub, + 'createdCompactJurisdiction': 'aslp/al', + 'permissions': { + 'aslp': { + 'actions': {'read'}, + 'jurisdictions': { + 'al': {'actions': {'write', 'admin'}} + } + }, + 'abc': { + 'read': True, + 'jurisdictions': { + 'al': {'actions': {'write', 'admin'}} + } + } + } + } + ) + + with self.assertRaises(ValueError): + UserScopes(self._user_sub) + + def test_disallowed_compact_action(self): + """ + If a user's permissions list an invalid compact, we will refuse to give them + any scopes at all. + """ + from user_scopes import UserScopes + + # Create a DB record with permissions for an unsupported compact + self._table.put_item( + Item={ + 'pk': self._user_sub, + 'createdCompactJurisdiction': 'aslp/al', + 'permissions': { + 'aslp': { + # Write is jurisdiction-specific + 'actions': {'read', 'write'}, + 'jurisdictions': { + 'al': {'actions': {'write', 'admin'}} + } + } + } + } + ) + + with self.assertRaises(ValueError): + UserScopes(self._user_sub) + + def test_disallowed_jurisdiction(self): + """ + If a user's permissions list an invalid jurisdiction, we will refuse to give them + any scopes at all. + """ + from user_scopes import UserScopes + + # Create a DB record with permissions for an unsupported compact + self._table.put_item( + Item={ + 'pk': self._user_sub, + 'createdCompactJurisdiction': 'aslp/aslp', + 'permissions': { + 'aslp': { + 'actions': {'read'}, + 'jurisdictions': { + 'ab': {'actions': {'write', 'admin'}} + } + } + } + } + ) + + with self.assertRaises(ValueError): + UserScopes(self._user_sub) + + def test_disallowed_action(self): + """ + If a user's permissions list an invalid action, we will refuse to give them + any scopes at all. + """ + from user_scopes import UserScopes + + # Create a DB record with permissions for an unsupported compact + self._table.put_item( + Item={ + 'pk': self._user_sub, + 'createdCompactJurisdiction': 'aslp/aslp', + 'permissions': { + 'aslp': { + 'actions': {'read'}, + 'jurisdictions': { + 'al': {'actions': {'write', 'hack'}} + } + } + } + } + ) + + with self.assertRaises(ValueError): + UserScopes(self._user_sub) diff --git a/backend/compact-connect/lambdas/staff-user-pre-token/user_scopes.py b/backend/compact-connect/lambdas/staff-user-pre-token/user_scopes.py new file mode 100644 index 000000000..b7bf6181a --- /dev/null +++ b/backend/compact-connect/lambdas/staff-user-pre-token/user_scopes.py @@ -0,0 +1,82 @@ +from config import config, logger + + +class UserScopes(set): + """ + Custom Set that will populate itself based on the user's database record contents + """ + def __init__(self, sub: str): + super().__init__() + self._get_scopes_from_db(sub) + + def _get_scopes_from_db(self, sub: str): + """ + Parse the user's database record to calculate scopes. + + Note: See the accompanying unit tests for expected db record shape. + :param sub: The `sub` field value from the Cognito Authorizer (which gets it from the JWT) + """ + user_data = self._get_user_data(sub) + permissions = user_data.get('permissions', {}) + + # Ensure included compacts are limited to supported values + disallowed_compcats = permissions.keys() - config.compacts + if disallowed_compcats: + raise ValueError(f'User permissions include disallowed compacts: {disallowed_compcats}') + + for compact_name, compact_permissions in permissions.items(): + self._process_compact_permissions(compact_name, compact_permissions) + + @staticmethod + def _get_user_data(sub: str): + try: + user_data = config.users_table.get_item(Key={'pk': sub})['Item'] + except KeyError as e: + logger.error('Authenticated user not found!', exc_info=e, sub=sub) + raise RuntimeError('Authenticated user not found!') from e + return user_data + + def _process_compact_permissions(self, compact_name, compact_permissions): + # Compact-level permissions + compact_actions = compact_permissions.get('actions', set()) + + # Ensure included actions are limited to supported values + disallowed_actions = compact_actions - {'read', 'admin'} + if disallowed_actions: + raise ValueError(f'User {compact_name} permissions include disallowed actions: {disallowed_actions}') + + # Read is the only truly compact-level permission + if 'read' in compact_actions: + self.add(f'{compact_name}/read') + + if 'admin' in compact_actions: + # Two levels of authz for admin + self.add(f'{compact_name}/admin') + self.add(f'{compact_name}/{compact_name}.admin') + + # Ensure included jurisdictions are limited to supported values + jurisdictions = compact_permissions['jurisdictions'] + disallowed_jurisdictions = jurisdictions.keys() - config.jurisdictions + if disallowed_jurisdictions: + raise ValueError( + f'User {compact_name} permissions include disallowed jurisdictions: {disallowed_jurisdictions}' + ) + + for jurisdiction_name, jurisdiction_permissions in compact_permissions['jurisdictions'].items(): + self._process_jurisdiction_permissions(compact_name, jurisdiction_name, jurisdiction_permissions) + + def _process_jurisdiction_permissions(self, compact_name, jurisdiction_name, jurisdiction_permissions): + # Jurisdiction-level permissions + jurisdiction_actions = jurisdiction_permissions.get('actions', set()) + + # Ensure included actions are limited to supported values + disallowed_actions = jurisdiction_actions - {'write', 'admin'} + if disallowed_actions: + raise ValueError( + f'User {compact_name}/{jurisdiction_name} permissions include disallowed actions: ' + f'{disallowed_actions}' + ) + for action in jurisdiction_actions: + # Two levels of authz + self.add(f'{compact_name}/{action}') + self.add(f'{compact_name}/{jurisdiction_name}.{action}') diff --git a/backend/compact-connect/pipeline/backend_stage.py b/backend/compact-connect/pipeline/backend_stage.py index cbf1b2187..dc453cc01 100644 --- a/backend/compact-connect/pipeline/backend_stage.py +++ b/backend/compact-connect/pipeline/backend_stage.py @@ -32,6 +32,7 @@ def __init__( self.persistent_stack = PersistentStack( self, 'PersistentStack', env=environment, + environment_context=environment_context, standard_tags=standard_tags, app_name=app_name, environment_name=environment_name @@ -40,6 +41,7 @@ def __init__( self.ingest_stack = IngestStack( self, 'IngestStack', env=environment, + environment_context=environment_context, standard_tags=standard_tags, persistent_stack=self.persistent_stack ) @@ -47,8 +49,8 @@ def __init__( self.ui_stack = UIStack( self, 'UIStack', env=environment, - standard_tags=standard_tags, environment_context=environment_context, + standard_tags=standard_tags, github_repo_string=github_repo_string, persistent_stack=self.persistent_stack ) @@ -56,8 +58,8 @@ def __init__( self.api_stack = ApiStack( self, 'APIStack', env=environment, + environment_context=environment_context, standard_tags=standard_tags, environment_name=environment_name, - environment_context=environment_context, persistent_stack=self.persistent_stack ) diff --git a/backend/compact-connect/requirements-dev.txt b/backend/compact-connect/requirements-dev.txt index 1fde72fa9..1ba91b0a7 100644 --- a/backend/compact-connect/requirements-dev.txt +++ b/backend/compact-connect/requirements-dev.txt @@ -4,7 +4,7 @@ # # pip-compile --no-emit-index-url compact-connect/requirements-dev.in # -astroid==3.2.2 +astroid==3.2.4 # via pylint boolean-py==4.0 # via license-expression @@ -20,7 +20,7 @@ charset-normalizer==3.3.2 # via requests click==8.1.7 # via pip-tools -coverage[toml]==7.5.4 +coverage[toml]==7.6.0 # via # -r compact-connect/requirements-dev.in # pytest-cov @@ -50,7 +50,7 @@ mdurl==0.1.2 # via markdown-it-py msgpack==1.0.8 # via cachecontrol -packageurl-python==0.15.3 +packageurl-python==0.15.6 # via cyclonedx-python-lib packaging==24.1 # via @@ -58,7 +58,7 @@ packaging==24.1 # pip-audit # pip-requirements-parser # pytest -pip-api==0.0.33 +pip-api==0.0.34 # via pip-audit pip-audit==2.7.3 # via -r compact-connect/requirements-dev.in @@ -74,7 +74,7 @@ py-serializable==1.1.0 # via cyclonedx-python-lib pygments==2.18.0 # via rich -pylint==3.2.5 +pylint==3.2.6 # via -r compact-connect/requirements-dev.in pyparsing==3.1.2 # via pip-requirements-parser @@ -82,7 +82,7 @@ pyproject-hooks==1.1.0 # via # build # pip-tools -pytest==8.2.2 +pytest==8.3.2 # via # -r compact-connect/requirements-dev.in # pytest-cov @@ -100,7 +100,7 @@ sortedcontainers==2.4.0 # via cyclonedx-python-lib toml==0.10.2 # via pip-audit -tomlkit==0.12.5 +tomlkit==0.13.0 # via pylint urllib3==2.2.2 # via requests diff --git a/backend/compact-connect/requirements.txt b/backend/compact-connect/requirements.txt index 6905674a5..6ccf0fbab 100644 --- a/backend/compact-connect/requirements.txt +++ b/backend/compact-connect/requirements.txt @@ -14,16 +14,16 @@ aws-cdk-asset-kubectl-v20==2.1.2 # via aws-cdk-lib aws-cdk-asset-node-proxy-agent-v6==2.0.3 # via aws-cdk-lib -aws-cdk-aws-lambda-python-alpha==2.148.0a0 +aws-cdk-aws-lambda-python-alpha==2.150.0a0 # via -r compact-connect/requirements.in -aws-cdk-lib==2.148.0 +aws-cdk-lib==2.150.0 # via # -r compact-connect/requirements.in # aws-cdk-aws-lambda-python-alpha # cdk-nag cattrs==23.2.3 # via jsii -cdk-nag==2.28.157 +cdk-nag==2.28.170 # via -r compact-connect/requirements.in constructs==10.3.0 # via diff --git a/backend/compact-connect/stacks/api_stack/__init__.py b/backend/compact-connect/stacks/api_stack/__init__.py index cdbe2f113..1651a407e 100644 --- a/backend/compact-connect/stacks/api_stack/__init__.py +++ b/backend/compact-connect/stacks/api_stack/__init__.py @@ -1,14 +1,13 @@ from __future__ import annotations -from aws_cdk.aws_route53 import HostedZone from constructs import Construct -from common_constructs.stack import Stack +from common_constructs.stack import AppStack from stacks.api_stack.license_api import LicenseApi from stacks import persistent_stack as ps -class ApiStack(Stack): +class ApiStack(AppStack): def __init__( self, scope: Construct, construct_id: str, *, environment_name: str, @@ -16,19 +15,10 @@ def __init__( persistent_stack: ps.PersistentStack, **kwargs ): - super().__init__(scope, construct_id, **kwargs) - - hosted_zone = None - domain_name = environment_context.get('domain_name') - if domain_name is not None: - hosted_zone = HostedZone.from_lookup( - self, 'HostedZone', - domain_name=domain_name - ) + super().__init__(scope, construct_id, environment_context=environment_context, **kwargs) self.license_api = LicenseApi( self, 'LicenseApi', environment_name=environment_name, - hosted_zone=hosted_zone, persistent_stack=persistent_stack ) diff --git a/backend/compact-connect/stacks/api_stack/license_api.py b/backend/compact-connect/stacks/api_stack/license_api.py index 6e98f6c55..22d2ee3f2 100644 --- a/backend/compact-connect/stacks/api_stack/license_api.py +++ b/backend/compact-connect/stacks/api_stack/license_api.py @@ -14,7 +14,7 @@ from cdk_nag import NagSuppressions from constructs import Construct -from common_constructs.stack import Stack +from common_constructs.stack import Stack, AppStack from common_constructs.webacl import WebACL, WebACLScope from stacks.api_stack.bulk_upload_url import BulkUploadUrl from stacks.api_stack.post_license import PostLicenses @@ -31,26 +31,24 @@ class LicenseApi(RestApi): def __init__( # pylint: disable=too-many-locals self, scope: Construct, construct_id: str, *, environment_name: str, - hosted_zone: IHostedZone = None, persistent_stack: ps.PersistentStack, **kwargs ): - + stack: AppStack = AppStack.of(scope) # For developer convenience, we will allow for the case where there is no # domain name configured domain_kwargs = {} - if hosted_zone is not None: - api_domain_name = f'api.{hosted_zone.zone_name}' + if stack.hosted_zone is not None: certificate = Certificate( scope, 'ApiCert', - domain_name=api_domain_name, - validation=CertificateValidation.from_dns(hosted_zone=hosted_zone), - subject_alternative_names=[hosted_zone.zone_name] + domain_name=stack.api_domain_name, + 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=api_domain_name + domain_name=stack.api_domain_name ) } @@ -107,10 +105,10 @@ def __init__( # pylint: disable=too-many-locals **kwargs ) - if hosted_zone is not None: + if stack.hosted_zone is not None: self._add_domain_name( - hosted_zone=hosted_zone, - api_domain_name=api_domain_name, + hosted_zone=stack.hosted_zone, + api_domain_name=stack.api_domain_name, ) self.log_groups = [access_log_group] @@ -171,21 +169,29 @@ def __init__( # pylint: disable=too-many-locals # Authenticated endpoints # /v0/licenses v0_resource = self.root.add_resource('v0') - scopes = [ - f'{resource_server}/{scope}' - for resource_server in persistent_stack.board_users.resource_servers.keys() - for scope in persistent_stack.board_users.scopes.keys() + read_scopes = [ + f'{resource_server}/read' + for resource_server in persistent_stack.staff_users.resource_servers.keys() + ] + write_scopes = [ + f'{resource_server}/write' + for resource_server in persistent_stack.staff_users.resource_servers.keys() ] - auth_method_options = MethodOptions( + read_auth_method_options = MethodOptions( + authorization_type=AuthorizationType.COGNITO, + authorizer=self.staff_users_authorizer, + authorization_scopes=read_scopes + ) + write_auth_method_options = MethodOptions( authorization_type=AuthorizationType.COGNITO, - authorizer=self.board_users_authorizer, - authorization_scopes=scopes + authorizer=self.staff_users_authorizer, + authorization_scopes=write_scopes ) # /v0/providers providers_resource = v0_resource.add_resource('providers') QueryProviders( providers_resource, - method_options=auth_method_options, + method_options=read_auth_method_options, data_encryption_key=persistent_stack.shared_encryption_key, license_data_table=persistent_stack.license_table ) @@ -197,12 +203,12 @@ def __init__( # pylint: disable=too-many-locals PostLicenses( mock_resource=False, resource=jurisdiction_resource, - method_options=auth_method_options, + method_options=write_auth_method_options, event_bus=persistent_stack.data_event_bus ) BulkUploadUrl( resource=jurisdiction_resource, - method_options=auth_method_options, + method_options=write_auth_method_options, bulk_uploads_bucket=persistent_stack.bulk_uploads_bucket ) @@ -266,10 +272,10 @@ def __init__( # pylint: disable=too-many-locals ) @cached_property - def board_users_authorizer(self): + def staff_users_authorizer(self): return CognitoUserPoolsAuthorizer( - self, 'BoardPoolsAuthorizer', - cognito_user_pools=[self._persistent_stack.board_users] + self, 'StaffPoolsAuthorizer', + cognito_user_pools=[self._persistent_stack.staff_users] ) @cached_property diff --git a/backend/compact-connect/stacks/api_stack/query_providers.py b/backend/compact-connect/stacks/api_stack/query_providers.py index d3d41411c..15443b552 100644 --- a/backend/compact-connect/stacks/api_stack/query_providers.py +++ b/backend/compact-connect/stacks/api_stack/query_providers.py @@ -83,7 +83,8 @@ def _add_get_provider( 'method.request.header.Authorization': True } if method_options.authorization_type != AuthorizationType.NONE else {}, authorization_type=method_options.authorization_type, - authorizer=method_options.authorizer + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes ) def _add_query_providers( @@ -124,7 +125,8 @@ def _add_query_providers( 'method.request.header.Authorization': True } if method_options.authorization_type != AuthorizationType.NONE else {}, authorization_type=method_options.authorization_type, - authorizer=method_options.authorizer + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes ) @property diff --git a/backend/compact-connect/stacks/ingest_stack.py b/backend/compact-connect/stacks/ingest_stack.py index 77f4125df..db1a9eb32 100644 --- a/backend/compact-connect/stacks/ingest_stack.py +++ b/backend/compact-connect/stacks/ingest_stack.py @@ -12,11 +12,11 @@ from constructs import Construct from common_constructs.python_function import PythonFunction -from common_constructs.stack import Stack +from common_constructs.stack import AppStack from stacks import persistent_stack as ps -class IngestStack(Stack): +class IngestStack(AppStack): def __init__( self, scope: Construct, construct_id: str, *, persistent_stack: ps.PersistentStack, diff --git a/backend/compact-connect/stacks/persistent_stack/__init__.py b/backend/compact-connect/stacks/persistent_stack/__init__.py index 30c9c3a66..109bcbf23 100644 --- a/backend/compact-connect/stacks/persistent_stack/__init__.py +++ b/backend/compact-connect/stacks/persistent_stack/__init__.py @@ -1,18 +1,18 @@ -from aws_cdk import RemovalPolicy +from aws_cdk import RemovalPolicy, CfnOutput from aws_cdk.aws_kms import Key from constructs import Construct from common_constructs.access_logs_bucket import AccessLogsBucket -from common_constructs.stack import Stack -from stacks.persistent_stack.admin_users import AdminUsers +from common_constructs.stack import AppStack from stacks.persistent_stack.board_users import BoardUsers from stacks.persistent_stack.bulk_uploads_bucket import BulkUploadsBucket from stacks.persistent_stack.license_table import LicenseTable from stacks.persistent_stack.event_bus import EventBus +from stacks.persistent_stack.staff_users import StaffUsers -class PersistentStack(Stack): +class PersistentStack(AppStack): """ The stack that holds long-lived resources such as license data and other things that should probably never be destroyed in production @@ -22,9 +22,10 @@ def __init__( self, scope: Construct, construct_id: str, *, app_name: str, environment_name: str, + environment_context: dict, **kwargs ) -> None: - super().__init__(scope, construct_id, **kwargs) + super().__init__(scope, construct_id, environment_context=environment_context, **kwargs) # If we delete this stack, retain the resource (orphan but prevent data loss) or destroy it (clean up)? removal_policy = RemovalPolicy.RETAIN if environment_name == 'prod' else RemovalPolicy.DESTROY @@ -74,16 +75,14 @@ def __init__( removal_policy=removal_policy ) - admin_prefix = f'{app_name}-admins' - self.admin_users = AdminUsers( - self, 'AdminUsers', - cognito_domain_prefix=admin_prefix if environment_name == 'prod' - else f'{admin_prefix}-{environment_name}', - environment_name=environment_name, - encryption_key=self.shared_encryption_key, - removal_policy=removal_policy - ) - + # We are replacing this UserPool with the StaffUsers UserPool. We could remove it now but we won't. Because + # the API stack references this user pool as an IDP, CloudFormation won't let us remove this from the + # Persistent stack until the API stack has been updated to remove that reference. This early in development, + # we _could_ opt to just tear down the API stack before deploying, but we can do this the more formal way as a + # learning exercise for everyone involved. Instead, the zero-downtime approach would to do a phased-rollout, + # where we create the new resource and update the API to use it in the first phase, then remove the deprecated + # resources in a subsequent phase. This also provides an opportunity for a migration of data from one to + # the other, such as migrating users from one pool to the other, if that were necessary. boards_prefix = f'{app_name}-boards' self.board_users = BoardUsers( self, 'BoardUsers', @@ -91,5 +90,27 @@ def __init__( else f'{boards_prefix}-{environment_name}', environment_name=environment_name, encryption_key=self.shared_encryption_key, + removal_policy=RemovalPolicy.DESTROY # Force this for clean removal across environments + ) + # Because our API stack no longer has a reference to the BoardUsers arn in it, CDK will drop the output from + # this stack, but that creates a sort of cross-stack deadlock. Here's a really good explanation of how that + # works: https://www.endoflineblog.com/cdk-tips-03-how-to-unblock-cross-stack-references + # To get past that in a formalized hands-off pipeline way, we manually create the export to keep it around + # for a single deploy: + user_pool_arn_output = CfnOutput( + self, 'HostedZoneOutput', + export_name=f'{self.stack_name}:ExportsOutputFnGetAttBoardUsers358C5099Arn2934C264', + value=self.board_users.user_pool_arn + ) + user_pool_arn_output.override_logical_id('ExportsOutputFnGetAttBoardUsers358C5099Arn2934C264') + + staff_prefix = f'{app_name}-staff' + self.staff_users = StaffUsers( + self, 'StaffUsers', + cognito_domain_prefix=staff_prefix if environment_name == 'prod' + else f'{staff_prefix}-{environment_name}', + environment_name=environment_name, + environment_context=environment_context, + encryption_key=self.shared_encryption_key, removal_policy=removal_policy ) diff --git a/backend/compact-connect/stacks/persistent_stack/admin_users.py b/backend/compact-connect/stacks/persistent_stack/admin_users.py deleted file mode 100644 index c45382d45..000000000 --- a/backend/compact-connect/stacks/persistent_stack/admin_users.py +++ /dev/null @@ -1,62 +0,0 @@ -from aws_cdk.aws_cognito import ResourceServerScope, OAuthScope -from aws_cdk.aws_kms import IKey -from constructs import Construct - -from common_constructs.user_pool import UserPool - - -class AdminUsers(UserPool): - def __init__( - self, scope: Construct, construct_id: str, *, - cognito_domain_prefix: str, - environment_name: str, - encryption_key: IKey, - removal_policy, - **kwargs - ): - super().__init__( - scope, construct_id, - cognito_domain_prefix=cognito_domain_prefix, - environment_name=environment_name, - encryption_key=encryption_key, - removal_policy=removal_policy, - **kwargs - ) - - self._add_resource_server() - self.ui_client = self.add_ui_client( - ui_scopes=[ - OAuthScope.OPENID, - OAuthScope.PROFILE, - OAuthScope.EMAIL, - OAuthScope.COGNITO_ADMIN, - OAuthScope.resource_server(self.resource_server, self.scope) - ] - ) - - # We will create some admins to get access started for the app and for support - # for email in compact_context.get('admins', []): - # user = CfnUserPoolUser( - # self, f'Admin{email}', - # user_pool_id=self.user_pool_id, - # username=email, - # user_attributes=[ - # CfnUserPoolUser.AttributeTypeProperty( - # name='email', - # value=email - # ) - # ], - # desired_delivery_mediums=['EMAIL'] - # ) - # user.add_dependency(self.node.default_child) - - def _add_resource_server(self): - """ - Add scopes for all compact/jurisdictions - """ - self.scope = ResourceServerScope(scope_name='*', scope_description='Full administrator access') - self.resource_server = self.add_resource_server( - 'Admins', - identifier='admin', - scopes=[self.scope] - ) diff --git a/backend/compact-connect/stacks/persistent_stack/board_users.py b/backend/compact-connect/stacks/persistent_stack/board_users.py index 0603e28e2..c055e0d1e 100644 --- a/backend/compact-connect/stacks/persistent_stack/board_users.py +++ b/backend/compact-connect/stacks/persistent_stack/board_users.py @@ -10,6 +10,9 @@ class BoardUsers(UserPool): + """ + DEPRECATED - see comment above constructor use + """ def __init__( self, scope: Construct, construct_id: str, *, cognito_domain_prefix: str, @@ -42,7 +45,9 @@ def __init__( self._add_scope_customization() # Do not allow resource server scopes via the client - they are assigned via token customization # to allow for user attribute-based access - self.ui_client = self.add_ui_client() + self.ui_client = self.add_ui_client(callback_urls=[ + 'http://localhost:3018/auth/callback' + ]) def _add_resource_servers(self): """ diff --git a/backend/compact-connect/stacks/persistent_stack/staff_users.py b/backend/compact-connect/stacks/persistent_stack/staff_users.py new file mode 100644 index 000000000..90e27cf06 --- /dev/null +++ b/backend/compact-connect/stacks/persistent_stack/staff_users.py @@ -0,0 +1,134 @@ +import json +import os + +from aws_cdk.aws_cognito import ResourceServerScope, UserPoolOperation, LambdaVersion +from aws_cdk.aws_kms import IKey +from cdk_nag import NagSuppressions +from constructs import Construct + +from common_constructs.python_function import PythonFunction +from common_constructs.stack import AppStack +from common_constructs.user_pool import UserPool +from stacks.persistent_stack.users_table import UsersTable + + +class StaffUsers(UserPool): + """ + User pool for Compact, Board, and CSG staff + """ + def __init__( + self, scope: Construct, construct_id: str, *, + cognito_domain_prefix: str, + environment_name: str, + environment_context: dict, + encryption_key: IKey, + removal_policy, + **kwargs + ): + super().__init__( + scope, construct_id, + cognito_domain_prefix=cognito_domain_prefix, + environment_name=environment_name, + encryption_key=encryption_key, + removal_policy=removal_policy, + **kwargs + ) + stack: AppStack = AppStack.of(self) + + self.user_table = UsersTable( + self, 'UsersTable', + encryption_key=encryption_key, + removal_policy=removal_policy + ) + + self._add_resource_servers() + self._add_scope_customization() + + callback_urls = [] + if stack.ui_domain_name is not None: + callback_urls.append(f'https://{stack.ui_domain_name}/auth/callback') + # This toggle will allow front-end devs to point their local UI at this environment's user pool to support + # authenticated actions. + if environment_context.get('allow_local_ui', False): + callback_urls.append('http://localhost:3018/auth/callback') + if not callback_urls: + raise ValueError( + "This app requires a callback url for its authentication path. Either provide 'domain_name' or set " + "allow_local_ui' to true in this environment's context.") + + # Do not allow resource server scopes via the client - they are assigned via token customization + # to allow for user attribute-based access + self.ui_client = self.add_ui_client(callback_urls=callback_urls) + + def _add_resource_servers(self): + """ + Add scopes for all compact/jurisdictions + """ + # {compact}.write, {compact}.admin, {compact}.read for every compact + # Note: the .write and .admin scopes will control access to API endpoints via the Cognito authorizer, however + # there will be a secondary level of authorization within the business logic that controls further granularity + # of authorization (i.e. 'aslp/write' will grant access to POST license data, but the business logic inside + # the endpoint also expects an 'aslp/co.write' if the POST includes data for Colorado.) + self.write_scope = ResourceServerScope( + scope_name='write', + scope_description='Write access for the compact, paired with a more specific scope' + ) + self.admin_scope = ResourceServerScope( + scope_name='admin', + scope_description='Admin access for the compact, paired with a more specific scope' + ) + self.read_scope = ResourceServerScope( + scope_name='read', + scope_description='Read access for the compact' + ) + + # One resource server for each compact + self.resource_servers = { + compact: self.add_resource_server( + f'LicenseData-{compact}', + identifier=compact, + scopes=[ + self.admin_scope, + self.write_scope, + self.read_scope + ] + ) + for compact in self.node.get_context('compacts') + } + + def _add_scope_customization(self): + """ + Add scopes to access tokens based on the Users table + """ + compacts = self.node.get_context('compacts') + jurisdictions = self.node.get_context('jurisdictions') + + scope_customization_handler = PythonFunction( + self, 'ScopeCustomizationHandler', + description='Auth scope customization handler', + entry=os.path.join('lambdas', 'staff-user-pre-token'), + index='main.py', + handler='customize_scopes', + environment={ + 'DEBUG': 'true', + 'USERS_TABLE_NAME': self.user_table.table_name, + 'COMPACTS': json.dumps(compacts), + 'JURISDICTIONS': json.dumps(jurisdictions) + } + ) + self.user_table.grant_read_data(scope_customization_handler) + + NagSuppressions.add_resource_suppressions( + scope_customization_handler, + apply_to_children=True, + suppressions=[{ + 'id': 'AwsSolutions-IAM5', + 'reason': 'This lambda role policy contains wildcards in its statements, but all of its actions are ' + 'limited specifically to the actions and the Table it needs read access to.' + }] + ) + self.add_trigger( + UserPoolOperation.PRE_TOKEN_GENERATION_CONFIG, + scope_customization_handler, + lambda_version=LambdaVersion.V2_0 + ) diff --git a/backend/compact-connect/stacks/persistent_stack/users_table.py b/backend/compact-connect/stacks/persistent_stack/users_table.py new file mode 100644 index 000000000..ec65e9ae1 --- /dev/null +++ b/backend/compact-connect/stacks/persistent_stack/users_table.py @@ -0,0 +1,42 @@ +from aws_cdk import RemovalPolicy +from aws_cdk.aws_dynamodb import Table, TableEncryption, BillingMode, Attribute, AttributeType, ProjectionType +from aws_cdk.aws_kms import IKey +from cdk_nag import NagSuppressions +from constructs import Construct + + +class UsersTable(Table): + """ + DynamoDB table to house staff user permissions data + """ + def __init__( + self, scope: Construct, construct_id: str, *, + encryption_key: IKey, + removal_policy: RemovalPolicy, + **kwargs + ): + super().__init__( + scope, construct_id, + encryption=TableEncryption.CUSTOMER_MANAGED, + encryption_key=encryption_key, + billing_mode=BillingMode.PAY_PER_REQUEST, + removal_policy=removal_policy, + point_in_time_recovery=True, + partition_key=Attribute(name='pk', type=AttributeType.STRING), + **kwargs + ) + self.compact_jurisdiction_index_name = 'compact_jurisdiction' + + self.add_global_secondary_index( + index_name=self.compact_jurisdiction_index_name, + partition_key=Attribute(name='createdCompactJur', type=AttributeType.STRING), + projection_type=ProjectionType.KEYS_ONLY + ) + NagSuppressions.add_resource_suppressions( + self, + suppressions=[{ + 'id': 'HIPAA.Security-DynamoDBInBackupPlan', + 'reason': 'We will implement data back-ups after we better understand regulatory data deletion' + ' requirements' + }] + ) diff --git a/backend/compact-connect/stacks/ui_stack/__init__.py b/backend/compact-connect/stacks/ui_stack/__init__.py index 40dd41a80..de61906b9 100644 --- a/backend/compact-connect/stacks/ui_stack/__init__.py +++ b/backend/compact-connect/stacks/ui_stack/__init__.py @@ -1,33 +1,23 @@ from aws_cdk import RemovalPolicy -from aws_cdk.aws_route53 import HostedZone from cdk_nag import NagSuppressions from constructs import Construct from common_constructs.bucket import Bucket from common_constructs.github_actions_access import GitHubActionsAccess -from common_constructs.stack import Stack +from common_constructs.stack import AppStack from stacks.persistent_stack import PersistentStack from stacks.ui_stack.distribution import UIDistribution -class UIStack(Stack): +class UIStack(AppStack): def __init__( self, scope: Construct, construct_id: str, *, github_repo_string: str, - environment_context: dict, persistent_stack: PersistentStack, **kwargs ): super().__init__(scope, construct_id, **kwargs) - hosted_zone = None - domain_name = environment_context.get('domain_name') - if domain_name is not None: - hosted_zone = HostedZone.from_lookup( - self, 'HostedZone', - domain_name=domain_name - ) - ui_bucket = Bucket( self, 'UIBucket', removal_policy=RemovalPolicy.DESTROY, @@ -58,7 +48,6 @@ def __init__( self.distribution = UIDistribution( self, 'UIDistribution', ui_bucket=ui_bucket, - hosted_zone=hosted_zone, persistent_stack=persistent_stack ) diff --git a/backend/compact-connect/stacks/ui_stack/distribution.py b/backend/compact-connect/stacks/ui_stack/distribution.py index 4d41d93ae..b0f2b17fa 100644 --- a/backend/compact-connect/stacks/ui_stack/distribution.py +++ b/backend/compact-connect/stacks/ui_stack/distribution.py @@ -1,18 +1,18 @@ import os -from aws_cdk import Stack from aws_cdk.aws_certificatemanager import Certificate, CertificateValidation from aws_cdk.aws_cloudfront import Distribution, BehaviorOptions, CachePolicy, OriginAccessIdentity, \ ViewerProtocolPolicy, SecurityPolicyProtocol, SSLMethod, ErrorResponse, AllowedMethods, EdgeLambda, \ LambdaEdgeEventType from aws_cdk.aws_cloudfront_origins import S3Origin from aws_cdk.aws_lambda import Function, Code, Runtime -from aws_cdk.aws_route53 import ARecord, RecordTarget, IHostedZone +from aws_cdk.aws_route53 import ARecord, RecordTarget from aws_cdk.aws_route53_targets import CloudFrontTarget from aws_cdk.aws_s3 import IBucket from cdk_nag import NagSuppressions from constructs import Construct +from common_constructs.stack import AppStack from common_constructs.webacl import WebACL, WebACLScope from stacks.persistent_stack import PersistentStack @@ -21,15 +21,13 @@ class UIDistribution(Distribution): def __init__( self, scope: Construct, construct_id: str, *, ui_bucket: IBucket, - persistent_stack: PersistentStack, - hosted_zone: IHostedZone = None - + persistent_stack: PersistentStack ): - stack = Stack.of(scope) + stack: AppStack = AppStack.of(scope) domain_name_kwargs = {} - if hosted_zone is not None: - ui_domain_name = f'app.{hosted_zone.zone_name}' + if stack.hosted_zone is not None: + ui_domain_name = f'app.{stack.hosted_zone.zone_name}' domain_name_kwargs = { 'domain_names': [ ui_domain_name @@ -37,7 +35,7 @@ def __init__( 'certificate': Certificate( scope, 'UICert', domain_name=ui_domain_name, - validation=CertificateValidation.from_dns(hosted_zone=hosted_zone) + validation=CertificateValidation.from_dns(hosted_zone=stack.hosted_zone) ) } @@ -145,10 +143,10 @@ def __init__( **domain_name_kwargs ) - if hosted_zone is not None: + if stack.hosted_zone is not None: self.record = ARecord( self, 'UiARecord', - zone=hosted_zone, + zone=stack.hosted_zone, record_name=ui_domain_name, target=RecordTarget( alias_target=CloudFrontTarget(self) diff --git a/backend/compact-connect/tests/unit/test_app.py b/backend/compact-connect/tests/unit/test_app.py index 025e2e859..41b683e5f 100644 --- a/backend/compact-connect/tests/unit/test_app.py +++ b/backend/compact-connect/tests/unit/test_app.py @@ -95,9 +95,28 @@ def test_synth_sandbox_no_domain(self): self._check_no_annotations(app.sandbox_stage.persistent_stack) self._check_no_annotations(app.sandbox_stage.ui_stack) self._check_no_annotations(app.sandbox_stage.api_stack) + self._check_no_annotations(app.sandbox_stage.ingest_stack) self._inspect_api_stack(app.sandbox_stage.api_stack) + def test_synth_no_ui_raises_value_error(self): + """ + If a developer tries to deploy this app without either a domain name or allowing a local UI, the app + should fail to synthesize. + """ + with open('cdk.json', 'r') as f: + context = json.load(f)['context'] + with open('cdk.context.sandbox-example.json', 'r') as f: + context.update(json.load(f)) + del context['ssm_context']['environments'][context['environment_name']]['domain_name'] + del context['ssm_context']['environments'][context['environment_name']]['allow_local_ui'] + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + + with self.assertRaises(ValueError): + CompactConnectApp(context=context) + def _inspect_api_stack(self, api_stack: ApiStack): api_template = Template.from_stack(api_stack) diff --git a/backend/multi-account/requirements-dev.txt b/backend/multi-account/requirements-dev.txt index bdf5376a2..e40769e64 100644 --- a/backend/multi-account/requirements-dev.txt +++ b/backend/multi-account/requirements-dev.txt @@ -10,5 +10,5 @@ packaging==24.1 # via pytest pluggy==1.5.0 # via pytest -pytest==8.2.2 +pytest==8.3.2 # via -r multi-account/requirements-dev.in diff --git a/backend/multi-account/requirements.txt b/backend/multi-account/requirements.txt index 9ba718637..374576c39 100644 --- a/backend/multi-account/requirements.txt +++ b/backend/multi-account/requirements.txt @@ -14,7 +14,7 @@ aws-cdk-asset-kubectl-v20==2.1.2 # via aws-cdk-lib aws-cdk-asset-node-proxy-agent-v6==2.0.3 # via aws-cdk-lib -aws-cdk-lib==2.148.0 +aws-cdk-lib==2.150.0 # via -r multi-account/requirements.in cattrs==23.2.3 # via jsii