Skip to content

Commit 9501053

Browse files
authored
Merge pull request #35 from datafold/chiel-feature-github-reverse-proxy
feat: Add GitHub reverse proxy support
2 parents 4dae432 + 5a7e5a5 commit 9501053

File tree

12 files changed

+538
-0
lines changed

12 files changed

+538
-0
lines changed

main.tf

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,21 @@ resource "random_password" "redis_password" {
300300
length = 12
301301
special = false
302302
}
303+
304+
module "github_reverse_proxy" {
305+
count = var.deploy_github_reverse_proxy ? 1 : 0
306+
307+
source = "./modules/github_reverse_proxy"
308+
309+
deployment_name = var.deployment_name
310+
environment = var.environment
311+
region = var.provider_region
312+
vpc_cidr = local.vpc_cidr
313+
vpc_id = local.vpc_id
314+
vpc_private_subnets = local.vpc_private_subnets
315+
github_cidrs = var.github_cidrs
316+
datadog_api_key = var.datadog_api_key
317+
use_private_egress = var.lb_internal
318+
319+
private_system_endpoint = module.load_balancer.load_balancer_dns
320+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# AWS Lambda in VPC with API Gateway and Least Privilege Access
2+
3+
To facilitate an internal load balancer, GitHub webhooks still need to have a way to reach the application. This is achieved by creating a reverse proxy for GitHub webhooks. This Terraform module deploys an AWS Lambda function inside a VPC, integrates it with API Gateway, and ensures least privilege access. The Lambda function receives webhooks from GitHub, processes them, and forwards them to a private system inside a VPC.
4+
5+
## Features
6+
- Deploys an AWS Lambda function inside a VPC.
7+
- Integrates the Lambda function with API Gateway for receiving webhooks.
8+
- Limits access to the API Gateway only to request from GitHub CIDR ranges.
9+
- Configures the necessary IAM roles, including the `AWSLambdaVPCAccessExecutionRole`.
10+
- Implements a custom deny policy to prevent the Lambda function from making EC2 network-related API calls.
11+
- DataDog Lambda extension for logging and monitoring.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
resource "aws_iam_role" "lambda_role" {
2+
name = "${var.deployment_name}-lambda-github-webhook-role"
3+
4+
assume_role_policy = jsonencode({
5+
Version = "2012-10-17",
6+
Statement = [{
7+
Action = "sts:AssumeRole",
8+
Effect = "Allow",
9+
Principal = {
10+
Service = "lambda.amazonaws.com"
11+
}
12+
}]
13+
})
14+
}
15+
16+
resource "aws_iam_policy" "lambda_secrets_policy" {
17+
name = "lambda-secrets-policy"
18+
policy = jsonencode({
19+
Version: "2012-10-17",
20+
Statement: [
21+
{
22+
Effect: "Allow",
23+
Action: "secretsmanager:GetSecretValue",
24+
Resource: aws_secretsmanager_secret.datadog_api_key.arn
25+
}
26+
]
27+
})
28+
}
29+
30+
# Attach the policy to the Lambda execution role
31+
resource "aws_iam_role_policy_attachment" "lambda_secrets_policy_attachment" {
32+
role = aws_iam_role.lambda_role.name
33+
policy_arn = aws_iam_policy.lambda_secrets_policy.arn
34+
}
35+
36+
resource "aws_iam_role_policy_attachment" "lambda_vpc_access_policy" {
37+
role = aws_iam_role.lambda_role.name
38+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
39+
}
40+
41+
resource "aws_iam_role_policy_attachment" "lambda_logging_policy" {
42+
role = aws_iam_role.lambda_role.name
43+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
44+
}
45+
46+
resource "aws_iam_policy" "lambda_describe_vpc_policy" {
47+
name = "${var.deployment_name}-lambda-describe-vpc-policy"
48+
policy = jsonencode({
49+
Version = "2012-10-17",
50+
Statement = [
51+
{
52+
Effect = "Allow",
53+
Action = [
54+
"ec2:DescribeSecurityGroups",
55+
"ec2:DescribeSubnets",
56+
"ec2:DescribeVpcs"
57+
],
58+
Resource = "*"
59+
}
60+
]
61+
})
62+
}
63+
64+
resource "aws_iam_role_policy_attachment" "lambda_describe_vpc_attachment" {
65+
role = aws_iam_role.lambda_role.name
66+
policy_arn = aws_iam_policy.lambda_describe_vpc_policy.arn
67+
}
68+
69+
# https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html#configuration-vpc-best-practice
70+
resource "aws_iam_policy" "lambda_deny_ec2_policy" {
71+
name = "${var.deployment_name}-lambda-deny-ec2-network-interface-policy"
72+
policy = jsonencode({
73+
Version = "2012-10-17",
74+
Statement = [
75+
{
76+
Effect = "Deny",
77+
Action = [
78+
"ec2:CreateNetworkInterface",
79+
"ec2:DeleteNetworkInterface",
80+
"ec2:DescribeNetworkInterfaces",
81+
"ec2:DetachNetworkInterface",
82+
"ec2:AssignPrivateIpAddresses",
83+
"ec2:UnassignPrivateIpAddresses"
84+
],
85+
Resource = "*",
86+
Condition = {
87+
"ArnEquals": {
88+
"lambda:SourceFunctionArn": [
89+
"arn:aws:lambda:${data.aws_caller_identity.current.account_id}:function:${aws_lambda_alias.prod_alias.function_name}"
90+
]
91+
}
92+
}
93+
}
94+
]
95+
})
96+
}
97+
98+
resource "aws_iam_role_policy_attachment" "lambda_deny_ec2_attachment" {
99+
role = aws_iam_role.lambda_role.name
100+
policy_arn = aws_iam_policy.lambda_deny_ec2_policy.arn
101+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import json
2+
import logging
3+
import os
4+
import urllib3
5+
6+
7+
if os.environ.get('DATADOG_MONITORING_ENABLED', None):
8+
from datadog_lambda.logger import initialize_logging
9+
initialize_logging(__name__)
10+
logger = logging.getLogger(__name__)
11+
else:
12+
logger = logging.getLogger()
13+
logger.setLevel(logging.INFO)
14+
15+
16+
def forward_to_private_system(data, private_endpoint, headers):
17+
"""
18+
Forward the data to the private system endpoint.
19+
"""
20+
http = urllib3.PoolManager(cert_reqs='CERT_NONE') # Disable SSL certificate verification
21+
response = http.request(
22+
'POST', private_endpoint, body=data, headers=headers, assert_same_host=False
23+
)
24+
return response.status, response.data
25+
26+
def lambda_handler(event, context):
27+
private_system_endpoint = os.getenv('PRIVATE_SYSTEM_ENDPOINT')
28+
logger.info(f"Private system endpoint: {private_system_endpoint}")
29+
30+
body = event.get('body', '')
31+
if not body:
32+
logger.error("No body found in the event")
33+
return {
34+
'statusCode': 400,
35+
'body': json.dumps('Bad Request: No payload found')
36+
}
37+
incoming_headers = event.get('headers', {})
38+
39+
logger.info("Starting to forward the payload")
40+
status, response = forward_to_private_system(
41+
body, private_system_endpoint, incoming_headers
42+
)
43+
logger.info(f"Forwarding status: {status}")
44+
45+
if status == 200:
46+
return {
47+
'statusCode': 200,
48+
'body': json.dumps('Webhook processed and forwarded')
49+
}
50+
else:
51+
logger.error(f"Error forwarding to private system ({status}): {response}")
52+
return {
53+
'statusCode': 500,
54+
'body': json.dumps('Internal Server Error: Failed to forward webhook')
55+
}
875 Bytes
Binary file not shown.
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
data "aws_caller_identity" "current" {}
2+
data "aws_region" "current" {}
3+
4+
data "archive_file" "zip_lambda_function" {
5+
type = "zip"
6+
source_file = "${path.module}/lambda_function.py"
7+
output_path = "${path.module}/lambda_function.zip"
8+
}
9+
10+
resource "aws_secretsmanager_secret" "datadog_api_key" {
11+
name = "datadog_api_key"
12+
description = "Datadog API Key used for monitoring Lambda"
13+
}
14+
15+
resource "aws_secretsmanager_secret_version" "datadog_api_key_version" {
16+
secret_id = aws_secretsmanager_secret.datadog_api_key.id
17+
secret_string = var.datadog_api_key # This should be the Datadog API key (input as a variable)
18+
}
19+
20+
locals {
21+
function_name = "${var.deployment_name}-github-webhook-handler"
22+
role = aws_iam_role.lambda_role.arn
23+
handler = "lambda_function.lambda_handler"
24+
runtime = "python3.12"
25+
memory_size = 256
26+
timeout = 30
27+
publish = true
28+
29+
filename = data.archive_file.zip_lambda_function.output_path
30+
source_code_hash = data.archive_file.zip_lambda_function.output_base64sha256
31+
32+
private_system_endpoint = "https://${var.private_system_endpoint}/integrations/github/v1/app_hook"
33+
34+
subnet_ids = var.vpc_private_subnets
35+
security_group_ids = length(var.security_group_ids) > 0 ? var.security_group_ids : [aws_security_group.lambda_sg.id]
36+
}
37+
38+
module "lambda_datadog" {
39+
count = var.monitor_lambda_datadog ? 1 : 0
40+
41+
source = "DataDog/lambda-datadog/aws"
42+
version = "1.4.0"
43+
44+
function_name = local.function_name
45+
role = local.role
46+
handler = local.handler
47+
runtime = local.runtime
48+
memory_size = local.memory_size
49+
timeout = local.timeout
50+
publish = local.publish
51+
52+
filename = local.filename
53+
source_code_hash = local.source_code_hash
54+
55+
environment_variables = {
56+
"DD_API_KEY_SECRET_ARN" : aws_secretsmanager_secret.datadog_api_key.arn
57+
"DD_ENV" : var.environment
58+
"DD_SERVICE" : "github-webhook-service"
59+
"DD_SITE": "datadoghq.com"
60+
"DD_VERSION" : "1.0.0"
61+
"DD_EXTENSION_VERSION": "next"
62+
"DD_SERVERLESS_LOGS_ENABLED": "true"
63+
"DD_LOG_LEVEL": "INFO"
64+
"DD_TAGS": "deployment:${var.deployment_name}"
65+
"PRIVATE_SYSTEM_ENDPOINT" : local.private_system_endpoint
66+
"DATADOG_MONITORING_ENABLED": "true"
67+
}
68+
69+
vpc_config_subnet_ids = local.subnet_ids
70+
vpc_config_security_group_ids = local.security_group_ids
71+
72+
datadog_extension_layer_version = 63
73+
datadog_python_layer_version = 98
74+
75+
# Depend on the zip operation
76+
depends_on = [data.archive_file.zip_lambda_function]
77+
}
78+
79+
resource "aws_lambda_function" "github_webhook_handler" {
80+
count = var.monitor_lambda_datadog ? 0 : 1
81+
82+
function_name = local.function_name
83+
role = local.role
84+
handler = local.handler
85+
runtime = local.runtime
86+
memory_size = local.memory_size
87+
timeout = local.timeout
88+
publish = local.publish
89+
90+
filename = local.filename
91+
source_code_hash = local.source_code_hash
92+
93+
environment {
94+
variables = {
95+
PRIVATE_SYSTEM_ENDPOINT = local.private_system_endpoint
96+
}
97+
}
98+
99+
vpc_config {
100+
subnet_ids = local.subnet_ids
101+
security_group_ids = local.security_group_ids
102+
}
103+
104+
# Depend on the zip operation
105+
depends_on = [data.archive_file.zip_lambda_function]
106+
}
107+
108+
locals {
109+
function_version = coalesce(concat(module.lambda_datadog[*].version, aws_lambda_function.github_webhook_handler[*].version)...)
110+
}
111+
112+
resource "aws_lambda_alias" "prod_alias" {
113+
name = "prod"
114+
function_name = local.function_name
115+
function_version = local.function_version
116+
}
117+
118+
resource "aws_lambda_provisioned_concurrency_config" "example" {
119+
function_name = aws_lambda_alias.prod_alias.function_name
120+
provisioned_concurrent_executions = 1
121+
qualifier = aws_lambda_alias.prod_alias.name
122+
}
123+
124+
resource "aws_lambda_function_event_invoke_config" "concurrency_limit" {
125+
function_name = aws_lambda_alias.prod_alias.function_name
126+
qualifier = aws_lambda_alias.prod_alias.name
127+
maximum_retry_attempts = 2
128+
}
129+
130+
resource "aws_lambda_permission" "lambda_permission" {
131+
statement_id = "AllowExecutionFromAPIGateway"
132+
action = "lambda:InvokeFunction"
133+
function_name = aws_lambda_alias.prod_alias.function_name
134+
qualifier = aws_lambda_alias.prod_alias.name
135+
principal = "apigateway.amazonaws.com"
136+
137+
# The /* part allows invocation from any stage, method and resource path
138+
# within API Gateway.
139+
source_arn = "${aws_api_gateway_rest_api.webhook_api.execution_arn}/*"
140+
}
141+
142+
# API Gateway to act as a reverse proxy
143+
resource "aws_api_gateway_rest_api" "webhook_api" {
144+
name = "GitHub Webhook Reverse Proxy"
145+
}
146+
147+
# Create resource for webhooks
148+
resource "aws_api_gateway_resource" "webhook_resource" {
149+
rest_api_id = aws_api_gateway_rest_api.webhook_api.id
150+
parent_id = aws_api_gateway_rest_api.webhook_api.root_resource_id
151+
path_part = "webhook"
152+
}
153+
154+
# API Gateway Method
155+
resource "aws_api_gateway_method" "post_webhook" {
156+
rest_api_id = aws_api_gateway_rest_api.webhook_api.id
157+
resource_id = aws_api_gateway_resource.webhook_resource.id
158+
http_method = "POST"
159+
authorization = "NONE"
160+
}
161+
162+
# Lambda integration for API Gateway
163+
resource "aws_api_gateway_integration" "lambda_integration" {
164+
rest_api_id = aws_api_gateway_rest_api.webhook_api.id
165+
resource_id = aws_api_gateway_resource.webhook_resource.id
166+
http_method = aws_api_gateway_method.post_webhook.http_method
167+
integration_http_method = "POST"
168+
type = "AWS_PROXY"
169+
uri = aws_lambda_alias.prod_alias.invoke_arn
170+
}
171+
172+
# Deployment of API Gateway
173+
resource "aws_api_gateway_deployment" "api_deployment" {
174+
rest_api_id = aws_api_gateway_rest_api.webhook_api.id
175+
stage_name = "prod"
176+
177+
depends_on = [aws_api_gateway_integration.lambda_integration]
178+
}
179+
180+
resource "aws_api_gateway_rest_api_policy" "github_ip_restriction" {
181+
rest_api_id = aws_api_gateway_rest_api.webhook_api.id
182+
183+
policy = jsonencode({
184+
Version = "2012-10-17",
185+
Statement = [
186+
{
187+
Effect: "Allow",
188+
Principal: "*",
189+
Action: "execute-api:Invoke",
190+
Resource: "arn:aws:execute-api:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:${aws_api_gateway_rest_api.webhook_api.id}/*/POST/*",
191+
},
192+
{
193+
Effect: "Deny",
194+
Principal: "*",
195+
Action: "execute-api:Invoke",
196+
Resource: "arn:aws:execute-api:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:${aws_api_gateway_rest_api.webhook_api.id}/*/POST/*",
197+
Condition: {
198+
"NotIpAddress": {
199+
"aws:SourceIp": var.github_cidrs
200+
}
201+
}
202+
}
203+
]
204+
})
205+
}

0 commit comments

Comments
 (0)