Automatically Rotating AWS AppSync API Keys
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