Automatically Rotating AWS AppSync API Keys

2020年10月01日


There are four ways you can authorize applications to interact with your AWS AppSync GraphQL API, including API_KEY, AWS_IAM, OPENID_CONNECT, and AMAZON_COGNITO_USER_POOLS.

When using API key for authentication, we normally store the key in the Secrets Manager service. Then an application code could retrieve the key via API calls, and we can manage key rotation policy in the Secrets Manager as well. The AppSync service is not able to rotated the key automatically.

To automatically rotate the API key, we need to write code to achieve the goal.

To illustrate the process, I'm going to host the code in the Lambda function, and let the Secrets Manger to invoke the Lambda function with a pre-defined rotation interval.

Prerequisites
Make sure you have below resources in place before move to the rest of this post.

  • An AppSync API
  • A secret in Secrets Manager with a secret configured
  • A VPC endpoint to access Secrets Manager without going out of VPC

Configure IAM role

First, we create an IAM role to be attached to a Lambda function, which we will create in the next step and use it for rotating the AppSyc API keys. This role should have below policy document associated to it:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "appsync:CreateApiKey",
                "appsync:DeleteApiKey",
                "secretsmanager:GetRandomPassword",
                "secretsmanager:GetSecretValue",
                "secretsmanager:DescribeSecret",
                "secretsmanager:PutSecretValue",
                "secretsmanager:UpdateSecretVersionStage"
            ],
            "Resource": "*"
        }
    ]
}
Also, you need to associate the manage policy "AWSLambdaVPCAccessExecutionRole", to make sure logs could be written into the CloudWatch Logs. This policy is also necessary for Lambda function, if it is deployed to a VPC environment.


Create Lambda Function

Second, create a Lambda function. Associate the IAM role created in the first step with this Lambda function. In this example, we use runtime of Python 3.8. You can use any language that is convenient for you. The Lambda is deployed into VPC.


Don't worry if you could not access above block.


Create two environment variables in the Lambda function. One uses a key called "AppSyncApiId", and its value is the API ID of the AppSync API. The other uses a key called "SECRETS_MANAGER_ENDPOINT", and its value is the DNS name of the VPC endpoint of the Secrets Manager.

The Python code:
import boto3
import json
import logging
import os


logger = logging.getLogger()
logger.setLevel(logging.INFO)

AppSyncApiId = os.environ['AppSyncApiId']

service_client = boto3.client(
    'secretsmanager', 
    endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT']
)
appsync_client = boto3.client('appsync')


def lambda_handler(event, context):
    """Secrets Manager Rotation Template
    This is a template for creating an AWS Secrets Manager rotation lambda
    Args:
        event (dict): Lambda dictionary of event parameters. These keys 
        must include the following:
            - SecretId: The secret ARN or identifier
            - ClientRequestToken: The ClientRequestToken of the secret version
            - Step: The rotation step (one of createSecret, setSecret, 
            testSecret, or finishSecret)
        context (LambdaContext): The Lambda runtime information
    Raises:
        ResourceNotFoundException: If the secret with the specified arn 
        and stage does not exist
        ValueError: If the secret is not properly configured for rotation
        KeyError: If the event parameters do not contain the expected keys
    """
    
    arn = event['SecretId']
    token = event['ClientRequestToken']
    step = event['Step']

    # Make sure the version is staged correctly
    metadata = service_client.describe_secret(SecretId=arn)
    if not metadata['RotationEnabled']:
        logger.error("Secret %s is not enabled for rotation" % arn)
        raise ValueError("Secret %s is not enabled for rotation" % arn)
    versions = metadata['VersionIdsToStages']
    if token not in versions:
        logger.error("Secret version %s has no stage for rotation of secret %s." % (token, arn))
        raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn))
    if "AWSCURRENT" in versions[token]:
        logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn))
        return
    elif "AWSPENDING" not in versions[token]:
        logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn))
        raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn))

    if step == "createSecret":
        create_secret(service_client, arn, token)

    elif step == "setSecret":
        set_secret(service_client, arn, token)

    elif step == "testSecret":
        test_secret(service_client, arn, token)

    elif step == "finishSecret":
        finish_secret(service_client, arn, token)

    else:
        raise ValueError("Invalid step parameter")


def create_secret(service_client, arn, token):
    """Create the secret
    This method first checks for the existence of a secret for the passed 
    in token. If one does not exist, it will generate a
    new secret and put it with the passed in token.
    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
    Raises:
        ResourceNotFoundException: If the secret with the specified arn and stage does not exist
    """
    # Make sure the current secret exists
    service_client.get_secret_value(SecretId=arn, VersionStage="AWSCURRENT")

    # Now try to get the secret version, if that fails, put a new secret
    try:
        service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING")
        logger.info("createSecret: Successfully retrieved secret for %s." % arn)
    except service_client.exceptions.ResourceNotFoundException:
        # Get exclude characters from environment variable
        exclude_characters = os.environ['EXCLUDE_CHARACTERS'] if 'EXCLUDE_CHARACTERS' in os.environ else '/@"\'\\'
        # Generate a random password
        passwd = service_client.get_random_password(ExcludeCharacters=exclude_characters)

        # Put the secret
        service_client.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=passwd['RandomPassword'], VersionStages=['AWSPENDING'])
        logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token))


def set_secret(service_client, arn, token):
    """
    This method should set the AWSPENDING secret in the service that the 
    secret belongs to. For example, if the secret is a database
    credential, this method should take the value of the AWSPENDING 
    secret and set the user's password to this value in the database.
    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
    """
    # This is where the secret should be set in the service
    
    try:
        secret_value = service_client.get_secret_value(
            SecretId=arn
        )
        apikey = json.loads(secret_value['SecretString'])["APIKEY"]
        
        # Generate new API key in AppSync service
        new_api_key = appsync_client.create_api_key(
            apiId=AppSyncApiId
        )['apiKey']['id']
        
        # Store the API key in Secrets Manager
        service_client.put_secret_value(
            SecretId=arn,
            SecretString=json.dumps({"APIKEY": new_api_key}),
            VersionStages=[
                "AWSCURRENT",
            ]
        )
        logger.info("setSecret: Successfully set secret for %s in Secrets Manager." % arn)
        
        # Delete the old API key in AppSync service, after successfully 
        # create the API key
        appsync_client.delete_api_key(
            apiId=AppSyncApiId,
            id=apikey
        )
        
    except Exception as e:
        print(e)
        raise NotImplementedError


def test_secret(service_client, arn, token):
    """Test the secret
    This method should validate that the AWSPENDING secret works in the 
    service that the secret belongs to. For example, if the secret
    is a database credential, this method should validate that the user can 
    login with the password in AWSPENDING and that the user has
    all of the expected permissions against the database.
    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
    """
    logger.info("Entered function: test_secret")
    # This is where the secret should be tested against the service
    raise NotImplementedError


def finish_secret(service_client, arn, token):
    """Finish the secret
    This method finalizes the rotation process by marking the secret 
    version passed in as the AWSCURRENT secret.
    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
    Raises:
        ResourceNotFoundException: If the secret with the specified arn 
        does not exist
    """
    # First describe the secret to get the current version
    metadata = service_client.describe_secret(SecretId=arn)
    current_version = None
    for version in metadata["VersionIdsToStages"]:
        if "AWSCURRENT" in metadata["VersionIdsToStages"][version]:
            if version == token:
                # The correct version is already marked as current, return
                logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn))
                return
            current_version = version
            break

    # Finalize by staging the secret version current
    service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version)
    logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (token, arn))


Grant Permission

The following command grants Secrets Manager permission to call the Lambda function.
~]$ aws lambda add-permission --function-name [Your Lambda Function Name] --action lambda:InvokeFunction --statement-id secretsmanager --principal secretsmanager.amazonaws.com
{
    "Statement": "{\"Sid\":\"secretsmanager\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"secretsmanager.amazonaws.com\"},\"Action\":\"lambda:InvokeFunction\",\"Resource\":\"arn:aws:lambda:us-west-2:123456789012:function:[LambdaFunctionName]\"}"
}


Configure Secret Rotation



If encount below error, make sure the permission is granted. Refer to the third step for guidance.



Test



Check AppSync, see if the API key has been actually rotated.


Check the Secrets Manager, see if the secret key has been updated there.


We noticed that both the AppSync API key and the secret in Secrets Manager have been successfully updated.


Wrap Up
To wrap up, we built a powerful solution to rotate the API key of the AppSync API. We accomplished it from several perspects:
  • Security: The security is reinforced by using a VPC endpoint to avoid sensitive traffic flow out of the VPC environment. We followed the least privilege principle and defined the IAM policy to only necessary API permissions. The logs that Lambda write to the CloudWatch Logs do not include any sensitive data, i.e. API keys and etc.. The security group around the VPC endpoint for Secrets Manager service only allows traffic from Lambda subnets to its HTTPS 443 port.
  • Simplicity: We used managed services to achieve the goal, including Secrets Manager and Lambda function. We developed the workflow using Python code and built the solution very fast.


References

GitHub - aws-samples / aws-secrets-manager-rotation-lambdas

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/appsync.html

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/secretsmanager.html

AppSync Developer Guide - Security

Rotating AWS Secrets Manager secrets for other databases or services

AWS service endpoints

Using resource-based policies for AWS Lambda

Overview of the Lambda rotation function



Category: AWS Tags: public

Upvote


Downvote