From e7c06ab0c87bdd2332a413fb703b1989943a87ba Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sat, 10 Oct 2026 17:20:35 +0530 Subject: [PATCH 1/6] refactor(aws-lambda): pick the operation in one classify function --- server/aws/lambda/classify.go | 432 +++++++++++++++++++ server/aws/lambda/classify_test.go | 116 +++++ server/aws/lambda/code_signing.go | 11 +- server/aws/lambda/concurrency.go | 10 +- server/aws/lambda/esm.go | 16 +- server/aws/lambda/event_invoke_config.go | 31 +- server/aws/lambda/functionurl.go | 39 +- server/aws/lambda/handler.go | 200 ++++----- server/aws/lambda/layers.go | 60 +-- server/aws/lambda/provisioned_concurrency.go | 18 +- 10 files changed, 720 insertions(+), 213 deletions(-) create mode 100644 server/aws/lambda/classify.go create mode 100644 server/aws/lambda/classify_test.go diff --git a/server/aws/lambda/classify.go b/server/aws/lambda/classify.go new file mode 100644 index 000000000..bdf9cd54c --- /dev/null +++ b/server/aws/lambda/classify.go @@ -0,0 +1,432 @@ +package lambda + +import ( + "net/http" + "strings" +) + +// opID names the Lambda API operation a request runs. classify picks it from +// the request, and ServeHTTP and IAMChecks both use that choice, so the +// operation IAM authorizes is the one that runs. opUnknown is every request +// the handler answers with an error and no side effect. +type opID = string + +// The Lambda operations this handler serves. +const ( + opUnknown opID = "" + + opInvokeFunctionURL opID = "InvokeFunctionUrl" + + opListFunctions opID = "ListFunctions" + opCreateFunction opID = "CreateFunction" + opGetFunction opID = "GetFunction" + opDeleteFunction opID = "DeleteFunction" + opInvoke opID = "Invoke" + opGetFunctionConfiguration opID = "GetFunctionConfiguration" + opUpdateFunctionConfiguration opID = "UpdateFunctionConfiguration" + opUpdateFunctionCode opID = "UpdateFunctionCode" + opPublishVersion opID = "PublishVersion" + opListVersionsByFunction opID = "ListVersionsByFunction" + opCreateAlias opID = "CreateAlias" + opListAliases opID = "ListAliases" + opGetAlias opID = "GetAlias" + opUpdateAlias opID = "UpdateAlias" + opDeleteAlias opID = "DeleteAlias" + opAddPermission opID = "AddPermission" + opGetPolicy opID = "GetPolicy" + opRemovePermission opID = "RemovePermission" + + opTagResource opID = "TagResource" + opUntagResource opID = "UntagResource" + opListTags opID = "ListTags" + + opCreateEventSourceMapping opID = "CreateEventSourceMapping" + opListEventSourceMappings opID = "ListEventSourceMappings" + opGetEventSourceMapping opID = "GetEventSourceMapping" + opUpdateEventSourceMapping opID = "UpdateEventSourceMapping" + opDeleteEventSourceMapping opID = "DeleteEventSourceMapping" + + opListLayers opID = "ListLayers" + opGetLayerVersionByArn opID = "GetLayerVersionByArn" + opPublishLayerVersion opID = "PublishLayerVersion" + opListLayerVersions opID = "ListLayerVersions" + opGetLayerVersion opID = "GetLayerVersion" + opDeleteLayerVersion opID = "DeleteLayerVersion" + opAddLayerVersionPermission opID = "AddLayerVersionPermission" + opGetLayerVersionPolicy opID = "GetLayerVersionPolicy" + opRemoveLayerVersionPermission opID = "RemoveLayerVersionPermission" + + opCreateFunctionURLConfig opID = "CreateFunctionUrlConfig" + opGetFunctionURLConfig opID = "GetFunctionUrlConfig" + opUpdateFunctionURLConfig opID = "UpdateFunctionUrlConfig" + opDeleteFunctionURLConfig opID = "DeleteFunctionUrlConfig" + opListFunctionURLConfigs opID = "ListFunctionUrlConfigs" + + opPutEventInvokeConfig opID = "PutFunctionEventInvokeConfig" + opUpdateEventInvokeConfig opID = "UpdateFunctionEventInvokeConfig" + opGetEventInvokeConfig opID = "GetFunctionEventInvokeConfig" + opDeleteEventInvokeConfig opID = "DeleteFunctionEventInvokeConfig" + opListEventInvokeConfigs opID = "ListFunctionEventInvokeConfigs" + + opPutProvisionedConcurrency opID = "PutProvisionedConcurrencyConfig" + opGetProvisionedConcurrency opID = "GetProvisionedConcurrencyConfig" + opDeleteProvisionedConcurrency opID = "DeleteProvisionedConcurrencyConfig" + opListProvisionedConcurrency opID = "ListProvisionedConcurrencyConfigs" + + opGetFunctionCodeSigningConfig opID = "GetFunctionCodeSigningConfig" + opDeleteFunctionCodeSigningConfig opID = "DeleteFunctionCodeSigningConfig" + + opPutFunctionConcurrency opID = "PutFunctionConcurrency" + opGetFunctionConcurrency opID = "GetFunctionConcurrency" + opDeleteFunctionConcurrency opID = "DeleteFunctionConcurrency" +) + +// route is the group of operations that share a URL prefix and the +// preconditions ServeHTTP checks before running one of them. +type route int + +const ( + routeFunctions route = iota + routeFunctionURLInvoke + routeTags + routeEventSourceMappings + routeLayers + routeFunctionURL + routeEventInvokeConfig + routeProvisionedConcurrency + routeCodeSigning + routeConcurrency +) + +// opArgs is what classify extracts from the request. +type opArgs struct { + route route + // parts are the path segments after the route's prefix, split on "/". + // For routeFunctions an empty slice is the /functions collection. + parts []string + // name is the function or layer name the operation acts on, exactly as + // dispatch passes it to the driver. For the /2015-03-31/functions tree it + // is the bare name resolveFunctionRef returns. + name string + // qualifier is the version or alias the request names: the qualifier + // resolveFunctionRef reconciles for the /functions tree, and ?Qualifier= + // on the other routes that read it. + qualifier string + // item is the alias name, statement id, event source mapping UUID, tag + // resource ARN or layer version the operation names. + item string + // qualifierConflict is a FunctionName qualifier that disagrees with + // ?Qualifier=, which dispatch answers with a ValidationException. + qualifierConflict bool +} + +// Function sub-resource path segments. +const ( + subInvocations = "invocations" + subConfiguration = "configuration" + subCode = "code" + subURL = "url" + subURLs = "urls" +) + +// Segment counts of a /2015-03-31/functions/... path. +const ( + partsResource = 1 // /functions/{name} + partsSubresource = 2 // /functions/{name}/{sub} + partsSubItem = 3 // /functions/{name}/{sub}/{id} +) + +// classify returns the operation r runs and its arguments. It reads only the +// Host, the URL path and query, and the method, and follows the dispatch +// order: a Function URL host first, then each version-prefixed sub-API, then +// the /2015-03-31/functions tree. It does not modify r. +func classify(r *http.Request) (opID, opArgs) { + if isFunctionURLHost(r.Host) { + return opInvokeFunctionURL, opArgs{route: routeFunctionURLInvoke} + } + + path := r.URL.Path + + switch { + case strings.HasPrefix(path, tagsPrefix): + return classifyTags(r, trimRoute(path, tagsPrefix)) + case strings.HasPrefix(path, esmPrefix): + return classifyEventSourceMappings(r, trimRoute(path, esmPrefix)) + case strings.HasPrefix(path, layersPrefix): + return classifyLayers(r, trimRoute(path, layersPrefix)) + case strings.HasPrefix(path, functionURLPrefix): + return classifyFunctionURL(r, trimRoute(path, functionURLPrefix)) + case strings.HasPrefix(path, eventInvokeConfigPrefix): + return classifyEventInvokeConfig(r, trimRoute(path, eventInvokeConfigPrefix)) + case isProvisionedConcurrencyPath(path): + return classifyProvisionedConcurrency(r) + case isCodeSigningPath(path): + return classifyCodeSigning(r) + } + + if name, ok := concurrencyFunctionName(path); ok { + return methodOp(r.Method, map[string]opID{ + http.MethodPut: opPutFunctionConcurrency, http.MethodGet: opGetFunctionConcurrency, + http.MethodDelete: opDeleteFunctionConcurrency, + }), opArgs{route: routeConcurrency, name: name} + } + + return classifyFunctions(r) +} + +// trimRoute strips a route prefix and the slash after it. +func trimRoute(path, prefix string) string { + return strings.TrimPrefix(strings.TrimPrefix(path, prefix), "/") +} + +// methodOp returns the operation ops binds to method, or opUnknown. +func methodOp(method string, ops map[string]opID) opID { + return ops[method] +} + +// classifyTags handles /2017-03-31/tags/{arn}. +func classifyTags(r *http.Request, arn string) (opID, opArgs) { + op := methodOp(r.Method, map[string]opID{ + http.MethodPost: opTagResource, http.MethodDelete: opUntagResource, http.MethodGet: opListTags, + }) + + return op, opArgs{route: routeTags, item: arn, name: functionNameFromARN(arn)} +} + +// classifyEventSourceMappings handles /2015-03-31/event-source-mappings[/{uuid}]. +func classifyEventSourceMappings(r *http.Request, uuid string) (opID, opArgs) { + a := opArgs{route: routeEventSourceMappings, item: uuid} + + if uuid == "" { + return methodOp(r.Method, map[string]opID{ + http.MethodPost: opCreateEventSourceMapping, http.MethodGet: opListEventSourceMappings, + }), a + } + + return methodOp(r.Method, map[string]opID{ + http.MethodGet: opGetEventSourceMapping, http.MethodPut: opUpdateEventSourceMapping, + http.MethodDelete: opDeleteEventSourceMapping, + }), a +} + +// classifyLayers handles /2018-10-31/layers[/...]. +func classifyLayers(r *http.Request, rest string) (opID, opArgs) { + a := opArgs{route: routeLayers} + + if rest == "" { + if r.Method != http.MethodGet { + return opUnknown, a + } + + if r.URL.Query().Get("find") == findLayerVersion { + return opGetLayerVersionByArn, a + } + + return opListLayers, a + } + + a.parts = strings.Split(rest, "/") + if len(a.parts) < partsVersions || a.parts[1] != subVersions { + return opUnknown, a + } + + a.name = a.parts[0] + + switch len(a.parts) { + case partsVersions: + return methodOp(r.Method, map[string]opID{ + http.MethodPost: opPublishLayerVersion, http.MethodGet: opListLayerVersions, + }), a + case partsVersion: + a.item = a.parts[2] + + return methodOp(r.Method, map[string]opID{ + http.MethodGet: opGetLayerVersion, http.MethodDelete: opDeleteLayerVersion, + }), a + } + + return classifyLayerPolicy(r, &a) +} + +// classifyLayerPolicy handles .../layers/{name}/versions/{v}/policy[/{sid}]. +func classifyLayerPolicy(r *http.Request, a *opArgs) (opID, opArgs) { + if a.parts[3] != subPolicy { + return opUnknown, *a + } + + a.item = a.parts[2] + + switch len(a.parts) { + case partsPolicy: + return methodOp(r.Method, map[string]opID{ + http.MethodPost: opAddLayerVersionPermission, http.MethodGet: opGetLayerVersionPolicy, + }), *a + case partsPolicyStmt: + if r.Method == http.MethodDelete { + return opRemoveLayerVersionPermission, *a + } + } + + return opUnknown, *a +} + +// classifyFunctionURL handles /2021-10-31/functions/{name}/url and /urls. +func classifyFunctionURL(r *http.Request, rest string) (opID, opArgs) { + a := opArgs{route: routeFunctionURL, parts: strings.Split(rest, "/")} + + const wantParts = 2 // {name}/url or {name}/urls + if len(a.parts) != wantParts || a.parts[0] == "" { + return opUnknown, a + } + + a.name = a.parts[0] + a.qualifier = r.URL.Query().Get("Qualifier") + + switch a.parts[1] { + case subURL: + return methodOp(r.Method, map[string]opID{ + http.MethodPost: opCreateFunctionURLConfig, http.MethodPut: opUpdateFunctionURLConfig, + http.MethodGet: opGetFunctionURLConfig, http.MethodDelete: opDeleteFunctionURLConfig, + }), a + case subURLs: + if r.Method == http.MethodGet { + return opListFunctionURLConfigs, a + } + } + + return opUnknown, a +} + +// classifyEventInvokeConfig handles /2019-09-25/functions/{name}/event-invoke-config[/list]. +func classifyEventInvokeConfig(r *http.Request, rest string) (opID, opArgs) { + a := opArgs{route: routeEventInvokeConfig, parts: strings.Split(rest, "/")} + + const ( + itemParts = 2 // {name}/event-invoke-config + listParts = 3 // {name}/event-invoke-config/list + ) + + if len(a.parts) < itemParts || a.parts[0] == "" || a.parts[1] != eventInvokeConfigSuffix { + return opUnknown, a + } + + a.name = a.parts[0] + a.qualifier = r.URL.Query().Get("Qualifier") + + if len(a.parts) == listParts && a.parts[2] == eventInvokeListSegment { + if r.Method == http.MethodGet { + return opListEventInvokeConfigs, a + } + + return opUnknown, a + } + + if len(a.parts) != itemParts { + return opUnknown, a + } + + return methodOp(r.Method, map[string]opID{ + http.MethodPut: opPutEventInvokeConfig, http.MethodPost: opUpdateEventInvokeConfig, + http.MethodGet: opGetEventInvokeConfig, http.MethodDelete: opDeleteEventInvokeConfig, + }), a +} + +// classifyProvisionedConcurrency handles /2019-09-30/functions/{name}/provisioned-concurrency. +func classifyProvisionedConcurrency(r *http.Request) (opID, opArgs) { + a := opArgs{route: routeProvisionedConcurrency} + + name, ok := provisionedConcurrencyFunctionName(r.URL.Path) + if !ok { + return opUnknown, a + } + + a.name = name + + if r.Method == http.MethodGet && r.URL.Query().Get(provisionedConcurrencyListParam) == provisionedConcurrencyListParamAll { + return opListProvisionedConcurrency, a + } + + a.qualifier = r.URL.Query().Get("Qualifier") + + return methodOp(r.Method, map[string]opID{ + http.MethodPut: opPutProvisionedConcurrency, http.MethodGet: opGetProvisionedConcurrency, + http.MethodDelete: opDeleteProvisionedConcurrency, + }), a +} + +// classifyCodeSigning handles /2020-06-30/functions/{name}/code-signing-config. +func classifyCodeSigning(r *http.Request) (opID, opArgs) { + name, _ := codeSigningFunctionName(r.URL.Path) + a := opArgs{route: routeCodeSigning, name: name} + + return methodOp(r.Method, map[string]opID{ + http.MethodGet: opGetFunctionCodeSigningConfig, http.MethodDelete: opDeleteFunctionCodeSigningConfig, + }), a +} + +// classifyFunctions handles the /2015-03-31/functions tree. +func classifyFunctions(r *http.Request) (opID, opArgs) { + a := opArgs{route: routeFunctions} + + rest := trimRoute(r.URL.Path, pathPrefix) + if rest == "" { + return methodOp(r.Method, map[string]opID{ + http.MethodGet: opListFunctions, http.MethodPost: opCreateFunction, + }), a + } + + a.parts = strings.Split(rest, "/") + + name, embedded := splitFunctionNameQualifier(a.parts[0]) + qualifier, ok := reconcileQualifier(embedded, r.URL.Query().Get("Qualifier")) + a.name, a.qualifier, a.qualifierConflict = name, qualifier, !ok + + if !ok { + return opUnknown, a + } + + switch len(a.parts) { + case partsResource: + return methodOp(r.Method, map[string]opID{http.MethodGet: opGetFunction, http.MethodDelete: opDeleteFunction}), a + case partsSubresource: + return subresourceOp(r.Method, a.parts[1]), a + case partsSubItem: + a.item = a.parts[2] + return subItemOp(r.Method, a.parts[1]), a + } + + return opUnknown, a +} + +// subresourceOps binds each /functions/{name}/{sub} segment to its methods. +// +//nolint:gochecknoglobals // static lookup table +var subresourceOps = map[string]map[string]opID{ + subInvocations: {http.MethodPost: opInvoke}, + subConfiguration: {http.MethodGet: opGetFunctionConfiguration, http.MethodPut: opUpdateFunctionConfiguration}, + subCode: {http.MethodPut: opUpdateFunctionCode}, + subVersions: {http.MethodPost: opPublishVersion, http.MethodGet: opListVersionsByFunction}, + subAliases: {http.MethodPost: opCreateAlias, http.MethodGet: opListAliases}, + subPolicy: {http.MethodPost: opAddPermission, http.MethodGet: opGetPolicy}, +} + +// subresourceOp returns the operation of /functions/{name}/{sub}. +func subresourceOp(method, sub string) opID { + return subresourceOps[sub][method] +} + +// subItemOp returns the operation of /functions/{name}/{sub}/{id}. +func subItemOp(method, sub string) opID { + switch sub { + case subAliases: + return methodOp(method, map[string]opID{ + http.MethodGet: opGetAlias, http.MethodPut: opUpdateAlias, http.MethodDelete: opDeleteAlias, + }) + case subPolicy: + if method == http.MethodDelete { + return opRemovePermission + } + } + + return opUnknown +} diff --git a/server/aws/lambda/classify_test.go b/server/aws/lambda/classify_test.go new file mode 100644 index 000000000..939706d8e --- /dev/null +++ b/server/aws/lambda/classify_test.go @@ -0,0 +1,116 @@ +package lambda + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestClassify(t *testing.T) { + cases := []struct { + method, target, host string + op opID + route route + name, qualifier string + item string + }{ + {method: "GET", target: "/2015-03-31/functions", op: opListFunctions}, + {method: "POST", target: "/2015-03-31/functions/", op: opCreateFunction}, + {method: "PUT", target: "/2015-03-31/functions", op: opUnknown}, + {method: "GET", target: "/2015-03-31/functions/f", op: opGetFunction, name: "f"}, + {method: "GET", target: "/2015-03-31/functions/f?Qualifier=1", op: opGetFunction, name: "f", qualifier: "1"}, + {method: "DELETE", target: "/2015-03-31/functions/f:prod", op: opDeleteFunction, name: "f", qualifier: "prod"}, + { + method: "POST", target: "/2015-03-31/functions/arn:aws:lambda:eu-west-1:999999999999:function:f:prod/invocations", + op: opInvoke, name: "f", qualifier: "prod", + }, + {method: "POST", target: "/2015-03-31/functions/f:a/invocations?Qualifier=b", op: opUnknown, name: "f"}, + {method: "GET", target: "/2015-03-31/functions/f/invocations", op: opUnknown, name: "f"}, + {method: "GET", target: "/2015-03-31/functions/f/configuration", op: opGetFunctionConfiguration, name: "f"}, + {method: "PUT", target: "/2015-03-31/functions/f/configuration", op: opUpdateFunctionConfiguration, name: "f"}, + {method: "PUT", target: "/2015-03-31/functions/f/code", op: opUpdateFunctionCode, name: "f"}, + {method: "POST", target: "/2015-03-31/functions/f/versions", op: opPublishVersion, name: "f"}, + {method: "GET", target: "/2015-03-31/functions/f/versions", op: opListVersionsByFunction, name: "f"}, + {method: "POST", target: "/2015-03-31/functions/f/aliases", op: opCreateAlias, name: "f"}, + {method: "GET", target: "/2015-03-31/functions/f/aliases", op: opListAliases, name: "f"}, + {method: "PUT", target: "/2015-03-31/functions/f/aliases/prod", op: opUpdateAlias, name: "f", item: "prod"}, + {method: "POST", target: "/2015-03-31/functions/f/policy", op: opAddPermission, name: "f"}, + {method: "GET", target: "/2015-03-31/functions/f/policy", op: opGetPolicy, name: "f"}, + {method: "DELETE", target: "/2015-03-31/functions/f/policy/s1", op: opRemovePermission, name: "f", item: "s1"}, + {method: "GET", target: "/2015-03-31/functions/f/policy/s1", op: opUnknown, name: "f", item: "s1"}, + {method: "GET", target: "/2015-03-31/functions/f/other", op: opUnknown, name: "f"}, + {method: "GET", target: "/2015-03-31/functions/f/a/b/c", op: opUnknown, name: "f"}, + {method: "GET", target: "/2015-03-31/functionsX", op: opGetFunction, name: "X"}, + + {method: "POST", target: "/anything", host: "abc.lambda-url.us-east-1.on.aws", op: opInvokeFunctionURL, route: routeFunctionURLInvoke}, + { + method: "DELETE", target: "/2015-03-31/functions/f", host: "abc.lambda-url.us-east-1.on.aws:4566", + op: opInvokeFunctionURL, route: routeFunctionURLInvoke, + }, + + {method: "POST", target: "/2017-03-31/tags/arn:aws:lambda:us-east-1:1:function:f", op: opTagResource, route: routeTags, name: "f", item: "arn:aws:lambda:us-east-1:1:function:f"}, + {method: "DELETE", target: "/2017-03-31/tags/x?tagKeys=a", op: opUntagResource, route: routeTags, name: "x", item: "x"}, + {method: "PUT", target: "/2017-03-31/tags/x", op: opUnknown, route: routeTags, name: "x", item: "x"}, + + {method: "POST", target: "/2015-03-31/event-source-mappings/", op: opCreateEventSourceMapping, route: routeEventSourceMappings}, + {method: "GET", target: "/2015-03-31/event-source-mappings", op: opListEventSourceMappings, route: routeEventSourceMappings}, + {method: "PUT", target: "/2015-03-31/event-source-mappings/u1", op: opUpdateEventSourceMapping, route: routeEventSourceMappings, item: "u1"}, + {method: "POST", target: "/2015-03-31/event-source-mappings/u1", op: opUnknown, route: routeEventSourceMappings, item: "u1"}, + + {method: "GET", target: "/2018-10-31/layers", op: opListLayers, route: routeLayers}, + {method: "GET", target: "/2018-10-31/layers?find=LayerVersion&Arn=x", op: opGetLayerVersionByArn, route: routeLayers}, + {method: "POST", target: "/2018-10-31/layers", op: opUnknown, route: routeLayers}, + {method: "POST", target: "/2018-10-31/layers/l/versions", op: opPublishLayerVersion, route: routeLayers, name: "l"}, + {method: "GET", target: "/2018-10-31/layers/l/versions", op: opListLayerVersions, route: routeLayers, name: "l"}, + {method: "DELETE", target: "/2018-10-31/layers/l/versions/2", op: opDeleteLayerVersion, route: routeLayers, name: "l", item: "2"}, + {method: "POST", target: "/2018-10-31/layers/l/versions/2/policy", op: opAddLayerVersionPermission, route: routeLayers, name: "l", item: "2"}, + { + method: "DELETE", target: "/2018-10-31/layers/l/versions/2/policy/s", + op: opRemoveLayerVersionPermission, route: routeLayers, name: "l", item: "2", + }, + {method: "GET", target: "/2018-10-31/layers/l/other", op: opUnknown, route: routeLayers}, + + {method: "POST", target: "/2021-10-31/functions/f/url?Qualifier=prod", op: opCreateFunctionURLConfig, route: routeFunctionURL, name: "f", qualifier: "prod"}, + {method: "GET", target: "/2021-10-31/functions/f/urls", op: opListFunctionURLConfigs, route: routeFunctionURL, name: "f"}, + {method: "GET", target: "/2021-10-31/functions/f", op: opUnknown, route: routeFunctionURL}, + + {method: "PUT", target: "/2019-09-25/functions/f/event-invoke-config", op: opPutEventInvokeConfig, route: routeEventInvokeConfig, name: "f"}, + {method: "POST", target: "/2019-09-25/functions/f/event-invoke-config", op: opUpdateEventInvokeConfig, route: routeEventInvokeConfig, name: "f"}, + {method: "GET", target: "/2019-09-25/functions/f/event-invoke-config/list", op: opListEventInvokeConfigs, route: routeEventInvokeConfig, name: "f"}, + + { + method: "PUT", target: "/2019-09-30/functions/f/provisioned-concurrency?Qualifier=1", + op: opPutProvisionedConcurrency, route: routeProvisionedConcurrency, name: "f", qualifier: "1", + }, + { + method: "GET", target: "/2019-09-30/functions/f/provisioned-concurrency?List=ALL", + op: opListProvisionedConcurrency, route: routeProvisionedConcurrency, name: "f", + }, + + {method: "GET", target: "/2020-06-30/functions/f/code-signing-config", op: opGetFunctionCodeSigningConfig, route: routeCodeSigning, name: "f"}, + {method: "PUT", target: "/2020-06-30/functions/f/code-signing-config", op: opUnknown, route: routeCodeSigning, name: "f"}, + + {method: "PUT", target: "/2017-10-31/functions/f/concurrency", op: opPutFunctionConcurrency, route: routeConcurrency, name: "f"}, + {method: "GET", target: "/2017-10-31/functions/f/concurrency", op: opGetFunctionConcurrency, route: routeConcurrency, name: "f"}, + {method: "DELETE", target: "/2019-09-30/functions/f/concurrency", op: opDeleteFunctionConcurrency, route: routeConcurrency, name: "f"}, + } + + for _, tc := range cases { + r := httptest.NewRequest(tc.method, tc.target, http.NoBody) + if tc.host != "" { + r.Host = tc.host + } + + before := r.URL.String() + op, a := classify(r) + + if op != tc.op || a.route != tc.route || a.name != tc.name || a.qualifier != tc.qualifier || a.item != tc.item { + t.Errorf("%s %s: got op=%q route=%d name=%q qualifier=%q item=%q, want op=%q route=%d name=%q qualifier=%q item=%q", + tc.method, tc.target, op, a.route, a.name, a.qualifier, a.item, tc.op, tc.route, tc.name, tc.qualifier, tc.item) + } + + if r.URL.String() != before { + t.Errorf("%s %s: classify changed the URL to %s", tc.method, tc.target, r.URL) + } + } +} diff --git a/server/aws/lambda/code_signing.go b/server/aws/lambda/code_signing.go index 39e9911c2..9539e6fba 100644 --- a/server/aws/lambda/code_signing.go +++ b/server/aws/lambda/code_signing.go @@ -38,9 +38,8 @@ func codeSigningFunctionName(path string) (string, bool) { // missing function 404s. Terraform's function read calls this on every refresh; // before this route existed the request fell through to the S3 catch-all and // returned XML the REST-JSON client could not parse. -func (h *Handler) serveFunctionCodeSigningConfig(w http.ResponseWriter, r *http.Request) { - name, ok := codeSigningFunctionName(r.URL.Path) - if !ok { +func (h *Handler) serveFunctionCodeSigningConfig(w http.ResponseWriter, r *http.Request, op opID, name string) { + if name == "" { writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") return } @@ -50,13 +49,13 @@ func (h *Handler) serveFunctionCodeSigningConfig(w http.ResponseWriter, r *http. return } - switch r.Method { - case http.MethodGet: + switch op { + case opGetFunctionCodeSigningConfig: writeJSON(w, http.StatusOK, map[string]any{ "FunctionName": name, "CodeSigningConfigArn": "", }) - case http.MethodDelete: + case opDeleteFunctionCodeSigningConfig: // No config is stored, so removal is a no-op that AWS answers 204. w.WriteHeader(http.StatusNoContent) default: diff --git a/server/aws/lambda/concurrency.go b/server/aws/lambda/concurrency.go index fa2dc94a2..db1db1736 100644 --- a/server/aws/lambda/concurrency.go +++ b/server/aws/lambda/concurrency.go @@ -31,9 +31,9 @@ func concurrencyFunctionName(path string) (string, bool) { // serveConcurrency handles the reserved-concurrency sub-resource: // PUT=PutFunctionConcurrency, GET=GetFunctionConcurrency, // DELETE=DeleteFunctionConcurrency. -func (h *Handler) serveConcurrency(w http.ResponseWriter, r *http.Request, name string) { - switch r.Method { - case http.MethodPut: +func (h *Handler) serveConcurrency(w http.ResponseWriter, r *http.Request, op opID, name string) { + switch op { + case opPutFunctionConcurrency: var req struct { ReservedConcurrentExecutions int `json:"ReservedConcurrentExecutions"` } @@ -52,7 +52,7 @@ func (h *Handler) serveConcurrency(w http.ResponseWriter, r *http.Request, name } writeJSON(w, http.StatusOK, map[string]any{"ReservedConcurrentExecutions": req.ReservedConcurrentExecutions}) - case http.MethodGet: + case opGetFunctionConcurrency: // GetFunctionConcurrency 404s only when the FUNCTION is missing. A function // with no reserved concurrency set is HTTP 200 with an empty body, matching // AWS. The provider reports NotFound for both cases, so the function's @@ -69,7 +69,7 @@ func (h *Handler) serveConcurrency(w http.ResponseWriter, r *http.Request, name } writeJSON(w, http.StatusOK, map[string]any{"ReservedConcurrentExecutions": cfg.ReservedConcurrentExecutions}) - case http.MethodDelete: + case opDeleteFunctionConcurrency: if err := h.fn.DeleteFunctionConcurrency(r.Context(), name); err != nil { writeErr(w, err) return diff --git a/server/aws/lambda/esm.go b/server/aws/lambda/esm.go index d6b0776c2..d9d90531f 100644 --- a/server/aws/lambda/esm.go +++ b/server/aws/lambda/esm.go @@ -49,12 +49,12 @@ func esmLastModified(ts string) float64 { // serveEventSourceMappings dispatches the /2015-03-31/event-source-mappings // paths: collection (POST create, GET list) and per-UUID (GET/DELETE). -func (h *Handler) serveEventSourceMappings(w http.ResponseWriter, r *http.Request, uuid string) { +func (h *Handler) serveEventSourceMappings(w http.ResponseWriter, r *http.Request, op opID, uuid string) { if uuid == "" { - switch r.Method { - case http.MethodPost: + switch op { + case opCreateEventSourceMapping: h.createEventSourceMapping(w, r) - case http.MethodGet: + case opListEventSourceMappings: h.listEventSourceMappings(w, r) default: writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") @@ -63,8 +63,8 @@ func (h *Handler) serveEventSourceMappings(w http.ResponseWriter, r *http.Reques return } - switch r.Method { - case http.MethodGet: + switch op { + case opGetEventSourceMapping: info, err := h.fn.GetEventSourceMapping(r.Context(), uuid) if err != nil { writeErr(w, err) @@ -72,9 +72,9 @@ func (h *Handler) serveEventSourceMappings(w http.ResponseWriter, r *http.Reques } writeJSON(w, http.StatusOK, toESMJSON(info)) - case http.MethodPut: + case opUpdateEventSourceMapping: h.updateEventSourceMapping(w, r, uuid) - case http.MethodDelete: + case opDeleteEventSourceMapping: // AWS returns 202 with the full EventSourceMappingConfiguration whose // State is "Deleting" (the mapping enters a Deleting state and is not // fully removed for several seconds). Snapshot it before deleting so the diff --git a/server/aws/lambda/event_invoke_config.go b/server/aws/lambda/event_invoke_config.go index 739870c06..104922725 100644 --- a/server/aws/lambda/event_invoke_config.go +++ b/server/aws/lambda/event_invoke_config.go @@ -3,7 +3,6 @@ package lambda import ( "context" "net/http" - "strings" "time" sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" @@ -132,31 +131,29 @@ func epochSeconds(rfc3339 string) float64 { // /2019-09-25/functions/{name}/event-invoke-config (PUT=put, POST=update, // GET=get, DELETE=delete) and .../event-invoke-config/list (GET=list). The // Qualifier query parameter scopes the config to a version or alias. -func (h *Handler) serveEventInvokeConfig(w http.ResponseWriter, r *http.Request) { +func (h *Handler) serveEventInvokeConfig(w http.ResponseWriter, r *http.Request, op opID, a *opArgs) { mgr, ok := h.fn.(eventInvokeConfigManager) if !ok { writeError(w, http.StatusNotImplemented, "InvalidRequestException", "event invoke config not supported") return } - rest := strings.TrimPrefix(r.URL.Path, eventInvokeConfigPrefix) - rest = strings.TrimPrefix(rest, "/") - parts := strings.Split(rest, "/") + parts := a.parts const ( itemParts = 2 // {name}/event-invoke-config listParts = 3 // {name}/event-invoke-config/list ) - if len(parts) < itemParts || parts[0] == "" || parts[1] != eventInvokeConfigSuffix { + if a.name == "" { writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") return } - name := parts[0] + name := a.name if len(parts) == listParts && parts[2] == eventInvokeListSegment { - if r.Method != http.MethodGet { + if op != opListEventInvokeConfigs { writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") return } @@ -171,20 +168,20 @@ func (h *Handler) serveEventInvokeConfig(w http.ResponseWriter, r *http.Request) return } - serveEventInvokeConfigItem(w, r, mgr, name) + serveEventInvokeConfigItem(w, r, op, mgr, name, a.qualifier) } // serveEventInvokeConfigItem handles PUT/POST/GET/DELETE on // .../{name}/event-invoke-config. -func serveEventInvokeConfigItem(w http.ResponseWriter, r *http.Request, mgr eventInvokeConfigManager, name string) { - qualifier := r.URL.Query().Get("Qualifier") - - switch r.Method { - case http.MethodPut: +func serveEventInvokeConfigItem( + w http.ResponseWriter, r *http.Request, op opID, mgr eventInvokeConfigManager, name, qualifier string, +) { + switch op { + case opPutEventInvokeConfig: writeEventInvokeConfig(w, r, mgr.PutFunctionEventInvokeConfig, name, qualifier) - case http.MethodPost: + case opUpdateEventInvokeConfig: writeEventInvokeConfig(w, r, mgr.UpdateFunctionEventInvokeConfig, name, qualifier) - case http.MethodGet: + case opGetEventInvokeConfig: c, err := mgr.GetFunctionEventInvokeConfig(r.Context(), name, qualifier) if err != nil { writeErr(w, err) @@ -192,7 +189,7 @@ func serveEventInvokeConfigItem(w http.ResponseWriter, r *http.Request, mgr even } writeJSON(w, http.StatusOK, toEventInvokeConfigResponse(c)) - case http.MethodDelete: + case opDeleteEventInvokeConfig: if err := mgr.DeleteFunctionEventInvokeConfig(r.Context(), name, qualifier); err != nil { writeErr(w, err) return diff --git a/server/aws/lambda/functionurl.go b/server/aws/lambda/functionurl.go index 56246f9a3..1166acc4b 100644 --- a/server/aws/lambda/functionurl.go +++ b/server/aws/lambda/functionurl.go @@ -3,7 +3,6 @@ package lambda import ( "context" "net/http" - "strings" sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" ) @@ -105,31 +104,23 @@ func toFunctionURLResponse(u *sdrv.FunctionURLConfig) functionURLResponse { // serveFunctionURL dispatches the Lambda Function URL API under // /2021-10-31/functions/{name}/url (create/get/update/delete) and // /2021-10-31/functions/{name}/urls (list). -func (h *Handler) serveFunctionURL(w http.ResponseWriter, r *http.Request) { +func (h *Handler) serveFunctionURL(w http.ResponseWriter, r *http.Request, op opID, a *opArgs) { mgr, ok := h.fn.(functionURLManager) if !ok { writeError(w, http.StatusNotImplemented, "InvalidRequestException", "function urls not supported") return } - rest := strings.TrimPrefix(r.URL.Path, functionURLPrefix) - rest = strings.TrimPrefix(rest, "/") - parts := strings.Split(rest, "/") - - const wantParts = 2 // {name}/url or {name}/urls - if len(parts) != wantParts || parts[0] == "" { + if a.name == "" { writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") return } - name := parts[0] - qualifier := r.URL.Query().Get("Qualifier") - - switch parts[1] { - case "url": - serveFunctionURLItem(w, r, mgr, name, qualifier) - case "urls": - listFunctionURLConfigs(w, r, mgr, name) + switch a.parts[1] { + case subURL: + serveFunctionURLItem(w, r, op, mgr, a.name, a.qualifier) + case subURLs: + listFunctionURLConfigs(w, r, op, mgr, a.name) default: writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") } @@ -138,13 +129,13 @@ func (h *Handler) serveFunctionURL(w http.ResponseWriter, r *http.Request) { // serveFunctionURLItem handles POST/GET/PUT/DELETE on .../{name}/url. All four // operations accept the target qualifier ("" and "$LATEST" both mean the // unqualified $LATEST URL) as the Qualifier query parameter, not the body. -func serveFunctionURLItem(w http.ResponseWriter, r *http.Request, mgr functionURLManager, name, qualifier string) { - switch r.Method { - case http.MethodPost: +func serveFunctionURLItem(w http.ResponseWriter, r *http.Request, op opID, mgr functionURLManager, name, qualifier string) { + switch op { + case opCreateFunctionURLConfig: writeFunctionURL(w, r, mgr.CreateFunctionURLConfig, name, qualifier, http.StatusCreated) - case http.MethodPut: + case opUpdateFunctionURLConfig: writeFunctionURL(w, r, mgr.UpdateFunctionURLConfig, name, qualifier, http.StatusOK) - case http.MethodGet: + case opGetFunctionURLConfig: u, err := mgr.GetFunctionURLConfig(r.Context(), name, qualifier) if err != nil { writeErr(w, err) @@ -152,7 +143,7 @@ func serveFunctionURLItem(w http.ResponseWriter, r *http.Request, mgr functionUR } writeJSON(w, http.StatusOK, toFunctionURLResponse(u)) - case http.MethodDelete: + case opDeleteFunctionURLConfig: if err := mgr.DeleteFunctionURLConfig(r.Context(), name, qualifier); err != nil { writeErr(w, err) return @@ -191,8 +182,8 @@ func writeFunctionURL( writeJSON(w, status, toFunctionURLResponse(u)) } -func listFunctionURLConfigs(w http.ResponseWriter, r *http.Request, mgr functionURLManager, name string) { - if r.Method != http.MethodGet { +func listFunctionURLConfigs(w http.ResponseWriter, r *http.Request, op opID, mgr functionURLManager, name string) { + if op != opListFunctionURLConfigs { writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") return } diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index 89b031d28..1c8c4866a 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -264,53 +264,61 @@ func (*Handler) Matches(r *http.Request) bool { isCodeSigningPath(r.URL.Path) } -// ServeHTTP dispatches Lambda operations based on path shape and method. +// ServeHTTP runs the operation classify picks for the request. // // /2015-03-31/functions GET=list, POST=create // /2015-03-31/functions/{name} GET=get, DELETE=delete // /2015-03-31/functions/{name}/invocations POST=invoke func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if isFunctionURLHost(r.Host) { - h.serveFunctionURLInvoke(w, r) - return - } - - if h.routePrefixed(w, r) { - return - } + op, a := classify(r) - rest := strings.TrimPrefix(r.URL.Path, pathPrefix) - rest = strings.TrimPrefix(rest, "/") - - if rest == "" { - h.serveCollection(w, r) + switch a.route { + case routeFunctionURLInvoke: + h.serveFunctionURLInvoke(w, r) + case routeTags: + h.serveTags(w, r, op, a.name) + case routeEventSourceMappings: + h.serveEventSourceMappings(w, r, op, a.item) + case routeLayers: + h.serveLayers(w, r, op, &a) + case routeFunctionURL: + h.serveFunctionURL(w, r, op, &a) + case routeEventInvokeConfig: + h.serveEventInvokeConfig(w, r, op, &a) + case routeProvisionedConcurrency: + h.serveProvisionedConcurrency(w, r, op, &a) + case routeCodeSigning: + h.serveFunctionCodeSigningConfig(w, r, op, a.name) + case routeConcurrency: + h.serveConcurrency(w, r, op, a.name) + case routeFunctions: + h.serveFunctions(w, r, op, &a) + } +} + +// serveFunctions serves the /2015-03-31/functions tree. +func (h *Handler) serveFunctions(w http.ResponseWriter, r *http.Request, op opID, a *opArgs) { + if len(a.parts) == 0 { + h.serveCollection(w, r, op) return } - parts := strings.Split(rest, "/") - - name, ok := h.resolveFunctionRef(w, r, parts[0]) + name, ok := h.resolveFunctionRef(w, r, a.parts[0]) if !ok { return } - const ( - partsResource = 1 // /functions/{name} - partsSubresource = 2 // /functions/{name}/{sub} - partsSubItem = 3 // /functions/{name}/{sub}/{id} - ) - - switch len(parts) { + switch len(a.parts) { case partsResource: - h.serveResource(w, r, name) + h.serveResource(w, r, op, name) case partsSubresource: - h.serveSubresource(w, r, name, parts[1]) + h.serveSubresource(w, r, op, name, a.parts[1]) case partsSubItem: - switch parts[1] { + switch a.parts[1] { case subAliases: - h.serveAlias(w, r, name, parts[2]) + h.serveAlias(w, r, op, name, a.parts[2]) case subPolicy: - h.serveRemovePermission(w, r, name, parts[2]) + h.serveRemovePermission(w, r, op, name, a.parts[2]) default: writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") } @@ -319,55 +327,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -// routePrefixed serves the Lambda sub-APIs that live on their own version -// prefix (tags, event-source-mappings, layers, reserved concurrency). It -// returns true when it has handled the request, false to fall through to the -// /2015-03-31/functions control plane. -func (h *Handler) routePrefixed(w http.ResponseWriter, r *http.Request) bool { - switch { - case strings.HasPrefix(r.URL.Path, tagsPrefix): - arn := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, tagsPrefix), "/") - h.serveTags(w, r, arn) - case strings.HasPrefix(r.URL.Path, esmPrefix): - uuid := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, esmPrefix), "/") - h.serveEventSourceMappings(w, r, uuid) - case strings.HasPrefix(r.URL.Path, layersPrefix): - h.serveLayers(w, r) - case strings.HasPrefix(r.URL.Path, functionURLPrefix): - h.serveFunctionURL(w, r) - case strings.HasPrefix(r.URL.Path, eventInvokeConfigPrefix): - h.serveEventInvokeConfig(w, r) - case isProvisionedConcurrencyPath(r.URL.Path): - h.serveProvisionedConcurrency(w, r) - case isCodeSigningPath(r.URL.Path): - h.serveFunctionCodeSigningConfig(w, r) - default: - name, ok := concurrencyFunctionName(r.URL.Path) - if !ok { - return false - } - - h.serveConcurrency(w, r, name) - } - - return true -} - // serveSubresource dispatches /functions/{name}/{sub} paths. -func (h *Handler) serveSubresource(w http.ResponseWriter, r *http.Request, name, sub string) { +func (h *Handler) serveSubresource(w http.ResponseWriter, r *http.Request, op opID, name, sub string) { switch sub { - case "invocations": - h.serveInvoke(w, r, name) - case "configuration": - h.serveConfiguration(w, r, name) - case "code": - h.serveCode(w, r, name) + case subInvocations: + h.serveInvoke(w, r, op, name) + case subConfiguration: + h.serveConfiguration(w, r, op, name) + case subCode: + h.serveCode(w, r, op, name) case subVersions: - h.serveVersions(w, r, name) + h.serveVersions(w, r, op, name) case subAliases: - h.serveAliases(w, r, name) + h.serveAliases(w, r, op, name) case subPolicy: - h.servePolicy(w, r, name) + h.servePolicy(w, r, op, name) default: writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") } @@ -375,17 +349,15 @@ func (h *Handler) serveSubresource(w http.ResponseWriter, r *http.Request, name, // serveTags handles the Lambda tagging API at /2017-03-31/tags/{arn}: // POST=TagResource, DELETE=UntagResource (?tagKeys=...), GET=ListTags. -func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, arn string) { +func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, op opID, name string) { tagger, ok := h.fn.(functionTagger) if !ok { writeError(w, http.StatusNotImplemented, "InvalidRequestException", "tagging not supported") return } - name := functionNameFromARN(arn) - - switch r.Method { - case http.MethodPost: + switch op { + case opTagResource: var req struct { Tags map[string]string `json:"Tags"` } @@ -400,14 +372,14 @@ func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, arn string) } w.WriteHeader(http.StatusNoContent) - case http.MethodDelete: + case opUntagResource: if err := tagger.UntagFunction(r.Context(), name, r.URL.Query()["tagKeys"]); err != nil { writeErr(w, err) return } w.WriteHeader(http.StatusNoContent) - case http.MethodGet: + case opListTags: tags, err := tagger.ListFunctionTags(r.Context(), name) if err != nil { writeErr(w, err) @@ -516,7 +488,7 @@ func setQualifier(r *http.Request, qualifier string) { // servePolicy handles POST (AddPermission) and GET (GetPolicy) on // .../{name}/policy. -func (h *Handler) servePolicy(w http.ResponseWriter, r *http.Request, name string) { +func (h *Handler) servePolicy(w http.ResponseWriter, r *http.Request, op opID, name string) { pm, ok := h.fn.(policyManager) if !ok { writeError(w, http.StatusNotImplemented, "InvalidRequestException", "resource policies not supported") @@ -525,8 +497,8 @@ func (h *Handler) servePolicy(w http.ResponseWriter, r *http.Request, name strin qualifier := r.URL.Query().Get("Qualifier") - switch r.Method { - case http.MethodPost: + switch op { + case opAddPermission: var req addPermissionRequest if !decodeJSON(w, r, &req) { return @@ -551,7 +523,7 @@ func (h *Handler) servePolicy(w http.ResponseWriter, r *http.Request, name strin } writeJSON(w, http.StatusCreated, map[string]string{"Statement": stmt}) - case http.MethodGet: + case opGetPolicy: policy, err := pm.GetPolicy(r.Context(), name, qualifier) if err != nil { writeErr(w, err) @@ -595,8 +567,8 @@ func addedStatement(ctx context.Context, pm policyManager, name, qualifier, stat } // serveRemovePermission handles DELETE .../{name}/policy/{statementId}. -func (h *Handler) serveRemovePermission(w http.ResponseWriter, r *http.Request, name, statementID string) { - if r.Method != http.MethodDelete { +func (h *Handler) serveRemovePermission(w http.ResponseWriter, r *http.Request, op opID, name, statementID string) { + if op != opRemovePermission { writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") return } @@ -619,8 +591,8 @@ func (h *Handler) serveRemovePermission(w http.ResponseWriter, r *http.Request, // GetFunctionConfiguration (the op FunctionActiveV2 / FunctionUpdatedV2 waiters // poll; a 405 here hangs every Terraform/SAM/CDK deploy), PUT is // UpdateFunctionConfiguration. -func (h *Handler) serveConfiguration(w http.ResponseWriter, r *http.Request, name string) { - if r.Method == http.MethodGet { +func (h *Handler) serveConfiguration(w http.ResponseWriter, r *http.Request, op opID, name string) { + if op == opGetFunctionConfiguration { cfg, err := h.resolvedConfiguration(r.Context(), name, r.URL.Query().Get("Qualifier")) if err != nil { writeErr(w, err) @@ -632,7 +604,7 @@ func (h *Handler) serveConfiguration(w http.ResponseWriter, r *http.Request, nam return } - if r.Method != http.MethodPut { + if op != opUpdateFunctionConfiguration { writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") return } @@ -708,8 +680,8 @@ func (h *Handler) writePublished( // content overlaid), then redeploys it to the engine via the provider so // update-function-code runs the new real code instead of leaving the stale // deployment in place. A request with no usable source is a hard error. -func (h *Handler) serveCode(w http.ResponseWriter, r *http.Request, name string) { - if r.Method != http.MethodPut { +func (h *Handler) serveCode(w http.ResponseWriter, r *http.Request, op opID, name string) { + if op != opUpdateFunctionCode { writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") return } @@ -781,9 +753,9 @@ func (h *Handler) codeAWSConfig(ctx context.Context, name string, arch []string) // serveVersions handles POST (PublishVersion) and GET (ListVersionsByFunction) // on .../{name}/versions. -func (h *Handler) serveVersions(w http.ResponseWriter, r *http.Request, name string) { - switch r.Method { - case http.MethodPost: +func (h *Handler) serveVersions(w http.ResponseWriter, r *http.Request, op opID, name string) { + switch op { + case opPublishVersion: var req publishVersionRequest if !decodeJSON(w, r, &req) { return @@ -802,7 +774,7 @@ func (h *Handler) serveVersions(w http.ResponseWriter, r *http.Request, name str } writeJSON(w, http.StatusCreated, toVersionConfiguration(info, ver, h.awsFnConfig(r.Context(), name))) - case http.MethodGet: + case opListVersionsByFunction: vers, err := h.fn.ListVersions(r.Context(), name) if err != nil { writeErr(w, err) @@ -838,9 +810,9 @@ func (h *Handler) serveVersions(w http.ResponseWriter, r *http.Request, name str // serveAliases handles POST (CreateAlias) and GET (ListAliases) on // .../{name}/aliases. -func (h *Handler) serveAliases(w http.ResponseWriter, r *http.Request, name string) { - switch r.Method { - case http.MethodPost: +func (h *Handler) serveAliases(w http.ResponseWriter, r *http.Request, op opID, name string) { + switch op { + case opCreateAlias: var req aliasRequest if !decodeJSON(w, r, &req) { return @@ -857,7 +829,7 @@ func (h *Handler) serveAliases(w http.ResponseWriter, r *http.Request, name stri } writeJSON(w, http.StatusCreated, toAliasResponse(a)) - case http.MethodGet: + case opListAliases: aliases, err := h.fn.ListAliases(r.Context(), name) if err != nil { writeErr(w, err) @@ -883,9 +855,9 @@ func (h *Handler) serveAliases(w http.ResponseWriter, r *http.Request, name stri } // serveAlias handles GET/PUT/DELETE on .../{name}/aliases/{aliasName}. -func (h *Handler) serveAlias(w http.ResponseWriter, r *http.Request, name, aliasName string) { - switch r.Method { - case http.MethodGet: +func (h *Handler) serveAlias(w http.ResponseWriter, r *http.Request, op opID, name, aliasName string) { + switch op { + case opGetAlias: a, err := h.fn.GetAlias(r.Context(), name, aliasName) if err != nil { writeErr(w, err) @@ -893,7 +865,7 @@ func (h *Handler) serveAlias(w http.ResponseWriter, r *http.Request, name, alias } writeJSON(w, http.StatusOK, toAliasResponse(a)) - case http.MethodPut: + case opUpdateAlias: var req aliasRequest if !decodeJSON(w, r, &req) { return @@ -910,7 +882,7 @@ func (h *Handler) serveAlias(w http.ResponseWriter, r *http.Request, name, alias } writeJSON(w, http.StatusOK, toAliasResponse(a)) - case http.MethodDelete: + case opDeleteAlias: if err := h.fn.DeleteAlias(r.Context(), name, aliasName); err != nil { writeErr(w, err) return @@ -959,30 +931,30 @@ func toAliasResponse(a *sdrv.Alias) aliasResponse { return resp } -func (h *Handler) serveCollection(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: +func (h *Handler) serveCollection(w http.ResponseWriter, r *http.Request, op opID) { + switch op { + case opListFunctions: h.list(w, r) - case http.MethodPost: + case opCreateFunction: h.create(w, r) default: writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") } } -func (h *Handler) serveResource(w http.ResponseWriter, r *http.Request, name string) { - switch r.Method { - case http.MethodGet: +func (h *Handler) serveResource(w http.ResponseWriter, r *http.Request, op opID, name string) { + switch op { + case opGetFunction: h.get(w, r, name) - case http.MethodDelete: + case opDeleteFunction: h.delete(w, r, name) default: writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") } } -func (h *Handler) serveInvoke(w http.ResponseWriter, r *http.Request, name string) { - if r.Method != http.MethodPost { +func (h *Handler) serveInvoke(w http.ResponseWriter, r *http.Request, op opID, name string) { + if op != opInvoke { writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") return } diff --git a/server/aws/lambda/layers.go b/server/aws/lambda/layers.go index 18a4019eb..fb557ad75 100644 --- a/server/aws/lambda/layers.go +++ b/server/aws/lambda/layers.go @@ -121,26 +121,24 @@ const findLayerVersion = "LayerVersion" // per-layer versions collection (POST=PublishLayerVersion, // GET=ListLayerVersions), a specific version (GET/DELETE), and a version's // resource policy (POST/GET=.../policy, DELETE=.../policy/{statementId}). -func (h *Handler) serveLayers(w http.ResponseWriter, r *http.Request) { - rest := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, layersPrefix), "/") - - if rest == "" { - h.serveLayersCollection(w, r) +func (h *Handler) serveLayers(w http.ResponseWriter, r *http.Request, op opID, a *opArgs) { + if a.parts == nil { + h.serveLayersCollection(w, r, op) return } - h.serveLayerSubpath(w, r, strings.Split(rest, "/")) + h.serveLayerSubpath(w, r, op, a.parts) } // serveLayersCollection handles the bare /2018-10-31/layers endpoint: // GET=ListLayers, or GET?find=LayerVersion&Arn=...=GetLayerVersionByArn. -func (h *Handler) serveLayersCollection(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { +func (h *Handler) serveLayersCollection(w http.ResponseWriter, r *http.Request, op opID) { + if op == opUnknown { writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") return } - if r.URL.Query().Get("find") == findLayerVersion { + if op == opGetLayerVersionByArn { h.getLayerVersionByArn(w, r) return } @@ -160,7 +158,7 @@ const ( // serveLayerSubpath routes a /2018-10-31/layers/{name}/... path to the // per-layer versions collection, a specific version, or (via // serveLayerVersionPolicyPath) a version's resource policy. -func (h *Handler) serveLayerSubpath(w http.ResponseWriter, r *http.Request, parts []string) { +func (h *Handler) serveLayerSubpath(w http.ResponseWriter, r *http.Request, op opID, parts []string) { if len(parts) < partsVersions || parts[1] != subVersions { writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda layers path") return @@ -170,17 +168,17 @@ func (h *Handler) serveLayerSubpath(w http.ResponseWriter, r *http.Request, part switch len(parts) { case partsVersions: - h.serveLayerVersions(w, r, name) + h.serveLayerVersions(w, r, op, name) case partsVersion: - h.serveLayerVersion(w, r, name, parts[2]) + h.serveLayerVersion(w, r, op, name, parts[2]) default: - h.serveLayerVersionPolicyPath(w, r, name, parts) + h.serveLayerVersionPolicyPath(w, r, op, name, parts) } } // serveLayerVersionPolicyPath routes the .../policy and // .../policy/{statementId} tails of a layer-version path. -func (h *Handler) serveLayerVersionPolicyPath(w http.ResponseWriter, r *http.Request, name string, parts []string) { +func (h *Handler) serveLayerVersionPolicyPath(w http.ResponseWriter, r *http.Request, op opID, name string, parts []string) { if parts[3] != subPolicy { writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda layers path") return @@ -188,9 +186,9 @@ func (h *Handler) serveLayerVersionPolicyPath(w http.ResponseWriter, r *http.Req switch len(parts) { case partsPolicy: - h.serveLayerVersionPolicy(w, r, name, parts[2]) + h.serveLayerVersionPolicy(w, r, op, name, parts[2]) case partsPolicyStmt: - h.serveRemoveLayerVersionPermission(w, r, name, parts[2], parts[4]) + h.serveRemoveLayerVersionPermission(w, r, op, name, parts[2], parts[4]) default: writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda layers path") } @@ -198,11 +196,11 @@ func (h *Handler) serveLayerVersionPolicyPath(w http.ResponseWriter, r *http.Req // serveLayerVersions handles POST (PublishLayerVersion) and GET // (ListLayerVersions) on .../layers/{name}/versions. -func (h *Handler) serveLayerVersions(w http.ResponseWriter, r *http.Request, name string) { - switch r.Method { - case http.MethodPost: +func (h *Handler) serveLayerVersions(w http.ResponseWriter, r *http.Request, op opID, name string) { + switch op { + case opPublishLayerVersion: h.publishLayerVersion(w, r, name) - case http.MethodGet: + case opListLayerVersions: h.listLayerVersions(w, r, name) default: writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") @@ -300,14 +298,14 @@ func filterLayerVersions(vers []sdrv.LayerVersion, form url.Values) []sdrv.Layer // serveLayerVersion handles GET (GetLayerVersion) and DELETE // (DeleteLayerVersion) on .../layers/{name}/versions/{version}. -func (h *Handler) serveLayerVersion(w http.ResponseWriter, r *http.Request, name, versionStr string) { +func (h *Handler) serveLayerVersion(w http.ResponseWriter, r *http.Request, op opID, name, versionStr string) { version, ok := parseLayerVersionNumber(w, versionStr) if !ok { return } - switch r.Method { - case http.MethodGet: + switch op { + case opGetLayerVersion: lv, gerr := h.fn.GetLayerVersion(r.Context(), name, version) if gerr != nil { writeErr(w, gerr) @@ -315,7 +313,7 @@ func (h *Handler) serveLayerVersion(w http.ResponseWriter, r *http.Request, name } writeJSON(w, http.StatusOK, toLayerVersionResponse(lv)) - case http.MethodDelete: + case opDeleteLayerVersion: if derr := h.fn.DeleteLayerVersion(r.Context(), name, version); derr != nil { writeErr(w, derr) return @@ -397,7 +395,7 @@ func (h *Handler) listLayers(w http.ResponseWriter, r *http.Request) { // serveLayerVersionPolicy handles POST (AddLayerVersionPermission) and GET // (GetLayerVersionPolicy) on .../layers/{name}/versions/{version}/policy. -func (h *Handler) serveLayerVersionPolicy(w http.ResponseWriter, r *http.Request, name, versionStr string) { +func (h *Handler) serveLayerVersionPolicy(w http.ResponseWriter, r *http.Request, op opID, name, versionStr string) { pm, ok := h.fn.(layerPolicyManager) if !ok { writeError(w, http.StatusNotImplemented, "InvalidRequestException", "layer version policies not supported") @@ -409,10 +407,10 @@ func (h *Handler) serveLayerVersionPolicy(w http.ResponseWriter, r *http.Request return } - switch r.Method { - case http.MethodPost: + switch op { + case opAddLayerVersionPermission: addLayerVersionPermission(w, r, pm, name, version) - case http.MethodGet: + case opGetLayerVersionPolicy: policy, revisionID, err := pm.GetLayerVersionPolicy(r.Context(), name, version) if err != nil { writeErr(w, err) @@ -449,8 +447,10 @@ func addLayerVersionPermission(w http.ResponseWriter, r *http.Request, pm layerP // serveRemoveLayerVersionPermission handles DELETE // .../layers/{name}/versions/{version}/policy/{statementId} // (RemoveLayerVersionPermission). -func (h *Handler) serveRemoveLayerVersionPermission(w http.ResponseWriter, r *http.Request, name, versionStr, statementID string) { - if r.Method != http.MethodDelete { +func (h *Handler) serveRemoveLayerVersionPermission( + w http.ResponseWriter, r *http.Request, op opID, name, versionStr, statementID string, +) { + if op != opRemoveLayerVersionPermission { writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") return } diff --git a/server/aws/lambda/provisioned_concurrency.go b/server/aws/lambda/provisioned_concurrency.go index 431a9921a..1d05059a4 100644 --- a/server/aws/lambda/provisioned_concurrency.go +++ b/server/aws/lambda/provisioned_concurrency.go @@ -114,32 +114,32 @@ func provisionedConcurrencyFunctionName(path string) (string, bool) { // sub-resource: PUT=Put, DELETE=Delete, GET=Get (or List when the List=ALL // query parameter is present, matching real Lambda's routing on this shared // path). -func (h *Handler) serveProvisionedConcurrency(w http.ResponseWriter, r *http.Request) { +func (h *Handler) serveProvisionedConcurrency(w http.ResponseWriter, r *http.Request, op opID, a *opArgs) { mgr, ok := h.fn.(provisionedConcurrencyManager) if !ok { writeError(w, http.StatusNotImplemented, "InvalidRequestException", "provisioned concurrency not supported") return } - name, ok := provisionedConcurrencyFunctionName(r.URL.Path) - if !ok { + name := a.name + if name == "" { writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") return } - if r.Method == http.MethodGet && r.URL.Query().Get(provisionedConcurrencyListParam) == provisionedConcurrencyListParamAll { + if op == opListProvisionedConcurrency { listProvisionedConcurrencyConfigs(w, r, mgr, name) return } - qualifier := r.URL.Query().Get("Qualifier") + qualifier := a.qualifier - switch r.Method { - case http.MethodPut: + switch op { + case opPutProvisionedConcurrency: putProvisionedConcurrency(w, r, mgr, name, qualifier) - case http.MethodGet: + case opGetProvisionedConcurrency: h.getProvisionedConcurrency(w, r, mgr, name, qualifier) - case http.MethodDelete: + case opDeleteProvisionedConcurrency: h.deleteProvisionedConcurrency(w, r, mgr, name, qualifier) default: writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") From d7f8bbf25b3090c3bb012dfc11ab4976b07e257b Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sat, 10 Oct 2026 17:24:36 +0530 Subject: [PATCH 2/6] fix(aws-lambda): keep AddPermission conditions and return them from GetPolicy --- providers/aws/lambda/layer_permissions.go | 2 +- providers/aws/lambda/policy.go | 86 ++++++++++++++++-- .../aws/lambda/policy_conditions_test.go | 71 +++++++++++++++ server/aws/lambda/handler.go | 3 + .../lambda/permission_conditions_sdk_test.go | 91 +++++++++++++++++++ server/aws/lambda/types.go | 13 ++- services/serverless/driver/driver.go | 11 +++ 7 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 providers/aws/lambda/policy_conditions_test.go create mode 100644 server/aws/lambda/permission_conditions_sdk_test.go diff --git a/providers/aws/lambda/layer_permissions.go b/providers/aws/lambda/layer_permissions.go index 016e307cf..ae271a031 100644 --- a/providers/aws/lambda/layer_permissions.go +++ b/providers/aws/lambda/layer_permissions.go @@ -192,7 +192,7 @@ func layerStatementJSON(s driver.LayerPermissionStatement, resource string) map[ if s.OrganizationID != "" { stmt["Condition"] = map[string]any{ - "StringEquals": map[string]string{"aws:PrincipalOrgID": s.OrganizationID}, + "StringEquals": map[string]string{condPrincipalOrgID: s.OrganizationID}, } } diff --git a/providers/aws/lambda/policy.go b/providers/aws/lambda/policy.go index ae749ac7a..5cc7675ed 100644 --- a/providers/aws/lambda/policy.go +++ b/providers/aws/lambda/policy.go @@ -13,6 +13,9 @@ import ( // principal renders as the account's IAM root ARN. const accountIDLen = 12 +// condPrincipalOrgID is the condition key of an organization-scoped grant. +const condPrincipalOrgID = "aws:PrincipalOrgID" + // policyKey normalizes a Qualifier into the resource-policy map key. AWS keeps a // separate policy per version/alias; an empty or "$LATEST" qualifier is the // function's unqualified policy. @@ -28,13 +31,21 @@ func policyKey(qualifier string) string { // backs Terraform's aws_lambda_permission and the grants S3/SNS/EventBridge // create to invoke a function. A Qualifier scopes the statement to a single // published version or alias. AWS stores a separate policy per qualifier. The -// emulator stores statements without evaluating them. Invocation is never -// actually denied. +// statements are evaluated by the wire server's Lambda handler, and only under +// EnforceAuth (see PolicyStatements). func (m *Mock) AddPermission(_ context.Context, functionName, qualifier string, stmt driver.PermissionStatement) error { if stmt.StatementID == "" { return cerrors.New(cerrors.InvalidArgument, "StatementId is required") } + switch stmt.FunctionURLAuthType { + case "", defaultAuthType, authTypeAWSIAM: + default: + return cerrors.Newf(cerrors.InvalidArgument, + "1 validation error detected: Value '%s' at 'functionUrlAuthType' failed to satisfy constraint: "+ + "Member must satisfy enum value set: [AWS_IAM, NONE]", stmt.FunctionURLAuthType) + } + m.mu.Lock() defer m.mu.Unlock() @@ -119,7 +130,7 @@ func (m *Mock) GetPolicy(_ context.Context, functionName, qualifier string) (str statements := make([]map[string]any, 0, len(scoped)) for _, s := range scoped { - statements = append(statements, statementJSON(s, resource)) + statements = append(statements, statementJSON(&s, resource)) } doc, err := json.Marshal(map[string]any{ @@ -136,7 +147,7 @@ func (m *Mock) GetPolicy(_ context.Context, functionName, qualifier string) (str // statementJSON renders one resource-policy statement in the IAM shape AWS // returns, with the principal classified by type (see principalJSON). -func statementJSON(s driver.PermissionStatement, resource string) map[string]any { +func statementJSON(s *driver.PermissionStatement, resource string) map[string]any { stmt := map[string]any{ "Sid": s.StatementID, "Effect": "Allow", @@ -145,13 +156,74 @@ func statementJSON(s driver.PermissionStatement, resource string) map[string]any "Resource": resource, } + if cond := statementCondition(s); len(cond) > 0 { + stmt["Condition"] = cond + } + + return stmt +} + +// statementCondition renders the AddPermission options of s as the Condition +// block AWS stores for them: ArnLike AWS:SourceArn; StringEquals on +// AWS:SourceAccount, aws:PrincipalOrgID, lambda:EventSourceToken and +// lambda:FunctionUrlAuthType; Bool lambda:InvokedViaFunctionUrl. +func statementCondition(s *driver.PermissionStatement) map[string]any { + cond := map[string]any{} + if s.SourceARN != "" { - stmt["Condition"] = map[string]any{ - "ArnLike": map[string]string{"AWS:SourceArn": s.SourceARN}, + cond["ArnLike"] = map[string]string{"AWS:SourceArn": s.SourceARN} + } + + equals := map[string]string{} + + for key, value := range map[string]string{ + "AWS:SourceAccount": s.SourceAccount, + condPrincipalOrgID: s.PrincipalOrgID, + "lambda:EventSourceToken": s.EventSourceToken, + "lambda:FunctionUrlAuthType": s.FunctionURLAuthType, + } { + if value != "" { + equals[key] = value } } - return stmt + if len(equals) > 0 { + cond["StringEquals"] = equals + } + + if s.InvokedViaFunctionURL { + cond["Bool"] = map[string]string{"lambda:InvokedViaFunctionUrl": "true"} + } + + return cond +} + +// PolicyStatements returns the statements of the function's resource-based +// policy for qualifier, and the function ARN that policy applies to (the +// qualified ARN for a version or alias). A qualifier with no policy returns no +// statements; a missing function is NotFound. +func (m *Mock) PolicyStatements(_ context.Context, functionName, qualifier string) (string, []driver.PermissionStatement, error) { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(functionName) + if !ok { + return "", nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + } + + key := policyKey(qualifier) + + resource := fd.info.ARN + if key != latestVersion { + resource += ":" + key + } + + stmts := make([]driver.PermissionStatement, 0, len(fd.policies[key])) + for sid := range fd.policies[key] { + stmts = append(stmts, fd.policies[key][sid]) + } + + return resource, stmts, nil } // principalJSON renders an AddPermission Principal in the shape AWS uses in the diff --git a/providers/aws/lambda/policy_conditions_test.go b/providers/aws/lambda/policy_conditions_test.go new file mode 100644 index 000000000..1490edabe --- /dev/null +++ b/providers/aws/lambda/policy_conditions_test.go @@ -0,0 +1,71 @@ +package lambda + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// TestAddPermissionConditions checks every AddPermission option is kept and +// rendered as the Condition AWS returns from GetPolicy. +func TestAddPermissionConditions(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + _, err := m.CreateFunction(ctx, defaultFuncConfig()) + requireNoError(t, err) + + requireNoError(t, m.AddPermission(ctx, "my-func", "", driver.PermissionStatement{ + StatementID: "url", Action: "lambda:InvokeFunctionUrl", Principal: "*", FunctionURLAuthType: "NONE", + })) + requireNoError(t, m.AddPermission(ctx, "my-func", "", driver.PermissionStatement{ + StatementID: "via", Action: "lambda:InvokeFunction", Principal: "*", InvokedViaFunctionURL: true, + })) + requireNoError(t, m.AddPermission(ctx, "my-func", "", driver.PermissionStatement{ + StatementID: "s3", Action: "lambda:InvokeFunction", Principal: "s3.amazonaws.com", + SourceARN: "arn:aws:s3:::b", SourceAccount: "123456789012", PrincipalOrgID: "o-abc", EventSourceToken: "tok", + })) + + err = m.AddPermission(ctx, "my-func", "", driver.PermissionStatement{ + StatementID: "bad", Action: "lambda:InvokeFunctionUrl", Principal: "*", FunctionURLAuthType: "BASIC", + }) + assertError(t, err, true) + + doc, err := m.GetPolicy(ctx, "my-func", "") + requireNoError(t, err) + + var policy struct { + Statement []struct { + Sid string + Condition map[string]map[string]string + } + } + + requireNoError(t, json.Unmarshal([]byte(doc), &policy)) + + got := map[string]map[string]map[string]string{} + for _, s := range policy.Statement { + got[s.Sid] = s.Condition + } + + assertEqual(t, "NONE", got["url"]["StringEquals"]["lambda:FunctionUrlAuthType"]) + assertEqual(t, "true", got["via"]["Bool"]["lambda:InvokedViaFunctionUrl"]) + assertEqual(t, "arn:aws:s3:::b", got["s3"]["ArnLike"]["AWS:SourceArn"]) + assertEqual(t, "123456789012", got["s3"]["StringEquals"]["AWS:SourceAccount"]) + assertEqual(t, "o-abc", got["s3"]["StringEquals"]["aws:PrincipalOrgID"]) + assertEqual(t, "tok", got["s3"]["StringEquals"]["lambda:EventSourceToken"]) + + resource, stmts, err := m.PolicyStatements(ctx, "my-func", "") + requireNoError(t, err) + assertEqual(t, 3, len(stmts)) + assertNotEmpty(t, resource) + + _, stmts, err = m.PolicyStatements(ctx, "my-func", "prod") + requireNoError(t, err) + assertEqual(t, 0, len(stmts)) + + _, _, err = m.PolicyStatements(ctx, "missing", "") + assertError(t, err, true) +} diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index 1c8c4866a..325c04e0d 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -507,6 +507,9 @@ func (h *Handler) servePolicy(w http.ResponseWriter, r *http.Request, op opID, n err := pm.AddPermission(r.Context(), name, qualifier, sdrv.PermissionStatement{ StatementID: req.StatementID, Action: req.Action, Principal: req.Principal, SourceARN: req.SourceArn, + SourceAccount: req.SourceAccount, PrincipalOrgID: req.PrincipalOrgID, + EventSourceToken: req.EventSourceToken, FunctionURLAuthType: req.FunctionURLAuthType, + InvokedViaFunctionURL: req.InvokedViaFunctionURL, }) if err != nil { writeErr(w, err) diff --git a/server/aws/lambda/permission_conditions_sdk_test.go b/server/aws/lambda/permission_conditions_sdk_test.go new file mode 100644 index 000000000..41123994d --- /dev/null +++ b/server/aws/lambda/permission_conditions_sdk_test.go @@ -0,0 +1,91 @@ +package lambda_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awslambda "github.com/aws/aws-sdk-go-v2/service/lambda" + lambdatypes "github.com/aws/aws-sdk-go-v2/service/lambda/types" +) + +// TestSDKAddPermissionConditionsRoundTrip checks AddPermission keeps +// FunctionUrlAuthType, InvokedViaFunctionUrl, SourceAccount, PrincipalOrgID +// and EventSourceToken, and GetPolicy returns them as the Condition AWS +// stores, which Terraform's aws_lambda_permission reads back. +func TestSDKAddPermissionConditionsRoundTrip(t *testing.T) { + client, _ := newSDKClient(t) + ctx := context.Background() + + if _, err := client.CreateFunction(ctx, &awslambda.CreateFunctionInput{ + FunctionName: aws.String("permcond"), + Runtime: lambdatypes.RuntimeGo1x, + Role: aws.String("arn:aws:iam::000000000000:role/test"), + Handler: aws.String("main"), + Code: &lambdatypes.FunctionCode{ZipFile: []byte("code")}, + }); err != nil { + t.Fatalf("CreateFunction: %v", err) + } + + inputs := []*awslambda.AddPermissionInput{ + { + FunctionName: aws.String("permcond"), StatementId: aws.String("url"), Action: aws.String("lambda:InvokeFunctionUrl"), + Principal: aws.String("*"), FunctionUrlAuthType: lambdatypes.FunctionUrlAuthTypeNone, + }, + { + FunctionName: aws.String("permcond"), StatementId: aws.String("via"), Action: aws.String("lambda:InvokeFunction"), + Principal: aws.String("*"), InvokedViaFunctionUrl: aws.Bool(true), + }, + { + FunctionName: aws.String("permcond"), StatementId: aws.String("s3"), Action: aws.String("lambda:InvokeFunction"), + Principal: aws.String("s3.amazonaws.com"), SourceArn: aws.String("arn:aws:s3:::b"), + SourceAccount: aws.String("123456789012"), + }, + } + + for _, in := range inputs { + out, err := client.AddPermission(ctx, in) + if err != nil { + t.Fatalf("AddPermission %s: %v", aws.ToString(in.StatementId), err) + } + + if aws.ToString(out.Statement) == "" { + t.Fatalf("AddPermission %s: empty statement", aws.ToString(in.StatementId)) + } + } + + pol, err := client.GetPolicy(ctx, &awslambda.GetPolicyInput{FunctionName: aws.String("permcond")}) + if err != nil { + t.Fatalf("GetPolicy: %v", err) + } + + var doc struct { + Statement []struct { + Sid string + Condition map[string]map[string]string + } + } + + if err := json.Unmarshal([]byte(aws.ToString(pol.Policy)), &doc); err != nil { + t.Fatalf("policy JSON: %v", err) + } + + cond := map[string]map[string]map[string]string{} + for _, s := range doc.Statement { + cond[s.Sid] = s.Condition + } + + checks := []struct{ sid, op, key, want string }{ + {"url", "StringEquals", "lambda:FunctionUrlAuthType", "NONE"}, + {"via", "Bool", "lambda:InvokedViaFunctionUrl", "true"}, + {"s3", "ArnLike", "AWS:SourceArn", "arn:aws:s3:::b"}, + {"s3", "StringEquals", "AWS:SourceAccount", "123456789012"}, + } + + for _, c := range checks { + if got := cond[c.sid][c.op][c.key]; got != c.want { + t.Errorf("%s %s %s = %q, want %q", c.sid, c.op, c.key, got, c.want) + } + } +} diff --git a/server/aws/lambda/types.go b/server/aws/lambda/types.go index 061e94377..67ccc419c 100644 --- a/server/aws/lambda/types.go +++ b/server/aws/lambda/types.go @@ -159,10 +159,15 @@ type listAliasesResponse struct { // addPermissionRequest is the body of AddPermission (POST .../{name}/policy). type addPermissionRequest struct { - StatementID string `json:"StatementId"` - Action string `json:"Action"` - Principal string `json:"Principal"` - SourceArn string `json:"SourceArn"` + StatementID string `json:"StatementId"` + Action string `json:"Action"` + Principal string `json:"Principal"` + SourceArn string `json:"SourceArn"` + SourceAccount string `json:"SourceAccount"` + PrincipalOrgID string `json:"PrincipalOrgID"` + EventSourceToken string `json:"EventSourceToken"` + FunctionURLAuthType string `json:"FunctionUrlAuthType"` + InvokedViaFunctionURL bool `json:"InvokedViaFunctionUrl"` } // functionResource is the shape returned by GetFunction: diff --git a/services/serverless/driver/driver.go b/services/serverless/driver/driver.go index 96350e2dc..b12e7f299 100644 --- a/services/serverless/driver/driver.go +++ b/services/serverless/driver/driver.go @@ -29,6 +29,17 @@ type PermissionStatement struct { Action string Principal string SourceARN string + // SourceAccount, PrincipalOrgID and EventSourceToken narrow the grant to + // a source account, an AWS Organization, or an Alexa skill token. + SourceAccount string + PrincipalOrgID string + EventSourceToken string + // FunctionURLAuthType ("NONE" or "AWS_IAM") limits a + // lambda:InvokeFunctionUrl grant to function URLs of that auth type. + FunctionURLAuthType string + // InvokedViaFunctionURL limits a lambda:InvokeFunction grant to calls + // made through a function URL. + InvokedViaFunctionURL bool } // FunctionURLConfig is a Lambda Function URL configuration. Function URLs are From ba560c06d6384426d9e491dffe218705de32434b Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sat, 10 Oct 2026 17:25:55 +0530 Subject: [PATCH 3/6] feat(auth): let a resolver add service condition keys to the request context --- server/aws/authzgate.go | 38 ++++++++++++++++++---- server/wire/awsauthz/awsauthz.go | 35 ++++++++++++++++++++ server/wire/awsauthz/merge_context_test.go | 37 +++++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 server/wire/awsauthz/merge_context_test.go diff --git a/server/aws/authzgate.go b/server/aws/authzgate.go index 1193a2587..57a3b828d 100644 --- a/server/aws/authzgate.go +++ b/server/aws/authzgate.go @@ -99,6 +99,8 @@ type authzPlan struct { // not parse. A deny is rendered from it, since its form is already parsed. req *http.Request checks []awsauthz.Check + // cond holds the service condition keys a ContextResolver returned. + cond map[string]string // action names the operation in a deny message for the plans without // checks (the service-wide action for planServiceWide). action string @@ -115,13 +117,8 @@ func (g *gateConfig) resolvePlan(probe *http.Request, h server.Handler, probed b return authzPlan{kind: planNoHandler} } - if res, ok := h.(awsauthz.Resolver); ok { - checks, known := res.IAMChecks(probe, g.scope) - if !known { - return authzPlan{kind: planUnknownOp, req: probe, action: iamService(h) + ":" + rawOperation(probe)} - } - - return authzPlan{kind: planChecks, req: probe, checks: checks} + if plan, ok := g.resolverPlan(probe, h); ok { + return plan } if g.jsonRPC[h] { @@ -139,6 +136,32 @@ func (g *gateConfig) resolvePlan(probe *http.Request, h server.Handler, probed b return authzPlan{kind: planUnmapped, req: probe} } +// resolverPlan is the plan of a handler that names its own checks (a +// Resolver, or a ContextResolver that also returns condition keys). ok=false +// when h is neither. +func (g *gateConfig) resolverPlan(probe *http.Request, h server.Handler) (authzPlan, bool) { + var ( + checks []awsauthz.Check + cond map[string]string + known bool + ) + + switch res := h.(type) { + case awsauthz.ContextResolver: + checks, cond, known = res.IAMChecksWithContext(probe, g.scope) + case awsauthz.Resolver: + checks, known = res.IAMChecks(probe, g.scope) + default: + return authzPlan{}, false + } + + if !known { + return authzPlan{kind: planUnknownOp, req: probe, action: iamService(h) + ":" + rawOperation(probe)}, true + } + + return authzPlan{kind: planChecks, req: probe, checks: checks, cond: cond}, true +} + // jsonRPCPlan binds a JSON-RPC request to its action through the target // table. The service the header names must be the handler's own, or the // request fails closed. The resource is unknown: a JSON-RPC handler that can @@ -238,6 +261,7 @@ func (g *gateConfig) authorize( } ev := awsauthz.Evaluation{Principal: *p, CondCtx: awsauthz.ConditionContext(r, p, g.scope)} + awsauthz.MergeContext(ev.CondCtx, plan.cond, iamService(h)) strict := kind == stssrv.KindRole shortcut := !strict && (isAdminPrincipal(*p) || !principalHasPolicies(r, *p, g.iam)) diff --git a/server/wire/awsauthz/awsauthz.go b/server/wire/awsauthz/awsauthz.go index 91bc8aaaa..bc7cfebd9 100644 --- a/server/wire/awsauthz/awsauthz.go +++ b/server/wire/awsauthz/awsauthz.go @@ -64,6 +64,41 @@ type Resolver interface { IAMChecks(r *http.Request, s Scope) (checks []Check, ok bool) } +// ContextResolver is implemented by a Resolver whose operations have +// service-specific condition keys (such as lambda:FunctionUrlAuthType or +// aws:RequestTag/*). The gate calls it instead of IAMChecks and adds the keys +// it returns to the request's condition context, through MergeContext. +type ContextResolver interface { + IAMChecksWithContext(r *http.Request, s Scope) (checks []Check, cond map[string]string, ok bool) +} + +// MergeContext copies into dst the keys of extra a handler of service may +// set: its own ":" keys and the request and resource tag keys. Any +// other key is dropped and a key already in dst is kept, so a handler can +// never replace a global key the gate derived (aws:PrincipalArn, +// aws:SourceIp, ...). +func MergeContext(dst, extra map[string]string, service string) { + for k, v := range extra { + if _, taken := dst[k]; taken || !handlerKey(k, service) { + continue + } + + dst[k] = v + } +} + +// handlerKey reports whether a handler of service may set condition key k. +func handlerKey(k, service string) bool { + switch { + case service != "" && strings.HasPrefix(k, service+":"): + return true + case k == "aws:TagKeys": + return true + default: + return strings.HasPrefix(k, "aws:RequestTag/") || strings.HasPrefix(k, "aws:ResourceTag/") + } +} + // ServiceNamer is implemented by every AWS handler. It returns the IAM // service prefix (such as "s3" or "elasticfilesystem") of the operations the // handler serves. A handler that is not a Resolver is authorized at service diff --git a/server/wire/awsauthz/merge_context_test.go b/server/wire/awsauthz/merge_context_test.go new file mode 100644 index 000000000..2ffa12688 --- /dev/null +++ b/server/wire/awsauthz/merge_context_test.go @@ -0,0 +1,37 @@ +package awsauthz + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMergeContext(t *testing.T) { + dst := map[string]string{"aws:SourceIp": "10.0.0.1", "aws:PrincipalArn": "arn:aws:iam::1:user/u"} + + MergeContext(dst, map[string]string{ + "lambda:FunctionUrlAuthType": "NONE", + "aws:RequestTag/team": "a", + "aws:ResourceTag/env": "dev", + "aws:TagKeys": "team", + "aws:SourceIp": "1.2.3.4", + "aws:PrincipalArn": "arn:aws:iam::1:root", + "aws:SecureTransport": "true", + "s3:prefix": "x", + }, "lambda") + + assert.Equal(t, map[string]string{ + "aws:SourceIp": "10.0.0.1", + "aws:PrincipalArn": "arn:aws:iam::1:user/u", + "lambda:FunctionUrlAuthType": "NONE", + "aws:RequestTag/team": "a", + "aws:ResourceTag/env": "dev", + "aws:TagKeys": "team", + }, dst) +} + +func TestMergeContextNoService(t *testing.T) { + dst := map[string]string{} + MergeContext(dst, map[string]string{":x": "1", "lambda:Principal": "s3.amazonaws.com"}, "") + assert.Empty(t, dst) +} From 70162cd569829d6287fd33cda0122012f7cae2d3 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sat, 10 Oct 2026 18:07:13 +0530 Subject: [PATCH 4/6] feat(aws-lambda): op-level IAM actions, resource ARNs and resource-based policy --- server/aws/authz_matrix_lambda_test.go | 436 +++++++++++++++++ server/aws/authz_matrix_test.go | 25 +- server/aws/aws.go | 8 +- server/aws/lambda/authz.go | 293 +++++++++++ server/aws/lambda/functionurl_invoke.go | 5 + server/aws/lambda/handler.go | 11 +- server/aws/lambda/iam_actions.go | 565 ++++++++++++++++++++++ server/aws/lambda/iam_actions_test.go | 186 +++++++ server/aws/lambda/resource_policy_test.go | 81 ++++ server/serveflags/serveflags.go | 6 +- 10 files changed, 1597 insertions(+), 19 deletions(-) create mode 100644 server/aws/authz_matrix_lambda_test.go create mode 100644 server/aws/lambda/authz.go create mode 100644 server/aws/lambda/iam_actions.go create mode 100644 server/aws/lambda/iam_actions_test.go create mode 100644 server/aws/lambda/resource_policy_test.go diff --git a/server/aws/authz_matrix_lambda_test.go b/server/aws/authz_matrix_lambda_test.go new file mode 100644 index 000000000..a2e087c86 --- /dev/null +++ b/server/aws/authz_matrix_lambda_test.go @@ -0,0 +1,436 @@ +package aws + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + + awsprovider "github.com/stackshy/cloudemu/v2/providers/aws" + sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +const ( + // lambdaDeny is the body of Lambda's AccessDeniedException. + lambdaDeny = `"Type":"User"` + // urlForbidden is the body of a denied call through a function URL. + urlForbidden = "Forbidden. For troubleshooting Function URL authorization issues" + fnARN = "arn:aws:lambda:us-east-1:123456789012:function:" + acctID = "123456789012" +) + +func stmt(effect, action, resource string) string { + return `{"Effect":"` + effect + `","Action":"` + action + `","Resource":"` + resource + `"}` +} + +func stmtCond(effect, action, resource, cond string) string { + return `{"Effect":"` + effect + `","Action":"` + action + `","Resource":"` + resource + `","Condition":` + cond + `}` +} + +// newFunction creates fn directly through the driver, with a published +// version 1 and an alias prod on it. +func newFunction(t *testing.T, cloud *awsprovider.Provider, name string, tags map[string]string) { + t.Helper() + + ctx := context.Background() + + if _, err := cloud.Lambda.CreateFunction(ctx, sdrv.FunctionConfig{ + Name: name, Runtime: "python3.12", Handler: "index.handler", Role: "arn:aws:iam::123456789012:role/r", Tags: tags, + }); err != nil { + t.Fatalf("CreateFunction: %v", err) + } + + if _, err := cloud.Lambda.PublishVersion(ctx, name, ""); err != nil { + t.Fatalf("PublishVersion: %v", err) + } + + if _, err := cloud.Lambda.CreateAlias(ctx, sdrv.AliasConfig{FunctionName: name, Name: "prod", FunctionVersion: "1"}); err != nil { + t.Fatalf("CreateAlias: %v", err) + } +} + +func functionExists(cloud *awsprovider.Provider, name string) bool { + _, err := cloud.Lambda.GetFunction(context.Background(), name) + return err == nil +} + +// invokeReq is a signed Invoke of function ref, with an optional query. +func invokeReq(ref string, query ...string) sreq { + path := lambdaPath + "/" + ref + "/invocations" + if len(query) > 0 { + path += "?" + strings.Join(query, "&") + } + + return sreq{path: path, ctype: "application/json", body: `{}`, service: "lambda"} +} + +// TestAuthzMatrixLambdaInvoke covers qualified and unqualified function +// ARNs: a policy on the function does not cover its versions or aliases, and +// a policy on an alias covers only that alias. +func TestAuthzMatrixLambdaInvoke(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "fn1", nil) + + invoker := userWithPolicy(t, cloud, "invoker", policyDoc(stmt("Allow", "lambda:InvokeFunction", fnARN+"fn1"))) + aliasInvoker := userWithPolicy(t, cloud, "aliasinvoker", policyDoc(stmt("Allow", "lambda:InvokeFunction", fnARN+"fn1:prod"))) + + for _, tc := range []struct { + name string + creds string + rq sreq + allowed bool + }{ + {"unqualified", "invoker", invokeReq("fn1"), true}, + {"function ARN of another account names the local function", "invoker", + invokeReq("arn:aws:lambda:eu-west-1:999999999999:function:fn1"), true}, + {"alias in the name", "invoker", invokeReq("fn1:prod"), false}, + {"alias in the query", "invoker", invokeReq("fn1", "Qualifier=prod"), false}, + {"alias for the alias user", "alias", invokeReq("fn1:prod"), true}, + {"alias by query for the alias user", "alias", invokeReq("fn1", "Qualifier=prod"), true}, + {"unqualified for the alias user", "alias", invokeReq("fn1"), false}, + {"version for the alias user", "alias", invokeReq("fn1", "Qualifier=1"), false}, + {"event invocation", "invoker", withHeader(invokeReq("fn1"), "X-Amz-Invocation-Type", "Event"), true}, + {"dry run", "invoker", withHeader(invokeReq("fn1"), "X-Amz-Invocation-Type", "DryRun"), true}, + {"GetFunction is a different action", "invoker", sreq{method: http.MethodGet, path: lambdaPath + "/fn1", service: "lambda"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + creds := invoker + if tc.creds == "alias" { + creds = aliasInvoker + } + + status, body := doSigned(t, ts, creds, tc.rq) + if tc.allowed { + wantNotDenied(t, status, body) + return + } + + wantDenied(t, status, body, lambdaDeny) + }) + } +} + +func withHeader(rq sreq, k, v string) sreq { + rq.header = map[string]string{k: v} + return rq +} + +// TestAuthzMatrixLambdaDenyOne checks a Deny on one Lambda action on one +// function blocks only that action on that function. +func TestAuthzMatrixLambdaDenyOne(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "prod", nil) + newFunction(t, cloud, "dev", nil) + + u := userWithPolicy(t, cloud, "denydelete", policyDoc(stmt("Allow", "*", "*"), stmt("Deny", "lambda:DeleteFunction", fnARN+"prod"))) + + status, body := doSigned(t, ts, u, sreq{method: http.MethodDelete, path: lambdaPath + "/prod", service: "lambda"}) + wantDenied(t, status, body, "lambda:DeleteFunction on resource: "+fnARN+"prod with an explicit deny") + + if !functionExists(cloud, "prod") { + t.Fatal("a denied DeleteFunction removed the function") + } + + status, body = doSigned(t, ts, u, sreq{path: lambdaPath, ctype: "application/json", + body: strings.Replace(lambdaCreate, "f1", "created", 1), service: "lambda"}) + wantNotDenied(t, status, body) + + status, body = doSigned(t, ts, u, sreq{method: http.MethodDelete, path: lambdaPath + "/dev", service: "lambda"}) + wantNotDenied(t, status, body) + + if functionExists(cloud, "dev") { + t.Fatal("DeleteFunction on dev did not run") + } +} + +// TestAuthzMatrixLambdaConditions covers the tag, layer, function URL, +// principal and event source mapping condition keys. +func TestAuthzMatrixLambdaConditions(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "devfn", map[string]string{"env": "dev"}) + newFunction(t, cloud, "prodfn", map[string]string{"env": "prod"}) + + t.Run("aws:RequestTag on TagResource", func(t *testing.T) { + u := userWithPolicy(t, cloud, "tagger", policyDoc(stmtCond("Allow", "lambda:TagResource", "*", + `{"StringEquals":{"aws:RequestTag/team":"a"}}`))) + tagPath := "/2017-03-31/tags/" + fnARN + "devfn" + + status, body := doSigned(t, ts, u, sreq{path: tagPath, ctype: "application/json", body: `{"Tags":{"team":"a"}}`, service: "lambda"}) + wantNotDenied(t, status, body) + + status, body = doSigned(t, ts, u, sreq{path: tagPath, ctype: "application/json", body: `{"Tags":{"team":"b"}}`, service: "lambda"}) + wantDenied(t, status, body, lambdaDeny) + }) + + t.Run("aws:ResourceTag on GetFunction", func(t *testing.T) { + u := userWithPolicy(t, cloud, "devreader", policyDoc(stmtCond("Allow", "lambda:GetFunction", "*", + `{"StringEquals":{"aws:ResourceTag/env":"dev"}}`))) + + status, body := doSigned(t, ts, u, sreq{method: http.MethodGet, path: lambdaPath + "/devfn", service: "lambda"}) + wantNotDenied(t, status, body) + + status, body = doSigned(t, ts, u, sreq{method: http.MethodGet, path: lambdaPath + "/prodfn", service: "lambda"}) + wantDenied(t, status, body, lambdaDeny) + }) + + t.Run("a layer needs lambda:GetLayerVersion", func(t *testing.T) { + u := userWithPolicy(t, cloud, "creator", policyDoc(stmt("Allow", "lambda:CreateFunction", "*"))) + withLayer := strings.Replace(lambdaCreate, `"f1",`, `"layered","Layers":["arn:aws:lambda:us-east-1:123456789012:layer:l:1"],`, 1) + + status, body := doSigned(t, ts, u, sreq{path: lambdaPath, ctype: "application/json", body: withLayer, service: "lambda"}) + wantDenied(t, status, body, "lambda:GetLayerVersion on resource: arn:aws:lambda:us-east-1:123456789012:layer:l:1") + + if functionExists(cloud, "layered") { + t.Fatal("a denied CreateFunction created the function") + } + }) + + t.Run("tagging at create needs lambda:TagResource", func(t *testing.T) { + u := userWithPolicy(t, cloud, "notagger", policyDoc(stmt("Allow", "lambda:CreateFunction", "*"))) + tagged := strings.Replace(lambdaCreate, `"f1",`, `"taggedfn","Tags":{"a":"b"},`, 1) + + status, body := doSigned(t, ts, u, sreq{path: lambdaPath, ctype: "application/json", body: tagged, service: "lambda"}) + wantDenied(t, status, body, "lambda:TagResource") + }) + + t.Run("lambda:FunctionUrlAuthType on CreateFunctionUrlConfig", func(t *testing.T) { + u := userWithPolicy(t, cloud, "urlmaker", policyDoc(stmtCond("Allow", "lambda:CreateFunctionUrlConfig", "*", + `{"StringEquals":{"lambda:FunctionUrlAuthType":"AWS_IAM"}}`))) + urlPath := "/2021-10-31/functions/devfn/url" + + status, body := doSigned(t, ts, u, sreq{path: urlPath, ctype: "application/json", body: `{"AuthType":"NONE"}`, service: "lambda"}) + wantDenied(t, status, body, lambdaDeny) + + status, body = doSigned(t, ts, u, sreq{path: urlPath, ctype: "application/json", body: `{"AuthType":"AWS_IAM"}`, service: "lambda"}) + wantNotDenied(t, status, body) + }) + + t.Run("lambda:Principal on AddPermission", func(t *testing.T) { + u := userWithPolicy(t, cloud, "granter", policyDoc(stmtCond("Allow", "lambda:AddPermission", "*", + `{"StringEquals":{"lambda:Principal":"s3.amazonaws.com"}}`))) + polPath := lambdaPath + "/prodfn/policy" + + status, body := doSigned(t, ts, u, sreq{path: polPath, ctype: "application/json", service: "lambda", + body: `{"StatementId":"sns","Action":"lambda:InvokeFunction","Principal":"sns.amazonaws.com"}`}) + wantDenied(t, status, body, lambdaDeny) + + status, body = doSigned(t, ts, u, sreq{path: polPath, ctype: "application/json", service: "lambda", + body: `{"StatementId":"s3","Action":"lambda:InvokeFunction","Principal":"s3.amazonaws.com"}`}) + wantNotDenied(t, status, body) + }) + + t.Run("lambda:FunctionArn on CreateEventSourceMapping", func(t *testing.T) { + u := userWithPolicy(t, cloud, "mapper", policyDoc(stmt("Allow", "*", "*"), + stmtCond("Deny", "lambda:CreateEventSourceMapping", "*", `{"StringEquals":{"lambda:FunctionArn":"`+fnARN+`prodfn"}}`))) + esm := `{"EventSourceArn":"arn:aws:sqs:us-east-1:123456789012:q","FunctionName":"prodfn"}` + + status, body := doSigned(t, ts, u, sreq{path: "/2015-03-31/event-source-mappings/", ctype: "application/json", body: esm, service: "lambda"}) + wantDenied(t, status, body, "with an explicit deny") + }) +} + +// TestAuthzMatrixLambdaResourcePolicy covers the function's resource-based +// policy: within one account it grants Invoke to a principal it names, but a +// grant to the account only delegates to the caller's IAM policies. +func TestAuthzMatrixLambdaResourcePolicy(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "shared", nil) + + ctx := context.Background() + other := allow("dynamodb:ListTables") + named := userWithPolicy(t, cloud, "named", other) + unnamed := userWithPolicy(t, cloud, "unnamed", other) + viaAccount := userWithPolicy(t, cloud, "viaaccount", other) + + for _, st := range []sdrv.PermissionStatement{ + {StatementID: "user", Action: "lambda:InvokeFunction", Principal: "arn:aws:iam::123456789012:user/named"}, + {StatementID: "acct", Action: "lambda:InvokeFunction", Principal: acctID}, + {StatementID: "svc", Action: "lambda:InvokeFunction", Principal: "s3.amazonaws.com"}, + } { + if err := cloud.Lambda.AddPermission(ctx, "shared", "", st); err != nil { + t.Fatalf("AddPermission: %v", err) + } + } + + status, body := doSigned(t, ts, named, invokeReq("shared")) + wantNotDenied(t, status, body) + + status, body = doSigned(t, ts, named, invokeReq("shared:prod")) + wantDenied(t, status, body, lambdaDeny) + + for _, c := range []aws.Credentials{unnamed, viaAccount} { + status, body = doSigned(t, ts, c, invokeReq("shared")) + wantDenied(t, status, body, "lambda:InvokeFunction on resource: "+fnARN+"shared") + } +} + +// functionURLHost creates a function URL for fn and returns its host. +func functionURLHost(t *testing.T, cloud *awsprovider.Provider, fn, authType string) string { + t.Helper() + + cfg, err := cloud.Lambda.CreateFunctionURLConfig(context.Background(), sdrv.FunctionURLConfig{FunctionName: fn, AuthType: authType}) + if err != nil { + t.Fatalf("CreateFunctionURLConfig: %v", err) + } + + u, err := url.Parse(cfg.FunctionURL) + if err != nil { + t.Fatalf("parse %s: %v", cfg.FunctionURL, err) + } + + return u.Host +} + +func doUnsigned(t *testing.T, ts *httptest.Server, host string) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/hello", http.NoBody) + if err != nil { + t.Fatalf("new request: %v", err) + } + + req.Host = host + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + b, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, string(b) +} + +// TestAuthzMatrixLambdaFunctionURL covers calls through function URLs: an +// AWS_IAM URL needs lambda:InvokeFunctionUrl and lambda:InvokeFunction from the +// caller; a NONE URL is public only when the resource-based policy grants both +// to everyone. +func TestAuthzMatrixLambdaFunctionURL(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "iamurl", nil) + newFunction(t, cloud, "puburl", nil) + + iamHost := functionURLHost(t, cloud, "iamurl", "AWS_IAM") + pubHost := functionURLHost(t, cloud, "puburl", "NONE") + urlReq := func(host string) sreq { return sreq{method: http.MethodGet, path: "/hello", host: host, service: "lambda"} } + + both := userWithPolicy(t, cloud, "urlboth", allow("lambda:InvokeFunctionUrl", "lambda:InvokeFunction")) + urlOnly := userWithPolicy(t, cloud, "urlonly", allow("lambda:InvokeFunctionUrl")) + + t.Run("AWS_IAM with both actions", func(t *testing.T) { + status, body := doSigned(t, ts, both, urlReq(iamHost)) + if status != http.StatusOK { + t.Fatalf("status %d %s, want 200", status, body) + } + }) + + t.Run("AWS_IAM with only InvokeFunctionUrl", func(t *testing.T) { + status, body := doSigned(t, ts, urlOnly, urlReq(iamHost)) + wantDenied(t, status, body, urlForbidden) + }) + + t.Run("AWS_IAM unsigned", func(t *testing.T) { + status, body := doUnsigned(t, ts, iamHost) + wantDenied(t, status, body, "MissingAuthenticationToken") + }) + + t.Run("NONE without a public grant", func(t *testing.T) { + status, body := doUnsigned(t, ts, pubHost) + wantDenied(t, status, body, urlForbidden) + }) + + ctx := context.Background() + if err := cloud.Lambda.AddPermission(ctx, "puburl", "", sdrv.PermissionStatement{ + StatementID: "url", Action: "lambda:InvokeFunctionUrl", Principal: "*", FunctionURLAuthType: "NONE", + }); err != nil { + t.Fatalf("AddPermission: %v", err) + } + + t.Run("NONE with only the InvokeFunctionUrl grant", func(t *testing.T) { + status, body := doUnsigned(t, ts, pubHost) + wantDenied(t, status, body, urlForbidden) + }) + + if err := cloud.Lambda.AddPermission(ctx, "puburl", "", sdrv.PermissionStatement{ + StatementID: "invoke", Action: "lambda:InvokeFunction", Principal: "*", InvokedViaFunctionURL: true, + }); err != nil { + t.Fatalf("AddPermission: %v", err) + } + + t.Run("NONE with the public grant", func(t *testing.T) { + status, body := doUnsigned(t, ts, pubHost) + if status != http.StatusOK { + t.Fatalf("status %d %s, want 200", status, body) + } + }) + + t.Run("the InvokedViaFunctionUrl grant does not allow a direct Invoke", func(t *testing.T) { + u := userWithPolicy(t, cloud, "direct", allow("dynamodb:ListTables")) + status, body := doSigned(t, ts, u, invokeReq("puburl")) + wantDenied(t, status, body, lambdaDeny) + }) +} + +// TestAuthzMatrixLambdaUnroutedOps checks Lambda operations this server does +// not route are never served by another handler under a Lambda signature. +func TestAuthzMatrixLambdaUnroutedOps(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "fn1", nil) + + u := userWithPolicy(t, cloud, "s3all", allow("s3:*", "lambda:*")) + + for _, rq := range []sreq{ + {method: http.MethodGet, path: "/2016-08-19/account-settings", service: "lambda"}, + {path: "/2014-11-13/functions/fn1/invoke-async/", ctype: "application/json", body: `{}`, service: "lambda"}, + {path: "/2021-11-15/functions/fn1/response-streaming-invocations", ctype: "application/json", body: `{}`, service: "lambda"}, + {path: "/2020-04-22/code-signing-configs/", ctype: "application/json", body: `{}`, service: "lambda"}, + {method: http.MethodPut, path: "/2021-07-20/functions/fn1/runtime-management-config", ctype: "application/json", + body: `{}`, service: "lambda"}, + } { + status, body := doSigned(t, ts, u, rq) + if status != http.StatusNotImplemented { + t.Errorf("%s %s: status %d %s, want 501", rq.method, rq.path, status, body) + } + } + + for _, b := range []string{"2016-08-19", "2014-11-13", "2021-11-15", "2020-04-22", "2021-07-20"} { + if bucketExists(t, cloud, b) { + t.Errorf("an unrouted Lambda request created bucket %s", b) + } + } +} + +// TestAuthzMatrixLambdaUnknownOp checks a request Lambda answers with an +// error is denied to a restricted caller and changes nothing for root. +func TestAuthzMatrixLambdaUnknownOp(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "fn1", nil) + + restricted := userWithPolicy(t, cloud, "readonly", allow("lambda:GetFunction")) + boot := userWithPolicy(t, cloud, "boot", "") + + for _, rq := range []sreq{ + {method: http.MethodPut, path: lambdaPath, service: "lambda"}, + {method: http.MethodGet, path: lambdaPath + "/fn1/invocations", service: "lambda"}, + {method: http.MethodPost, path: lambdaPath + "/fn1/other", service: "lambda"}, + {method: http.MethodPost, path: lambdaPath + "/fn1:prod/invocations?Qualifier=1", service: "lambda"}, + } { + status, body := doSigned(t, ts, restricted, rq) + wantDenied(t, status, body, "lambda:UnknownOperation") + + status, body = doSigned(t, ts, boot, rq) + if status < http.StatusBadRequest || status == http.StatusForbidden { + t.Errorf("%s %s as boot: status %d %s, want a 4xx error", rq.method, rq.path, status, body) + } + } + + if fns, _ := cloud.Lambda.ListFunctions(context.Background()); len(fns) != 1 { + t.Fatalf("functions %v, want only fn1", fns) + } +} diff --git a/server/aws/authz_matrix_test.go b/server/aws/authz_matrix_test.go index ea75f55aa..40938648c 100644 --- a/server/aws/authz_matrix_test.go +++ b/server/aws/authz_matrix_test.go @@ -82,8 +82,8 @@ func allow(actions ...string) string { const ( allowAllDenyBucket = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"},` + `{"Effect":"Deny","Action":"s3:DeleteBucket","Resource":"arn:aws:s3:::prod"}]}` - allowAllDenyFunction = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"},` + - `{"Effect":"Deny","Action":"lambda:DeleteFunction","Resource":"arn:aws:lambda:us-east-1:123456789012:function:prod"}]}` + allowAllDenyRestAPI = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"},` + + `{"Effect":"Deny","Action":"apigateway:DELETE","Resource":"arn:aws:apigateway:us-east-1::/restapis/prod"}]}` xmlAccessDenied = "AccessDenied" createEvilUser = "Action=CreateUser&Version=2010-05-08&UserName=evil" lambdaCreate = `{"FunctionName":"f1","Runtime":"python3.12","Role":"arn:aws:iam::123456789012:role/r",` + @@ -348,7 +348,7 @@ func TestAuthzMatrixForgedTarget(t *testing.T) { status, body := doSigned(t, ts, put, sreq{ path: lambdaPath, ctype: "application/json", body: lambdaCreate, service: "lambda", header: forged, }) - wantDenied(t, status, body, accessDeny) + wantDenied(t, status, body, "lambda:CreateFunction on resource: arn:aws:lambda:us-east-1:123456789012:function:f1") if fns, _ := cloud.Lambda.ListFunctions(context.Background()); len(fns) != 0 { t.Fatalf("functions %v, want none", fns) @@ -399,17 +399,18 @@ func TestAuthzMatrixREST(t *testing.T) { }) for _, tc := range []struct { - name string - rq sreq + name string + rq sreq + shape string }{ - {"lambda CreateFunction", sreq{path: lambdaPath, ctype: "application/json", body: lambdaCreate, service: "lambda"}}, - {"apigateway CreateRestApi", sreq{path: "/restapis", ctype: "application/json", body: `{"name":"a"}`, service: "apigateway"}}, + {"lambda CreateFunction", sreq{path: lambdaPath, ctype: "application/json", body: lambdaCreate, service: "lambda"}, lambdaDeny}, + {"apigateway CreateRestApi", sreq{path: "/restapis", ctype: "application/json", body: `{"name":"a"}`, service: "apigateway"}, accessDeny}, {"eks CreateCluster", sreq{path: "/clusters", ctype: "application/json", service: "eks", - body: `{"name":"c1","roleArn":"arn:aws:iam::123456789012:role/r","resourcesVpcConfig":{}}`}}, + body: `{"name":"c1","roleArn":"arn:aws:iam::123456789012:role/r","resourcesVpcConfig":{}}`}, accessDeny}, } { t.Run(tc.name+" by a dynamodb-only user", func(t *testing.T) { status, body := doSigned(t, ts, dyn, tc.rq) - wantDenied(t, status, body, accessDeny) + wantDenied(t, status, body, tc.shape) }) } @@ -424,9 +425,9 @@ func TestAuthzMatrixREST(t *testing.T) { }) t.Run("allow-all with one deny on a service-level service", func(t *testing.T) { - u := userWithPolicy(t, cloud, "denyone", allowAllDenyFunction) - status, body := doSigned(t, ts, u, sreq{path: lambdaPath, ctype: "application/json", - body: strings.Replace(lambdaCreate, "f1", "denyone-fn", 1), service: "lambda"}) + u := userWithPolicy(t, cloud, "denyone", allowAllDenyRestAPI) + status, body := doSigned(t, ts, u, sreq{path: "/restapis", ctype: "application/json", + body: `{"name":"denyone"}`, service: "apigateway"}) wantDenied(t, status, body, accessDeny) }) diff --git a/server/aws/aws.go b/server/aws/aws.go index 361001b6e..ec14c928b 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -443,7 +443,11 @@ type Drivers struct { // (IAM, STS, EC2 and Auto Scaling, RDS, Redshift, ElastiCache, ELBv2, SNS, // CloudFormation, CloudWatch) and SageMaker are checked per operation. // JSON-RPC services are checked per operation through X-Amz-Target. S3 is - // checked per operation on the bucket and object ARNs. The other REST + // checked per operation on the bucket and object ARNs. Lambda is checked per + // operation on function, version, alias, layer and event source mapping + // ARNs; Invoke and function URL calls also honor the function's + // resource-based policy, and a function URL with AuthType NONE is public + // only when that policy grants public access. The other REST // services are checked at service level for now: only a grant covering // every action of the service (such as s3:* or AdministratorAccess) lets a // request through, so a fine-grained or resource-scoped REST policy fails @@ -1154,6 +1158,8 @@ func newServer(d Drivers) (*server.Server, authzSets) { lambdaOpts = append(lambdaOpts, lambda.WithObjectStore(d.S3)) } + lambdaOpts = append(lambdaOpts, lambda.WithEnforceAuth(d.EnforceAuth)) + srv.Register(lambda.New(d.Lambda, lambdaOpts...)) } diff --git a/server/aws/lambda/authz.go b/server/aws/lambda/authz.go new file mode 100644 index 000000000..64278d8c3 --- /dev/null +++ b/server/aws/lambda/authz.go @@ -0,0 +1,293 @@ +package lambda + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/server/authctx" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// serviceName is the IAM service prefix of every Lambda action, and the +// service field of Lambda ARNs. +const serviceName = "lambda" + +// Function URL auth types. +const urlAuthNone = "NONE" + +const ( + // bodyMessage is the message field of a Lambda error body. + bodyMessage = "Message" + // bodyType is the error type field of a Lambda error body. + bodyType = "Type" + // condTrue is the value of a true Bool condition key. + condTrue = "true" +) + +// functionURLForbidden is the body Lambda returns when a call through a +// function URL is not authorized. +const functionURLForbidden = "Forbidden. For troubleshooting Function URL authorization issues, see: " + + "https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html" + +// policyStatementLister reads the statements of a function's resource-based +// policy (AWS-only, asserted like policyManager). +type policyStatementLister interface { + PolicyStatements(ctx context.Context, functionName, qualifier string) (string, []sdrv.PermissionStatement, error) +} + +// WithEnforceAuth tells the handler the server authenticates and authorizes +// requests (EnforceAuth). The handler then also enforces the parts of Lambda +// authorization only it can decide: the public-access grant a function URL +// with AuthType NONE needs in the function's resource-based policy. +func WithEnforceAuth(on bool) Option { + return func(h *Handler) { h.enforce = on } +} + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return serviceName } + +// PublicRequest reports whether r is a call through a function URL whose +// AuthType is NONE. Lambda does not authenticate those calls (Lambda +// Developer Guide, "Control access to Lambda function URLs"), so the gate lets +// them through unsigned; the handler then requires the public grant. +func (h *Handler) PublicRequest(r *http.Request) bool { + if op, _ := classify(r); op != opInvokeFunctionURL { + return false + } + + cfg, ok := h.resolveURL(r) + + return ok && cfg.AuthType == urlAuthNone +} + +// WriteAccessDenied writes the 403 Lambda returns when IAM denies a call: +// AccessDeniedException with a {"Type":"User","Message":...} body, or the +// function URL Forbidden response for a call through a function URL. +func (*Handler) WriteAccessDenied(w http.ResponseWriter, r *http.Request, msg string) { + if isFunctionURLHost(r.Host) { + writeFunctionURLForbidden(w) + return + } + + writeAccessDenied(w, msg) +} + +func writeAccessDenied(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", contentTypeJSON) + w.Header().Set("X-Amzn-Errortype", "AccessDeniedException") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{bodyType: "User", bodyMessage: msg}) +} + +func writeFunctionURLForbidden(w http.ResponseWriter) { + w.Header().Set("Content-Type", contentTypeJSON) + w.Header().Set("X-Amzn-Errortype", "AccessDeniedException") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{bodyMessage: functionURLForbidden}) +} + +// resolveURL returns the function URL config r's host addresses. +func (h *Handler) resolveURL(r *http.Request) (*sdrv.FunctionURLConfig, bool) { + resolver, ok := h.fn.(functionURLResolver) + if !ok { + return nil, false + } + + cfg, err := resolver.ResolveFunctionURL(r.Context(), requestHost(r.Host)) + + return cfg, err == nil && cfg != nil +} + +// functionPolicy is a function's resource-based policy for one qualifier. +type functionPolicy struct { + statements []sdrv.PermissionStatement +} + +// policyStatements reads the resource-based policy of name for qualifier. +func (h *Handler) policyStatements(r *http.Request, name, qualifier string) (functionPolicy, bool) { + lister, ok := h.fn.(policyStatementLister) + if !ok { + return functionPolicy{}, false + } + + _, stmts, err := lister.PolicyStatements(r.Context(), name, qualifier) + if err != nil { + return functionPolicy{}, false + } + + return functionPolicy{statements: stmts}, true +} + +// authorizeInvoke finishes the lambda:InvokeFunction check of a direct +// Invoke. Within one account a call is allowed when either the caller's +// identity policies or the function's resource-based policy allow it, and +// neither denies it (IAM User Guide, "Policy evaluation logic"). The gate has +// already refused an explicit identity deny, and recorded the identity +// decision; an implicit deny is overturned only by a resource-policy +// statement that names the caller. It returns the deny message, or "". +func (h *Handler) authorizeInvoke(r *http.Request, name, qualifier string) string { + ev, ok := awsauthz.EvaluationFrom(r.Context()) + if !ok { + return "" + } + + return h.finishResourcePolicy(r, &ev, name, qualifier, actionInvokeFunction) +} + +// authorizeURLInvoke decides a call through a function URL. An AWS_IAM URL +// needs lambda:InvokeFunctionUrl and lambda:InvokeFunction, each from the +// caller's identity policies or the function's resource-based policy. A NONE +// URL is not authenticated, so under EnforceAuth both actions must be granted +// to everyone ("*") by the resource-based policy; since October 2025 Lambda +// requires both for new function URLs. +func (h *Handler) authorizeURLInvoke(r *http.Request, cfg *sdrv.FunctionURLConfig) bool { + if cfg.AuthType == urlAuthNone { + if !h.enforce { + return true + } + + return h.publicURLGrant(r, cfg) + } + + ev, ok := awsauthz.EvaluationFrom(r.Context()) + if !ok { + return !h.enforce + } + + for _, act := range []string{actionInvokeFunctionURL, actionInvokeFunction} { + if h.finishResourcePolicy(r, &ev, cfg.FunctionName, cfg.Qualifier, act) != "" { + return false + } + } + + return true +} + +// publicURLGrant reports whether the resource-based policy for the URL's +// qualifier grants lambda:InvokeFunctionUrl and lambda:InvokeFunction to +// everyone, under the context of a call through a NONE function URL. +func (h *Handler) publicURLGrant(r *http.Request, cfg *sdrv.FunctionURLConfig) bool { + pol, ok := h.policyStatements(r, cfg.FunctionName, cfg.Qualifier) + if !ok { + return false + } + + cctx := map[string]string{condFunctionURLAuth: urlAuthNone, condInvokedViaURL: condTrue} + + for _, act := range []string{actionInvokeFunctionURL, actionInvokeFunction} { + if !pol.grants(act, nil, cctx) { + return false + } + } + + return true +} + +// finishResourcePolicy returns "" when the identity decision the gate +// recorded for act allows the call, or the function's resource-based policy +// grants act to the caller; otherwise the deny message. +func (h *Handler) finishResourcePolicy(r *http.Request, ev *awsauthz.Evaluation, name, qualifier, act string) string { + for c, d := range ev.Decisions { + if c.Action != act { + continue + } + + switch d { + case awsauthz.Allowed: + return "" + case awsauthz.ExplicitDeny: + return denyMessage(&ev.Principal, act, c.Resource, true) + case awsauthz.ImplicitDeny: + if pol, ok := h.policyStatements(r, name, qualifier); ok && pol.grants(act, &ev.Principal, ev.CondCtx) { + return "" + } + + return denyMessage(&ev.Principal, act, c.Resource, false) + } + } + + return denyMessage(&ev.Principal, act, "", false) +} + +// denyMessage is the AWS AccessDenied message for act on resource. +func denyMessage(p *authctx.Principal, act, resource string, explicit bool) string { + if resource == "" { + resource = "*" + } + + msg := "User: " + p.ARN + " is not authorized to perform: " + act + " on resource: " + resource + if explicit { + return msg + " with an explicit deny in an identity-based policy" + } + + return msg + " because no identity-based policy allows the " + act + " action" +} + +// grants reports whether a statement of the policy allows act for caller +// (nil for an anonymous call through a NONE function URL) under cctx. +func (p functionPolicy) grants(act string, caller *authctx.Principal, cctx map[string]string) bool { + for i := range p.statements { + st := &p.statements[i] + if actionMatches(st.Action, act) && principalGranted(st.Principal, caller) && conditionsHold(st, cctx) { + return true + } + } + + return false +} + +// actionMatches reports whether a statement Action (AddPermission accepts +// "lambda:", "lambda:*" or "*") covers act. +func actionMatches(stmtAction, act string) bool { + return stmtAction == "*" || strings.EqualFold(stmtAction, serviceName+":*") || strings.EqualFold(stmtAction, act) +} + +// principalGranted reports whether a statement Principal grants access to +// caller on its own. "*" grants everyone, and the exact ARN of an IAM user or +// a role session grants that caller. A grant to the account (an account id or +// root ARN) only delegates to the account's IAM policies, and a service +// principal never matches a signed IAM caller, so neither grants anything +// here. A grant to a role ARN is limited by the role's permissions boundary, +// which this handler cannot read, so it is not applied (the caller then needs +// an identity-based allow). A nil caller (an anonymous call) only matches "*". +func principalGranted(principal string, caller *authctx.Principal) bool { + if principal == "*" { + return true + } + + return caller != nil && caller.ARN != "" && strings.HasPrefix(principal, "arn:") && principal == caller.ARN +} + +// conditionsHold evaluates the Condition AddPermission attached to st. A key +// the request does not carry fails its condition, as in IAM: a signed caller +// has no aws:SourceArn, aws:SourceAccount, aws:PrincipalOrgID or +// lambda:EventSourceToken, so a grant scoped by them never applies to it. +func conditionsHold(st *sdrv.PermissionStatement, cctx map[string]string) bool { + required := map[string]string{ + "aws:SourceArn": st.SourceARN, + "aws:SourceAccount": st.SourceAccount, + "aws:PrincipalOrgID": st.PrincipalOrgID, + "lambda:EventSourceToken": st.EventSourceToken, + condFunctionURLAuth: st.FunctionURLAuthType, + } + + if st.InvokedViaFunctionURL { + required[condInvokedViaURL] = condTrue + } + + for key, want := range required { + if want == "" { + continue + } + + if got, ok := cctx[key]; !ok || !strings.EqualFold(got, want) { + return false + } + } + + return true +} diff --git a/server/aws/lambda/functionurl_invoke.go b/server/aws/lambda/functionurl_invoke.go index 37363fdff..08c8189e1 100644 --- a/server/aws/lambda/functionurl_invoke.go +++ b/server/aws/lambda/functionurl_invoke.go @@ -101,6 +101,11 @@ func (h *Handler) serveFunctionURLInvoke(w http.ResponseWriter, r *http.Request) return } + if !h.authorizeURLInvoke(r, cfg) { + writeFunctionURLForbidden(w) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) body, err := io.ReadAll(r.Body) diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index 325c04e0d..8f6850697 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -227,6 +227,8 @@ type Handler struct { // "name:version", so a function importing the layer can have its files // overlaid into the deployment package. layerContent map[string][]byte + // enforce is set under EnforceAuth (WithEnforceAuth). + enforce bool } // Option configures a Handler. @@ -1357,6 +1359,11 @@ func (h *Handler) invoke(w http.ResponseWriter, r *http.Request, name string) { qualifier = q } + if msg := h.authorizeInvoke(r, functionName, qualifier); msg != "" { + writeAccessDenied(w, msg) + return + } + invokeType := r.Header.Get("X-Amz-Invocation-Type") // A DryRun invocation validates the request without executing the function: @@ -1710,7 +1717,3 @@ func writeThrottle(w http.ResponseWriter, msg string) { "Reason": reservedConcurrencyReason, }) } - -// IAMService returns the IAM service prefix of the operations this handler -// serves. -func (*Handler) IAMService() string { return "lambda" } diff --git a/server/aws/lambda/iam_actions.go b/server/aws/lambda/iam_actions.go new file mode 100644 index 000000000..356e95567 --- /dev/null +++ b/server/aws/lambda/iam_actions.go @@ -0,0 +1,565 @@ +package lambda + +import ( + "net/http" + "strconv" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +// Condition keys this handler sets (Service Authorization Reference, AWS +// Lambda, "Condition keys"). +const ( + condFunctionARN = "lambda:FunctionArn" + condFunctionURLAuth = "lambda:FunctionUrlAuthType" + condInvokedViaURL = "lambda:InvokedViaFunctionUrl" + condPrincipal = "lambda:Principal" + condLayer = "lambda:Layer" + condSubnetIDs = "lambda:SubnetIds" + condSecurityGroupIDs = "lambda:SecurityGroupIds" + condCodeSigningConfig = "lambda:CodeSigningConfigArn" + condTagKeys = "aws:TagKeys" + condRequestTagPrefix = "aws:RequestTag/" + condResourceTagPrefix = "aws:ResourceTag/" +) + +// The IAM actions whose name differs from the operation, or that an +// operation needs on top of its own. +const ( + actionInvokeFunction = "lambda:InvokeFunction" + actionInvokeFunctionURL = "lambda:InvokeFunctionUrl" + actionGetLayerVersion = "lambda:GetLayerVersion" + actionTagResource = "lambda:TagResource" +) + +// IAMChecks names the IAM actions and resource ARNs a request needs. +func (h *Handler) IAMChecks(r *http.Request, s awsauthz.Scope) ([]awsauthz.Check, bool) { + checks, _, ok := h.IAMChecksWithContext(r, s) + + return checks, ok +} + +// IAMChecksWithContext names the IAM actions and resource ARNs a request +// needs, and the Lambda condition keys known from it. It takes the operation +// from classify, the same function ServeHTTP dispatches on, so the authorized +// operation is always the one that runs. ok=false is returned only for +// requests the handler answers with an error and no side effect. +// +// The actions and resources follow the Service Authorization Reference for +// AWS Lambda: Invoke needs lambda:InvokeFunction, GetLayerVersionByArn needs +// lambda:GetLayerVersion, and a request that names a version or alias is +// authorized on the qualified function ARN. Resource ARNs are built from the +// server's own partition, region and account and the name dispatch uses, so +// a FunctionName ARN for another account never becomes the checked resource. +func (h *Handler) IAMChecksWithContext(r *http.Request, s awsauthz.Scope) ([]awsauthz.Check, map[string]string, bool) { + op, a := classify(r) + + rule, ok := iamRules[op] + if !ok { + return nil, nil, false + } + + if s.Partition == "" { + s.Partition = "aws" + } + + c := &checkSet{h: h, r: r, scope: s, args: &a, cond: map[string]string{}} + if !rule(c, op) { + return nil, nil, false + } + + return c.checks, c.cond, true +} + +// checkSet collects the checks and condition keys of one request. +type checkSet struct { + h *Handler + r *http.Request + scope awsauthz.Scope + args *opArgs + checks []awsauthz.Check + cond map[string]string +} + +func (c *checkSet) add(action, resource string, mode awsauthz.CheckMode) { + c.checks = append(c.checks, awsauthz.Check{Action: action, Resource: resource, Mode: mode}) +} + +// functionARN is the ARN of function name in this account, qualified when +// qualifier is set. +func (c *checkSet) functionARN(name, qualifier string) string { + arn := c.scope.ARN(serviceName, "function:"+name) + if qualifier != "" { + arn += ":" + qualifier + } + + return arn +} + +// target is the function ARN the request names: its FunctionName (with any +// qualifier embedded in it) and its Qualifier. +func (c *checkSet) target() string { + name, embedded := splitFunctionNameQualifier(c.args.name) + + qualifier := c.args.qualifier + if qualifier == "" { + qualifier = embedded + } + + return c.functionARN(name, qualifier) +} + +// unqualified is the unqualified ARN of the function the request names. +func (c *checkSet) unqualified() string { + name, _ := splitFunctionNameQualifier(c.args.name) + + return c.functionARN(name, "") +} + +func (c *checkSet) set(key, value string) { + if value != "" { + c.cond[key] = value + } +} + +func (c *checkSet) setList(key string, values []string) { + c.set(key, strings.Join(values, iamdriver.ConditionValueSeparator)) +} + +// requestTags sets aws:RequestTag/* and aws:TagKeys from tags. +func (c *checkSet) requestTags(tags map[string]string) { + keys := make([]string, 0, len(tags)) + for k, v := range tags { + keys = append(keys, k) + c.cond[condRequestTagPrefix+k] = v + } + + c.setList(condTagKeys, keys) +} + +// resourceTags sets aws:ResourceTag/* from the tags of the function name. +func (c *checkSet) resourceTags(name string) { + tagger, ok := c.h.fn.(functionTagger) + if !ok { + return + } + + bare, _ := splitFunctionNameQualifier(name) + + tags, err := tagger.ListFunctionTags(c.r.Context(), bare) + if err != nil { + return + } + + for k, v := range tags { + c.cond[condResourceTagPrefix+k] = v + } +} + +// layers adds lambda:GetLayerVersion on each layer version a function +// imports, and sets lambda:Layer. +func (c *checkSet) layers(arns []string) { + for _, arn := range arns { + c.add(actionGetLayerVersion, c.layerVersionARN(arn), awsauthz.Required) + } + + c.setList(condLayer, arns) +} + +// layerVersionARN rebuilds a layer version ARN in this account, or "" (an +// unknown resource) when arn is not one. +func (c *checkSet) layerVersionARN(arn string) string { + name, version, ok := parseLayerARN(arn) + if !ok { + return "" + } + + return c.scope.ARN(serviceName, "layer:"+name+":"+strconv.Itoa(version)) +} + +// vpc sets lambda:SubnetIds and lambda:SecurityGroupIds. +func (c *checkSet) vpc(v *vpcConfigEnvelope) { + if v == nil { + return + } + + c.setList(condSubnetIDs, v.SubnetIDs) + c.setList(condSecurityGroupIDs, v.SecurityGroupIDs) +} + +// iamRule adds the checks of one operation. It returns false when the +// handler will reject the request without side effects. +type iamRule func(c *checkSet, op opID) bool + +// action is the IAM action of an operation whose action is its own name. +func action(op opID) string { return serviceName + ":" + op } + +// onTarget: the operation's own action on the function (version or alias) +// the request names. +func onTarget(c *checkSet, op opID) bool { + c.add(action(op), c.target(), awsauthz.Required) + c.resourceTags(c.args.name) + + return true +} + +// onFunction: the operation's own action on the unqualified function. +func onFunction(c *checkSet, op opID) bool { + c.add(action(op), c.unqualified(), awsauthz.Required) + c.resourceTags(c.args.name) + + return true +} + +// onAny: the operation's own action, which takes no resource. +func onAny(c *checkSet, op opID) bool { + c.add(action(op), "*", awsauthz.Required) + return true +} + +// iamRules maps every operation the handler runs to its checks. Operations +// absent here are the error paths, reported as unknown. +// +//nolint:gochecknoglobals // static lookup table +var iamRules = map[opID]iamRule{ + opInvokeFunctionURL: functionURLInvokeChecks, + + opListFunctions: onAny, + opCreateFunction: createFunctionChecks, + opGetFunction: onTarget, + opDeleteFunction: onTarget, + opInvoke: invokeChecks, + opGetFunctionConfiguration: onTarget, + opUpdateFunctionConfiguration: updateConfigurationChecks, + opUpdateFunctionCode: onTarget, + opPublishVersion: onTarget, + opListVersionsByFunction: onTarget, + opCreateAlias: onTarget, + opListAliases: onTarget, + opGetAlias: onTarget, + opUpdateAlias: onTarget, + opDeleteAlias: onTarget, + opAddPermission: addPermissionChecks, + opGetPolicy: onTarget, + opRemovePermission: removePermissionChecks, + + opTagResource: tagChecks, + opUntagResource: tagChecks, + opListTags: tagChecks, + + opCreateEventSourceMapping: createESMChecks, + opListEventSourceMappings: onAny, + opGetEventSourceMapping: esmChecks, + opUpdateEventSourceMapping: esmChecks, + opDeleteEventSourceMapping: esmChecks, + + opListLayers: onAny, + opGetLayerVersionByArn: layerByARNChecks, + opPublishLayerVersion: publishLayerChecks, + opListLayerVersions: onAny, + opGetLayerVersion: layerVersionChecks, + opDeleteLayerVersion: layerVersionChecks, + opAddLayerVersionPermission: layerVersionChecks, + opGetLayerVersionPolicy: layerVersionChecks, + opRemoveLayerVersionPermission: layerVersionChecks, + + opCreateFunctionURLConfig: urlConfigBodyChecks, + opUpdateFunctionURLConfig: urlConfigBodyChecks, + opGetFunctionURLConfig: urlConfigStoredChecks, + opDeleteFunctionURLConfig: urlConfigStoredChecks, + opListFunctionURLConfigs: onFunction, + + opPutEventInvokeConfig: onTarget, + opUpdateEventInvokeConfig: onTarget, + opGetEventInvokeConfig: onTarget, + opDeleteEventInvokeConfig: onTarget, + opListEventInvokeConfigs: onFunction, + + opPutProvisionedConcurrency: onTarget, + opGetProvisionedConcurrency: onTarget, + opDeleteProvisionedConcurrency: onTarget, + opListProvisionedConcurrency: onFunction, + + opGetFunctionCodeSigningConfig: onFunction, + opDeleteFunctionCodeSigningConfig: onFunction, + + opPutFunctionConcurrency: onFunction, + opGetFunctionConcurrency: onFunction, + opDeleteFunctionConcurrency: onFunction, +} + +// invokeChecks: lambda:InvokeFunction on the function, version or alias. The +// function's resource-based policy can also grant it, so an implicit +// identity deny is finished by the handler (authorizeInvoke). +func invokeChecks(c *checkSet, _ opID) bool { + c.add(actionInvokeFunction, c.target(), awsauthz.ResourcePolicy) + c.resourceTags(c.args.name) + + return true +} + +// createFunctionChecks: lambda:CreateFunction, plus lambda:TagResource when +// the request tags the function and lambda:GetLayerVersion on each layer. +func createFunctionChecks(c *checkSet, _ opID) bool { + var req struct { + FunctionName string `json:"FunctionName"` + Tags map[string]string `json:"Tags"` + Layers []string `json:"Layers"` + VpcConfig *vpcConfigEnvelope `json:"VpcConfig"` + CodeSigningConfigArn string `json:"CodeSigningConfigArn"` + } + + if !awsauthz.JSONBody(c.r, &req) { + return false + } + + name, _ := splitFunctionNameQualifier(req.FunctionName) + arn := c.functionARN(name, "") + + c.add(action(opCreateFunction), arn, awsauthz.Required) + + if len(req.Tags) > 0 { + c.add(actionTagResource, arn, awsauthz.Required) + c.requestTags(req.Tags) + } + + c.layers(req.Layers) + c.vpc(req.VpcConfig) + c.set(condCodeSigningConfig, req.CodeSigningConfigArn) + + return true +} + +// updateConfigurationChecks: lambda:UpdateFunctionConfiguration, plus +// lambda:GetLayerVersion on each layer the request sets. +func updateConfigurationChecks(c *checkSet, op opID) bool { + var req struct { + Layers []string `json:"Layers"` + VpcConfig *vpcConfigEnvelope `json:"VpcConfig"` + } + + if !awsauthz.JSONBody(c.r, &req) { + return false + } + + onTarget(c, op) + c.layers(req.Layers) + c.vpc(req.VpcConfig) + + return true +} + +// addPermissionChecks: lambda:AddPermission, with lambda:Principal and +// lambda:FunctionUrlAuthType from the statement being added. +func addPermissionChecks(c *checkSet, op opID) bool { + var req addPermissionRequest + if !awsauthz.JSONBody(c.r, &req) { + return false + } + + onTarget(c, op) + c.set(condPrincipal, req.Principal) + c.set(condFunctionURLAuth, req.FunctionURLAuthType) + + return true +} + +// removePermissionChecks: lambda:RemovePermission, with lambda:Principal and +// lambda:FunctionUrlAuthType from the statement being removed, when it +// exists. +func removePermissionChecks(c *checkSet, op opID) bool { + onTarget(c, op) + + if st, ok := c.h.policyStatements(c.r, c.args.name, c.args.qualifier); ok { + for i := range st.statements { + if st.statements[i].StatementID == c.args.item { + c.set(condPrincipal, st.statements[i].Principal) + c.set(condFunctionURLAuth, st.statements[i].FunctionURLAuthType) + } + } + } + + return true +} + +// tagChecks: the tagging actions act on the function the path ARN names. A +// path that is not a function ARN is evaluated as an unknown resource. +func tagChecks(c *checkSet, op opID) bool { + resource := "" + if strings.Contains(c.args.item, ":function:") { + resource = c.target() + } + + c.add(action(op), resource, awsauthz.Required) + c.resourceTags(c.args.name) + + switch op { + case opTagResource: + var req struct { + Tags map[string]string `json:"Tags"` + } + + if !awsauthz.JSONBody(c.r, &req) { + return false + } + + c.requestTags(req.Tags) + case opUntagResource: + c.setList(condTagKeys, c.r.URL.Query()["tagKeys"]) + } + + return true +} + +// createESMChecks: lambda:CreateEventSourceMapping takes no resource; its +// target function is the lambda:FunctionArn condition. Tagging the mapping +// at create also needs lambda:TagResource. The event source's own +// permissions (sqs:ReceiveMessage and so on) belong to the function's +// execution role, not the caller. +func createESMChecks(c *checkSet, op opID) bool { + var req struct { + FunctionName string `json:"FunctionName"` + Tags map[string]string `json:"Tags"` + } + + if !awsauthz.JSONBody(c.r, &req) { + return false + } + + c.add(action(op), "*", awsauthz.Required) + c.set(condFunctionARN, c.namedFunctionARN(req.FunctionName)) + + if len(req.Tags) > 0 { + c.add(actionTagResource, "", awsauthz.Required) + c.requestTags(req.Tags) + } + + return true +} + +// esmChecks: the operation's own action on the event source mapping, with +// lambda:FunctionArn naming its function (the new one, for an update that +// changes it). +func esmChecks(c *checkSet, op opID) bool { + c.add(action(op), c.scope.ARN(serviceName, "event-source-mapping:"+c.args.item), awsauthz.Required) + + if op == opUpdateEventSourceMapping { + var req struct { + FunctionName string `json:"FunctionName"` + } + + if !awsauthz.JSONBody(c.r, &req) { + return false + } + + if req.FunctionName != "" { + c.set(condFunctionARN, c.namedFunctionARN(req.FunctionName)) + return true + } + } + + if info, err := c.h.fn.GetEventSourceMapping(c.r.Context(), c.args.item); err == nil { + c.set(condFunctionARN, info.FunctionArn) + } + + return true +} + +// namedFunctionARN is the function ARN a FunctionName value names, or "". +func (c *checkSet) namedFunctionARN(functionName string) string { + if functionName == "" { + return "" + } + + name, qualifier := splitFunctionNameQualifier(functionName) + + return c.functionARN(name, qualifier) +} + +// layerByARNChecks: GetLayerVersionByArn needs lambda:GetLayerVersion on the +// layer version. An Arn that does not parse is answered with a 400. +func layerByARNChecks(c *checkSet, _ opID) bool { + arn := c.layerVersionARN(c.r.URL.Query().Get("Arn")) + if arn == "" { + return false + } + + c.add(actionGetLayerVersion, arn, awsauthz.Required) + + return true +} + +// publishLayerChecks: lambda:PublishLayerVersion on the layer. +func publishLayerChecks(c *checkSet, op opID) bool { + c.add(action(op), c.scope.ARN(serviceName, "layer:"+c.args.name), awsauthz.Required) + return true +} + +// layerVersionChecks: the layer-version actions act on the layer version. +// GetLayerVersion is the action of GetLayerVersion; the others are their own. +// A version that is not a number is answered with a 400. +func layerVersionChecks(c *checkSet, op opID) bool { + version, err := strconv.Atoi(c.args.item) + if err != nil { + return false + } + + c.add(action(op), c.scope.ARN(serviceName, "layer:"+c.args.name+":"+strconv.Itoa(version)), awsauthz.Required) + + return true +} + +// urlConfigBodyChecks: Create/UpdateFunctionUrlConfig, with +// lambda:FunctionUrlAuthType from the requested AuthType. +func urlConfigBodyChecks(c *checkSet, op opID) bool { + var req functionURLRequest + if !awsauthz.JSONBody(c.r, &req) { + return false + } + + onTarget(c, op) + c.set(condFunctionARN, c.target()) + c.set(condFunctionURLAuth, req.AuthType) + + return true +} + +// urlConfigStoredChecks: Get/DeleteFunctionUrlConfig, with +// lambda:FunctionUrlAuthType from the stored config. +func urlConfigStoredChecks(c *checkSet, op opID) bool { + onTarget(c, op) + c.set(condFunctionARN, c.target()) + + if mgr, ok := c.h.fn.(functionURLManager); ok { + if cfg, err := mgr.GetFunctionURLConfig(c.r.Context(), c.args.name, c.args.qualifier); err == nil { + c.set(condFunctionURLAuth, cfg.AuthType) + } + } + + return true +} + +// functionURLInvokeChecks: a call through a function URL needs +// lambda:InvokeFunctionUrl and lambda:InvokeFunction on the URL's function +// (Lambda Developer Guide, "Control access to Lambda function URLs"). Either +// can also be granted by the function's resource-based policy, which the +// handler checks (authorizeURLInvoke). A host with no URL is answered 404. +func functionURLInvokeChecks(c *checkSet, _ opID) bool { + cfg, ok := c.h.resolveURL(c.r) + if !ok { + return false + } + + arn := c.functionARN(cfg.FunctionName, cfg.Qualifier) + + c.add(actionInvokeFunctionURL, arn, awsauthz.ResourcePolicy) + c.add(actionInvokeFunction, arn, awsauthz.ResourcePolicy) + c.set(condFunctionARN, arn) + c.set(condFunctionURLAuth, cfg.AuthType) + c.set(condInvokedViaURL, condTrue) + c.resourceTags(cfg.FunctionName) + + return true +} diff --git a/server/aws/lambda/iam_actions_test.go b/server/aws/lambda/iam_actions_test.go new file mode 100644 index 000000000..a44860959 --- /dev/null +++ b/server/aws/lambda/iam_actions_test.go @@ -0,0 +1,186 @@ +package lambda + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + awslambda "github.com/stackshy/cloudemu/v2/providers/aws/lambda" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" + sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +const ( + testFn = "arn:aws:lambda:us-east-1:123456789012:function:" + testLayer = "arn:aws:lambda:us-east-1:123456789012:layer:" +) + +func testHandler(t *testing.T) *Handler { + t.Helper() + + m := awslambda.New(config.NewOptions(config.WithRegion("us-east-1"), config.WithAccountID("123456789012"))) + ctx := context.Background() + + if _, err := m.CreateFunction(ctx, sdrv.FunctionConfig{ + Name: "f", Runtime: "python3.12", Handler: "h", Tags: map[string]string{"env": "dev"}, + }); err != nil { + t.Fatalf("CreateFunction: %v", err) + } + + return New(m) +} + +func testScope() awsauthz.Scope { + return awsauthz.Scope{AccountID: "123456789012", Region: "us-east-1", Partition: "aws"} +} + +// TestIAMChecks pins the action and resource of every operation. +func TestIAMChecks(t *testing.T) { + h := testHandler(t) + + cases := []struct { + method, target, body string + want []string // "action resource" + }{ + {"GET", "/2015-03-31/functions", "", []string{"lambda:ListFunctions *"}}, + {"POST", "/2015-03-31/functions", `{"FunctionName":"n","Tags":{"a":"b"},"Layers":["` + testLayer + `l:3"]}`, + []string{"lambda:CreateFunction " + testFn + "n", "lambda:TagResource " + testFn + "n", "lambda:GetLayerVersion " + testLayer + "l:3"}}, + {"GET", "/2015-03-31/functions/f", "", []string{"lambda:GetFunction " + testFn + "f"}}, + {"GET", "/2015-03-31/functions/f:prod", "", []string{"lambda:GetFunction " + testFn + "f:prod"}}, + {"DELETE", "/2015-03-31/functions/f?Qualifier=2", "", []string{"lambda:DeleteFunction " + testFn + "f:2"}}, + {"POST", "/2015-03-31/functions/arn:aws:lambda:eu-west-1:999999999999:function:f:prod/invocations", "", + []string{"lambda:InvokeFunction " + testFn + "f:prod"}}, + {"PUT", "/2015-03-31/functions/f/configuration", `{"Layers":["` + testLayer + `l:1"]}`, + []string{"lambda:UpdateFunctionConfiguration " + testFn + "f", "lambda:GetLayerVersion " + testLayer + "l:1"}}, + {"PUT", "/2015-03-31/functions/f/code", "{}", []string{"lambda:UpdateFunctionCode " + testFn + "f"}}, + {"POST", "/2015-03-31/functions/f/versions", "{}", []string{"lambda:PublishVersion " + testFn + "f"}}, + {"DELETE", "/2015-03-31/functions/f/aliases/prod", "", []string{"lambda:DeleteAlias " + testFn + "f"}}, + {"POST", "/2015-03-31/functions/f/policy", `{"Principal":"s3.amazonaws.com"}`, []string{"lambda:AddPermission " + testFn + "f"}}, + {"DELETE", "/2015-03-31/functions/f/policy/s1?Qualifier=prod", "", []string{"lambda:RemovePermission " + testFn + "f:prod"}}, + {"POST", "/2017-03-31/tags/" + testFn + "f", `{"Tags":{"k":"v"}}`, []string{"lambda:TagResource " + testFn + "f"}}, + {"GET", "/2017-03-31/tags/arn:aws:lambda:eu-west-1:999999999999:function:f", "", []string{"lambda:ListTags " + testFn + "f"}}, + {"DELETE", "/2017-03-31/tags/not-an-arn?tagKeys=k", "", []string{"lambda:UntagResource "}}, + {"POST", "/2015-03-31/event-source-mappings/", `{"FunctionName":"f"}`, []string{"lambda:CreateEventSourceMapping *"}}, + {"GET", "/2015-03-31/event-source-mappings/u1", "", + []string{"lambda:GetEventSourceMapping arn:aws:lambda:us-east-1:123456789012:event-source-mapping:u1"}}, + {"GET", "/2018-10-31/layers", "", []string{"lambda:ListLayers *"}}, + {"GET", "/2018-10-31/layers?find=LayerVersion&Arn=arn:aws:lambda:eu-west-1:999999999999:layer:l:4", "", + []string{"lambda:GetLayerVersion " + testLayer + "l:4"}}, + {"POST", "/2018-10-31/layers/l/versions", "{}", []string{"lambda:PublishLayerVersion " + testLayer + "l"}}, + {"GET", "/2018-10-31/layers/l/versions", "", []string{"lambda:ListLayerVersions *"}}, + {"DELETE", "/2018-10-31/layers/l/versions/2", "", []string{"lambda:DeleteLayerVersion " + testLayer + "l:2"}}, + {"DELETE", "/2018-10-31/layers/l/versions/2/policy/s", "", []string{"lambda:RemoveLayerVersionPermission " + testLayer + "l:2"}}, + {"POST", "/2021-10-31/functions/f/url?Qualifier=prod", `{"AuthType":"NONE"}`, []string{"lambda:CreateFunctionUrlConfig " + testFn + "f:prod"}}, + {"GET", "/2021-10-31/functions/f/urls", "", []string{"lambda:ListFunctionUrlConfigs " + testFn + "f"}}, + {"PUT", "/2019-09-25/functions/f/event-invoke-config", "{}", []string{"lambda:PutFunctionEventInvokeConfig " + testFn + "f"}}, + {"PUT", "/2019-09-30/functions/f/provisioned-concurrency?Qualifier=1", "{}", + []string{"lambda:PutProvisionedConcurrencyConfig " + testFn + "f:1"}}, + {"GET", "/2020-06-30/functions/f/code-signing-config", "", []string{"lambda:GetFunctionCodeSigningConfig " + testFn + "f"}}, + {"PUT", "/2017-10-31/functions/f/concurrency", "{}", []string{"lambda:PutFunctionConcurrency " + testFn + "f"}}, + } + + for _, tc := range cases { + r := httptest.NewRequest(tc.method, tc.target, strings.NewReader(tc.body)) + + checks, ok := h.IAMChecks(r, testScope()) + if !ok { + t.Errorf("%s %s: ok=false", tc.method, tc.target) + continue + } + + got := make([]string, 0, len(checks)) + for _, c := range checks { + got = append(got, c.Action+" "+c.Resource) + } + + if strings.Join(got, "|") != strings.Join(tc.want, "|") { + t.Errorf("%s %s: checks %v, want %v", tc.method, tc.target, got, tc.want) + } + } +} + +// TestIAMChecksUnknown checks the requests the handler answers with an +// error are reported as unknown. +func TestIAMChecksUnknown(t *testing.T) { + h := testHandler(t) + + for _, tc := range []struct{ method, target, body string }{ + {"PUT", "/2015-03-31/functions", ""}, + {"POST", "/2015-03-31/functions/f:a/invocations?Qualifier=b", ""}, + {"POST", "/2015-03-31/functions", "{not json"}, + {"GET", "/2018-10-31/layers/l/versions/x", ""}, + {"GET", "/2018-10-31/layers?find=LayerVersion&Arn=bad", ""}, + {"PUT", "/2020-06-30/functions/f/code-signing-config", ""}, + {"GET", "/", ""}, + } { + r := httptest.NewRequest(tc.method, tc.target, strings.NewReader(tc.body)) + r.Host = "x.lambda-url.us-east-1.on.aws" + + if tc.target != "/" { + r.Host = "localhost" + } + + if _, ok := h.IAMChecks(r, testScope()); ok { + t.Errorf("%s %s: ok=true, want unknown", tc.method, tc.target) + } + } +} + +// TestIAMCheckContext checks the condition keys of the operations that have +// them. +func TestIAMCheckContext(t *testing.T) { + h := testHandler(t) + + cases := []struct { + method, target, body string + key, want string + }{ + {"GET", "/2015-03-31/functions/f", "", "aws:ResourceTag/env", "dev"}, + {"POST", "/2015-03-31/functions", `{"FunctionName":"n","Tags":{"team":"a"}}`, "aws:RequestTag/team", "a"}, + {"POST", "/2015-03-31/functions", `{"FunctionName":"n","VpcConfig":{"SubnetIds":["s1"]}}`, "lambda:SubnetIds", "s1"}, + {"POST", "/2015-03-31/functions/f/policy", `{"Principal":"s3.amazonaws.com","FunctionUrlAuthType":"NONE"}`, + "lambda:Principal", "s3.amazonaws.com"}, + {"POST", "/2021-10-31/functions/f/url", `{"AuthType":"AWS_IAM"}`, "lambda:FunctionUrlAuthType", "AWS_IAM"}, + {"POST", "/2015-03-31/event-source-mappings", `{"FunctionName":"f:prod"}`, "lambda:FunctionArn", testFn + "f:prod"}, + {"DELETE", "/2017-03-31/tags/" + testFn + "f?tagKeys=a&tagKeys=b", "", "aws:TagKeys", "a\x00b"}, + } + + for _, tc := range cases { + r := httptest.NewRequest(tc.method, tc.target, strings.NewReader(tc.body)) + + _, cond, ok := h.IAMChecksWithContext(r, testScope()) + if !ok { + t.Errorf("%s %s: ok=false", tc.method, tc.target) + continue + } + + want := strings.ReplaceAll(tc.want, "\x00", iamdriver.ConditionValueSeparator) + if cond[tc.key] != want { + t.Errorf("%s %s: %s = %q, want %q", tc.method, tc.target, tc.key, cond[tc.key], want) + } + } +} + +// TestIAMChecksKeepBody checks reading the body for the checks leaves it for +// dispatch. +func TestIAMChecksKeepBody(t *testing.T) { + h := testHandler(t) + body := `{"FunctionName":"kept","Runtime":"python3.12","Handler":"h","Role":"arn:aws:iam::123456789012:role/r",` + + `"Code":{"ZipFile":"UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=="}}` + r := httptest.NewRequest(http.MethodPost, "/2015-03-31/functions", strings.NewReader(body)) + + if _, ok := h.IAMChecks(r, testScope()); !ok { + t.Fatal("IAMChecks: ok=false") + } + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusCreated { + t.Fatalf("CreateFunction after IAMChecks: %d %s", w.Code, w.Body) + } +} diff --git a/server/aws/lambda/resource_policy_test.go b/server/aws/lambda/resource_policy_test.go new file mode 100644 index 000000000..96f05bdfb --- /dev/null +++ b/server/aws/lambda/resource_policy_test.go @@ -0,0 +1,81 @@ +package lambda + +import ( + "testing" + + "github.com/stackshy/cloudemu/v2/server/authctx" + sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +func TestPrincipalGranted(t *testing.T) { + user := &authctx.Principal{ARN: "arn:aws:iam::123456789012:user/path/u"} + session := &authctx.Principal{ARN: "arn:aws:sts::123456789012:assumed-role/app/s1"} + + cases := []struct { + principal string + caller *authctx.Principal + want bool + }{ + {"*", nil, true}, + {"*", user, true}, + {"arn:aws:iam::123456789012:user/path/u", user, true}, + {"arn:aws:iam::123456789012:user/u", user, false}, + {"123456789012", user, false}, + {"arn:aws:iam::123456789012:root", user, false}, + {"s3.amazonaws.com", user, false}, + {"arn:aws:iam::123456789012:role/svc/app", session, false}, + {"arn:aws:iam::999999999999:role/app", session, false}, + {"arn:aws:iam::123456789012:role/other", session, false}, + {"arn:aws:sts::123456789012:assumed-role/app/s1", session, true}, + {"arn:aws:iam::123456789012:user/path/u", nil, false}, + } + + for _, tc := range cases { + if got := principalGranted(tc.principal, tc.caller); got != tc.want { + t.Errorf("principalGranted(%q, %v) = %v, want %v", tc.principal, tc.caller, got, tc.want) + } + } +} + +func TestConditionsHold(t *testing.T) { + urlCtx := map[string]string{condFunctionURLAuth: urlAuthNone, condInvokedViaURL: "true"} + + cases := []struct { + name string + st sdrv.PermissionStatement + cctx map[string]string + want bool + }{ + {"no condition", sdrv.PermissionStatement{}, nil, true}, + {"auth type matches", sdrv.PermissionStatement{FunctionURLAuthType: "NONE"}, urlCtx, true}, + {"auth type differs", sdrv.PermissionStatement{FunctionURLAuthType: "AWS_IAM"}, urlCtx, false}, + {"via URL on a URL call", sdrv.PermissionStatement{InvokedViaFunctionURL: true}, urlCtx, true}, + {"via URL on a direct call", sdrv.PermissionStatement{InvokedViaFunctionURL: true}, map[string]string{}, false}, + {"source ARN never matches a signed caller", sdrv.PermissionStatement{SourceARN: "arn:aws:s3:::b"}, urlCtx, false}, + {"source account never matches a signed caller", sdrv.PermissionStatement{SourceAccount: "123456789012"}, urlCtx, false}, + } + + for _, tc := range cases { + if got := conditionsHold(&tc.st, tc.cctx); got != tc.want { + t.Errorf("%s: %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestActionMatches(t *testing.T) { + for _, tc := range []struct { + stmt string + want bool + }{ + {"lambda:InvokeFunction", true}, + {"lambda:invokefunction", true}, + {"lambda:*", true}, + {"*", true}, + {"lambda:InvokeFunctionUrl", false}, + {"lambda:GetFunction", false}, + } { + if got := actionMatches(tc.stmt, actionInvokeFunction); got != tc.want { + t.Errorf("actionMatches(%q) = %v, want %v", tc.stmt, got, tc.want) + } + } +} diff --git a/server/serveflags/serveflags.go b/server/serveflags/serveflags.go index c201d656a..84bca9688 100644 --- a/server/serveflags/serveflags.go +++ b/server/serveflags/serveflags.go @@ -241,8 +241,10 @@ func registerEnforceAuthFlag(fs *flag.FlagSet, c *CommonConfig) { "require authentication on each request; off by default. AWS: verify the SigV4 signature against a registered IAM access "+ "key or an STS temporary (ASIA) credential (403 on failure). ASIA credentials are verified against the secret STS "+ "recorded when it issued them, and unknown or expired sessions are rejected. Each request is then authorized against "+ - "the caller's IAM policies: per operation for query and JSON-RPC services and S3 (on bucket and object ARNs), and at "+ - "service level for the other REST services (only a service-wide grant such as lambda:* passes, so fine-grained "+ + "the caller's IAM policies: per operation for query and JSON-RPC services, S3 (on bucket and object ARNs) and Lambda "+ + "(on function, version, alias, layer and event source mapping ARNs, with the function's resource-based policy for "+ + "Invoke and function URLs; a function URL with AuthType NONE is public only when that policy grants public access), "+ + "and at service level for the other REST services (only a service-wide grant such as eks:* passes, so fine-grained "+ "policies on them are denied until per-operation checks land). Root and users with no policies are unrestricted. "+ "AssumeRole is decided by the role's trust policy for the signed caller, and signed AssumeRoleWithWebIdentity/SAML "+ "calls are refused. Azure: "+ From 66607c4bdd62ed29b267fe3d00394ddc06f506fa Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sat, 10 Oct 2026 18:07:13 +0530 Subject: [PATCH 5/6] test(contrib): Lambda authorization under serve --enforce-auth --- contrib/server/README.md | 29 +++-- contrib/server/enforce_authz_lambda_test.go | 119 ++++++++++++++++++++ contrib/server/go.mod | 1 + 3 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 contrib/server/enforce_authz_lambda_test.go diff --git a/contrib/server/README.md b/contrib/server/README.md index 60e8a2166..32dd7154c 100644 --- a/contrib/server/README.md +++ b/contrib/server/README.md @@ -189,14 +189,27 @@ actually run the request, so neither the SigV4 signing scope nor a forged on the source. A DeleteObjects request is checked key by key, and one denied key denies the whole request (real S3 deletes the allowed keys and reports the others). Bucket policies are not evaluated yet. -- The other REST services (Lambda, API Gateway, EKS, Route 53, CloudFront and - the rest) are checked at service level for now. A request passes only when - the caller's policies allow every action of that service on every resource, - such as `lambda:*` on `*` or `AdministratorAccess`. **A fine-grained or - resource-scoped policy on one of these services (for example - `lambda:InvokeFunction` on one function) is denied until that service gets - per-operation checks.** A `Deny` that touches the service also denies the - request. +- Lambda is checked per operation on the function ARN, or the version or alias + ARN when the request names one (`function:name:prod`); layers on the layer or + layer version ARN, and event source mappings on their ARN. Lambda condition + keys such as `lambda:FunctionUrlAuthType`, `lambda:Principal`, + `lambda:FunctionArn` and `lambda:Layer`, and the `aws:RequestTag`, + `aws:TagKeys` and `aws:ResourceTag` keys, are set. Creating a function with + tags also needs `lambda:TagResource`, and with layers `lambda:GetLayerVersion` + on each layer version. `Invoke` is also allowed when the function's + resource-based policy names the caller's user or role session ARN. A function + URL with `AWS_IAM` auth needs `lambda:InvokeFunctionUrl` and + `lambda:InvokeFunction`; one with `NONE` auth is called without a signature, + and only when the function's resource-based policy grants both actions to + `*` (as `aws lambda add-permission --principal '*'` does); otherwise it + answers 403 Forbidden. +- The other REST services (API Gateway, EKS, Route 53, CloudFront and the + rest) are checked at service level for now. A request passes only when the + caller's policies allow every action of that service on every resource, such + as `eks:*` on `*` or `AdministratorAccess`. **A fine-grained or + resource-scoped policy on one of these services is denied until that service + gets per-operation checks.** A `Deny` that touches the service also denies + the request. - The account root and IAM users with no policies are unrestricted, so a freshly created user can bootstrap others. Role sessions are always evaluated on the role's policies. diff --git a/contrib/server/enforce_authz_lambda_test.go b/contrib/server/enforce_authz_lambda_test.go new file mode 100644 index 000000000..e29470055 --- /dev/null +++ b/contrib/server/enforce_authz_lambda_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/lambda" + lambdatypes "github.com/aws/aws-sdk-go-v2/service/lambda/types" +) + +func lambdaClient(t *testing.T, endpoint string, c aws.Credentials) *lambda.Client { + t.Helper() + + return lambda.NewFromConfig(credsConfig(t, c), func(o *lambda.Options) { o.BaseEndpoint = aws.String(endpoint) }) +} + +// urlGet calls a function URL by its host, on the server's listener. +func urlGet(t *testing.T, endpoint, functionURL string) (int, string) { + t.Helper() + + u, err := url.Parse(functionURL) + if err != nil { + t.Fatalf("parse %s: %v", functionURL, err) + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint+"/", http.NoBody) + if err != nil { + t.Fatalf("new request: %v", err) + } + + req.Host = u.Host + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", u.Host, err) + } + defer resp.Body.Close() + + b, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, string(b) +} + +// TestEnforceAuthLambda drives the real Lambda SDK against cloudemu serve +// with --enforce-auth: Invoke and the control plane are authorized on the +// function, version or alias ARN, and a NONE function URL is public only once +// its resource-based policy grants public access. +func TestEnforceAuthLambda(t *testing.T) { + endpoint, stop := enforceAuthServer(t) + defer stop() + + ctx := context.Background() + boot := clientsFor(t, endpoint, seedBootUser(t, endpoint)) + admin := lambdaClient(t, endpoint, boot.cred) + + _, err := admin.CreateFunction(ctx, &lambda.CreateFunctionInput{ + FunctionName: aws.String("app"), Runtime: lambdatypes.RuntimePython312, Handler: aws.String("index.handler"), + Role: aws.String("arn:aws:iam::000000000000:role/r"), Code: &lambdatypes.FunctionCode{ZipFile: []byte("code")}, + Publish: true, + }) + wantOK(t, "CreateFunction as boot", err) + + _, err = admin.CreateAlias(ctx, &lambda.CreateAliasInput{ + FunctionName: aws.String("app"), Name: aws.String("prod"), FunctionVersion: aws.String("1"), + }) + wantOK(t, "CreateAlias as boot", err) + + got, err := admin.GetFunction(ctx, &lambda.GetFunctionInput{FunctionName: aws.String("app")}) + wantOK(t, "GetFunction as boot", err) + + arn := aws.ToString(got.Configuration.FunctionArn) + invoker := lambdaClient(t, endpoint, boot.newUser(t, "invoker", `{"Version":"2012-10-17","Statement":[`+ + `{"Effect":"Allow","Action":"lambda:InvokeFunction","Resource":"`+arn+`:prod"},`+ + `{"Effect":"Allow","Action":"lambda:GetFunction","Resource":"`+arn+`"}]}`)) + + _, err = invoker.Invoke(ctx, &lambda.InvokeInput{FunctionName: aws.String("app"), Qualifier: aws.String("prod")}) + wantOK(t, "Invoke the alias", err) + _, err = invoker.Invoke(ctx, &lambda.InvokeInput{FunctionName: aws.String("app:prod")}) + wantOK(t, "Invoke name:alias", err) + _, err = invoker.Invoke(ctx, &lambda.InvokeInput{FunctionName: aws.String("app")}) + wantCode(t, "Invoke unqualified", err, "AccessDeniedException") + _, err = invoker.GetFunction(ctx, &lambda.GetFunctionInput{FunctionName: aws.String("app")}) + wantOK(t, "GetFunction", err) + _, err = invoker.DeleteFunction(ctx, &lambda.DeleteFunctionInput{FunctionName: aws.String("app")}) + wantCode(t, "DeleteFunction", err, "AccessDeniedException") + + if !strings.Contains(err.Error(), "lambda:DeleteFunction on resource: "+arn) { + t.Fatalf("deny message = %v", err) + } + + u, err := admin.CreateFunctionUrlConfig(ctx, &lambda.CreateFunctionUrlConfigInput{ + FunctionName: aws.String("app"), AuthType: lambdatypes.FunctionUrlAuthTypeNone, + }) + wantOK(t, "CreateFunctionUrlConfig", err) + + if status, body := urlGet(t, endpoint, aws.ToString(u.FunctionUrl)); status != http.StatusForbidden { + t.Fatalf("NONE URL without a public grant: %d %s, want 403", status, body) + } + + _, err = admin.AddPermission(ctx, &lambda.AddPermissionInput{ + FunctionName: aws.String("app"), StatementId: aws.String("url"), Action: aws.String("lambda:InvokeFunctionUrl"), + Principal: aws.String("*"), FunctionUrlAuthType: lambdatypes.FunctionUrlAuthTypeNone, + }) + wantOK(t, "AddPermission InvokeFunctionUrl", err) + _, err = admin.AddPermission(ctx, &lambda.AddPermissionInput{ + FunctionName: aws.String("app"), StatementId: aws.String("invoke"), Action: aws.String("lambda:InvokeFunction"), + Principal: aws.String("*"), InvokedViaFunctionUrl: aws.Bool(true), + }) + wantOK(t, "AddPermission InvokeFunction", err) + + if status, body := urlGet(t, endpoint, aws.ToString(u.FunctionUrl)); status != http.StatusOK { + t.Fatalf("NONE URL with the public grant: %d %s, want 200", status, body) + } +} diff --git a/contrib/server/go.mod b/contrib/server/go.mod index 33c3fdc4b..6738e38e9 100644 --- a/contrib/server/go.mod +++ b/contrib/server/go.mod @@ -17,6 +17,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ec2 v1.322.0 github.com/aws/aws-sdk-go-v2/service/elasticache v1.55.0 github.com/aws/aws-sdk-go-v2/service/iam v1.53.10 + github.com/aws/aws-sdk-go-v2/service/lambda v1.90.1 github.com/aws/aws-sdk-go-v2/service/rds v1.124.4 github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27 From fd28d9894d0ef6b285b1477a0c79b4423261cb22 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sat, 10 Oct 2026 19:50:33 +0530 Subject: [PATCH 6/6] fix(aws-lambda): authorize the resource dispatch acts on when a qualifier is given --- providers/aws/lambda/policy.go | 14 ++ .../aws/authz_matrix_lambda_qualifier_test.go | 168 ++++++++++++++++++ server/aws/authz_matrix_lambda_test.go | 10 +- server/aws/aws.go | 2 +- server/aws/lambda/authz.go | 42 +++++ server/aws/lambda/classify.go | 32 +++- server/aws/lambda/handler.go | 28 ++- server/aws/lambda/iam_actions.go | 47 +++-- server/aws/lambda/iam_actions_test.go | 17 +- 9 files changed, 338 insertions(+), 22 deletions(-) create mode 100644 server/aws/authz_matrix_lambda_qualifier_test.go diff --git a/providers/aws/lambda/policy.go b/providers/aws/lambda/policy.go index 5cc7675ed..38a30f682 100644 --- a/providers/aws/lambda/policy.go +++ b/providers/aws/lambda/policy.go @@ -38,6 +38,13 @@ func (m *Mock) AddPermission(_ context.Context, functionName, qualifier string, return cerrors.New(cerrors.InvalidArgument, "StatementId is required") } + // "Lambda does not support adding policies to version $LATEST" + // (AddPermission API reference): the unqualified function's policy is + // written without a qualifier. + if qualifier == latestVersion { + return cerrors.New(cerrors.InvalidArgument, "We currently do not support adding policies for $LATEST.") + } + switch stmt.FunctionURLAuthType { case "", defaultAuthType, authTypeAWSIAM: default: @@ -83,6 +90,13 @@ func (m *Mock) AddPermission(_ context.Context, functionName, qualifier string, // RemovePermission drops a statement from a function's (qualifier-scoped) // resource-based policy. func (m *Mock) RemovePermission(_ context.Context, functionName, qualifier, statementID string) error { + // $LATEST has no policy of its own (AddPermission refuses it), so a + // $LATEST-qualified remove is refused too rather than editing the + // unqualified function's policy. + if qualifier == latestVersion { + return cerrors.New(cerrors.InvalidArgument, "We currently do not support removing policies for $LATEST.") + } + m.mu.Lock() defer m.mu.Unlock() diff --git a/server/aws/authz_matrix_lambda_qualifier_test.go b/server/aws/authz_matrix_lambda_qualifier_test.go new file mode 100644 index 000000000..51a0ae55e --- /dev/null +++ b/server/aws/authz_matrix_lambda_qualifier_test.go @@ -0,0 +1,168 @@ +package aws + +import ( + "context" + "net/http" + "net/url" + "testing" + + sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// TestAuthzMatrixLambdaQualifiedDeny checks a Deny on the unqualified +// function is not bypassed by naming a version, an alias or $LATEST in the +// FunctionName or the Qualifier of an operation that acts on the function +// itself: each is checked on the resource dispatch changes. +func TestAuthzMatrixLambdaQualifiedDeny(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "f", nil) + + bob := userWithPolicy(t, cloud, "bob", policyDoc(stmt("Allow", "lambda:*", "*"), stmt("Deny", "lambda:*", fnARN+"f"))) + ctx := context.Background() + versionsBefore, _ := cloud.Lambda.ListVersions(ctx, "f") + + refs := []struct{ name, query string }{ + {"f:prod", ""}, {"f:1", ""}, {"f:$LATEST", ""}, {url.PathEscape(fnARN + "f:prod"), ""}, + {"f", "Qualifier=prod"}, {"f", "Qualifier=1"}, {"f", "Qualifier=$LATEST"}, + } + + for _, ref := range refs { + base := lambdaPath + "/" + ref.name + q := "" + + if ref.query != "" { + q = "?" + ref.query + } + + for _, rq := range []sreq{ + {method: http.MethodPut, path: base + "/configuration" + q, ctype: "application/json", body: `{"Description":"changed"}`}, + {method: http.MethodPut, path: base + "/code" + q, ctype: "application/json", body: `{"ZipFile":"UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=="}`}, + {method: http.MethodPost, path: base + "/versions" + q, ctype: "application/json", body: `{}`}, + {method: http.MethodGet, path: base + "/versions" + q}, + {method: http.MethodPost, path: base + "/aliases" + q, ctype: "application/json", body: `{"Name":"evil","FunctionVersion":"1"}`}, + {method: http.MethodGet, path: base + "/aliases" + q}, + {method: http.MethodGet, path: base + "/aliases/prod" + q}, + {method: http.MethodPut, path: base + "/aliases/prod" + q, ctype: "application/json", body: `{"FunctionVersion":"$LATEST"}`}, + {method: http.MethodDelete, path: base + "/aliases/prod" + q}, + } { + rq.service = "lambda" + status, body := doSigned(t, ts, bob, rq) + wantDenied(t, status, body, lambdaDeny) + } + } + + info, err := cloud.Lambda.GetFunction(ctx, "f") + if err != nil || info.Description == "changed" { + t.Fatalf("a denied update changed f: %+v %v", info, err) + } + + if vers, _ := cloud.Lambda.ListVersions(ctx, "f"); len(vers) != len(versionsBefore) { + t.Fatalf("versions %d, want %d", len(vers), len(versionsBefore)) + } + + if aliases, _ := cloud.Lambda.ListAliases(ctx, "f"); len(aliases) != 1 || aliases[0].FunctionVersion != "1" { + t.Fatalf("aliases changed: %+v", aliases) + } + + t.Run("Invoke", func(t *testing.T) { + // $LATEST is the unqualified function, so the Deny applies. A version + // or alias is a different resource: a policy on the unqualified ARN + // does not match it (Lambda Developer Guide, "Referencing functions in + // the Resource section of policies"). + for _, ref := range []string{"f:$LATEST", "f"} { + status, body := doSigned(t, ts, bob, invokeReq(ref)) + wantDenied(t, status, body, lambdaDeny) + } + + status, body := doSigned(t, ts, bob, invokeReq("f", "Qualifier=$LATEST")) + wantDenied(t, status, body, lambdaDeny) + + for _, ref := range []string{"f:prod", "f:1"} { + status, body := doSigned(t, ts, bob, invokeReq(ref)) + wantNotDenied(t, status, body) + } + }) +} + +// TestAuthzMatrixLambdaLatestPolicy checks a $LATEST-qualified AddPermission +// cannot write the unqualified function's policy: Lambda does not support +// policies on $LATEST. +func TestAuthzMatrixLambdaLatestPolicy(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "f", nil) + + bob := userWithPolicy(t, cloud, "bob", policyDoc(stmt("Allow", "lambda:*", "*"), stmt("Deny", "lambda:*", fnARN+"f"))) + boot := userWithPolicy(t, cloud, "boot", "") + nogrant := userWithPolicy(t, cloud, "nogrant", allow("dynamodb:ListTables")) + + grant := `{"StatementId":"open","Action":"lambda:InvokeFunction","Principal":"*"}` + policyPath := lambdaPath + "/f/policy?Qualifier=$LATEST" + + status, body := doSigned(t, ts, bob, sreq{path: policyPath, ctype: "application/json", body: grant, service: "lambda"}) + wantDenied(t, status, body, lambdaDeny) + + status, body = doSigned(t, ts, bob, sreq{path: lambdaPath + "/f:$LATEST/policy", ctype: "application/json", body: grant, service: "lambda"}) + wantDenied(t, status, body, lambdaDeny) + + status, body = doSigned(t, ts, boot, sreq{path: policyPath, ctype: "application/json", body: grant, service: "lambda"}) + if status != http.StatusBadRequest { + t.Fatalf("AddPermission on $LATEST: %d %s, want 400", status, body) + } + + if _, stmts, err := cloud.Lambda.PolicyStatements(context.Background(), "f", ""); err != nil || len(stmts) != 0 { + t.Fatalf("policy of f = %v %v, want empty", stmts, err) + } + + if err := cloud.Lambda.AddPermission(context.Background(), "f", "$LATEST", sdrv.PermissionStatement{ + StatementID: "x", Action: "lambda:InvokeFunction", Principal: "*", + }); err == nil { + t.Fatal("the driver accepted a $LATEST policy") + } + + status, body = doSigned(t, ts, nogrant, invokeReq("f")) + wantDenied(t, status, body, lambdaDeny) +} + +// TestAuthzMatrixLambdaForeignARN checks a FunctionName ARN of another +// account or region never acts on the local function of the same name. +func TestAuthzMatrixLambdaForeignARN(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "f", nil) + + boot := userWithPolicy(t, cloud, "boot", "") + + for _, rq := range []sreq{ + invokeReq("arn:aws:lambda:us-east-1:999999999999:function:f"), + invokeReq("arn:aws:lambda:eu-west-1:123456789012:function:f"), + {method: http.MethodDelete, path: lambdaPath + "/999999999999:function:f", service: "lambda"}, + {path: "/2017-03-31/tags/arn:aws:lambda:us-east-1:999999999999:function:f", ctype: "application/json", + body: `{"Tags":{"a":"b"}}`, service: "lambda"}, + } { + status, body := doSigned(t, ts, boot, rq) + if status != http.StatusNotFound { + t.Errorf("%s %s: %d %s, want 404", rq.method, rq.path, status, body) + } + } + + if !functionExists(cloud, "f") { + t.Fatal("a foreign ARN deleted the local function") + } + + if tags, _ := cloud.Lambda.ListFunctionTags(context.Background(), "f"); tags["a"] != "" { + t.Fatalf("a foreign ARN tagged the local function: %v", tags) + } + + status, body := doSigned(t, ts, boot, sreq{path: lambdaPath, ctype: "application/json", service: "lambda", + body: `{"FunctionName":"g:prod","Runtime":"python3.12","Role":"arn:aws:iam::123456789012:role/r","Handler":"h",` + + `"Code":{"ZipFile":"UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=="}}`}) + if status != http.StatusBadRequest { + t.Fatalf("CreateFunction g:prod: %d %s, want 400", status, body) + } + + status, body = doSigned(t, ts, boot, sreq{path: lambdaPath, ctype: "application/json", service: "lambda", + body: `{"FunctionName":"` + fnARN + `g","Runtime":"python3.12","Role":"arn:aws:iam::123456789012:role/r","Handler":"h",` + + `"Code":{"ZipFile":"UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=="}}`}) + if status != http.StatusCreated || !functionExists(cloud, "g") { + t.Fatalf("CreateFunction by local ARN: %d %s, want 201 creating g", status, body) + } +} diff --git a/server/aws/authz_matrix_lambda_test.go b/server/aws/authz_matrix_lambda_test.go index a2e087c86..bea0f0247 100644 --- a/server/aws/authz_matrix_lambda_test.go +++ b/server/aws/authz_matrix_lambda_test.go @@ -86,8 +86,10 @@ func TestAuthzMatrixLambdaInvoke(t *testing.T) { allowed bool }{ {"unqualified", "invoker", invokeReq("fn1"), true}, - {"function ARN of another account names the local function", "invoker", - invokeReq("arn:aws:lambda:eu-west-1:999999999999:function:fn1"), true}, + {"function ARN of this account and region", "invoker", + invokeReq("arn:aws:lambda:us-east-1:123456789012:function:fn1"), true}, + {"function ARN of another account is not the local function", "invoker", + invokeReq("arn:aws:lambda:us-east-1:999999999999:function:fn1"), false}, {"alias in the name", "invoker", invokeReq("fn1:prod"), false}, {"alias in the query", "invoker", invokeReq("fn1", "Qualifier=prod"), false}, {"alias for the alias user", "alias", invokeReq("fn1:prod"), true}, @@ -318,7 +320,9 @@ func TestAuthzMatrixLambdaFunctionURL(t *testing.T) { iamHost := functionURLHost(t, cloud, "iamurl", "AWS_IAM") pubHost := functionURLHost(t, cloud, "puburl", "NONE") - urlReq := func(host string) sreq { return sreq{method: http.MethodGet, path: "/hello", host: host, service: "lambda"} } + urlReq := func(host string) sreq { + return sreq{method: http.MethodGet, path: "/hello", host: host, service: "lambda"} + } both := userWithPolicy(t, cloud, "urlboth", allow("lambda:InvokeFunctionUrl", "lambda:InvokeFunction")) urlOnly := userWithPolicy(t, cloud, "urlonly", allow("lambda:InvokeFunctionUrl")) diff --git a/server/aws/aws.go b/server/aws/aws.go index ec14c928b..733fcfce7 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -1158,7 +1158,7 @@ func newServer(d Drivers) (*server.Server, authzSets) { lambdaOpts = append(lambdaOpts, lambda.WithObjectStore(d.S3)) } - lambdaOpts = append(lambdaOpts, lambda.WithEnforceAuth(d.EnforceAuth)) + lambdaOpts = append(lambdaOpts, lambda.WithEnforceAuth(d.EnforceAuth), lambda.WithScope(d.AccountID, d.Region)) srv.Register(lambda.New(d.Lambda, lambdaOpts...)) } diff --git a/server/aws/lambda/authz.go b/server/aws/lambda/authz.go index 64278d8c3..8b6c7b2bf 100644 --- a/server/aws/lambda/authz.go +++ b/server/aws/lambda/authz.go @@ -46,6 +46,48 @@ func WithEnforceAuth(on bool) Option { return func(h *Handler) { h.enforce = on } } +// WithScope sets the account and region this handler serves. A FunctionName +// ARN that names another account or region then does not resolve to a local +// function: it is answered with ResourceNotFoundException, as a function +// that does not exist here. An empty value skips that check. +func WithScope(accountID, region string) Option { + return func(h *Handler) { h.accountID, h.region = accountID, region } +} + +// foreignRef reports whether the request's function reference names another +// account or region than the one the handler serves (WithScope), or, for +// IAMChecks, than the gate's scope s. +func foreignRef(a *opArgs, accountID, region string) bool { + return (a.refAccount != "" && accountID != "" && a.refAccount != accountID) || + (a.refRegion != "" && region != "" && a.refRegion != region) +} + +// createFunctionName returns the bare name a CreateFunction FunctionName +// creates. FunctionName may be a name, a full ARN or a partial ARN, but it +// names a new function, so a version or alias qualifier, or an ARN of another +// account or region, is invalid; invalid is then the error message. +func createFunctionName(ref, accountID, region string) (name, invalid string) { + name, qualifier := splitFunctionNameQualifier(ref) + if qualifier != "" { + return "", "Unsupported qualifier in FunctionName " + ref + ": a version or alias cannot be created with CreateFunction" + } + + a := opArgs{} + a.refRegion, a.refAccount = functionRefScope(ref) + + if foreignRef(&a, accountID, region) { + return "", "FunctionName " + ref + " is not in this account and region" + } + + return name, "" +} + +// writeForeignRef answers a reference to a function in another account or +// region, which does not exist on this server. +func writeForeignRef(w http.ResponseWriter, ref string) { + writeError(w, http.StatusNotFound, "ResourceNotFoundException", "Function not found: "+ref) +} + // IAMService returns the IAM service prefix of the operations this handler // serves. func (*Handler) IAMService() string { return serviceName } diff --git a/server/aws/lambda/classify.go b/server/aws/lambda/classify.go index bdf9cd54c..3f1e06d84 100644 --- a/server/aws/lambda/classify.go +++ b/server/aws/lambda/classify.go @@ -118,6 +118,32 @@ type opArgs struct { // qualifierConflict is a FunctionName qualifier that disagrees with // ?Qualifier=, which dispatch answers with a ValidationException. qualifierConflict bool + // refRegion and refAccount are the region and account a FunctionName (or + // tag resource) ARN names, "" when it does not name one. + refRegion, refAccount string +} + +// functionRefScope returns the region and account a function reference +// names: both for a full ARN (arn:aws:lambda:::function:...), +// the account for a partial ARN (:function:...), and neither for a +// plain name. +func functionRefScope(ref string) (region, account string) { + const arnFields = 6 // arn, partition, service, region, account, rest + + if strings.HasPrefix(ref, "arn:") { + parts := strings.SplitN(ref, ":", arnFields) + if len(parts) == arnFields { + return parts[3], parts[4] + } + + return "", "" + } + + if i := strings.Index(ref, ":function:"); i >= 0 { + return "", ref[:i] + } + + return "", "" } // Function sub-resource path segments. @@ -190,7 +216,10 @@ func classifyTags(r *http.Request, arn string) (opID, opArgs) { http.MethodPost: opTagResource, http.MethodDelete: opUntagResource, http.MethodGet: opListTags, }) - return op, opArgs{route: routeTags, item: arn, name: functionNameFromARN(arn)} + a := opArgs{route: routeTags, item: arn, name: functionNameFromARN(arn)} + a.refRegion, a.refAccount = functionRefScope(arn) + + return op, a } // classifyEventSourceMappings handles /2015-03-31/event-source-mappings[/{uuid}]. @@ -376,6 +405,7 @@ func classifyFunctions(r *http.Request) (opID, opArgs) { } a.parts = strings.Split(rest, "/") + a.refRegion, a.refAccount = functionRefScope(a.parts[0]) name, embedded := splitFunctionNameQualifier(a.parts[0]) qualifier, ok := reconcileQualifier(embedded, r.URL.Query().Get("Qualifier")) diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index 8f6850697..eabc1a1cc 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -229,6 +229,8 @@ type Handler struct { layerContent map[string][]byte // enforce is set under EnforceAuth (WithEnforceAuth). enforce bool + // accountID and region are the scope the handler serves (WithScope). + accountID, region string } // Option configures a Handler. @@ -278,6 +280,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case routeFunctionURLInvoke: h.serveFunctionURLInvoke(w, r) case routeTags: + if foreignRef(&a, h.accountID, h.region) { + writeForeignRef(w, a.item) + return + } + h.serveTags(w, r, op, a.name) case routeEventSourceMappings: h.serveEventSourceMappings(w, r, op, a.item) @@ -305,6 +312,11 @@ func (h *Handler) serveFunctions(w http.ResponseWriter, r *http.Request, op opID return } + if foreignRef(a, h.accountID, h.region) { + writeForeignRef(w, a.parts[0]) + return + } + name, ok := h.resolveFunctionRef(w, r, a.parts[0]) if !ok { return @@ -977,7 +989,7 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { return } - if err := validateCreateRequest(&req); err != nil { + if err := h.checkCreateRequest(&req); err != nil { writeErr(w, err) return } @@ -1041,6 +1053,20 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, toConfiguration(info, awsCfg)) } +// checkCreateRequest resolves the FunctionName of a CreateFunction request to +// the bare name it creates (createFunctionName, the same rule IAMChecks +// authorizes), then applies validateCreateRequest. +func (h *Handler) checkCreateRequest(req *createFunctionRequest) error { + name, invalid := createFunctionName(req.FunctionName, h.accountID, h.region) + if invalid != "" { + return cerrors.New(cerrors.InvalidArgument, invalid) + } + + req.FunctionName = name + + return validateCreateRequest(req) +} + // validateCreateRequest enforces the CreateFunction input rules the emulator // checks up front: a .zip package requires Runtime and Handler, and an // EphemeralStorage size must be within the accepted range. Each violation is an diff --git a/server/aws/lambda/iam_actions.go b/server/aws/lambda/iam_actions.go index 356e95567..ca0e86e79 100644 --- a/server/aws/lambda/iam_actions.go +++ b/server/aws/lambda/iam_actions.go @@ -57,7 +57,7 @@ func (h *Handler) IAMChecksWithContext(r *http.Request, s awsauthz.Scope) ([]aws op, a := classify(r) rule, ok := iamRules[op] - if !ok { + if !ok || foreignRef(&a, s.AccountID, s.Region) { return nil, nil, false } @@ -98,8 +98,11 @@ func (c *checkSet) functionARN(name, qualifier string) string { return arn } -// target is the function ARN the request names: its FunctionName (with any -// qualifier embedded in it) and its Qualifier. +// target is the function, version or alias ARN of an operation that acts on +// the version or alias the request names: its FunctionName (with any +// qualifier embedded in it) and its Qualifier. $LATEST is the unpublished +// function itself, which dispatch serves as the unqualified function, so it +// is checked on the unqualified ARN. func (c *checkSet) target() string { name, embedded := splitFunctionNameQualifier(c.args.name) @@ -108,6 +111,10 @@ func (c *checkSet) target() string { qualifier = embedded } + if qualifier == latestVersion { + qualifier = "" + } + return c.functionARN(name, qualifier) } @@ -222,6 +229,14 @@ func onAny(c *checkSet, op opID) bool { // iamRules maps every operation the handler runs to its checks. Operations // absent here are the error paths, reported as unknown. // +// The resource is always the one dispatch acts on. Operations that read or +// act on the version or alias the request names (GetFunction, Invoke, +// DeleteFunction, the policy, URL, event invoke and provisioned concurrency +// operations) are checked on that qualified ARN. The others act on the +// function whatever qualifier the FunctionName carries (configuration, code, +// versions, aliases, tags, concurrency), so they are checked on the +// unqualified function ARN. +// //nolint:gochecknoglobals // static lookup table var iamRules = map[opID]iamRule{ opInvokeFunctionURL: functionURLInvokeChecks, @@ -233,14 +248,14 @@ var iamRules = map[opID]iamRule{ opInvoke: invokeChecks, opGetFunctionConfiguration: onTarget, opUpdateFunctionConfiguration: updateConfigurationChecks, - opUpdateFunctionCode: onTarget, - opPublishVersion: onTarget, - opListVersionsByFunction: onTarget, - opCreateAlias: onTarget, - opListAliases: onTarget, - opGetAlias: onTarget, - opUpdateAlias: onTarget, - opDeleteAlias: onTarget, + opUpdateFunctionCode: onFunction, + opPublishVersion: onFunction, + opListVersionsByFunction: onFunction, + opCreateAlias: onFunction, + opListAliases: onFunction, + opGetAlias: onFunction, + opUpdateAlias: onFunction, + opDeleteAlias: onFunction, opAddPermission: addPermissionChecks, opGetPolicy: onTarget, opRemovePermission: removePermissionChecks, @@ -315,7 +330,11 @@ func createFunctionChecks(c *checkSet, _ opID) bool { return false } - name, _ := splitFunctionNameQualifier(req.FunctionName) + name, invalid := createFunctionName(req.FunctionName, c.scope.AccountID, c.scope.Region) + if invalid != "" { + return false + } + arn := c.functionARN(name, "") c.add(action(opCreateFunction), arn, awsauthz.Required) @@ -344,7 +363,7 @@ func updateConfigurationChecks(c *checkSet, op opID) bool { return false } - onTarget(c, op) + onFunction(c, op) c.layers(req.Layers) c.vpc(req.VpcConfig) @@ -389,7 +408,7 @@ func removePermissionChecks(c *checkSet, op opID) bool { func tagChecks(c *checkSet, op opID) bool { resource := "" if strings.Contains(c.args.item, ":function:") { - resource = c.target() + resource = c.unqualified() } c.add(action(op), resource, awsauthz.Required) diff --git a/server/aws/lambda/iam_actions_test.go b/server/aws/lambda/iam_actions_test.go index a44860959..76f1d9661 100644 --- a/server/aws/lambda/iam_actions_test.go +++ b/server/aws/lambda/iam_actions_test.go @@ -52,17 +52,25 @@ func TestIAMChecks(t *testing.T) { {"GET", "/2015-03-31/functions/f", "", []string{"lambda:GetFunction " + testFn + "f"}}, {"GET", "/2015-03-31/functions/f:prod", "", []string{"lambda:GetFunction " + testFn + "f:prod"}}, {"DELETE", "/2015-03-31/functions/f?Qualifier=2", "", []string{"lambda:DeleteFunction " + testFn + "f:2"}}, - {"POST", "/2015-03-31/functions/arn:aws:lambda:eu-west-1:999999999999:function:f:prod/invocations", "", + {"POST", "/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:f:prod/invocations", "", []string{"lambda:InvokeFunction " + testFn + "f:prod"}}, {"PUT", "/2015-03-31/functions/f/configuration", `{"Layers":["` + testLayer + `l:1"]}`, []string{"lambda:UpdateFunctionConfiguration " + testFn + "f", "lambda:GetLayerVersion " + testLayer + "l:1"}}, {"PUT", "/2015-03-31/functions/f/code", "{}", []string{"lambda:UpdateFunctionCode " + testFn + "f"}}, + {"PUT", "/2015-03-31/functions/f:prod/configuration", "{}", []string{"lambda:UpdateFunctionConfiguration " + testFn + "f"}}, + {"PUT", "/2015-03-31/functions/f/code?Qualifier=1", "{}", []string{"lambda:UpdateFunctionCode " + testFn + "f"}}, + {"POST", "/2015-03-31/functions/f:prod/versions", "{}", []string{"lambda:PublishVersion " + testFn + "f"}}, + {"POST", "/2015-03-31/functions/f:prod/aliases", "{}", []string{"lambda:CreateAlias " + testFn + "f"}}, + {"DELETE", "/2015-03-31/functions/f:1/aliases/prod", "", []string{"lambda:DeleteAlias " + testFn + "f"}}, + {"GET", "/2015-03-31/functions/f:$LATEST", "", []string{"lambda:GetFunction " + testFn + "f"}}, + {"POST", "/2015-03-31/functions/f/invocations?Qualifier=$LATEST", "", []string{"lambda:InvokeFunction " + testFn + "f"}}, + {"POST", "/2017-03-31/tags/" + testFn + "f:prod", `{"Tags":{"k":"v"}}`, []string{"lambda:TagResource " + testFn + "f"}}, {"POST", "/2015-03-31/functions/f/versions", "{}", []string{"lambda:PublishVersion " + testFn + "f"}}, {"DELETE", "/2015-03-31/functions/f/aliases/prod", "", []string{"lambda:DeleteAlias " + testFn + "f"}}, {"POST", "/2015-03-31/functions/f/policy", `{"Principal":"s3.amazonaws.com"}`, []string{"lambda:AddPermission " + testFn + "f"}}, {"DELETE", "/2015-03-31/functions/f/policy/s1?Qualifier=prod", "", []string{"lambda:RemovePermission " + testFn + "f:prod"}}, {"POST", "/2017-03-31/tags/" + testFn + "f", `{"Tags":{"k":"v"}}`, []string{"lambda:TagResource " + testFn + "f"}}, - {"GET", "/2017-03-31/tags/arn:aws:lambda:eu-west-1:999999999999:function:f", "", []string{"lambda:ListTags " + testFn + "f"}}, + {"GET", "/2017-03-31/tags/arn:aws:lambda:us-east-1:123456789012:function:f", "", []string{"lambda:ListTags " + testFn + "f"}}, {"DELETE", "/2017-03-31/tags/not-an-arn?tagKeys=k", "", []string{"lambda:UntagResource "}}, {"POST", "/2015-03-31/event-source-mappings/", `{"FunctionName":"f"}`, []string{"lambda:CreateEventSourceMapping *"}}, {"GET", "/2015-03-31/event-source-mappings/u1", "", @@ -115,6 +123,11 @@ func TestIAMChecksUnknown(t *testing.T) { {"GET", "/2018-10-31/layers/l/versions/x", ""}, {"GET", "/2018-10-31/layers?find=LayerVersion&Arn=bad", ""}, {"PUT", "/2020-06-30/functions/f/code-signing-config", ""}, + {"POST", "/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:f/invocations", ""}, + {"GET", "/2015-03-31/functions/999999999999:function:f", ""}, + {"POST", "/2017-03-31/tags/arn:aws:lambda:us-east-1:999999999999:function:f", `{"Tags":{"a":"b"}}`}, + {"POST", "/2015-03-31/functions", `{"FunctionName":"f:prod"}`}, + {"POST", "/2015-03-31/functions", `{"FunctionName":"arn:aws:lambda:us-east-1:999999999999:function:n"}`}, {"GET", "/", ""}, } { r := httptest.NewRequest(tc.method, tc.target, strings.NewReader(tc.body))