diff --git a/lib/workload/stateful/token_service/README.md b/lib/workload/stateful/token_service/README.md index 688c0d987..8b5791b9a 100644 --- a/lib/workload/stateful/token_service/README.md +++ b/lib/workload/stateful/token_service/README.md @@ -2,7 +2,9 @@ This service provides the JWT token for API authentication and authorization (AAI) purpose. We use the Cognito as AAI service broker and, it is set up at our infrastructure repo. This service maintains 2 secrets with rotation enabled. -## JWT +## User Guide + +### JWT For most cases, you would want to lookup JWT token from the secret manager at the following coordinate. ``` @@ -17,7 +19,7 @@ import boto3 sm_client = boto3.client('secretsmanager') -resp = sm_client().get_secret_value(SecretId='orcabus/token-service-jwt') +resp = sm_client.get_secret_value(SecretId='orcabus/token-service-jwt') jwt_json = resp['SecretString'] jwt_dict = json.loads(jwt_json) @@ -34,7 +36,7 @@ from libumccr.aws import libsm tok = json.loads(libsm.get_secret('orcabus/token-service-jwt'))['id_token'] ``` -## Service User +### Service User As an admin, you must register the service user. This has to be done at Cognito AAI terraform stack. Please follow `AdminCreateUser` [flow noted](https://github.com/umccr/infrastructure/pull/412/files) in `users.tf` at upstream infrastructure repo. @@ -55,9 +57,7 @@ aws secretsmanager put-secret-value \ After then, the scheduled secret rotation should carry on rotating password, every set days. ---- - -## Stack +## Development ### TL;DR @@ -77,7 +77,7 @@ The stack contains 2 Lambda Python code that do secret rotation. This code is de ### Cognitor And, there is the thin service layer package called `cognitor` for interfacing with AWS Cognito through boto3 - in fact it just [a façade](https://www.google.com/search?q=fa%C3%A7ade+pattern) of boto3 for Cognito. See its test cases for how to use and operate it. -### Local Dev +### Local DX #### App @@ -136,7 +136,7 @@ Then, do CloudFormation lint check: cfn-lint .local/template.yml ``` -If that all good, then you may diff e & push straight to dev for giving it the WIP a try... +If that all good, then you may `diff -e` & `deploy -e` straight to dev for giving it the WIP a try... ``` export AWS_PROFILE=umccr-dev-admin diff --git a/lib/workload/stateful/token_service/deploy/construct/policy/index.ts b/lib/workload/stateful/token_service/deploy/construct/policy/index.ts index 8212104cb..753cdfcb7 100644 --- a/lib/workload/stateful/token_service/deploy/construct/policy/index.ts +++ b/lib/workload/stateful/token_service/deploy/construct/policy/index.ts @@ -12,7 +12,6 @@ export function getLambdaBasicExecPolicy(resources: string[]) { * https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AWSLambdaBasicExecutionRole.html */ return new PolicyStatement({ - sid: 'LambdaBasicExecStmt1711498867457', effect: Effect.ALLOW, actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], resources: resources, @@ -31,7 +30,6 @@ export function getLambdaVPCPolicy() { * https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AWSLambdaVPCAccessExecutionRole.html */ return new PolicyStatement({ - sid: 'LambdaVPCStmt1711498867457', effect: Effect.ALLOW, actions: [ 'ec2:CreateNetworkInterface', @@ -51,7 +49,6 @@ export function getSSMPolicy(resources: string[]) { * https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AmazonSSMReadOnlyAccess.html */ return new PolicyStatement({ - sid: 'SSMStmt1711498867457', effect: Effect.ALLOW, actions: ['ssm:Describe*', 'ssm:Get*', 'ssm:List*'], resources: resources, @@ -70,8 +67,6 @@ export const getCognitoAdminActions = () => { 'cognito-idp:DescribeUserPool', 'cognito-idp:AdminGetUser', 'cognito-idp:AdminSetUserPassword', - 'cognito-idp:InitiateAuth', - 'cognito-idp:ListUserPools', 'cognito-idp:ListUsers', ]; }; @@ -81,29 +76,28 @@ export function getCognitoAdminPolicy(resources: string[]) { * The Cognito policy that specifically required for Token Service `cognitor.py` application */ return new PolicyStatement({ - sid: 'CognitoAdminStmt1711498867457', effect: Effect.ALLOW, actions: getCognitoAdminActions(), resources: resources, }); } -export const getCognitoJWTActions = () => { - /** - * Always return new string array of permission flags that is allowed. - */ - return ['cognito-idp:InitiateAuth']; -}; - -export function getCognitoJWTPolicy(resources: string[]) { +export function getCognitoJWTPolicy() { /** + * NOTE: This function only tailors to policy statement for `InitiateAuth` API call. This API + * endpoint is `public` call by design. See `Unauthenticated user operations` section, below. + * https://docs.aws.amazon.com/cognito/latest/developerguide/user-pools-API-operations.html + * + * Therefore, the resource-level permission filtering is not needed/supported. This is try with + * Policy Simulator. See thread in `#orcabus` channel for howto. + * https://umccr.slack.com/archives/C03ABJTSN7J/p1711576104287009 + * * The Cognito policy that specifically required for Token Service `cognitor.py` application */ return new PolicyStatement({ - sid: 'CognitoJWTStmt1711498867457', effect: Effect.ALLOW, - actions: getCognitoJWTActions(), - resources: resources, + actions: ['cognito-idp:InitiateAuth'], + resources: ['*'], // see docstring ^^ }); } diff --git a/lib/workload/stateful/token_service/deploy/stack.ts b/lib/workload/stateful/token_service/deploy/stack.ts index 55de657ba..599b467fe 100644 --- a/lib/workload/stateful/token_service/deploy/stack.ts +++ b/lib/workload/stateful/token_service/deploy/stack.ts @@ -11,7 +11,7 @@ import { LogGroup } from 'aws-cdk-lib/aws-logs'; import { IVpc, Vpc } from 'aws-cdk-lib/aws-ec2'; import { getCognitoAdminActions, - getCognitoJWTActions, + getCognitoJWTPolicy, getLambdaVPCPolicy, getServiceUserSecretResourcePolicy, } from './construct/policy'; @@ -127,8 +127,7 @@ export class TokenServiceStack extends Stack { }); lambdaLogGroup.grantWrite(jwtRotationFn); - this.userPool.grant(jwtRotationFn, ...getCognitoJWTActions()); - + jwtRotationFn.addToRolePolicy(getCognitoJWTPolicy()); jwtRotationFn.addToRolePolicy(getLambdaVPCPolicy()); return jwtRotationFn; diff --git a/lib/workload/stateful/token_service/token_service/cognitor/__init__.py b/lib/workload/stateful/token_service/token_service/cognitor/__init__.py index a556b6d8f..e65f4f153 100644 --- a/lib/workload/stateful/token_service/token_service/cognitor/__init__.py +++ b/lib/workload/stateful/token_service/token_service/cognitor/__init__.py @@ -22,8 +22,14 @@ def __init__(self, user_pool_id: str, user_pool_app_client_id: str): @staticmethod def generate_password(length: int = 32) -> str: + """ + Must meet the Cognito password requirements policy + https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-policies.html + """ + if length < 8: + raise ValueError('Length must be at least 8 characters or more better') return ''.join( - random.SystemRandom().choice(string.ascii_letters + string.digits + '!@#$%&.') for _ in range(length) + random.SystemRandom().choice(string.ascii_letters + string.digits + '_-!@#%&.') for _ in range(length) ) def list_users(self, **kwargs) -> dict: @@ -135,10 +141,7 @@ def rotate_service_user_password(self, user_dto: ServiceUserDto): """ Forced set Cognito Service User login credential info from pass-in dto """ - if not self.username_exists(user_dto.username): - return - - _ = self.client.admin_set_user_password( + self.client.admin_set_user_password( UserPoolId=self.user_pool_id, Username=user_dto.username, Password=user_dto.password, diff --git a/lib/workload/stateful/token_service/token_service/cognitor/tests.py b/lib/workload/stateful/token_service/token_service/cognitor/tests.py index 32e034d8e..d558ebb8d 100644 --- a/lib/workload/stateful/token_service/token_service/cognitor/tests.py +++ b/lib/workload/stateful/token_service/token_service/cognitor/tests.py @@ -194,7 +194,6 @@ def test_rotate_service_user_password(self): python -m unittest token_service.cognitor.tests.CognitorUnitTest.test_rotate_service_user_password """ with Stubber(self.mock_client) as stubber: - stubber.add_response('admin_get_user', {'Username': self.jjb_dto.username, }) stubber.add_response('admin_set_user_password', {}) self.srv.rotate_service_user_password(user_dto=self.jjb_dto) diff --git a/lib/workload/stateful/token_service/token_service/helper.py b/lib/workload/stateful/token_service/token_service/helper.py deleted file mode 100644 index ddc4d17db..000000000 --- a/lib/workload/stateful/token_service/token_service/helper.py +++ /dev/null @@ -1,44 +0,0 @@ -import json - - -def get_secret_dict(service_client, arn, stage, token=None): - """Gets the secret dictionary corresponding for the secret arn, stage, and token - - This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the - JSON string - - Args: - service_client (client): The secrets manager service client - - arn (string): The secret ARN or other identifier - - token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired - - stage (string): The stage identifying the secret version - - Returns: - SecretDictionary: Secret dictionary - - Raises: - ResourceNotFoundException: If the secret with the specified arn and stage does not exist - - ValueError: If the secret is not valid JSON - - """ - required_fields = ['username', 'password'] - - # Only do VersionId validation against the stage if a token is passed in - if token: - secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage) - else: - secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage) - plaintext = secret['SecretString'] - secret_dict = json.loads(plaintext) - - # Run validations against the secret - for field in required_fields: - if field not in secret_dict: - raise KeyError("%s key is missing from secret JSON" % field) - - # Parse and return the secret JSON string - return secret_dict diff --git a/lib/workload/stateful/token_service/token_service/rotate_service_jwt.py b/lib/workload/stateful/token_service/token_service/rotate_service_jwt.py index 4de13ab77..27ba44745 100644 --- a/lib/workload/stateful/token_service/token_service/rotate_service_jwt.py +++ b/lib/workload/stateful/token_service/token_service/rotate_service_jwt.py @@ -21,7 +21,6 @@ import boto3 from cognitor import CognitoTokenService, ServiceUserDto -from helper import get_secret_dict logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -123,15 +122,16 @@ def create_secret(service_client, arn, token): """ # Make sure the current secret exists - current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") + current_dict = _get_secret_dict(service_client, arn, "AWSCURRENT") # Now try to get the secret version, if that fails, put a new secret try: - get_secret_dict(service_client, arn, "AWSPENDING", token) + _get_secret_dict(service_client, arn, "AWSPENDING", token) logger.info("createSecret: Successfully retrieved secret for %s." % arn) except service_client.exceptions.ResourceNotFoundException: # Get Cognito Service User login credential info from another (peer) rotating secret from Secret Manager - service_user_info = service_client.get_secret_value(SecretId=service_user_secret_id) + resp = service_client.get_secret_value(SecretId=service_user_secret_id) + service_user_info = json.loads(resp['SecretString']) # Generate new token object current_dict = token_srv.generate_service_user_tokens( user_dto=ServiceUserDto( @@ -168,11 +168,11 @@ def set_secret(service_client, arn, token): """ try: - previous_dict = get_secret_dict(service_client, arn, "AWSPREVIOUS") + previous_dict = _get_secret_dict(service_client, arn, "AWSPREVIOUS") except (service_client.exceptions.ResourceNotFoundException, KeyError): previous_dict = None - current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") - pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) + current_dict = _get_secret_dict(service_client, arn, "AWSCURRENT") + pending_dict = _get_secret_dict(service_client, arn, "AWSPENDING", token) # First try to login with the pending secret, if it succeeds, return id_token = pending_dict['id_token'] @@ -219,7 +219,7 @@ def test_secret(service_client, arn, token): """ # Try to login with the pending secret, if it succeeds, return - pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) + pending_dict = _get_secret_dict(service_client, arn, "AWSPENDING", token) id_token = pending_dict['id_token'] if _is_valid_jwt(id_token): # Being able to generate tokens using pending username/password consider success. @@ -263,6 +263,49 @@ def finish_secret(service_client, arn, token): # --- module internal functions +def _get_secret_dict(service_client, arn, stage, token=None): + """Gets the secret dictionary corresponding for the secret arn, stage, and token + + This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the + JSON string + + Args: + service_client (client): The secrets manager service client + + arn (string): The secret ARN or other identifier + + token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired + + stage (string): The stage identifying the secret version + + Returns: + SecretDictionary: Secret dictionary + + Raises: + ResourceNotFoundException: If the secret with the specified arn and stage does not exist + + ValueError: If the secret is not valid JSON + + """ + required_fields = ['id_token'] + + # Only do VersionId validation against the stage if a token is passed in + if token: + secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage) + else: + secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage) + plaintext = secret['SecretString'] + secret_dict = json.loads(plaintext) + + # Run validations against the secret + for field in required_fields: + if field not in secret_dict: + raise KeyError("%s key is missing from secret JSON" % field) + + # Parse and return the secret JSON string + return secret_dict + + def _is_valid_jwt(this_id_token: str) -> bool: """ The following should be a good self-contained JWT check for now. diff --git a/lib/workload/stateful/token_service/token_service/rotate_service_user.py b/lib/workload/stateful/token_service/token_service/rotate_service_user.py index 2870c9709..8dcff78c3 100644 --- a/lib/workload/stateful/token_service/token_service/rotate_service_user.py +++ b/lib/workload/stateful/token_service/token_service/rotate_service_user.py @@ -19,7 +19,6 @@ import boto3 from cognitor import CognitoTokenService, ServiceUserDto -from helper import get_secret_dict logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -117,11 +116,11 @@ def create_secret(service_client, arn, token): """ # Make sure the current secret exists - current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") + current_dict = _get_secret_dict(service_client, arn, "AWSCURRENT") # Now try to get the secret version, if that fails, put a new secret try: - get_secret_dict(service_client, arn, "AWSPENDING", token) + _get_secret_dict(service_client, arn, "AWSPENDING", token) logger.info("createSecret: Successfully retrieved secret for %s." % arn) except service_client.exceptions.ResourceNotFoundException: # Generate a random password @@ -154,49 +153,43 @@ def set_secret(service_client, arn, token): """ try: - previous_dict = get_secret_dict(service_client, arn, "AWSPREVIOUS") + previous_dict = _get_secret_dict(service_client, arn, "AWSPREVIOUS") except (service_client.exceptions.ResourceNotFoundException, KeyError): previous_dict = None - current_dict = get_secret_dict(service_client, arn, "AWSCURRENT") - pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) + current_dict = _get_secret_dict(service_client, arn, "AWSCURRENT") + pending_dict = _get_secret_dict(service_client, arn, "AWSPENDING", token) - # First try to login with the pending secret, if it succeeds, return - tokens = _generate_new_tokens_for(pending_dict) - if _is_valid(tokens): + # First try to rotate using pending secret, if it succeeds, return + is_rotated = _rotate(pending_dict, arn) + if is_rotated: logger.info("setSecret: AWSPENDING secret is already set as password in Cognito User for secret arn %s." % arn) return + # If rotation with pending secret didn't work then fall back to current secret + # Make sure the user from current and pending match if current_dict['username'] != pending_dict['username']: logger.error("setSecret: Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) raise ValueError("Attempting to modify user %s other than current user %s" % (pending_dict['username'], current_dict['username'])) # Now try the current password - tokens = _generate_new_tokens_for(current_dict) + is_rotated = _rotate(current_dict, arn) - # If both current and pending do not work, try previous - if not _is_valid(tokens) and previous_dict: - - tokens = _generate_new_tokens_for(previous_dict) + # If both current and pending do not work, try previous as last resort + if not is_rotated and previous_dict: # Make sure the user/host from previous and pending match if previous_dict['username'] != pending_dict['username']: logger.error("setSecret: Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) raise ValueError("Attempting to modify user %s other than previous valid user %s" % (pending_dict['username'], previous_dict['username'])) + is_rotated = _rotate(previous_dict, arn) + # If we still don't have a connection, raise a ValueError - if not _is_valid(tokens): + if not is_rotated: logger.error("setSecret: Unable to log into Cognito with previous, current, or pending secret of secret arn %s" % arn) raise ValueError("Unable to log into Cognito with previous, current, or pending secret of secret arn %s" % arn) - # Now set the password to the pending password - token_srv.rotate_service_user_password(user_dto=ServiceUserDto( - username=pending_dict['username'], - password=pending_dict['password'], - email=pending_dict['email'], - )) - logger.info("setSecret: Successfully set password for user %s in Cognitor for secret arn %s." % (pending_dict['username'], arn)) - def test_secret(service_client, arn, token): """Test the pending secret against the database @@ -220,8 +213,8 @@ def test_secret(service_client, arn, token): """ # Try to login with the pending secret, if it succeeds, return - pending_dict = get_secret_dict(service_client, arn, "AWSPENDING", token) - tokens = _generate_new_tokens_for(pending_dict) + pending_dict = _get_secret_dict(service_client, arn, "AWSPENDING", token) + tokens = _generate_new_tokens_for(pending_dict) # test whether we can generate tokens using pending secret if _is_valid(tokens): # Being able to generate tokens using pending username/password consider success. logger.info("testSecret: Successfully signed into Cognito with AWSPENDING secret in %s." % arn) @@ -264,6 +257,66 @@ def finish_secret(service_client, arn, token): # --- module internal functions +def _get_secret_dict(service_client, arn, stage, token=None): + """Gets the secret dictionary corresponding for the secret arn, stage, and token + + This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the + JSON string + + Args: + service_client (client): The secrets manager service client + + arn (string): The secret ARN or other identifier + + token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired + + stage (string): The stage identifying the secret version + + Returns: + SecretDictionary: Secret dictionary + + Raises: + ResourceNotFoundException: If the secret with the specified arn and stage does not exist + + ValueError: If the secret is not valid JSON + + """ + required_fields = ['username', 'password'] + + # Only do VersionId validation against the stage if a token is passed in + if token: + secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage) + else: + secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage) + plaintext = secret['SecretString'] + secret_dict = json.loads(plaintext) + + # Run validations against the secret + for field in required_fields: + if field not in secret_dict: + raise KeyError("%s key is missing from secret JSON" % field) + + # Parse and return the secret JSON string + return secret_dict + + +def _rotate(this_dict, arn) -> bool: + """ + Try to rotate user password in Cognito. If any exception is raised, returns False. Otherwise, returns True. + """ + try: + token_srv.rotate_service_user_password(user_dto=ServiceUserDto( + username=this_dict['username'], + password=this_dict['password'], + email=this_dict['email'], + )) + logger.info("setSecret: Successfully set password for user %s in Cognitor for secret arn %s." % (this_dict['username'], arn)) + return True + except Exception as e: + logger.error(e) + return False + + def _generate_new_tokens_for(this_dict: dict) -> dict: """ Generate JWT (id_token, refresh_token, access_token, ...) tokens for the given Service User `this_dict` credential.