DEV Community

Cover image for Your Lambda Passes Tests Then Throws AccessDeniedException in Production
Siddharth Pandey
Siddharth Pandey

Posted on

Your Lambda Passes Tests Then Throws AccessDeniedException in Production

You asked Claude Code to add a step to your order handler: pull the Stripe key out of Secrets Manager, then publish an order.settled event to SNS. It wrote clean code. Your unit tests mock both SDK clients, so they pass. CI is green. You deploy.

Then the first real invocation logs this:

AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/order-processor-role/order-processor
is not authorized to perform: sns:Publish on resource: arn:aws:sns:us-east-1:123456789012:order-events
Enter fullscreen mode Exit fullscreen mode

Nothing was wrong with the code. The execution role was written months ago, when the function only touched DynamoDB. The code grew a new dependency and the policy did not follow.

This is one of the most annoying classes of AWS bug because it is invisible to every tool in the loop. The type checker sees a valid PublishCommand. The linter sees valid TypeScript. The AI assistant reads your handler and has no idea what order-processor-role allows, because the policy lives in AWS (or in a Terraform file three directories away), not in the file it is editing.

Infrawise closes that gap by comparing the two directly. GitHub · npm

Why the assistant cannot see this on its own

An AI coding assistant works from the files you give it. When it writes a Lambda handler, it can see:

  • the handler source
  • imports, so it knows which AWS SDK clients you use
  • maybe a nearby test file

What it cannot see is the runtime identity that code will execute as. The link between "this handler" and "this IAM role" is a Role property on the deployed function, resolved through an ARN, and the policy behind that ARN is a set of JSON documents attached to a role name. Three lookups away from anything in your repo.

So the assistant does the only thing it can: it assumes the permissions exist. Ninety percent of the time they do, which is exactly what makes the other ten percent expensive. The failure surfaces at runtime, in an environment where you are reading CloudWatch instead of your editor.

What infrawise actually compares

Infrawise already builds a graph of your infrastructure and your code. The AST scan produces typed edges from functions to the things they touch: a query or scan edge to a DynamoDB table, a reads_secret edge to a Secrets Manager secret, a reads_parameter edge to an SSM parameter, a publishes_to edge to an SQS queue or an SNS topic.

Separately, the Lambda extractor pulls roleArn off every function, then fetches what that role permits. It batches the fetch per unique role ARN, so ten functions sharing one role cost one IAM round trip, not ten. For each role it walks both attached managed policies and inline policies, decodes the policy documents, and reduces every Allow statement to the service prefix of its actions. sns:Publish becomes sns. dynamodb:GetItem becomes dynamodb. A bare "*" action becomes *.

The result is allowedServices, a flat list like ["logs", "dynamodb"].

Now the comparison is trivial. Walk the function's outgoing edges, map each to the service prefix it requires, and subtract allowedServices. What is left is what the code needs and the role does not grant.

The mapping is deliberately narrow. Five edge types map to five services:

Edge Required service
query or scan on a DynamoDB table dynamodb
reads_secret secretsmanager
reads_parameter ssm
publishes_to a queue sqs
publishes_to a topic sns

Two cases produce no finding at all, on purpose:

No IAM data. If the IAM read fails (missing permission, SCP, whatever), allowedServices comes back undefined and the analyzer skips the function entirely. Infrawise will not tell you a permission is missing when it could not read the policy. Silence beats a false alarm you learn to ignore.

Wildcard. If the role has "Action": "*" anywhere, the service list contains * and the function is skipped. AdministratorAccess is its own problem, but it is not a missing-permission problem.

Where you see it

Two places, and they cover the two moments where it matters.

While you are writing the handler. analyze_function returns missingPermissions alongside everything else it knows about the function:

{
  "function": "order-processor",
  "found": true,
  "file": "src/handlers/order-processor.ts",
  "triggers": [
    { "type": "sqs", "source": "orders", "eventShape": "event.Records[0].body" }
  ],
  "accesses": [
    { "targetId": "table:dynamo:Orders", "edgeType": "query", "targetName": "Orders", "targetType": "table" },
    { "targetId": "secret:aws:stripe/live", "edgeType": "reads_secret", "targetName": "stripe/live", "targetType": "secret" },
    { "targetId": "topic:aws:order-events", "edgeType": "publishes_to", "targetName": "order-events", "targetType": "topic" }
  ],
  "missingPermissions": ["secretsmanager", "sns"],
  "issues": [ ... ],
  "recommendations": [ ... ]
}
Enter fullscreen mode Exit fullscreen mode

That is the whole point. The assistant asked one question before writing code and got back the trigger event shape and the fact that two of the three services this function talks to are not in its role. It can tell you to fix the policy in the same breath as it writes the handler.

Note the field is omitted entirely when IAM data was unavailable, rather than returned as an empty array. An empty missingPermissions means "checked, nothing missing." An absent one means "not checked." Those are different answers and conflating them is how you get misplaced confidence.

Before you deploy. The same comparison runs as a high-severity analyzer in infrawise analyze and infrawise check:

  1.  HIGH   Lambda "order-processor" accesses sns but execution role has no sns permissions
       "order-processor" calls sns in code but its IAM execution role (arn:aws:iam::123456789012:role/order-processor-role) has no sns:* permissions. This will cause AccessDeniedException at runtime — code passes tests but fails in AWS.
       → Add sns permissions to the execution role for "order-processor". Minimum required: sns:Publish.
Enter fullscreen mode Exit fullscreen mode

The recommendation names actual actions, not sns:*. Each service has a minimal action set baked in: secretsmanager:GetSecretValue for secrets, ssm:GetParameter, ssm:GetParameters, ssm:GetParametersByPath for parameters, sqs:SendMessage, sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes for queues, and the CRUD-plus-query set for DynamoDB. You can paste the list into the policy without thinking about whether you just granted dynamodb:DeleteTable.

Because it is a high-severity finding, infrawise check fails the build on it by default. That is the whole CI story: check runs a fresh analysis (never a cached graph, since CI gating on stale data is worse than no gating) and exits non-zero when anything at or above --fail-on shows up. Default threshold is high.

infrawise check
Enter fullscreen mode Exit fullscreen mode

Two things to know before you trust it

Names have to line up. The check joins a code function node to a Lambda node by exact name match. If your handler is exported as handler in src/orders.ts but deployed as order-processor, infrawise finds no code node for that Lambda and produces no finding. It fails closed, which is the right direction, but it means the check is only as good as your naming discipline. If you use a framework that derives function names from the deployed name, you get this for free.

Granularity stops at the service prefix. Infrawise compares sns against sns, not arn:aws:sns:us-east-1:123456789012:order-events against the resource in your PublishCommand. A role scoped to one topic passes the check even if your code publishes to a different topic. So this catches "the policy has no SNS at all," which is the common case, not "the policy has the wrong SNS resource." Resource-level checking is a different problem and pretending otherwise would make the finding untrustworthy.

It needs read access to IAM. Five actions: iam:ListAttachedRolePolicies, iam:ListRolePolicies, iam:GetRolePolicy, iam:GetPolicy, iam:GetPolicyVersion. They are optional in the infrawise IAM policy. Drop them and everything else still works, you just lose this check. Infrawise is read-only across the board, so nothing here writes to your account.

Try it

cd your-project
npx infrawise start --claude
Enter fullscreen mode Exit fullscreen mode

That probes your environment, writes infrawise.yaml and .mcp.json, and opens Claude Code with all 21 tools connected. Ask it to write a Lambda that reads a secret, and watch it check the role before it writes the code.

GitHub · npm

Key takeaways

  • AccessDeniedException in a Lambda is almost never a code bug. It is a drift bug: the code grew a dependency, the execution role did not follow.
  • Your AI assistant cannot catch this from source files alone, because the role that code runs as is three lookups away from anything in your repo.
  • Infrawise compares the function's actual outgoing edges (DynamoDB query, secret read, SSM read, SQS send, SNS publish) against the service prefixes its IAM role permits, then reports the difference.
  • Call analyze_function before writing a handler to get missingPermissions alongside the trigger event shape. An absent field means IAM was not readable; an empty array means it was checked and clean.
  • Run infrawise check in CI. Missing permissions are high severity, so the default --fail-on high blocks the deploy.
  • The check is service-level, not resource-level, and joins code to Lambda by exact function name. Know both limits so you know what a clean result actually proves.

Top comments (0)