Accessing a non-public Amazon Aurora database in a VPC from a Lambda function
The scenarios where we use AWS Lambda functions are growing every day. By default, AWS does not launch Lambda functions within a Virtual Private Cloud (VPC), so they can only connect to public resources accessible through the internet. But for security reasons, many resources are kept inaccessible from the internet and only accessible from within a VPC. Examples of those resources are databases, cache instances, or internal services.
Luckily, AWS Lambda functions allow us to change the default behavior and configure them within a VPC. But there is a catch the Lambda function will lose access to the internet (and many services are accessible from the internet, such as DynamoDB, SNS, SQS, etc.) There are ways to recover access to the internet or specific AWS services, but in general, unless needed, do not set up Lambda function in VPC.
Prerequisites
An Amazon Aurora Server (PostgreSQL compatible edition with IAM Authentication enabled).
An IAM User with programmatic access.
Install AWS CLI.
Install the Amazon Lambda Templates (
dotnet new -i Amazon.Lambda.Templates
).Install the Amazon Lambda Tools (
dotnet tool install -g Amazon.Lambda.Tools
).Install AWS SAM CLI.
Database setup
Create a database (we are using the postgres
user to run the following scripts):
CREATE DATABASE journal
WITH
ENCODING = 'UTF8';
Then a table inside the new database:
CREATE TABLE posts(
id UUID PRIMARY KEY,
title VARCHAR(255),
description VARCHAR(1024)
);
And finally, the database user:
CREATE USER db_iam_user;
GRANT rds_iam TO db_iam_user;
GRANT postgres TO db_iam_user;
Let's code
We will create two AWS Lambda Functions, one to register a post and the other to list them. We will store the data in the database previously created. And to avoid using a password during the connection against the database, we will use IAM Authentication. We will use AWS SAM as a deployment mechanism. Run the following command to create the .NET projects and solution:
dotnet new lambda.EmptyFunction -n RegisterPost -o .
dotnet new lambda.EmptyFunction -n ListPosts -o .
dotnet new classlib -n Library -o src/Library
dotnet new sln -n aurora-sandbox
dotnet sln add --in-root src/RegisterPost
dotnet sln add --in-root src/ListPosts
dotnet sln add --in-root src/Library
Add the following NuGet packages to each project:
dotnet add src/Library package AWSSDK.RDS
dotnet add src/Library package Dapper
dotnet add src/Library package Npgsql
dotnet add src/RegisterPost package Amazon.Lambda.APIGatewayEvents
dotnet add src/ListPosts package Amazon.Lambda.APIGatewayEvents
And the references between projects:
dotnet add src/RegisterPost/RegisterPost.csproj reference src/Library/Library.csproj
dotnet add src/ListPosts/ListPosts.csproj reference src/Library/Library.csproj
Open the solution and under the Library
project, add a Post.cs
file with the following content:
public class Post
{
public Guid Id { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
}
In the same project add a PostsRepository.cs
file as follows:
public class PostsRepository
{
private readonly NpgsqlConnection _connection;
public PostsRepository(string connectionString)
{
_connection = new NpgsqlConnection(connectionString);
_connection.ProvidePasswordCallback = RequestAwsIamAuthToken;
}
private string RequestAwsIamAuthToken(string host, int port, string database, string username)
{
return RDSAuthTokenGenerator.GenerateAuthToken(host, port, username);
}
public Task Create(Post post)
{
return _connection.ExecuteAsync("INSERT INTO public.posts(id, title, description) VALUES (@Id, @Title, @Description)", post);
}
public Task<IEnumerable<Post>> List()
{
return _connection.QueryAsync<Post>("SELECT id, title, description from public.posts");
}
}
Under the RegisterPost
project, modify the Function.cs
file as follow:
public class Function
{
private PostsRepository _repository;
public Function()
{
var host = Environment.GetEnvironmentVariable("DB_HOST");
var database = Environment.GetEnvironmentVariable("DB_NAME");
var user = Environment.GetEnvironmentVariable("DB_USER");
_repository = new PostsRepository($"host={host};Port=5432;Database={database};Username={user};SSL Mode=Require;TrustServerCertificate=true");
}
public async Task<APIGatewayHttpApiV2ProxyResponse> FunctionHandler(APIGatewayHttpApiV2ProxyRequest input, ILambdaContext context)
{
var request = JsonSerializer.Deserialize<RegisterPostRequest>(input.Body)!;
var post = new Post() { Id = Guid.NewGuid(), Description = request.Description, Title = request.Title };
await _repository.Create(post);
var response = JsonSerializer.Serialize(new RegisterPostResponse(post.Id));
return new APIGatewayHttpApiV2ProxyResponse
{
Body = response,
StatusCode = 200,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
public record RegisterPostRequest(string Description, string Title);
public record RegisterPostResponse(Guid Id);
}
Under the LisPosts
project, modify the Function.cs
file as follow:
public class Function
{
private PostsRepository _repository;
public Function()
{
var host = Environment.GetEnvironmentVariable("DB_HOST");
var database = Environment.GetEnvironmentVariable("DB_NAME");
var user = Environment.GetEnvironmentVariable("DB_USER");
_repository = new PostsRepository($"host={host};Port=5432;Database={database};Username={user};SSL Mode=Require;TrustServerCertificate=true");
}
public async Task<APIGatewayHttpApiV2ProxyResponse> FunctionHandler(APIGatewayHttpApiV2ProxyRequest input, ILambdaContext context)
{
var posts = await _repository.List();
var response = JsonSerializer.Serialize(posts);
return new APIGatewayHttpApiV2ProxyResponse
{
Body = response,
StatusCode = 200,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
}
And finally, create a template.yml
file at the solution level:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
Posts API
Parameters:
User:
Type: String
Default: db_iam_user
Description: Database user
Name:
Type: String
Default: journal
Description: Database name
Host:
Type: String
Default: <DATABASE_HOST>
Description: Database host prefix
Cluster:
Type: String
Default: <DATABASE_RESOURCEID>
Description: Database cluster
Vpc:
Type: String
Default: <VPCID>
Description: RDS Vpc
Subnet:
Type: String
Default: <SUBNETID>
Description: RDS Subnet
Globals:
Function:
Timeout: 60
MemorySize: 512
Runtime: dotnet6
Architectures:
- x86_64
Environment:
Variables:
DB_USER: !Ref User
DB_HOST: !Sub "${Host}.${AWS::Region}.rds.amazonaws.com"
DB_NAME: !Ref Name
Resources:
RDSPasswordlessPolicy:
Type: 'AWS::IAM::ManagedPolicy'
Properties:
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- rds-db:connect
Resource: !Sub "arn:aws:rds-db:${AWS::Region}:${AWS::AccountId}:dbuser:${Cluster}/${User}"
LambdaSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: lambda security group
VpcId: !Ref Vpc
RegisterPostFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: dotnet6
Handler: RegisterPost::RegisterPost.Function::FunctionHandler
CodeUri: ./src/RegisterPost/
VpcConfig:
SecurityGroupIds:
- !Ref LambdaSecurityGroup
SubnetIds:
- !Ref Subnet
Policies:
- AWSLambda_FullAccess
- AWSLambdaVPCAccessExecutionRole
- !Ref RDSPasswordlessPolicy
Events:
RegisterPost:
Type: Api
Properties:
Path: /posts
Method: post
ListPostsFunction:
Type: AWS::Serverless::Function
Properties:
Handler: ListPosts::ListPosts.Function::FunctionHandler
CodeUri: ./src/ListPosts/
VpcConfig:
SecurityGroupIds:
- !Ref LambdaSecurityGroup
SubnetIds:
- !Ref Subnet
Policies:
- AWSLambda_FullAccess
- AWSLambdaVPCAccessExecutionRole
- !Ref RDSPasswordlessPolicy
Events:
ListPosts:
Type: Api
Properties:
Path: /posts
Method: get
Outputs:
PostsApi:
Description: "API Gateway endpoint URL for Prod stage for posts api"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/posts/"
To connect the Lambda function to the VPC, we did three things:
Assign the
AWSLambdaVPCAccessExecutionRole
andAWSLambda_FullAccess
policies.Create the
LambdaSecurityGroup
security group(inside our VPC) and use it in our Lambda function(SecurityGroupIds
property).Assign a subnet(the same subnet where our database lives) to the Lambda function(
SubnetIds
property)
To use the IAM Authentication, we created the RDSPasswordlessPolicy
policy and assigned it to the Lambda function. At the solution level, run sam build
to build the serverless application, and then sam deploy --guided
, the command will guide you through the deployment. And that's it; we should have a Lambda function with access to a VPC resource. All the code is available here. Thanks, and happy coding.