feat: LambdaAccessKey module
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import json
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
import base64
|
||||
import hashlib
|
||||
# from cryptography.fernet import Fernet
|
||||
|
||||
def decrypt_data(encrypted_data: str, secret_key: str) -> str:
|
||||
key_hash = hashlib.sha256(secret_key.encode()).digest()
|
||||
encrypted_bytes = base64.b64decode(encrypted_data.encode())
|
||||
|
||||
decrypted = bytes(b ^ key_hash[i % len(key_hash)] for i, b in enumerate(encrypted_bytes))
|
||||
return decrypted.decode()
|
||||
|
||||
def lambda_handler(event, context):
|
||||
# 1. Extract parameters from the incoming Lambda event payload
|
||||
role_arn = "${target_role}"
|
||||
session_name = "AssumedRole"
|
||||
|
||||
# Validation: Ensure the Role ARN was provided
|
||||
if not role_arn:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"body": json.dumps(
|
||||
{"error": "Missing required parameter: 'role_arn'"}
|
||||
),
|
||||
}
|
||||
|
||||
# 2. Initialize the STS client
|
||||
# Note: Lambda uses its own Execution Role to make this call.
|
||||
# Ensure the Lambda role has the 'sts:AssumeRole' permission for the target ARN.
|
||||
sts_client = boto3.client("sts")
|
||||
|
||||
try:
|
||||
# 3. Assume the target role
|
||||
response = sts_client.assume_role(
|
||||
RoleArn=role_arn,
|
||||
RoleSessionName=session_name,
|
||||
ExternalId='${external_id}'
|
||||
)
|
||||
|
||||
# Extract the credentials block
|
||||
credentials = response["Credentials"]
|
||||
plainText = f"export AWS_ACCESS_KEY_ID={credentials["AccessKeyId"]} AWS_SECRET_ACCESS_KEY={credentials["SecretAccessKey"]} AWS_SESSION_TOKEN={credentials["SessionToken"]}"
|
||||
|
||||
# Encrypt the credentials
|
||||
key_hash = hashlib.sha256('${encryption_pass}'.encode()).digest()
|
||||
encrypted = bytes(b ^ key_hash[i % len(key_hash)] for i, b in enumerate(plainText.encode()))
|
||||
|
||||
# 4. Return the standard Lambda proxy response containing the JSON payload
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"result" : base64.b64encode(encrypted).decode()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
except ClientError as e:
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"error": "Failed to assume role",
|
||||
"details": e.response["Error"]["Message"],
|
||||
}
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<!-- This readme file is generated with terraform-docs -->
|
||||
## Example
|
||||
|
||||
```hcl
|
||||
module "TrustedAccess" {
|
||||
source = "../"
|
||||
role_name = "TrustedAccess"
|
||||
}
|
||||
```
|
||||
|
||||
# LambdaAccessKey
|
||||
|
||||
Module to create a lambda function, which assumes to a certain role and
|
||||
get temporary access credentials. The lambda function url is protected
|
||||
by cloudfront and origin access control. Credentials are encrypted. Once
|
||||
resources are deployed, run client.py to send http request and decrypt
|
||||
the response
|
||||
|
||||
Cloudfront fixed-rate pricing cannot be controlled by terraform or awscli
|
||||
at time of writing. Change to the free plan on aws console.
|
||||
|
||||
To destroy the cloudfront distribution, you need to cancel the fixed rate plan
|
||||
|
||||
## Requirements
|
||||
|
||||
No requirements.
|
||||
|
||||
## Providers
|
||||
|
||||
| Name | Version |
|
||||
| ---- | ------- |
|
||||
| archive | n/a |
|
||||
| aws | n/a |
|
||||
| local | n/a |
|
||||
| random | n/a |
|
||||
|
||||
## Modules
|
||||
|
||||
| Name | Source | Version |
|
||||
| ---- | ------ | ------- |
|
||||
| LambdaExecRole | ../iam-role-v2 | n/a |
|
||||
| TargetIam | ../iam-role-v2 | n/a |
|
||||
|
||||
## Resources
|
||||
|
||||
| Name | Type |
|
||||
| ---- | ---- |
|
||||
| [aws_cloudfront_distribution.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution) | resource |
|
||||
| [aws_cloudfront_origin_access_control.CloudfrontOac](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_origin_access_control) | resource |
|
||||
| [aws_iam_policy.LamdaExecRole](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource |
|
||||
| [aws_lambda_function.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource |
|
||||
| [aws_lambda_function_url.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function_url) | resource |
|
||||
| [aws_lambda_permission.AllowCloudFrontServicePrincipal](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource |
|
||||
| [aws_lambda_permission.AllowCloudFrontServicePrincipalInvokeFunction](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource |
|
||||
| [local_file.FunctionCode](https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/file) | resource |
|
||||
| [local_file.client](https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/file) | resource |
|
||||
| [random_password.this](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) | resource |
|
||||
| [random_uuid.ExternalId](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/uuid) | resource |
|
||||
| [archive_file.LambdaZip](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source |
|
||||
| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
|
||||
| [aws_cloudfront_cache_policy.NoCache](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/cloudfront_cache_policy) | data source |
|
||||
| [aws_cloudfront_origin_request_policy.AllButHost](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/cloudfront_origin_request_policy) | data source |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Default | Required |
|
||||
| ---- | ----------- | ---- | ------- | :------: |
|
||||
| role\_name | Name of target role | `string` | n/a | yes |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
| ---- | ----------- |
|
||||
| CloudFrontDist | n/a |
|
||||
| LambdaFunctionArn | n/a |
|
||||
| TargetRole | n/a |
|
||||
|
||||
---
|
||||
## Authorship
|
||||
This module was developed by UPDATE_THIS.
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/python3
|
||||
import base64
|
||||
import hashlib
|
||||
import requests
|
||||
|
||||
def decrypt_data(encrypted_data: str) -> str:
|
||||
key_hash = hashlib.sha256('${encryption_pass}'.encode()).digest()
|
||||
encrypted_bytes = base64.b64decode(encrypted_data.encode())
|
||||
|
||||
decrypted = bytes(b ^ key_hash[i % len(key_hash)] for i, b in enumerate(encrypted_bytes))
|
||||
return decrypted.decode()
|
||||
|
||||
url = "${cloudfront_url}"
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
print(decrypt_data(data['result']))
|
||||
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
print(f"HTTP error occurred: {http_err}")
|
||||
except Exception as err:
|
||||
print(f"An error occurred: {err}")
|
||||
@@ -0,0 +1,4 @@
|
||||
module "TrustedAccess" {
|
||||
source = "../"
|
||||
role_name = "TrustedAccess"
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* # LambdaAccessKey
|
||||
*
|
||||
* Module to create a lambda function, which assumes to a certain role and
|
||||
* get temporary access credentials. The lambda function url is protected
|
||||
* by cloudfront and origin access control. Credentials are encrypted. Once
|
||||
* resources are deployed, run client.py to send http request and decrypt
|
||||
* the response
|
||||
*
|
||||
* Cloudfront fixed-rate pricing cannot be controlled by terraform or awscli
|
||||
* at time of writing. Change to the free plan on aws console.
|
||||
*
|
||||
* To destroy the cloudfront distribution, you need to cancel the fixed rate plan
|
||||
*/
|
||||
|
||||
# data sources
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
# IAM role to assume role to
|
||||
resource "random_uuid" "ExternalId" {}
|
||||
|
||||
module "TargetIam" {
|
||||
source = "../iam-role-v2"
|
||||
trusted-entity = jsonencode(
|
||||
{
|
||||
"Version" : "2012-10-17",
|
||||
"Statement" : [
|
||||
{
|
||||
"Effect" : "Allow",
|
||||
"Principal" : {
|
||||
"AWS" : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
|
||||
},
|
||||
"Action" : "sts:AssumeRole",
|
||||
"Condition" : {
|
||||
"StringEquals" : {
|
||||
"sts:ExternalId" : random_uuid.ExternalId.id
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
role-name = var.role_name
|
||||
description = "Trusted access role"
|
||||
path = "/"
|
||||
max-session-duration = 14400 # 4 hours
|
||||
attach-managed-policies = [
|
||||
"arn:aws:iam::aws:policy/IAMFullAccess",
|
||||
"arn:aws:iam::aws:policy/PowerUserAccess"
|
||||
]
|
||||
}
|
||||
|
||||
# Lambda execution IAM role
|
||||
resource "aws_iam_policy" "LamdaExecRole" {
|
||||
name_prefix = "${var.role_name}-LambdaExecRole"
|
||||
description = "Lambda execution role policy"
|
||||
policy = jsonencode(
|
||||
{
|
||||
"Version" : "2012-10-17",
|
||||
"Statement" : [
|
||||
{
|
||||
"Effect" : "Allow",
|
||||
"Action" : "logs:CreateLogGroup",
|
||||
"Resource" : "arn:aws:logs:us-east-1:${data.aws_caller_identity.current.account_id}:*"
|
||||
},
|
||||
{
|
||||
"Effect" : "Allow",
|
||||
"Action" : [
|
||||
"logs:CreateLogStream",
|
||||
"logs:PutLogEvents"
|
||||
],
|
||||
"Resource" : [
|
||||
"arn:aws:logs:us-east-1:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/${var.role_name}:*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect" : "Allow",
|
||||
"Action" : [
|
||||
"sts:AssumeRole"
|
||||
],
|
||||
"Resource" : [
|
||||
module.TargetIam.role-arn
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
module "LambdaExecRole" {
|
||||
source = "../iam-role-v2"
|
||||
trusted-entity = "lambda.amazonaws.com"
|
||||
role-name = "${var.role_name}-LambdaExecRole"
|
||||
description = "Lambda execution role"
|
||||
path = "/"
|
||||
max-session-duration = 3600 # 1 hour
|
||||
attach-managed-policies = [
|
||||
aws_iam_policy.LamdaExecRole.arn
|
||||
]
|
||||
}
|
||||
|
||||
# Lambda function
|
||||
resource "random_password" "this" {
|
||||
length = 50
|
||||
override_special = "~@#%^&*-_=+:;<,>./"
|
||||
}
|
||||
|
||||
resource "local_file" "FunctionCode" {
|
||||
content = templatefile("${path.module}/FunctionCode.tpl", {
|
||||
target_role = module.TargetIam.role-arn
|
||||
encryption_pass = random_password.this.result
|
||||
external_id = random_uuid.ExternalId.result
|
||||
})
|
||||
filename = "${path.module}/FunctionCode.py"
|
||||
}
|
||||
|
||||
data "archive_file" "LambdaZip" {
|
||||
type = "zip"
|
||||
source_file = local_file.FunctionCode.filename
|
||||
output_path = "${path.module}/FunctionCode.zip"
|
||||
}
|
||||
|
||||
# Lambda function
|
||||
resource "aws_lambda_function" "this" {
|
||||
filename = data.archive_file.LambdaZip.output_path
|
||||
code_sha256 = data.archive_file.LambdaZip.output_base64sha256
|
||||
function_name = var.role_name
|
||||
role = module.LambdaExecRole.role-arn
|
||||
handler = "FunctionCode.lambda_handler"
|
||||
runtime = "python3.14"
|
||||
architectures = ["arm64"]
|
||||
reserved_concurrent_executions = 2
|
||||
}
|
||||
|
||||
resource "aws_lambda_function_url" "this" {
|
||||
function_name = aws_lambda_function.this.function_name
|
||||
authorization_type = "AWS_IAM"
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "AllowCloudFrontServicePrincipal" {
|
||||
statement_id = "AllowCloudFrontServicePrincipal"
|
||||
action = "lambda:InvokeFunctionUrl"
|
||||
function_name = aws_lambda_function.this.function_name
|
||||
principal = "cloudfront.amazonaws.com"
|
||||
source_arn = ""
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "AllowCloudFrontServicePrincipalInvokeFunction" {
|
||||
statement_id = "AllowCloudFrontServicePrincipalInvokeFunction"
|
||||
action = "lambda:InvokeFunction"
|
||||
function_name = aws_lambda_function.this.function_name
|
||||
principal = "cloudfront.amazonaws.com"
|
||||
source_arn = ""
|
||||
}
|
||||
|
||||
|
||||
# CloudFront flatrate plan
|
||||
resource "aws_cloudfront_origin_access_control" "CloudfrontOac" {
|
||||
name = "lambda-function-url-oac"
|
||||
description = "OAC for secure Lambda Function URL backend connection"
|
||||
origin_access_control_origin_type = "lambda"
|
||||
signing_behavior = "always"
|
||||
signing_protocol = "sigv4"
|
||||
}
|
||||
|
||||
resource "aws_cloudfront_distribution" "this" {
|
||||
enabled = true
|
||||
is_ipv6_enabled = true
|
||||
price_class = "PriceClass_All"
|
||||
|
||||
origin {
|
||||
domain_name = split("/", aws_lambda_function_url.this.function_url)[2]
|
||||
origin_id = "LambdaBackendOrigin"
|
||||
origin_access_control_id = aws_cloudfront_origin_access_control.CloudfrontOac.id
|
||||
custom_origin_config {
|
||||
http_port = 80
|
||||
https_port = 443
|
||||
origin_protocol_policy = "https-only"
|
||||
origin_ssl_protocols = ["TLSv1.2"]
|
||||
}
|
||||
}
|
||||
|
||||
default_cache_behavior {
|
||||
allowed_methods = ["GET", "HEAD"]
|
||||
cached_methods = ["GET", "HEAD"]
|
||||
target_origin_id = "LambdaBackendOrigin"
|
||||
viewer_protocol_policy = "redirect-to-https"
|
||||
|
||||
# Use default AWS-managed Cache/Origin Request policies to stay within flat-rate free bounds
|
||||
cache_policy_id = data.aws_cloudfront_cache_policy.NoCache.id
|
||||
origin_request_policy_id = data.aws_cloudfront_origin_request_policy.AllButHost.id # host header must be dropped for OAC
|
||||
}
|
||||
|
||||
# Flat-rate plans include basic managed WAF rule support automatically
|
||||
restrictions {
|
||||
geo_restriction {
|
||||
restriction_type = "none"
|
||||
}
|
||||
}
|
||||
|
||||
viewer_certificate {
|
||||
cloudfront_default_certificate = true
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
web_acl_id # after converting to flat-rate plan, a waf acl will be attached and we want to allow it
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_cloudfront_cache_policy" "NoCache" {
|
||||
name = "Managed-CachingDisabled"
|
||||
}
|
||||
|
||||
data "aws_cloudfront_origin_request_policy" "AllButHost" {
|
||||
name = "Managed-AllViewerExceptHostHeader"
|
||||
}
|
||||
|
||||
# Decryption file / client
|
||||
resource "local_file" "client" {
|
||||
content = templatefile("${path.module}/client.tpl", {
|
||||
encryption_pass = random_password.this.result
|
||||
cloudfront_url = "https://${aws_cloudfront_distribution.this.domain_name}"
|
||||
})
|
||||
filename = "${path.module}/client.py"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
output "TargetRole" {
|
||||
value = module.TargetIam.role-arn
|
||||
}
|
||||
|
||||
output "LambdaFunctionArn" {
|
||||
value = aws_lambda_function.this.arn
|
||||
}
|
||||
|
||||
output "CloudFrontDist" {
|
||||
value = aws_cloudfront_distribution.this.arn
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
variable "role_name" {
|
||||
type = string
|
||||
description = "Name of target role"
|
||||
}
|
||||
@@ -50,7 +50,7 @@ resource "aws_iam_role_policy_attachment" "pa" {
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "mp-attachments" {
|
||||
for_each = toset(var.attach-managed-policies)
|
||||
for_each = { for index, value in var.attach-managed-policies : tostring(index + 1) => value } # var.attach-managed-policies
|
||||
role = aws_iam_role.r.name
|
||||
policy_arn = each.value
|
||||
}
|
||||
Reference in New Issue
Block a user