Scenario
You need to build an internal serverless API for an order management service. Orders go into a DynamoDB table, and all access to that table must go through a Lambda function, never directly. The Lambda's execution role should only have the exact permissions it needs, nothing more. Functionality of our app needs to read a specific order by key (never scan the entire database) and write orders into DynamoDB.
Note: A preconfigured Lambda handler is available at /tmp/handler.py; use it for your function.
Requirements
| Component |
Name |
Constraint / Outcome |
| DynamoDB Table |
orders |
Partition key: orderId (String) |
| IAM Role |
lambda-orders-role |
Must be assumable by Lambda Must follow least privilege for access to orders table (no wildcard actions or resources) |
| Lambda Function |
orders-handler |
Runtime: python3.12 Environment variable: TABLE_NAME with value orders. Code is provided at /tmp/handler.py |
| API Gateway |
orders-api |
REST API with /orders endpoint that can handle GET and POST methods invoking Lambda function. |
| Deployment Stage |
dev |
API must be deployed and publicly callable |
Note: You can use either the AWS Management Console or AWS CLI to complete this task.
Step 1: Create the DynamoDB Table
aws dynamodb create-table \
--table-name orders \
--key-schema AttributeName=orderId,KeyType=HASH \
--attribute-definitions AttributeName=orderId,AttributeType=S \
--billing-mode PAY_PER_REQUEST
This sets up the orders table with orderId as the partition key. We use on-demand billing so we don't have to think about provisioned capacity for this exercise.
Step 2: Create the Trust Policy for the IAM Role
cat > /tmp/trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
EOF
This JSON file tells AWS that the Lambda service is allowed to assume this role. We write it to disk first since create-role expects a file reference for the trust policy.
Step 3: Create the IAM Role
aws iam create-role \
--role-name lambda-orders-role \
--assume-role-policy-document file:///tmp/trust-policy.json
Now we actually create the role. In LocalStack, the resulting ARN will be arn:aws:iam::000000000000:role/lambda-orders-role.
Step 4: Attach a Least-Privilege Inline Policy
aws iam put-role-policy \
--role-name lambda-orders-role \
--policy-name orders-dynamodb-policy \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/orders"
}]
}'
This is the key part for least-privilege. We only give the role PutItem and GetItem on the specific orders table, not dynamodb:* on *. The resource ARN points directly at our table so the role can't touch anything else.
Step 5: Package and Deploy the Lambda Function
cd /tmp && zip function.zip handler.py
aws lambda create-function \
--function-name orders-handler \
--runtime python3.12 \
--handler handler.lambda_handler \
--role arn:aws:iam::000000000000:role/lambda-orders-role \
--zip-file fileb:///tmp/function.zip \
--environment Variables={TABLE_NAME=orders}
The handler file is already sitting at /tmp/handler.py, so we just zip it up and deploy. The role ARN uses account ID 000000000000 because that's what LocalStack expects.
Step 6: Create the REST API and the /orders Resource
API_ID=$(aws apigateway create-rest-api \
--name orders-api \
--query 'id' --output text)
ROOT_ID=$(aws apigateway get-resources \
--rest-api-id $API_ID \
--query 'items[0].id' --output text)
RESOURCE_ID=$(aws apigateway create-resource \
--rest-api-id $API_ID \
--parent-id $ROOT_ID \
--path-part orders \
--query 'id' --output text)
We create the API, then grab the root resource ID (every API starts with a / resource), and create our /orders resource under it. We save the IDs since we'll need them for the method and integration setup.
Step 7: Set Up the POST Method and Lambda Integration
LAMBDA_ARN=$(aws lambda get-function \
--function-name orders-handler \
--query 'Configuration.FunctionArn' --output text)
aws apigateway put-method \
--rest-api-id $API_ID \
--resource-id $RESOURCE_ID \
--http-method POST \
--authorization-type NONE
aws apigateway put-integration \
--rest-api-id $API_ID \
--resource-id $RESOURCE_ID \
--http-method POST \
--type AWS_PROXY \
--integration-http-method POST \
--uri "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${LAMBDA_ARN}/invocations"
We grab the Lambda ARN first, then create the POST method and wire it up to our function. One thing that trips people up: --integration-http-method is always POST for Lambda integrations, even when the API method itself is GET. That's because API Gateway calls the Lambda Invoke API internally, which only accepts POST.
Step 8: Set Up the GET Method and Lambda Integration
aws apigateway put-method \
--rest-api-id $API_ID \
--resource-id $RESOURCE_ID \
--http-method GET \
--authorization-type NONE
aws apigateway put-integration \
--rest-api-id $API_ID \
--resource-id $RESOURCE_ID \
--http-method GET \
--type AWS_PROXY \
--integration-http-method POST \
--uri "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${LAMBDA_ARN}/invocations"
Same pattern as POST. AWS_PROXY means API Gateway passes the full request (headers, body, query params, method) straight to Lambda as a structured event, so we don't need to mess with mapping templates.
Step 9: Deploy the API and Grant Invoke Permission
aws apigateway create-deployment \
--rest-api-id $API_ID \
--stage-name dev
aws lambda add-permission \
--function-name orders-handler \
--statement-id apigateway-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:us-east-1:000000000000:${API_ID}/*/*/orders"
Two things here that are easy to forget. First, without create-deployment the API just returns {"message": "Not Found"} in LocalStack, even though the methods are set up. Second, add-permission adds a resource-based policy to the Lambda so API Gateway is actually allowed to call it. Skip this and your requests will fail silently.
Step 10: Verify End-to-End
# POST an order
curl -X POST \
"${AWS_ENDPOINT_URL}/restapis/${API_ID}/dev/_user_request_/orders" \
-H "Content-Type: application/json" \
-d '{"orderId": "order-001", "item": "laptop", "quantity": 2}'
# GET the order back
curl \
"${AWS_ENDPOINT_URL}/restapis/${API_ID}/dev/_user_request_/orders?orderId=order-001"
If everything is wired up correctly, the POST should come back with a success response and the GET should return the order you just created.