Docs/ Use Cases/ Lambda x3 runtimes + least-privilege

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 .java source: 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/ (the whole project)
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.)

The execution role's inline S3 policy
{
  "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.

python/handler.py (complete)
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/index.mjs (complete)
// 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.

Handler.java (complete)
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.

java/pom.xml (complete)
<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.

build_java.sh (complete)
#!/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.

run.sh (complete)
#!/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.

Terminal: the exact steps
# 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

Terminal: all checks pass
$ 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.

Where to go next