Skip to content

feat(aws-lambda): op-level IAM authorization under --enforce-auth (AUTHZ-X1f) - #1530

Merged
NitinKumar004 merged 7 commits into
developmentfrom
feat/aws-lambda-iam-actions
Oct 10, 2026
Merged

NitinKumar004 merged 7 commits into
developmentfrom
feat/aws-lambda-iam-actions

Conversation

@NitinKumar004

@NitinKumar004 NitinKumar004 commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #1495 (P0 "Auth (--enforce-auth)"), tracker row AUTHZ-X1f.

Summary

Under cloudemu serve --enforce-auth, Lambda was authorized at service level only: just lambda:* on * passed, so any fine-grained Lambda policy was denied and a Deny on one action blocked all of Lambda. Lambda is now authorized per operation, on the ARNs and condition keys AWS uses, together with the function's resource-based policy.

  • Same operation for the gate and dispatch. One pure classify(r) picks the operation. ServeHTTP and IAMChecks both use it. It reads the Host header first (function URLs), then the path prefixes in dispatch order. It never reads X-Amz-Target or the signing scope. This refactor is its own commit, and the existing Lambda suites are unchanged.
  • Actions and resources. Every served operation is mapped, and the checked resource is always the one dispatch acts on.
    • Operations that act on the version or alias the request names are checked on the qualified ARN (name:prod, a qualified ARN, or ?Qualifier=): GetFunction, GetFunctionConfiguration, Invoke, DeleteFunction, AddPermission, GetPolicy, RemovePermission, and the function URL, event invoke config and provisioned concurrency operations. A policy on function:app therefore does not cover function:app:prod, as AWS documents.
    • Operations that act on the function whatever qualifier the FunctionName carries are checked on the unqualified ARN: UpdateFunctionConfiguration, UpdateFunctionCode, PublishVersion, ListVersionsByFunction, the alias operations, tagging and reserved concurrency.
    • $LATEST is the unpublished function itself, so it is checked on the unqualified ARN everywhere.
    • Layers are checked on layer:name or layer:name:version, and event source mappings on event-source-mapping:uuid.
    • ARNs are rebuilt from the server's partition, region and account, using the name dispatch uses.
  • Qualifier and ARN binding (Gate 2 fix).
    • AddPermission and RemovePermission with Qualifier $LATEST are rejected with InvalidParameterValueException. The AddPermission API reference says "Lambda does not support adding policies to version $LATEST". Before this, they wrote into the unqualified function's policy.
    • A FunctionName (or tag resource) ARN for another account or region no longer runs the local function of the same name. It gets ResourceNotFoundException "Function not found: ", because no such function exists on this server, and the gate treats it as an unknown operation.
    • CreateFunction accepts a name, a full ARN or a partial ARN of this account and region, and creates the bare name. A version or alias qualifier, or another account's ARN, is rejected with InvalidParameterValueException. Before this, the raw string became the function name.
    • New matrix rows (authz_matrix_lambda_qualifier_test.go) cover a user with Allow lambda:* and Deny lambda:* on function:f. With f:prod, f:1, f:$LATEST, a qualified ARN and ?Qualifier= for prod, 1 and $LATEST, every function-level operation is denied and f is unchanged.
      • Invoke of f and f:$LATEST is denied. Invoke of f:prod and f:1 is allowed, because a policy on the unqualified ARN does not match a version or alias.
      • The $LATEST AddPermission escalation is closed.
      • Foreign-ARN Invoke, DeleteFunction and TagResource leave the local function untouched.
  • Extra checks AWS makes.
    • CreateFunction with Tags also needs lambda:TagResource.
    • Layers on CreateFunction or UpdateFunctionConfiguration need lambda:GetLayerVersion on each layer version.
    • GetLayerVersionByArn needs lambda:GetLayerVersion.
  • Condition keys. The handler sets lambda:FunctionUrlAuthType, lambda:FunctionArn, lambda:Principal, lambda:Layer, lambda:SubnetIds, lambda:SecurityGroupIds, lambda:CodeSigningConfigArn, lambda:InvokedViaFunctionUrl, aws:RequestTag/*, aws:TagKeys and aws:ResourceTag/*.
    • They go through a new optional awsauthz.ContextResolver.
    • The gate merges only the handler's own lambda: keys and the tag keys, and the gate's global keys always win.
  • Resource-based policy.
    • Invoke and AWS_IAM function URLs. Invoke is checked in ResourcePolicy mode. Within one account the call is allowed when either the identity policy or the function policy for that version or alias allows it, and nothing explicitly denies it.
      • A statement that names the caller's user ARN or role-session ARN grants on its own.
      • A grant to the account (account ID or root ARN) only delegates to IAM.
      • Statements carrying SourceArn, SourceAccount, PrincipalOrgID or EventSourceToken never match a signed caller.
      • An AWS_IAM function URL needs both lambda:InvokeFunctionUrl and lambda:InvokeFunction, each from either source.
    • NONE function URLs. These are answered without a signature (PublicRequester). Under --enforce-auth they run only when the function policy grants both lambda:InvokeFunctionUrl and lambda:InvokeFunction to "*", with the lambda:FunctionUrlAuthType / lambda:InvokedViaFunctionUrl conditions satisfied. Otherwise they get 403 Forbidden.
    • Auth off is unchanged.
  • AddPermission drift fix. AddPermission used to drop FunctionUrlAuthType, InvokedViaFunctionUrl, SourceAccount, PrincipalOrgID and EventSourceToken, and GetPolicy did not return their Condition blocks. The NONE check needs them, and Terraform aws_lambda_permission read them back as drift. They are now stored, persisted and returned as AWS returns them: ArnLike AWS:SourceArn; StringEquals AWS:SourceAccount, aws:PrincipalOrgID, lambda:EventSourceToken and lambda:FunctionUrlAuthType; Bool lambda:InvokedViaFunctionUrl. An invalid FunctionUrlAuthType is rejected.
  • Deny shape. 403 with X-Amzn-Errortype: AccessDeniedException and body {"Type":"User","Message":"User: ... is not authorized to perform: lambda:X on resource: ..."}. Function URL hosts get {"Message":"Forbidden. For troubleshooting Function URL authorization issues, see: https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html"}.

Sources

  • Service Authorization Reference, "Actions, resources, and condition keys for AWS Lambda": https://docs.aws.amazon.com/service-authorization/latest/reference/list_awslambda.html. The tables were taken from AWS's machine-readable copy, https://servicereference.us-east-1.amazonaws.com/v1/lambda/lambda.json, using its Actions, Resources, ConditionKeys and Operations sections. For example, Operations lists CreateFunction as authorizing CreateFunction, GetLayerVersion, iam:PassRole and TagResource, and GetLayerVersionByArn as authorizing lambda:GetLayerVersion.
  • Lambda Developer Guide, "Fine-tuning the Resources and Conditions sections of policies": https://docs.aws.amazon.com/lambda/latest/dg/lambda-api-permissions-ref.html. It states "Lambda makes authorization decisions by comparing the resource element in the IAM policy with both the FunctionName and Qualifier passed in API calls", and "if your policy references the unqualified ARN, Lambda accepts requests that reference the unqualified ARN but denies requests that reference a qualified ARN".
  • Lambda Developer Guide, "Control access to Lambda function URLs": https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html. The InvokeFunction requirement is quoted exactly: "Starting in October 2025, new function URLs will require both lambda:InvokeFunctionUrl and lambda:InvokeFunction permissions." For NONE: "your function's resource-based policy is always in effect and must grant public access before your function URL can receive requests", and "If a function's resource-based policy doesn't grant lambda:invokeFunctionUrl and lambda:InvokeFunction permissions, users get a 403 Forbidden error code when they try to invoke your function URL. This occurs even if the function URL uses the NONE auth type."

Live repro of the Gate 2 findings (cloudemu serve --enforce-auth, aws CLI)

All 21 checks pass. bob has Allow lambda:* on * and Deny lambda:* on function:f.

  • bob is denied (AccessDeniedException):
    • update-function-configuration on f:prod, f:$LATEST and f:1;
    • update-function-code f:prod and publish-version f:prod;
    • create-alias --function-name f:prod, delete-alias f:1 and update-alias f:prod;
    • list-versions-by-function f:prod;
    • add-permission --qualifier '$LATEST';
    • invoke f:$LATEST;
    • tag-resource on the alias ARN.
  • Account root (boot): add-permission and remove-permission with --qualifier '$LATEST' give InvalidParameterValueException.
  • No escalation: a user with no grant still gets AccessDeniedException on invoke f, and f has no policy.
  • Foreign ARN and create: invoke by another account's ARN gives ResourceNotFoundException. create-function g:prod gives InvalidParameterValueException.
  • No changes: f's configuration and aliases are unchanged.
  • Version and alias are separate resources: invoke f:prod by bob still returns 200.

Unrouted Lambda APIs (checked, no fail-open)

I verified what happens today, before deferring them. A Lambda-signed request to InvokeAsync (/2014-11-13/...), InvokeWithResponseStream (/2021-11-15/...), GetAccountSettings (/2016-08-19/account-settings), code-signing-config CRUD (/2020-04-22/...) or the runtime management config (/2021-07-20/...) is not matched by any handler. The S3 catch-all declines other services' SigV4 scope (#1301), so the request gets a clean 501 and nothing runs. TestAuthzMatrixLambdaUnroutedOps pins this and checks no S3 bucket is created. The live e2e repeats it for GetAccountSettings. The missing routes are a feature gap, recorded below as LAMBDA-ROUTES.

Tests

  • Unit, server/aws/lambda.
    • classify_test.go: every route, error path and ordering edge case; classify never changes the URL.
    • iam_actions_test.go: action and resource for every operation, the unknown error paths, condition keys, and a check that the body is kept for dispatch.
    • resource_policy_test.go: principal, condition and action matching.
    • permission_conditions_sdk_test.go: AddPermission and GetPolicy round trip through the SDK.
  • Provider. providers/aws/lambda/policy_conditions_test.go.
  • Gate.
    • server/aws/authz_matrix_lambda_test.go covers:
      • qualified and unqualified Invoke, Event and DryRun, and a cross-account FunctionName;
      • deny-one;
      • RequestTag, ResourceTag, layers, TagResource at create, FunctionUrlAuthType, lambda:Principal and lambda:FunctionArn;
      • resource-policy grants and account delegation;
      • AWS_IAM and NONE function URLs;
      • unrouted operations, and unknown operations with no side effect.
    • server/wire/awsauthz/merge_context_test.go covers the key merge.
    • Two existing matrix cases changed. The service-level deny-one case moved from Lambda to API Gateway, which is still service-level, and Lambda denies now match the Lambda body.
  • contrib. TestEnforceAuthLambda runs the real Lambda SDK against serve --enforce-auth.
  • Gates.
    • go build ./... and go vet pass.
    • go test -race passes on server/aws/..., server/wire/..., providers/aws/lambda/... and persist/....
    • go -C contrib/server test -run Enforce passes.
    • golangci-lint --new-from-rev=origin/development reports 0 issues.
    • go generate produces no docs diff.
  • Live e2e against the built binary with cloudemu serve --enforce-auth: 28 of 28 checks pass.
    • aws CLI, invoke:
      • Invoke via --qualifier prod, app:prod and the alias ARN works for a user allowed on function:app:prod.
      • The same user is denied unqualified invoke and version 1.
      • The same user is denied delete-function, publish-layer-version, create-event-source-mapping and add-permission.
    • aws CLI, resource policy:
      • add-permission --principal arn:aws:iam::000000000000:user/named lets that user invoke.
      • An account-ID grant does not.
    • AWS_IAM function URL via curl --aws-sigv4 "aws:amz:us-east-1:lambda":
      • A user with both actions gets 200.
      • A user with only InvokeFunctionUrl gets 403 Forbidden.
      • Unsigned gets 403.
    • NONE function URL, unsigned:
      • 403 Forbidden with no policy, and 403 with only the InvokeFunctionUrl statement.
      • 200 after add-permission --principal '*' --function-url-auth-type NONE and --invoked-via-function-url.
      • get-policy returns both conditions.
      • The URL-only InvokeFunction statement does not allow a direct Invoke.
    • Terraform (hashicorp/aws 6.68.0) as a user scoped to the Lambda actions the provider calls, with aws_lambda_function, aws_lambda_function_url (NONE) and aws_lambda_permission (function_url_auth_type, invoked_via_function_url, source_arn and source_account):
      • apply, plan clean, URL returns 200, destroy.
      • A user without lambda:AddPermission fails the apply with AccessDeniedException naming that action.

Blast radius

  • Users with fine-grained or resource-scoped Lambda policies now work; before, they were all denied. Root, users with no policies and lambda:* users see no change.
  • NONE function URLs can now be called without a signature under --enforce-auth; before, every unsigned call was rejected. They run only with the public grant, as in AWS.
  • Shared code touched:
    • server/wire/awsauthz gains ContextResolver and MergeContext.
    • server/aws/authzgate.go prefers ContextResolver and merges its keys.
    • server/aws/aws.go passes WithEnforceAuth.
    • services/serverless/driver.PermissionStatement gains fields. They persist through the existing snapshot struct.
  • No other resolver changes. Auth-off responses are unchanged (TestAuthOffResponsesUnchanged, plus the unchanged Lambda suites).

Deferrals (new tracker rows)

Row What Where Why deferred
AUTHZ-X1f2 A resource-policy grant naming a role ARN (not the session ARN) is not applied; the caller needs an identity allow server/aws/lambda/authz.go:257 principalGranted AWS limits role-ARN grants by the role's permissions boundary, which the Lambda handler cannot read; needs the boundary evaluator X1d uses in sts
AUTHZ-X1f2 GetFunction should return Tags only when lambda:ListTags is explicitly allowed (API reference, GetFunction Tags) server/aws/lambda/handler.go:1204 get; awsauthz CheckMode needs an advisory CheckMode (record, never deny) in the gate
AUTHZ-X1f2 lambda:VpcIds is not set server/aws/lambda/iam_actions.go vpc needs an EC2 subnet to VPC lookup
AUTHZ-X1f2 Unsigned call to an AWS_IAM function URL returns the gate's MissingAuthenticationToken body, not {"Message":"Forbidden..."} (status 403 matches) server/aws/authgate.go:225 writeAuthError gate-wide auth error shapes
AUTHZ-X1f2 The aws:SourceArn statement condition is compared exactly, not with ArnLike wildcards server/aws/lambda/authz.go:269 conditionsHold only service principals send it, and they never reach the gate
AUTHZ-X1k iam:PassRole on CreateFunction / UpdateFunctionConfiguration Role, and the S3 code fetch on the caller's behalf server/aws/lambda/handler.go:1174 resolveCode on-behalf-of authorization row
LAMBDA-ROUTES InvokeAsync, InvokeWithResponseStream, GetAccountSettings, code-signing-config CRUD, Put/GetRuntimeManagementConfig, recursion and scaling config, PutFunctionCodeSigningConfig are not routed (501) server/aws/lambda/handler.go:256 Matches, classify.go feature work; they fail closed today

@NitinKumar004
NitinKumar004 marked this pull request as ready for review October 10, 2026 14:45
@NitinKumar004
NitinKumar004 merged commit 859ed4d into development Oct 10, 2026
22 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant