Three Lambda runtimes, one least-privilege role, and an S3 trigger
This is a complete, copy-and-run tutorial. You will give one Lambda a role
that can read objects under
read/ and
write objects under
write/, and nothing else.
The function reads a file from read/,
tries to copy it back into read/
under a new name (which needs write access there, so it must be denied),
then writes it to write/.
You run the identical logic on Python 3.14,
Node.js 24 and
Java 25, then fire it from an
S3 event.
Everything runs on LocalEmu started with
IAM_ENFORCEMENT=1, so the
role is genuinely enforced: the disallowed copy comes back as
AccessDenied, not a rubber
stamp. No AWS account, no cost. Every file is given in full below, followed
by the exact commands to run and verify it.
Before you start: interpreted vs compiled runtimes
This is an AWS fact, not a LocalEmu one, and it changes how you package each Lambda:
- • Python and Node are interpreted.
You zip and upload your source
(
handler.py,index.mjs). The runtime already ships the AWS SDK (boto3 for Python, the v3 SDK for Node 18+). No build step. - • Java is compiled. AWS Lambda
runs JVM bytecode, so it does not accept
.javasource: you compile to a .jar and upload that. And because a Java Lambda calling S3 needs the AWS SDK for Java bundled into the jar (the runtime does not ship it), you build a fat jar with Maven or Gradle. The same applies to Go, Rust and .NET: compiled runtimes need a build step before upload.
To avoid installing a JDK or Maven on your machine, we build the jar inside a throwaway Maven container (step 3).
Project structure
Six files. Python and Node are single source files; Java is a tiny Maven project; two shell scripts build and run everything.
le-lambda-e2e/
python/handler.py # python3.14 handler (source, no build)
node/index.mjs # nodejs24.x handler (source, no build)
java/
pom.xml # Maven deps: AWS SDK + Lambda core
src/main/java/com/example/Handler.java # java25 handler (compiled to a jar)
build_java.sh # builds the jar in a Maven container
run.sh # role + bucket, deploy x3, assert, trigger The least-privilege role
Read on one folder, write on another. The copy-into-read step fails against
this policy, which is the whole point. (run.sh
below substitutes the real bucket name for $BUCKET.)
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "ReadFolder", "Effect": "Allow",
"Action": ["s3:GetObject"], "Resource": "arn:aws:s3:::$BUCKET/read/*" },
{ "Sid": "WriteFolder", "Effect": "Allow",
"Action": ["s3:PutObject"], "Resource": "arn:aws:s3:::$BUCKET/write/*" }
]
} python/handler.py
runtime python3.14. boto3 is
in the runtime; LocalEmu injects AWS_ENDPOINT_URL.
import os
import boto3
from botocore.exceptions import ClientError
BUCKET = os.environ["E2E_BUCKET"]
s3 = boto3.client("s3") # AWS_ENDPOINT_URL is injected by LocalEmu
def handler(event, context):
result = {"runtime": "python3.14", "read_ok": False,
"copy_denied": None, "write_ok": False}
# 1. read a file from the read/ folder (role allows s3:GetObject on read/*)
obj = s3.get_object(Bucket=BUCKET, Key="read/source.txt")
content = obj["Body"].read()
result["read_ok"] = True
# 2. try to copy it WITHIN read/ -> needs s3:PutObject on read/* -> DENIED
try:
s3.copy_object(
Bucket=BUCKET, Key="read/source-copy.txt",
CopySource={"Bucket": BUCKET, "Key": "read/source.txt"},
)
result["copy_denied"] = False # unexpected: it was allowed
except ClientError as e:
result["copy_denied"] = e.response["Error"]["Code"] == "AccessDenied"
# 3. write it to the write/ folder (role allows s3:PutObject on write/*)
s3.put_object(Bucket=BUCKET, Key="write/source-copy.txt", Body=content)
result["write_ok"] = True
return result node/index.mjs
runtime nodejs24.x. The AWS
SDK v3 is provided by the runtime.
// Node 24.x Lambda. The AWS SDK v3 is provided by the runtime (no bundling).
import {
S3Client,
GetObjectCommand,
CopyObjectCommand,
PutObjectCommand,
} from "@aws-sdk/client-s3";
const BUCKET = process.env.E2E_BUCKET;
const s3 = new S3Client({}); // AWS_ENDPOINT_URL injected by LocalEmu
export const handler = async (event) => {
const result = { runtime: "nodejs24.x", read_ok: false, copy_denied: null, write_ok: false };
// 1. read from read/ (role allows s3:GetObject on read/*)
const obj = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: "read/source.txt" }));
const content = await obj.Body.transformToByteArray();
result.read_ok = true;
// 2. try to copy WITHIN read/ -> needs s3:PutObject on read/* -> DENIED
try {
await s3.send(new CopyObjectCommand({
Bucket: BUCKET, Key: "read/source-copy.txt",
CopySource: `${BUCKET}/read/source.txt`,
}));
result.copy_denied = false; // unexpected
} catch (e) {
result.copy_denied = e.name === "AccessDenied" || String(e).includes("AccessDenied");
}
// 3. write to write/ (role allows s3:PutObject on write/*)
await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: "write/source-copy.txt", Body: content }));
result.write_ok = true;
return result;
}; java/src/main/java/com/example/Handler.java
runtime java25. The AWS SDK
for Java is bundled into the jar, not provided by the runtime.
package com.example;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
import java.util.HashMap;
import java.util.Map;
public class Handler implements RequestHandler<Map<String, Object>, Map<String, Object>> {
private static final String BUCKET = System.getenv("E2E_BUCKET");
@Override
public Map<String, Object> handleRequest(Map<String, Object> event, Context context) {
Map<String, Object> result = new HashMap<>();
result.put("runtime", "java25");
result.put("read_ok", false);
result.put("copy_denied", null);
result.put("write_ok", false);
// AWS_ENDPOINT_URL + AWS_REGION are injected by LocalEmu / the runtime.
S3Client s3 = S3Client.create();
// 1. read from read/ (role allows s3:GetObject on read/*)
ResponseBytes<GetObjectResponse> obj = s3.getObjectAsBytes(
GetObjectRequest.builder().bucket(BUCKET).key("read/source.txt").build());
byte[] content = obj.asByteArray();
result.put("read_ok", true);
// 2. try to copy WITHIN read/ -> needs s3:PutObject on read/* -> DENIED
try {
s3.copyObject(CopyObjectRequest.builder()
.sourceBucket(BUCKET).sourceKey("read/source.txt")
.destinationBucket(BUCKET).destinationKey("read/source-copy.txt")
.build());
result.put("copy_denied", false); // unexpected
} catch (S3Exception e) {
String code = e.awsErrorDetails() != null ? e.awsErrorDetails().errorCode() : "";
result.put("copy_denied", "AccessDenied".equals(code) || e.statusCode() == 403);
}
// 3. write to write/ (role allows s3:PutObject on write/*)
s3.putObject(PutObjectRequest.builder().bucket(BUCKET).key("write/source-copy.txt").build(),
RequestBody.fromBytes(content));
result.put("write_ok", true);
return result;
}
} java/pom.xml
Declares the bundled dependencies and the shade plugin that packages the fat jar.
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>lambda-e2e</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.29.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-core</artifactId>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>url-connection-client</artifactId>
</dependency>
</dependencies>
<build>
<finalName>lambda-e2e</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project> build_java.sh
Compiles and packages the jar in a throwaway Maven container. Output:
java/target/lambda-e2e.jar.
Nothing is installed on the host.
#!/usr/bin/env bash
# Build the Java Lambda fat jar inside a throwaway Maven container (nothing is
# installed on the host). Output: java/target/lambda-e2e.jar
set -e
docker run --rm \
-v /tmp/le-lambda-e2e/java:/app \
-v le-maven-cache:/root/.m2 \
-w /app \
maven:3-eclipse-temurin-21 \
mvn -q -DskipTests clean package
echo "built:"
ls -lh /tmp/le-lambda-e2e/java/target/lambda-e2e.jar run.sh
The whole orchestration via awsemu:
it creates the role and bucket, packages the Python and Node zips, deploys all
three functions, invokes each and asserts read OK / copy DENIED / write OK,
then wires the S3 trigger and checks it fired. Every command is here.
#!/usr/bin/env bash
# E2E: 3 Lambda runtimes + a scoped IAM role + an S3 trigger, via awsemu.
# REQUIRES the LocalEmu server running with IAM_ENFORCEMENT=1 (so the role's
# read/write folder scoping is actually enforced) and Docker (Lambda runs in
# real containers). Build the Java jar first: bash build_java.sh
set -u
ENDPOINT="http://localhost:4566"
AWSEMU="/Users/tarek/.virtualenvs/localemu-dev/bin/awsemu"
PY="/Users/tarek/.virtualenvs/localemu-dev/bin/python"
DIR="/tmp/le-lambda-e2e"
SFX=$RANDOM
BUCKET="le-lambda-e2e-$SFX"
ROLE="le-lambda-role-$SFX"
JAR="$DIR/java/target/lambda-e2e.jar"
F=0; ok(){ echo " PASS: $1"; }; no(){ echo " FAIL: $1"; F=$((F+1)); }
echo "=================================================================="
echo " Lambda x3 runtimes + scoped role + S3 trigger ($ENDPOINT)"
echo "=================================================================="
echo "[setup] IAM role: GetObject on read/*, PutObject on write/* (nothing else)"
TRUST='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
ROLE_ARN=$("$AWSEMU" iam create-role --role-name "$ROLE" --assume-role-policy-document "$TRUST" --query Role.Arn --output text)
"$AWSEMU" iam attach-role-policy --role-name "$ROLE" --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole >/dev/null 2>&1
SCOPED='{"Version":"2012-10-17","Statement":[{"Sid":"ReadFolder","Effect":"Allow","Action":["s3:GetObject"],"Resource":"arn:aws:s3:::'"$BUCKET"'/read/*"},{"Sid":"WriteFolder","Effect":"Allow","Action":["s3:PutObject"],"Resource":"arn:aws:s3:::'"$BUCKET"'/write/*"}]}'
"$AWSEMU" iam put-role-policy --role-name "$ROLE" --policy-name s3scoped --policy-document "$SCOPED" >/dev/null
echo "[setup] bucket + read/source.txt"
"$AWSEMU" s3api create-bucket --bucket "$BUCKET" >/dev/null
printf 'hello from the read folder' > "$DIR/source.txt"
"$AWSEMU" s3api put-object --bucket "$BUCKET" --key read/source.txt --body "$DIR/source.txt" >/dev/null
echo "[setup] package python + node zips"
( cd "$DIR/python" && rm -f /tmp/py.zip && zip -q -j /tmp/py.zip handler.py )
( cd "$DIR/node" && rm -f /tmp/node.zip && zip -q -j /tmp/node.zip index.mjs )
deploy_and_test() { # fn_name runtime handler package
local fn="$1" runtime="$2" handler="$3" pkg="$4"
echo "=== $runtime ($fn) ==="
if ! "$AWSEMU" lambda create-function --function-name "$fn" --runtime "$runtime" --handler "$handler" \
--role "$ROLE_ARN" --zip-file "fileb://$pkg" --timeout 60 --memory-size 512 \
--environment "Variables={E2E_BUCKET=$BUCKET}" >/dev/null 2>/tmp/cf_$fn; then
no "$runtime create-function failed: $(tr -d '\n' </tmp/cf_$fn | cut -c1-200)"; return
fi
"$AWSEMU" lambda wait function-active-v2 --function-name "$fn" 2>/dev/null
"$AWSEMU" lambda invoke --function-name "$fn" --payload '{}' "/tmp/out_$fn.json" >"/tmp/inv_$fn" 2>&1
local body; body=$(cat "/tmp/out_$fn.json" 2>/dev/null)
echo " result: $body"
local r; r=$("$PY" -c "import json,sys
try:
d=json.load(open('/tmp/out_$fn.json'))
print(int(bool(d.get('read_ok'))), int(bool(d.get('copy_denied'))), int(bool(d.get('write_ok'))))
except Exception as e:
print('parse_error', e)" 2>/dev/null)
case "$r" in
"1 1 1") ok "$runtime: read OK, copy-within-read DENIED, write OK" ;;
*) no "$runtime: read/copy_denied/write = '$r' (want '1 1 1'); inv=$(tr -d '\n' </tmp/inv_$fn | cut -c1-200)" ;;
esac
}
deploy_and_test "py-$SFX" python3.14 handler.handler /tmp/py.zip
deploy_and_test "node-$SFX" nodejs24.x index.handler /tmp/node.zip
if [ -f "$JAR" ]; then
deploy_and_test "java-$SFX" java25 'com.example.Handler::handleRequest' "$JAR"
else
no "java jar missing ($JAR) -- run: bash $DIR/build_java.sh"
fi
echo "=== S3 trigger (put on read/ invokes the python lambda) ==="
PYFN="py-$SFX"
PYARN=$("$AWSEMU" lambda get-function --function-name "$PYFN" --query Configuration.FunctionArn --output text 2>/dev/null)
"$AWSEMU" lambda add-permission --function-name "$PYFN" --statement-id s3invoke \
--action lambda:InvokeFunction --principal s3.amazonaws.com --source-arn "arn:aws:s3:::$BUCKET" >/dev/null 2>&1
"$AWSEMU" s3api put-bucket-notification-configuration --bucket "$BUCKET" \
--notification-configuration '{"LambdaFunctionConfigurations":[{"LambdaFunctionArn":"'"$PYARN"'","Events":["s3:ObjectCreated:*"],"Filter":{"Key":{"FilterRules":[{"Name":"prefix","Value":"read/"}]}}}]}' >/dev/null 2>/tmp/notif_err
"$AWSEMU" s3api delete-object --bucket "$BUCKET" --key write/source-copy.txt >/dev/null 2>&1
"$AWSEMU" s3api put-object --bucket "$BUCKET" --key read/trigger.txt --body "$DIR/source.txt" >/dev/null
FOUND=0
for i in $(seq 1 20); do
if "$AWSEMU" s3api head-object --bucket "$BUCKET" --key write/source-copy.txt >/dev/null 2>&1; then FOUND=1; break; fi
sleep 1
done
[ "$FOUND" = "1" ] && ok "S3 trigger invoked the lambda (write/source-copy.txt appeared)" || no "S3 trigger did not fire (notif_err: $(tr -d '\n' </tmp/notif_err | cut -c1-200))"
echo "[cleanup]"
for fn in "py-$SFX" "node-$SFX" "java-$SFX"; do "$AWSEMU" lambda delete-function --function-name "$fn" >/dev/null 2>&1; done
"$AWSEMU" iam delete-role-policy --role-name "$ROLE" --policy-name s3scoped >/dev/null 2>&1
"$AWSEMU" iam detach-role-policy --role-name "$ROLE" --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole >/dev/null 2>&1
"$AWSEMU" iam delete-role --role-name "$ROLE" >/dev/null 2>&1
for k in read/source.txt read/trigger.txt write/source-copy.txt; do "$AWSEMU" s3api delete-object --bucket "$BUCKET" --key "$k" >/dev/null 2>&1; done
"$AWSEMU" s3api delete-bucket --bucket "$BUCKET" >/dev/null 2>&1
echo "=================================================================="
[ "$F" -eq 0 ] && { echo " RESULT: ALL CHECKS PASSED ✅"; exit 0; } || { echo " RESULT: $F CHECK(S) FAILED ❌"; exit 1; } Run it and verify
Three steps. The first time you use each runtime, LocalEmu pulls the official
AWS Lambda image (public.ecr.aws/lambda/python:3.14,
nodejs:24,
java:25) on demand, so your
code runs on the same image AWS uses.
# 0. one-time: create the folder + files shown above
mkdir -p le-lambda-e2e && cd le-lambda-e2e
# (drop in python/handler.py, node/index.mjs, java/pom.xml,
# java/src/main/java/com/example/Handler.java, build_java.sh, run.sh)
# 1. start LocalEmu with IAM enforcement on (the Docker socket is auto-mounted,
# so Lambda can run its containers). Leave it running in another terminal.
IAM_ENFORCEMENT=1 localemu start
# 2. build the Java jar inside a throwaway Maven container (nothing on your machine)
bash build_java.sh
# 3. run the whole scenario and watch the assertions
bash run.sh What you should see
$ bash run.sh
==================================================================
Lambda x3 runtimes + scoped role + S3 trigger (http://localhost:4566)
==================================================================
[setup] IAM role: GetObject on read/*, PutObject on write/* (nothing else)
[setup] bucket + read/source.txt
=== python3.14 === result: {"runtime": "python3.14", "read_ok": true, "copy_denied": true, "write_ok": true}
PASS: python3.14: read OK, copy-within-read DENIED, write OK
=== nodejs24.x === result: {"runtime":"nodejs24.x","read_ok":true,"copy_denied":true,"write_ok":true}
PASS: nodejs24.x: read OK, copy-within-read DENIED, write OK
=== java25 === result: {"read_ok":true,"runtime":"java25","copy_denied":true,"write_ok":true}
PASS: java25: read OK, copy-within-read DENIED, write OK
=== S3 trigger (put on read/ invokes the python lambda) ===
PASS: S3 trigger invoked the lambda (write/source-copy.txt appeared)
==================================================================
RESULT: ALL CHECKS PASSED What you just proved, locally and at zero cost: your code runs on the real AWS runtime images for three languages, an execution role genuinely denies the action it should deny, and an S3 event invokes your function. The disallowed copy failing is the signal that the permissions are real, not a stub.