# Terraform owns the function skeletons (role, runtime, memory, environment). # Code is owned by .github/workflows/deploy.yaml, which uploads # functions//.zip and calls update-function-code. The lifecycle # block is the seam: an app deploy is not drift, and a Terraform apply never # rolls the code back to the bootstrap stub. data "aws_iam_policy_document" "lambda_assume" { statement { effect = "Allow" actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["lambda.amazonaws.com"] } } } locals { table_arn = aws_dynamodb_table.dashboard.arn cmk_arn = local.dynamodb_cmk_arn lambda_identity = { process_csv = [ { sid = "CsvRead" actions = ["s3:GetObject", "s3:GetObjectVersion"] resources = ["${aws_s3_bucket.csv.arn}/*"] }, { sid = "DdbCrud" actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"] resources = [local.table_arn, "${local.table_arn}/*"] }, { sid = "Cmk" actions = ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"] resources = [local.cmk_arn] }, { sid = "BoaCheckMgmtSecret" actions = ["secretsmanager:GetSecretValue"] resources = [local.secret_arns["payments-dashboard/boa-check-mgmt"]] }, { sid = "DlqSend" actions = ["sqs:SendMessage"] resources = [aws_sqs_queue.process_csv_dlq.arn] }, ] slack_app_home = [ { sid = "DdbRead" actions = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:DescribeTable"] resources = [local.table_arn, "${local.table_arn}/*"] }, { sid = "CmkDecrypt" actions = ["kms:Decrypt", "kms:DescribeKey"] resources = [local.cmk_arn] }, { sid = "SlackSecrets" actions = ["secretsmanager:GetSecretValue"] resources = [ local.secret_arns["payments-dashboard/slack-bot-token"], local.secret_arns["payments-dashboard/slack-signing-secret"], ] }, ] fetch_boa = [ { sid = "DdbCrud" actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"] resources = [local.table_arn, "${local.table_arn}/*"] }, { sid = "BoaRawPut" actions = ["s3:PutObject"] resources = ["${aws_s3_bucket.boa_raw.arn}/*"] }, { sid = "Cmk" actions = ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"] resources = [local.cmk_arn] }, { sid = "BoaReportingSecret" actions = ["secretsmanager:GetSecretValue"] resources = [local.secret_arns["payments-dashboard/boa-reporting"]] }, ] expense_receiver = [ { sid = "InvokeProcessor" actions = ["lambda:InvokeFunction"] resources = ["arn:aws:lambda:${var.aws_region}:${local.account_id}:function:payments-expenseProcessor"] }, { sid = "ExpenseSigningSecret" actions = ["secretsmanager:GetSecretValue"] resources = [local.secret_arns["payments-dashboard/expense-slack-signing-secret"]] }, ] expense_processor = [ { sid = "ExpenseBotSecret" actions = ["secretsmanager:GetSecretValue"] resources = [local.secret_arns["payments-dashboard/expense-slack-token"]] }, ] } lambda_env = { process_csv = { TABLE_NAME = aws_dynamodb_table.dashboard.name BOA_BASE_URL = var.boa_base_url BOA_CHECK_MGMT_SECRET_NAME = "payments-dashboard/boa-check-mgmt" } slack_app_home = { TABLE_NAME = aws_dynamodb_table.dashboard.name SLACK_BOT_TOKEN_SECRET_NAME = "payments-dashboard/slack-bot-token" SLACK_SIGNING_SECRET_NAME = "payments-dashboard/slack-signing-secret" } fetch_boa = { TABLE_NAME = aws_dynamodb_table.dashboard.name BOA_BASE_URL = var.boa_base_url BOA_REPORTING_SECRET_NAME = "payments-dashboard/boa-reporting" BOA_RAW_BUCKET = aws_s3_bucket.boa_raw.id } expense_receiver = { TABLE_NAME = aws_dynamodb_table.dashboard.name EXPENSE_PROCESSOR_FN = "payments-expenseProcessor" EXPENSE_SIGNING_SECRET_NAME = "payments-dashboard/expense-slack-signing-secret" } expense_processor = { TABLE_NAME = aws_dynamodb_table.dashboard.name EXPENSE_BOT_TOKEN_SECRET_NAME = "payments-dashboard/expense-slack-token" } } } resource "aws_iam_role" "lambda" { for_each = local.functions name = each.value.role_name path = "/tf-managed/" description = "Lambda execution role for ${each.value.function_name}" assume_role_policy = data.aws_iam_policy_document.lambda_assume.json permissions_boundary = aws_iam_policy.lambda_boundary.arn } data "aws_iam_policy_document" "lambda" { for_each = local.functions dynamic "statement" { for_each = local.lambda_identity[each.key] content { sid = statement.value.sid effect = "Allow" actions = statement.value.actions resources = statement.value.resources } } } resource "aws_iam_role_policy" "lambda" { for_each = local.functions name = each.key role = aws_iam_role.lambda[each.key].id policy = data.aws_iam_policy_document.lambda[each.key].json } resource "aws_iam_role_policy_attachment" "lambda_basic" { for_each = local.functions role = aws_iam_role.lambda[each.key].name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } resource "aws_iam_role_policy_attachment" "lambda_vpc" { for_each = { for k, v in local.functions : k => v if v.vpc } role = aws_iam_role.lambda[each.key].name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } resource "aws_lambda_function" "this" { for_each = local.functions function_name = each.value.function_name role = aws_iam_role.lambda[each.key].arn handler = each.value.handler runtime = "nodejs24.x" architectures = ["arm64"] memory_size = 256 timeout = each.value.timeout s3_bucket = aws_s3_bucket.artifacts.id s3_key = aws_s3_object.bootstrap_stub.key source_code_hash = data.archive_file.bootstrap_stub.output_base64sha256 environment { variables = local.lambda_env[each.key] } dynamic "vpc_config" { for_each = each.value.vpc ? [1] : [] content { subnet_ids = [aws_subnet.private.id] security_group_ids = [aws_security_group.lambda.id] } } lifecycle { ignore_changes = [filename, s3_bucket, s3_key, s3_object_version, source_code_hash] } depends_on = [ aws_cloudwatch_log_group.lambda, aws_iam_role_policy.lambda, aws_iam_role_policy_attachment.lambda_basic, aws_iam_role_policy_attachment.lambda_vpc, aws_nat_gateway.this, ] } resource "aws_lambda_function_event_invoke_config" "process_csv" { function_name = aws_lambda_function.this["process_csv"].function_name maximum_event_age_in_seconds = 21600 maximum_retry_attempts = 2 destination_config { on_failure { destination = aws_sqs_queue.process_csv_dlq.arn } } } resource "aws_lambda_permission" "s3_csv" { statement_id = "AllowS3InvokeProcessCsv" action = "lambda:InvokeFunction" function_name = aws_lambda_function.this["process_csv"].function_name principal = "s3.amazonaws.com" source_arn = aws_s3_bucket.csv.arn source_account = local.account_id }