{"slug": "your-lambda-passes-tests-then-throws-accessdeniedexception-in-production", "title": "Your Lambda Passes Tests Then Throws AccessDeniedException in Production", "summary": "A developer built Infrawise, an open-source tool that prevents AWS AccessDeniedException errors by comparing a Lambda function's code dependencies against its IAM role permissions. The tool scans code for service edges like DynamoDB queries or SNS publishes, fetches the actual allowed services from the role's policies, and reports mismatches before deployment.", "body_md": "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`\n\nevent to SNS. It wrote clean code. Your unit tests mock both SDK clients, so they pass. CI is green. You deploy.\n\nThen the first real invocation logs this:\n\n```\nAccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/order-processor-role/order-processor\nis not authorized to perform: sns:Publish on resource: arn:aws:sns:us-east-1:123456789012:order-events\n```\n\nNothing 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.\n\nThis 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`\n\n. The linter sees valid TypeScript. The AI assistant reads your handler and has no idea what `order-processor-role`\n\nallows, because the policy lives in AWS (or in a Terraform file three directories away), not in the file it is editing.\n\nInfrawise closes that gap by comparing the two directly. [GitHub](https://github.com/Sidd27/infrawise) · [npm](https://www.npmjs.com/package/infrawise)\n\nAn AI coding assistant works from the files you give it. When it writes a Lambda handler, it can see:\n\nWhat it cannot see is the runtime identity that code will execute as. The link between \"this handler\" and \"this IAM role\" is a `Role`\n\nproperty 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.\n\nSo 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.\n\nInfrawise 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`\n\nor `scan`\n\nedge to a DynamoDB table, a `reads_secret`\n\nedge to a Secrets Manager secret, a `reads_parameter`\n\nedge to an SSM parameter, a `publishes_to`\n\nedge to an SQS queue or an SNS topic.\n\nSeparately, the Lambda extractor pulls `roleArn`\n\noff 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`\n\nstatement to the service prefix of its actions. `sns:Publish`\n\nbecomes `sns`\n\n. `dynamodb:GetItem`\n\nbecomes `dynamodb`\n\n. A bare `\"*\"`\n\naction becomes `*`\n\n.\n\nThe result is `allowedServices`\n\n, a flat list like `[\"logs\", \"dynamodb\"]`\n\n.\n\nNow the comparison is trivial. Walk the function's outgoing edges, map each to the service prefix it requires, and subtract `allowedServices`\n\n. What is left is what the code needs and the role does not grant.\n\nThe mapping is deliberately narrow. Five edge types map to five services:\n\n| Edge | Required service |\n|---|---|\n`query` or `scan` on a DynamoDB table |\n`dynamodb` |\n`reads_secret` |\n`secretsmanager` |\n`reads_parameter` |\n`ssm` |\n`publishes_to` a queue |\n`sqs` |\n`publishes_to` a topic |\n`sns` |\n\nTwo cases produce no finding at all, on purpose:\n\n**No IAM data.** If the IAM read fails (missing permission, SCP, whatever), `allowedServices`\n\ncomes 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.\n\n**Wildcard.** If the role has `\"Action\": \"*\"`\n\nanywhere, the service list contains `*`\n\nand the function is skipped. AdministratorAccess is its own problem, but it is not a missing-permission problem.\n\nTwo places, and they cover the two moments where it matters.\n\n**While you are writing the handler.** `analyze_function`\n\nreturns `missingPermissions`\n\nalongside everything else it knows about the function:\n\n```\n{\n  \"function\": \"order-processor\",\n  \"found\": true,\n  \"file\": \"src/handlers/order-processor.ts\",\n  \"triggers\": [\n    { \"type\": \"sqs\", \"source\": \"orders\", \"eventShape\": \"event.Records[0].body\" }\n  ],\n  \"accesses\": [\n    { \"targetId\": \"table:dynamo:Orders\", \"edgeType\": \"query\", \"targetName\": \"Orders\", \"targetType\": \"table\" },\n    { \"targetId\": \"secret:aws:stripe/live\", \"edgeType\": \"reads_secret\", \"targetName\": \"stripe/live\", \"targetType\": \"secret\" },\n    { \"targetId\": \"topic:aws:order-events\", \"edgeType\": \"publishes_to\", \"targetName\": \"order-events\", \"targetType\": \"topic\" }\n  ],\n  \"missingPermissions\": [\"secretsmanager\", \"sns\"],\n  \"issues\": [ ... ],\n  \"recommendations\": [ ... ]\n}\n```\n\nThat 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.\n\nNote the field is omitted entirely when IAM data was unavailable, rather than returned as an empty array. An empty `missingPermissions`\n\nmeans \"checked, nothing missing.\" An absent one means \"not checked.\" Those are different answers and conflating them is how you get misplaced confidence.\n\n**Before you deploy.** The same comparison runs as a high-severity analyzer in `infrawise analyze`\n\nand `infrawise check`\n\n:\n\n```\n  1.  HIGH   Lambda \"order-processor\" accesses sns but execution role has no sns permissions\n       \"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.\n       → Add sns permissions to the execution role for \"order-processor\". Minimum required: sns:Publish.\n```\n\nThe recommendation names actual actions, not `sns:*`\n\n. Each service has a minimal action set baked in: `secretsmanager:GetSecretValue`\n\nfor secrets, `ssm:GetParameter, ssm:GetParameters, ssm:GetParametersByPath`\n\nfor parameters, `sqs:SendMessage, sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes`\n\nfor 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`\n\n.\n\nBecause it is a high-severity finding, `infrawise check`\n\nfails the build on it by default. That is the whole CI story: `check`\n\nruns 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`\n\nshows up. Default threshold is `high`\n\n.\n\n```\ninfrawise check\n```\n\n**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`\n\nin `src/orders.ts`\n\nbut deployed as `order-processor`\n\n, 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.\n\n**Granularity stops at the service prefix.** Infrawise compares `sns`\n\nagainst `sns`\n\n, not `arn:aws:sns:us-east-1:123456789012:order-events`\n\nagainst the resource in your `PublishCommand`\n\n. 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.\n\n**It needs read access to IAM.** Five actions: `iam:ListAttachedRolePolicies`\n\n, `iam:ListRolePolicies`\n\n, `iam:GetRolePolicy`\n\n, `iam:GetPolicy`\n\n, `iam:GetPolicyVersion`\n\n. 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.\n\n```\ncd your-project\nnpx infrawise start --claude\n```\n\nThat probes your environment, writes `infrawise.yaml`\n\nand `.mcp.json`\n\n, 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.\n\n`AccessDeniedException`\n\nin a Lambda is almost never a code bug. It is a drift bug: the code grew a dependency, the execution role did not follow.`analyze_function`\n\nbefore writing a handler to get `missingPermissions`\n\nalongside the trigger event shape. An absent field means IAM was not readable; an empty array means it was checked and clean.`infrawise check`\n\nin CI. Missing permissions are high severity, so the default `--fail-on high`\n\nblocks the deploy.", "url": "https://wpnews.pro/news/your-lambda-passes-tests-then-throws-accessdeniedexception-in-production", "canonical_source": "https://dev.to/siddharth_pandey_27/your-lambda-passes-tests-then-throws-accessdeniedexception-in-production-28bc", "published_at": "2026-07-27 06:12:56+00:00", "updated_at": "2026-07-27 06:35:24.868774+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Infrawise", "AWS", "Secrets Manager", "SNS", "DynamoDB", "IAM", "Lambda", "Claude Code"], "alternates": {"html": "https://wpnews.pro/news/your-lambda-passes-tests-then-throws-accessdeniedexception-in-production", "markdown": "https://wpnews.pro/news/your-lambda-passes-tests-then-throws-accessdeniedexception-in-production.md", "text": "https://wpnews.pro/news/your-lambda-passes-tests-then-throws-accessdeniedexception-in-production.txt", "jsonld": "https://wpnews.pro/news/your-lambda-passes-tests-then-throws-accessdeniedexception-in-production.jsonld"}}