LAB27_AWS_Serverless CRUD API: API Gateway + Lambda + DynamoDB with IAM Auth

Serverless CRUD API: API Gateway + Lambda + DynamoDB with IAM Auth

☁ AWS 🔥 Advanced Serverless API Gateway Lambda DynamoDB IAM Authorization for_each

Scenario

You are building a serverless items catalog API. The API must support full CRUD operations — create, read, update, and delete items — each handled by a dedicated Lambda function. Data is stored in DynamoDB. The API must be protected: only callers with valid AWS credentials and the correct IAM permission can invoke it.

Rather than copy-pasting four Lambda resources, write a single resource block driven by for_each over a map of operations. Each Lambda gets its own CloudWatch Log Group, and all four functions share one least-privilege IAM role.

Your Objectives
  • Create a DynamoDB table (PAY_PER_REQUEST) with item_id as partition key, TTL, and PITR enabled.
  • Define a map variable with 4 operations: create, get, update, delete — each with its HTTP method and path.
  • Use for_each to create one Lambda function, one CloudWatch Log Group, and one API Gateway method+integration per operation from a single resource block each.
  • Grant the Lambda role least-privilege DynamoDB access scoped to the specific table ARN.
  • Configure API Gateway methods with AWS_IAM authorization and deploy to a named stage.
  • Output the API base URL and a map of operation → Lambda ARN.

Additional Context

IAM vs Cognito vs API Key authorization: AWS_IAM uses AWS Signature Version 4 (SigV4) — callers sign their requests with their AWS credentials. This is the most secure option for internal service-to-service calls. Cognito User Pools are used for end-user authentication (generating JWTs). API Keys are the weakest option — suitable only for rate limiting, not authentication.

Each Lambda function needs an aws_lambda_permission resource granting API Gateway the right to invoke it. When using for_each for both Lambdas and permissions, the source_arn must include the specific HTTP method and resource path for the least-privilege principle to apply.

variables.tf

variable "aws_region"  { type = string; default = "us-east-1" }
variable "api_name"    { type = string; default = "items-api" }
variable "environment" { type = string; default = "prod" }

variable "operations" {
  type = map(object({ http_method = string; path = string }))
  default = {
    "create" = { http_method = "POST";   path = "items" }
    "get"    = { http_method = "GET";    path = "items" }
    "update" = { http_method = "PUT";    path = "items" }
    "delete" = { http_method = "DELETE"; path = "items" }
  }
}

main.tf

# main.tf

provider "aws" { region = var.aws_region }

locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }

# DynamoDB table for the items catalog.
resource "aws_dynamodb_table" "items" {
  name         = "items-${var.environment}"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "item_id"
  attribute { name = "item_id"; type = "S" }
  ttl { enabled = true; attribute_name = "expires_at" }
  point_in_time_recovery { enabled = true }
  tags = local.tags
}

# Shared Lambda execution role (all 4 functions assume this role).
data "aws_iam_policy_document" "lambda_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals { type = "Service"; identifiers = ["lambda.amazonaws.com"] }
  }
}

resource "aws_iam_role" "lambda" {
  name               = "${var.api_name}-lambda-role"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}

resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# Least-privilege DynamoDB policy scoped to just the items table.
data "aws_iam_policy_document" "dynamo_access" {
  statement {
    sid    = "CRUDItems"
    effect = "Allow"
    actions = ["dynamodb:PutItem","dynamodb:GetItem","dynamodb:UpdateItem",
               "dynamodb:DeleteItem","dynamodb:Query","dynamodb:Scan"]
    resources = [aws_dynamodb_table.items.arn]
  }
}

resource "aws_iam_role_policy" "dynamo" {
  name   = "dynamo-crud"
  role   = aws_iam_role.lambda.id
  policy = data.aws_iam_policy_document.dynamo_access.json
}

# One CloudWatch Log Group per Lambda function.
resource "aws_cloudwatch_log_group" "lambda" {
  for_each          = var.operations
  name              = "/aws/lambda/${var.api_name}-${each.key}-${var.environment}"
  retention_in_days = 30
  tags              = local.tags
}

# One Lambda per operation — driven entirely by for_each.
resource "aws_lambda_function" "ops" {
  for_each      = var.operations
  function_name = "${var.api_name}-${each.key}-${var.environment}"
  runtime       = "python3.12"
  handler       = "handler.lambda_handler"
  role          = aws_iam_role.lambda.arn
  filename      = "${path.module}/lambda.zip"

  environment {
    variables = {
      TABLE_NAME = aws_dynamodb_table.items.name
      OPERATION  = each.key
    }
  }

  depends_on = [aws_cloudwatch_log_group.lambda]
  tags       = merge(local.tags, { Operation = each.key })
}

# REST API.
resource "aws_api_gateway_rest_api" "main" {
  name        = var.api_name
  endpoint_configuration { types = ["REGIONAL"] }
}

# Resource, method, and Lambda proxy integration — one of each per operation.
resource "aws_api_gateway_resource" "ops" {
  for_each    = var.operations
  rest_api_id = aws_api_gateway_rest_api.main.id
  parent_id   = aws_api_gateway_rest_api.main.root_resource_id
  path_part   = each.value.path
}

resource "aws_api_gateway_method" "ops" {
  for_each      = var.operations
  rest_api_id   = aws_api_gateway_rest_api.main.id
  resource_id   = aws_api_gateway_resource.ops[each.key].id
  http_method   = each.value.http_method
  authorization = "AWS_IAM"
}

resource "aws_api_gateway_integration" "ops" {
  for_each                = var.operations
  rest_api_id             = aws_api_gateway_rest_api.main.id
  resource_id             = aws_api_gateway_resource.ops[each.key].id
  http_method             = aws_api_gateway_method.ops[each.key].http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = aws_lambda_function.ops[each.key].invoke_arn
}

resource "aws_lambda_permission" "ops" {
  for_each      = var.operations
  statement_id  = "AllowAPIGW-${each.key}"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.ops[each.key].function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.main.execution_arn}/*/*"
}

resource "aws_api_gateway_deployment" "main" {
  rest_api_id = aws_api_gateway_rest_api.main.id
  triggers = {
    redeployment = sha1(jsonencode([for k in keys(var.operations) : aws_api_gateway_integration.ops[k].id]))
  }
  lifecycle { create_before_destroy = true }
  depends_on = [aws_api_gateway_integration.ops]
}

resource "aws_api_gateway_stage" "main" {
  deployment_id = aws_api_gateway_deployment.main.id
  rest_api_id   = aws_api_gateway_rest_api.main.id
  stage_name    = var.environment
}

outputs.tf

output "api_base_url" { value = aws_api_gateway_stage.main.invoke_url }
output "table_name"   { value = aws_dynamodb_table.items.name }
output "lambda_arns"  {
  value = { for k, v in aws_lambda_function.ops : k => v.arn }
}

Workflow Commands

terraform init
terraform apply

BASE=$(terraform output -raw api_base_url)

# Create an item (sign requests with AWS SigV4 using awscurl)
awscurl --service execute-api -X POST "$BASE/items" \
  -d '{"item_id":"abc-123","name":"Hammer","price":9.99}'

# Get items
awscurl --service execute-api "$BASE/items"

# Delete
awscurl --service execute-api -X DELETE "$BASE/items" \
  -d '{"item_id":"abc-123"}'

# View state addresses — note map keys, not numeric indices
terraform state list | grep aws_lambda_function

terraform destroy
✓ Expected Output After Apply
Apply complete! Resources: 19 added, 0 changed, 0 destroyed.

Outputs:

api_base_url = "https://abc1234.execute-api.us-east-1.amazonaws.com/prod"
lambda_arns  = {
  "create" = "arn:aws:lambda:us-east-1:123456789012:function:items-api-create-prod"
  "delete" = "arn:aws:lambda:us-east-1:123456789012:function:items-api-delete-prod"
  "get"    = "arn:aws:lambda:us-east-1:123456789012:function:items-api-get-prod"
  "update" = "arn:aws:lambda:us-east-1:123456789012:function:items-api-update-prod"
}
table_name   = "items-prod"