AWS AppSync Pipeline Resolver Templates, Secrets Manager, and External HTTP APIs
UPDATE: As of July 6, 2021, it seems that the AWS Amplify team has updated the tool to support secrets. The official announcement is here: AWS Amplify CLI adds support for storing environment variables and secrets accessed by AWS Lambda functions (amazon.com). The Amplify documentation is here: Functions – Access secret values – Amplify Docs. Therefore, I recommend using this and save yourself the extra work of using Secrets Manager.
I’ve been working with AWS Amplify and AppSync over the last few weeks and it’s truly been a love-hate relationship with both of these.
As an aside, having worked with both application development on AWS and Azure now, my opinion is that Azure is generally more productive. I don’t know if the Azure documentation is better or not because I’ve encountered my fair share of gaps in the Azure documentation (one of the reasons I wrote this post on Functions with JWT authentication), but I know that the AWS documentation — at least as it pertains to Amplify and AppSync — is severely lacking in examples. I think the root cause of the issue is that Azure serverless (Functions) kind of “just works”; the connectivity within the ecosystem is dead simple with function bindings built in and really straight forward inter-service authentication; you just don’t need to muck around as much. On the other hand, the combination of Amplify, AppSync, and CloudFormation is insanely powerful, but also kind of inaccessible given the tremendous breadth required to use the tools. If I were to put it another way, because of the breadth of knowledge required to make use of Amplify and AppSync with CloudFormation, it demands far superior documentation.
The Scenario
Recently, I’ve been trying to figure out how to implement pipeline resovlers in AppSync (as part of an Amplify project).
Consider the following scenario:
- I want to call an external REST API using an AppSync HTTP resolver which allows me to call the REST endpoint without writing a Lambda
- The REST API requires an API key which I want to keep in AWS Secrets Manager
- Therefore, I need to retrieve the API key from Secrets Manager and pass that into my HTTP resolver
This is an optimal solution because it means the secret is not exposed to the client side and I don’t need to write a Lambda to handle what is otherwise just an HTTP POST.
To build this solution, I need to provision the following artifacts in AWS via CloudFormation:
- An AppSync HTTP data source for Secrets Manager (assuming Secrets Manager is already provisioned)
- An AppSync HTTP data source for the REST API
- An AppSync resolver function for getting the secret API key
- An AppSync resolver function for making the REST API call
- An AWS IAM role for the AppSync HTTP data source for Secrets Manager to authenticate the function call from my function to Secrets Manager
- An AppSync pipeline resolver for chaining the call to (3) and (4)
In this particular case, I’m making a REST API call to an endpoint in Azure that requires an API key that I’m keeping in Secrets Manager, but this could be any REST endpoint.
Of course, you could write a Lambda that just encapsulates this series of actions (if you aren’t a masochist like me), but then you wouldn’t really be leveraging all of the power of AppSync to entirely bypass Lambdas.
OK, Where Do We Start?
One would assume that this is a really straight forward use case given that this should be a common pattern in AppSync. Well, you’d be wrong. The best resources I found for this are a series of articles from Josh Kahn:
- Invoking even more AWS services directly from AWS AppSync
- Securely Storing API Secrets for AWS AppSync HTTP Data Sources
- Invoke AWS services directly from AWS AppSync
Josh’s articles were a good foundation, but I struggled to get this scenario working due to the gaps in each of his articles. In particular, the biggest gap is that none of the articles demonstrated how to assemble item 2 and item 5 on that list. So here, I present you the CloudFormation JSON that will provision items 1, 2, and 5:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
{ "AWSTemplateFormatVersion": "2010-09-09", "Description": "Test stack", "Metadata": {}, "Parameters": { "AppSyncApiId": { "Type": "String", "Description": "The id of the AppSync API associated with this project." } }, "Resources": { "AppSyncServiceRoleTest": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "appsync.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }, "Policies": [ { "PolicyName": "AppSyncSecretsManagerTestPolicy", "PolicyDocument": { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "secretsmanager:Get*" ], "Resource": "*" } ] } } ] } }, "SecretsManagerDataSourceTest": { "Type": "AWS::AppSync::DataSource", "Properties": { "ApiId": { "Ref": "AppSyncApiId" }, "Name": "SecretsManager_Test", "Type": "HTTP", "ServiceRoleArn": { "Fn::GetAtt": "AppSyncServiceRoleTest.Arn" }, "HttpConfig": { "Endpoint": { "Fn::Sub": [ "https://secretsmanager.${region}.amazonaws.com", { "region": { "Ref": "AWS::Region" } } ] }, "AuthorizationConfig": { "AuthorizationType": "AWS_IAM", "AwsIamConfig": { "SigningRegion": { "Ref": "AWS::Region" }, "SigningServiceName": "secretsmanager" } } } } }, "AzureQnaDataSourceTest": { "Type": "AWS::AppSync::DataSource", "Properties": { "ApiId": { "Ref": "AppSyncApiId" }, "Name": "AzureQna_Test", "Type": "HTTP", "HttpConfig": { "Endpoint": "https://your-domain-here.cognitiveservices.azure.com" } } } } } |
In this example, the role AppSyncServiceRoleTest is actually the secret sauce. It’s critical because it’s not possible to configure this in the AWS console UI in any way whatsoever (I really hate this); you must define it as part of a CloudFormation template or update it via the AWS CLI.
You’ll notice that the template for the Secrets Manager HTTP data source is slightly more complex. This is necessary to allow the AuthorizationConfig on the SecretsManagerDataSourceTest resource to work by authorizing the HTTP resolver to call into Secrets Manager; without it, pushing this via Amplify will fail with the following error:
I only found this by reducing by iteratively reducing my template down (until I got it to deploy) and then iteratively building it back up.
My opinion is that this really should be more straight forward; the integration between Secrets Manager and AppSync should be transparent as this will promote best practices (storing secrets away from code). Requiring these extra steps and artifacts to do something as simple as accessing a secret from an AppSync resolver function is just asking for less initiated developers to fall into bad habits. Indeed, there is a Github issue on this very topic.
I learned a number of other lessons along the way.
Lesson 1: Use CloudFormation Directly
If you’ve worked with Amplify, you know how mind-numbing it is to perform a push and wait for the results. To be fair, it’s doing a good bit of magic to pull all of the AWS resources together on your behalf, but half my day was spent waiting for it to deploy and dump out some obscure CloudFormation error (see above). To that end, one lesson I learned through this process is that it’s way easier to test by directly uploading your stack definition into CloudFormation instead of using Amplify to push during testing:
If you’re testing something complex or exotic, then create a simplified standalone copy outside of your Amplify project and just test the deployment of the CloudFormation template by itself.
This allows you to bypass the rest of the CloudFormation artifacts generated by Amplify and significantly cut down the time it takes to test tricky CloudFormation scenarios.
Lesson 2: VTL Templates Are Tricky; Test Edits in the Console
When working with VTL templates for the functions, I found that the fastest way to do so was to edit the functions directly in the AWS console since — once again — this avoided a time consuming push operation.
Then you can edit the VTL templates directly:
(Don’t forget to copy your changes back to your codebase!)
To test this, go to the AppSync Queries and test your API call:
Of note is that there is a delay from when you save a change to a function VTL and when this gets picked up by the API call. This can be anywhere from 30-60 seconds in my experience and this caused me a good number of wasted cycles as I kept scratching my head wondering why my templates were not outputting correctly before I realized there was a delay.
What’s perhaps even more confounding is that pipeline resolvers are not visible anywhere in the console so you really want to make sure each of your individual pipelined functions are executing correctly to avoid having to push to test the full chain over and over.
Lesson 3: Be Wary of VTL Examples
The reason why you need to test is that you can’t actually trust any VTL example you find online. I have yet to find one that Just Works; every single time it seems like the example is off in some nuanced way. In this case, Josh’s examples don’t factor that the result from GetSecretValue is actually a JSON object and not a direct string value. So the VTL template of the REST API call should do something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#set( $secrets = $util.parseJson($context.prev.result) ) { "version": "2018-05-29", "method": "POST", "resourcePath": "/qnamaker/v5.0-preview.1/knowledgebases/$context.args.kbId/generateAnswer", "params":{ "body": { "threshold": 25, "top": 3, "question": "$util.escapeJavaScript($context.args.question)" }, "headers": { "Content-Type": "application/json", "Ocp-Apim-Subscription-Key": "$secrets.qnaApiKey" } } } |
See the first line? We take the result from the call to Secrets Manager and convert it into a JS object and then we can access the key-value pairs as $secrets.qnaApiKey below. Here’s what it looks like in Secrets Manager:
Putting It All Together
Here’s how my Amplify project is organized:
Here is the full CustomResources.json CloudFormation template including the resolver functions and pipeline resolver:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
{ "AWSTemplateFormatVersion": "2010-09-09", "Description": "An auto-generated nested stack.", "Metadata": {}, "Parameters": { "AppSyncApiId": { "Type": "String", "Description": "The id of the AppSync API associated with this project." }, "AppSyncApiName": { "Type": "String", "Description": "The name of the AppSync API", "Default": "AppSyncSimpleTransform" }, "env": { "Type": "String", "Description": "The environment name. e.g. Dev, Test, or Production", "Default": "NONE" }, "S3DeploymentBucket": { "Type": "String", "Description": "The S3 bucket containing all deployment assets for the project." }, "S3DeploymentRootKey": { "Type": "String", "Description": "An S3 key relative to the S3DeploymentBucket that points to the root\nof the deployment directory." } }, "Resources": { "AppSyncServiceRole": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "appsync.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }, "Policies": [ { "PolicyName": "AppSyncSecretsManagerTestPolicy", "PolicyDocument": { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "secretsmanager:Get*" ], "Resource": "*" } ] } } ] } }, "SecretsManagerDataSource": { "Type": "AWS::AppSync::DataSource", "Properties": { "ApiId": { "Ref": "AppSyncApiId" }, "Name": "SecretsManager", "Type": "HTTP", "ServiceRoleArn": { "Fn::GetAtt": "AppSyncServiceRole.Arn" }, "HttpConfig": { "Endpoint": { "Fn::Sub": [ "https://secretsmanager.${region}.amazonaws.com", { "region": { "Ref": "AWS::Region" } } ] }, "AuthorizationConfig": { "AuthorizationType": "AWS_IAM", "AwsIamConfig": { "SigningRegion": { "Ref": "AWS::Region" }, "SigningServiceName": "secretsmanager" } } } } }, "AzureQnaDataSource": { "Type": "AWS::AppSync::DataSource", "Properties": { "ApiId": { "Ref": "AppSyncApiId" }, "Name": "AzureQna", "Type": "HTTP", "HttpConfig": { "Endpoint": "https://your-domain-here.cognitiveservices.azure.com" } } }, "GetSecretsValue": { "Type": "AWS::AppSync::FunctionConfiguration", "Properties": { "ApiId": { "Ref": "AppSyncApiId" }, "Name": "GetSecretValue", "Description": "HTTP resolver to get a secret", "DataSourceName": { "Fn::GetAtt": [ "SecretsManagerDataSource", "Name" ] }, "FunctionVersion": "2018-05-29", "RequestMappingTemplateS3Location": { "Fn::Sub": [ "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", { "S3DeploymentBucket": { "Ref": "S3DeploymentBucket" }, "S3DeploymentRootKey": { "Ref": "S3DeploymentRootKey" }, "ResolverFileName": "SecretsManager.req.vtl" } ] }, "ResponseMappingTemplateS3Location": { "Fn::Sub": [ "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", { "S3DeploymentBucket": { "Ref": "S3DeploymentBucket" }, "S3DeploymentRootKey": { "Ref": "S3DeploymentRootKey" }, "ResolverFileName": "SecretsManager.res.vtl" } ] } } }, "AzureQueryKb": { "Type": "AWS::AppSync::FunctionConfiguration", "Properties": { "ApiId": { "Ref": "AppSyncApiId" }, "Name": "QueryKb", "Description": "HTTP resolver to query Azure Q&A API", "DataSourceName": { "Fn::GetAtt": [ "AzureQnaDataSource", "Name" ] }, "FunctionVersion": "2018-05-29", "RequestMappingTemplateS3Location": { "Fn::Sub": [ "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", { "S3DeploymentBucket": { "Ref": "S3DeploymentBucket" }, "S3DeploymentRootKey": { "Ref": "S3DeploymentRootKey" }, "ResolverFileName": "AzureQnaQuery.req.vtl" } ] }, "ResponseMappingTemplateS3Location": { "Fn::Sub": [ "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", { "S3DeploymentBucket": { "Ref": "S3DeploymentBucket" }, "S3DeploymentRootKey": { "Ref": "S3DeploymentRootKey" }, "ResolverFileName": "AzureQnaQuery.res.vtl" } ] } } }, "AzQueryKbPipelineResolver": { "Type": "AWS::AppSync::Resolver", "Properties": { "ApiId": { "Ref": "AppSyncApiId" }, "TypeName": "Query", "Kind": "PIPELINE", "FieldName": "ask", "PipelineConfig": { "Functions": [ { "Fn::GetAtt": [ "GetSecretsValue", "FunctionId" ] }, { "Fn::GetAtt": [ "AzureQueryKb", "FunctionId" ] } ] }, "RequestMappingTemplateS3Location": { "Fn::Sub": [ "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", { "S3DeploymentBucket": { "Ref": "S3DeploymentBucket" }, "S3DeploymentRootKey": { "Ref": "S3DeploymentRootKey" }, "ResolverFileName": "PipelineAzureQnaQuery.req.vtl" } ] }, "ResponseMappingTemplateS3Location": { "Fn::Sub": [ "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/${ResolverFileName}", { "S3DeploymentBucket": { "Ref": "S3DeploymentBucket" }, "S3DeploymentRootKey": { "Ref": "S3DeploymentRootKey" }, "ResolverFileName": "PipelineAzureQnaQuery.res.vtl" } ] } } } }, "Conditions": { "HasEnvironmentParameter": { "Fn::Not": [ { "Fn::Equals": [ { "Ref": "env" }, "NONE" ] } ] }, "AlwaysFalse": { "Fn::Equals": [ "true", "false" ] } }, "Outputs": {} } |
It looks intimidating, but it’s just the 6 components I outlined at the outset pulled together in one file.
Here are the relevant portions of the GQL schema:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# Encapsulates the operations to the knowledge base. type Query { ask( kbId: String!, question: String! ): MutationResult } # Used to generically return a result since it seems like GQL definition requires the resolver to return something. type MutationResult { succeeded: Boolean message: String } |
This is relevant as MutationResult is the target return structure from the AzureQnaQuery.res.vtl (I’m going to replace that with something less generic, I think).
And here are all of the VTL templates:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
##AzureQnaQuery.req.vtl (The HTTP request going out to an Azure REST API endpoint) #set( $secrets = $util.parseJson($context.prev.result) ) { "version": "2018-05-29", "method": "POST", "resourcePath": "/qnamaker/v5.0-preview.1/knowledgebases/$context.args.kbId/generateAnswer", "params":{ "body": { "threshold": 25, "top": 3, "question": "$util.escapeJavaScript($context.args.question)" }, "headers": { "Content-Type": "application/json", "Ocp-Apim-Subscription-Key": "$secrets.qnaApiKey" } } } ##AzureQnaQuery.res.vtl (Shaping the response from that call into MutationResult) "{\"succeeded\": true, \"message\": $util.escapeJavaScript($util.toJson($ctx.result.body))}" ############################### ##PipelineAzureQnaQuery.req.vtl (The request to the full pipeline setting the name of the secret) $util.qr($ctx.stash.put("SecretId", "dev/teams/qna")) {} ##PipelineAzureQnaQuery.res.vtl (The response from the pipeline which is the output of the AzureQnaQuery.req.vtl as the last step in our pipeline) $ctx.result ############################### ##SecretsManager.req.vtl (The HTTP request to the AWS Secrets Manager; note that there is no authorization. The authz is handled by the IAM role) { "version": "2018-05-29", "method": "POST", "resourcePath": "/", "params": { "headers": { "content-type": "application/x-amz-json-1.1", "x-amz-target": "secretsmanager.GetSecretValue" }, "body": { "SecretId": "$ctx.stash.SecretId" } } } ##SecretsManager.res.vtl (Handling the response from the call to GetSecretValue; SecretString is actually a JSON object) #set( $result = $util.parseJson($ctx.result.body) ) $util.toJson($result.SecretString) |
And there you have it. Hopefully this fills in the gaps left by the few samples online of how to put together an AppSync pipeline resolver with Secrets Manager.
The blog is very helpful. I am wondering if I can get the value stored in secret manager directly in http request mapping template without using pipeline. Do you have any idea regarding it?
Thanks
Ramesh,
I do not think that is possible since it is two discrete operations. A simpler approach might be to use as Lambda resolver instead and then use something like axios to fetch the secret and then execute the HTTP request.
If you want to avoid writing a Lambda, then you’ll need to create the pipeline.