Cloud platforms (AWS, Azure, Google Cloud) · Intermediate

An event-driven function with a least-privilege role

4 min read

Why it matters

Managed and serverless services let small teams ship without running servers, and job descriptions for cloud, DevOps and data engineering roles name Lambda, Azure Functions and Cloud Run constantly. The two skills interviewers probe are wiring services together with events, and granting only the permissions each piece needs.

The concept

  • Event-driven: a new object in storage, a queue message or an HTTP request triggers a function. You pay per invocation and duration, not for idle servers.

  • Function: a handler that receives an event and returns a result. Keep it small, stateless and idempotent (safe to run twice on the same event, because retries happen).

  • Execution role: the identity the function runs as. Its policy should allow exactly the actions and resources the function uses: least privilege.

  • The same pattern exists everywhere: S3 plus Lambda on AWS, Blob Storage plus Functions (Event Grid) on Azure, Cloud Storage plus Cloud Run functions (Eventarc) on Google Cloud.

Worked example: summarise new CSV files

handler.py
"""AWS Lambda: when a CSV lands under raw/, count its rows and write a summary under reports/."""
import csv
import io
import json
import urllib.parse

import boto3

s3 = boto3.client("s3")


def lambda_handler(event, context):
    results = []
    for record in event["Records"]:
        bucket = record["s3"]["bucket"]["name"]
        key = urllib.parse.unquote_plus(record["s3"]["object"]["key"])
        body = s3.get_object(Bucket=bucket, Key=key)["Body"].read().decode("utf-8")
        rows = list(csv.DictReader(io.StringIO(body)))
        summary = {"source": key, "rows": len(rows), "columns": list(rows[0].keys()) if rows else []}
        out_key = "reports/" + key.rsplit("/", 1)[-1].replace(".csv", ".json")
        s3.put_object(Bucket=bucket, Key=out_key, Body=json.dumps(summary).encode(), ContentType="application/json")
        results.append(summary)
    return {"processed": len(results), "results": results}

The execution role's policy allows reading only raw/ and writing only reports/ in one bucket, plus writing its own logs:

lambda-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadRawFiles",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::unimitra-demo-raw-2026/raw/*"
    },
    {
      "Sid": "WriteReports",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::unimitra-demo-raw-2026/reports/*"
    },
    {
      "Sid": "WriteOwnLogs",
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:ap-south-1:*:log-group:/aws/lambda/summarise-csv*"
    }
  ]
}

Writing reports to a different prefix from the trigger (raw/) also prevents an infinite loop: if the function wrote back under raw/, each output would trigger it again.

Test it offline with moto:

test_handler.py
import boto3
from moto import mock_aws


@mock_aws
def test_summary_written(monkeypatch):
    monkeypatch.setenv("AWS_DEFAULT_REGION", "ap-south-1")
    s3 = boto3.client("s3", region_name="ap-south-1")
    s3.create_bucket(Bucket="demo", CreateBucketConfiguration={"LocationConstraint": "ap-south-1"})
    s3.put_object(Bucket="demo", Key="raw/zones.csv", Body=b"LocationID,Borough\n1,EWR\n2,Queens\n")

    import handler                                          # import inside the mock so its client is mocked
    event = {"Records": [{"s3": {"bucket": {"name": "demo"}, "object": {"key": "raw/zones.csv"}}}]}
    result = handler.lambda_handler(event, None)

    assert result["results"][0]["rows"] == 2
    report = s3.get_object(Bucket="demo", Key="reports/zones.json")["Body"].read()
    assert b'"rows": 2' in report

AWS_ACCESS_KEY_ID=testing AWS_SECRET_ACCESS_KEY=testing pytest -q passes. To deploy for real, package the handler, create the role with this policy, and add an S3 event notification for the raw/ prefix (the Infrastructure as Code shelf shows how to do all of it in Terraform).

Common mistakes

  • "Action": "s3:*", "Resource": "*" because it is quicker. It turns any bug or compromised dependency into full account data access.

  • Writing output where the trigger listens. Recursive invocations can run up a large bill in minutes.

  • Reading huge files into memory. Lambda has memory and time limits; stream large objects or use a batch service.

  • Assuming exactly-once delivery. Events can be delivered more than once; make handlers idempotent.

Interview angle

"Design a pipeline that processes files as they arrive." Describe the event trigger, a small idempotent function, a scoped role, a dead-letter queue for failures, and monitoring. Mention the recursion trap and how prefixes avoid it.

Practice task

  1. Add a test for an empty CSV and for a key with spaces (raw/my file.csv, which arrives URL-encoded).

  2. Write the equivalent Azure Function or Cloud Run function trigger configuration.

  3. Tighten the log resource ARN to a specific account id and explain why wildcards in ARNs are risky.

Further reading