From 5611195a0d91eb0726d04d3bb14a7f0b80bcbc49191f1de8ab770e4fc1d3f920 Mon Sep 17 00:00:00 2001 From: xpk Date: Sun, 14 Jun 2026 16:05:47 +0800 Subject: [PATCH] feat: LambdaAccessKey module --- .../LambdaAccessKey/FunctionCode.tpl | 69 ++++++ .../LambdaAccessKey/README.md | 80 ++++++ .../LambdaAccessKey/client.tpl | 23 ++ .../LambdaAccessKey/example/main.tf | 4 + .../LambdaAccessKey/main.tf | 227 ++++++++++++++++++ .../LambdaAccessKey/outputs.tf | 11 + .../LambdaAccessKey/variables.tf | 4 + .../iam-role-v2/main.tf | 2 +- 8 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 modules/security_identity_compliance/LambdaAccessKey/FunctionCode.tpl create mode 100644 modules/security_identity_compliance/LambdaAccessKey/README.md create mode 100644 modules/security_identity_compliance/LambdaAccessKey/client.tpl create mode 100644 modules/security_identity_compliance/LambdaAccessKey/example/main.tf create mode 100644 modules/security_identity_compliance/LambdaAccessKey/main.tf create mode 100644 modules/security_identity_compliance/LambdaAccessKey/outputs.tf create mode 100644 modules/security_identity_compliance/LambdaAccessKey/variables.tf diff --git a/modules/security_identity_compliance/LambdaAccessKey/FunctionCode.tpl b/modules/security_identity_compliance/LambdaAccessKey/FunctionCode.tpl new file mode 100644 index 0000000..c7c95b8 --- /dev/null +++ b/modules/security_identity_compliance/LambdaAccessKey/FunctionCode.tpl @@ -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"], + } + ), + } \ No newline at end of file diff --git a/modules/security_identity_compliance/LambdaAccessKey/README.md b/modules/security_identity_compliance/LambdaAccessKey/README.md new file mode 100644 index 0000000..d331999 --- /dev/null +++ b/modules/security_identity_compliance/LambdaAccessKey/README.md @@ -0,0 +1,80 @@ + +## 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. \ No newline at end of file diff --git a/modules/security_identity_compliance/LambdaAccessKey/client.tpl b/modules/security_identity_compliance/LambdaAccessKey/client.tpl new file mode 100644 index 0000000..53cd045 --- /dev/null +++ b/modules/security_identity_compliance/LambdaAccessKey/client.tpl @@ -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}") diff --git a/modules/security_identity_compliance/LambdaAccessKey/example/main.tf b/modules/security_identity_compliance/LambdaAccessKey/example/main.tf new file mode 100644 index 0000000..c6ecbbf --- /dev/null +++ b/modules/security_identity_compliance/LambdaAccessKey/example/main.tf @@ -0,0 +1,4 @@ +module "TrustedAccess" { + source = "../" + role_name = "TrustedAccess" +} \ No newline at end of file diff --git a/modules/security_identity_compliance/LambdaAccessKey/main.tf b/modules/security_identity_compliance/LambdaAccessKey/main.tf new file mode 100644 index 0000000..5b18332 --- /dev/null +++ b/modules/security_identity_compliance/LambdaAccessKey/main.tf @@ -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" +} \ No newline at end of file diff --git a/modules/security_identity_compliance/LambdaAccessKey/outputs.tf b/modules/security_identity_compliance/LambdaAccessKey/outputs.tf new file mode 100644 index 0000000..9ece85a --- /dev/null +++ b/modules/security_identity_compliance/LambdaAccessKey/outputs.tf @@ -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 +} \ No newline at end of file diff --git a/modules/security_identity_compliance/LambdaAccessKey/variables.tf b/modules/security_identity_compliance/LambdaAccessKey/variables.tf new file mode 100644 index 0000000..a79191b --- /dev/null +++ b/modules/security_identity_compliance/LambdaAccessKey/variables.tf @@ -0,0 +1,4 @@ +variable "role_name" { + type = string + description = "Name of target role" +} \ No newline at end of file diff --git a/modules/security_identity_compliance/iam-role-v2/main.tf b/modules/security_identity_compliance/iam-role-v2/main.tf index ade58ab..7104e5f 100644 --- a/modules/security_identity_compliance/iam-role-v2/main.tf +++ b/modules/security_identity_compliance/iam-role-v2/main.tf @@ -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 } \ No newline at end of file