From 977e56e03e70dc31b862b24158302dabb4bf91ef Mon Sep 17 00:00:00 2001 From: Luke Hoban Date: Tue, 6 Jun 2017 18:42:00 -0700 Subject: [PATCH] Add aws.lambda.Permission resource (#224) Adds a Permission resource to enable lambda Functions to be invoked by event sources. Per #223, we are continuing to discuss whether these should really be a seperate resource or just an inline component of a Function resource. However, until we support cycle-breaking, we'll need them to be a separate resource type. Progress on #222. --- lib/aws/pack/serverless/api.ts | 15 ++ lib/aws/provider/arn/arn.go | 4 +- lib/aws/provider/lambda/function_test.go | 19 ++- lib/aws/provider/lambda/permission.go | 195 +++++++++++++++++++++++ lib/aws/provider/provider.go | 1 + lib/aws/provider/testutil/provider.go | 29 ++-- lib/mantle/http/api.ts | 1 - 7 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 lib/aws/provider/lambda/permission.go diff --git a/lib/aws/pack/serverless/api.ts b/lib/aws/pack/serverless/api.ts index 373eefdb9..eb3c3a938 100644 --- a/lib/aws/pack/serverless/api.ts +++ b/lib/aws/pack/serverless/api.ts @@ -15,6 +15,7 @@ import { jsonStringify, sha1hash, printf } from "@lumi/lumi/runtime" import { Deployment, RestAPI, Stage } from "../apigateway" +import { Permission } from "../lambda" import { Function } from "./function" import { region } from "../config" @@ -74,6 +75,10 @@ function createPathSpec(lambdaARN: string): SwaggerOperation { } } +function createSourceARN(region: string, account: string, apiid: string, functionName: string): string { + return "arn:aws:execute-api:"+region+":"+account+":"+apiid+"/*/*/"+ functionName; +} + // API is a higher level abstraction for working with AWS APIGateway reources. export class API { public api: RestAPI @@ -110,6 +115,16 @@ export class API { default: throw new Error("Method not supported: " + method); } + let apiName = ""; + if(this.api.apiName !== undefined) { + apiName = this.api.apiName; + } + let invokePermission = new Permission(this.apiName + "_invoke_" + method + path, { + action: "lambda:invokeFunction", + function: lambda.lambda, + principal: "apigateway.amazonaws.com", + sourceARN: createSourceARN("us-east-1", "490047557317", apiName, "webapi-test-func"), + }); // TODO[pulumi/lumi#90]: Once we suport output properties, we can use `lambda.lambda.arn` as input // to constructing this apigateway lambda invocation uri. // this.swaggerSpec.paths[path][swaggerMethod] = createPathSpec(lambda.lambda.arn); diff --git a/lib/aws/provider/arn/arn.go b/lib/aws/provider/arn/arn.go index 95a053284..c911e8378 100644 --- a/lib/aws/provider/arn/arn.go +++ b/lib/aws/provider/arn/arn.go @@ -103,8 +103,8 @@ func (arn ARN) Parse() (Parts, error) { if len(ps) > 5 { parts.Resource = ps[5] } - if len(ps) > 6 { - parts.Resource = parts.Resource + ":" + ps[6] + for i := 6; i < len(ps); i++ { + parts.Resource = parts.Resource + ":" + ps[i] } return parts, nil } diff --git a/lib/aws/provider/lambda/function_test.go b/lib/aws/provider/lambda/function_test.go index 13363a2db..340a8a26a 100644 --- a/lib/aws/provider/lambda/function_test.go +++ b/lib/aws/provider/lambda/function_test.go @@ -29,12 +29,10 @@ func Test(t *testing.T) { cleanupFunctions(ctx) cleanupRoles(ctx) - functionProvider := NewFunctionProvider(ctx) - roleProvider := iamprovider.NewRoleProvider(ctx) - resources := map[string]testutil.Resource{ - "role": {Provider: roleProvider, Token: iam.RoleToken}, - "f": {Provider: functionProvider, Token: FunctionToken}, + "role": {Provider: iamprovider.NewRoleProvider(ctx), Token: iam.RoleToken}, + "f": {Provider: NewFunctionProvider(ctx), Token: FunctionToken}, + "permission": {Provider: NewPermissionProvider(ctx), Token: PermissionToken}, } steps := []testutil.Step{ testutil.Step{ @@ -80,6 +78,17 @@ func Test(t *testing.T) { } }, }, + testutil.ResourceGenerator{ + Name: "permission", + Creator: func(ctx testutil.Context) interface{} { + return &lambda.Permission{ + Name: aws.String(RESOURCEPREFIX), + Function: ctx.GetResourceID("f"), + Action: "lambda:InvokeFunction", + Principal: "apigateway.amazonaws.com", + } + }, + }, }, } diff --git a/lib/aws/provider/lambda/permission.go b/lib/aws/provider/lambda/permission.go new file mode 100644 index 000000000..cce0acf92 --- /dev/null +++ b/lib/aws/provider/lambda/permission.go @@ -0,0 +1,195 @@ +// Licensed to Pulumi Corporation ("Pulumi") under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// Pulumi licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lambda + +import ( + "crypto/sha1" + "encoding/json" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go/aws" + awslambda "github.com/aws/aws-sdk-go/service/lambda" + "github.com/pulumi/lumi/pkg/resource" + "github.com/pulumi/lumi/pkg/util/contract" + "github.com/pulumi/lumi/sdk/go/pkg/lumirpc" + "golang.org/x/net/context" + + "github.com/pulumi/lumi/lib/aws/provider/arn" + "github.com/pulumi/lumi/lib/aws/provider/awsctx" + awscommon "github.com/pulumi/lumi/lib/aws/rpc" + "github.com/pulumi/lumi/lib/aws/rpc/lambda" +) + +const PermissionToken = lambda.PermissionToken + +const ( + maxStatementID = 100 +) + +type policy struct { + Version string + ID string `json:"Id"` + Statement []statement +} + +type statement struct { + Sid string + Effect string + Principal principal + Action string + Resource string + Condition map[string]map[string]string +} + +type principal struct { + Service string +} + +// NewPermissionID returns an AWS APIGateway Deployment ARN ID for the given restAPIID and deploymentID +func NewPermissionID(region, account, functionName, statementID string) resource.ID { + return arn.NewID("lambda", region, account, "function:"+functionName+":policy:"+statementID) +} + +// ParsePermissionID parses an AWS APIGateway Deployment ARN ID to extract the restAPIID and deploymentID +func ParsePermissionID(id resource.ID) (string, string, error) { + res, err := arn.ParseResourceName(id) + if err != nil { + return "", "", err + } + parts := strings.Split(res, ":") + if len(parts) != 3 || parts[1] != "policy" { + return "", "", fmt.Errorf("expected Permission ARN of the form %v: %v", + "arn:aws:lambda:region:account:function:function-name:policy:statement-id", id) + } + return parts[0], parts[2], nil +} + +// NewPermissionProvider creates a provider that handles Lambda permission operations. +func NewPermissionProvider(ctx *awsctx.Context) lumirpc.ResourceProviderServer { + ops := &permissionProvider{ctx} + return lambda.NewPermissionProvider(ops) +} + +type permissionProvider struct { + ctx *awsctx.Context +} + +// Check validates that the given property bag is valid for a resource of the given type. +func (p *permissionProvider) Check(ctx context.Context, obj *lambda.Permission) ([]error, error) { + var failures []error + + return failures, nil +} + +// Create allocates a new instance of the provided resource and returns its unique ID afterwards. (The input ID +// must be blank.) If this call fails, the resource must not have been created (i.e., it is "transacational"). +func (p *permissionProvider) Create(ctx context.Context, obj *lambda.Permission) (resource.ID, error) { + // Auto-generate a name in part based on the resource name. + statementID := resource.NewUniqueHex(*obj.Name+"-", maxStatementID, sha1.Size) + functionName, err := arn.ParseResourceName(obj.Function) + if err != nil { + return "", err + } + fmt.Printf("Creating Lambda Permission '%v' with statement ID '%v'\n", obj.Name, statementID) + create := &awslambda.AddPermissionInput{ + Action: aws.String(obj.Action), + FunctionName: aws.String(functionName), + Principal: aws.String(obj.Principal), + SourceAccount: obj.SourceAccount, + StatementId: aws.String(statementID), + } + if obj.SourceARN != nil { + create.SourceArn = aws.String(string(*obj.SourceARN)) + } + _, err = p.ctx.Lambda().AddPermission(create) + if err != nil { + return "", err + } + + return NewPermissionID(p.ctx.Region(), p.ctx.AccountID(), functionName, statementID), nil +} + +// Get reads the instance state identified by ID, returning a populated resource object, or an error if not found. +func (p *permissionProvider) Get(ctx context.Context, id resource.ID) (*lambda.Permission, error) { + functionName, statementID, err := ParsePermissionID(id) + if err != nil { + return nil, err + } + resp, err := p.ctx.Lambda().GetPolicy(&awslambda.GetPolicyInput{ + FunctionName: aws.String(functionName), + }) + if err != nil { + return nil, err + } + contract.Assert(resp != nil) + contract.Assert(resp.Policy != nil) + policy := policy{} + err = json.Unmarshal([]byte(*resp.Policy), &policy) + if err != nil { + return nil, err + } + for _, statement := range policy.Statement { + if statement.Sid == statementID { + permission := &lambda.Permission{ + Action: statement.Action, + Function: resource.ID(statement.Resource), + Principal: statement.Principal.Service, + } + if arnLike, ok := statement.Condition["ArnLike"]; ok { + sourceARN := awscommon.ARN(arnLike["AWS:SourceArn"]) + permission.SourceARN = &sourceARN + } + if stringEquals, ok := statement.Condition["StringEquals"]; ok { + sourceAccount := stringEquals["AWS:SourceAccount"] + permission.SourceAccount = &sourceAccount + } + return permission, nil + } + } + return nil, fmt.Errorf("No statement found for id '%v'", id) +} + +// InspectChange checks what impacts a hypothetical update will have on the resource's properties. +func (p *permissionProvider) InspectChange(ctx context.Context, id resource.ID, + old *lambda.Permission, new *lambda.Permission, diff *resource.ObjectDiff) ([]string, error) { + return nil, nil +} + +// Update updates an existing resource with new values. Only those values in the provided property bag are updated +// to new values. The resource ID is returned and may be different if the resource had to be recreated. +func (p *permissionProvider) Update(ctx context.Context, id resource.ID, + old *lambda.Permission, new *lambda.Permission, diff *resource.ObjectDiff) error { + contract.Failf("No properties of Permission resource are updatable.") + return nil +} + +// Delete tears down an existing resource with the given ID. If it fails, the resource is assumed to still exist. +func (p *permissionProvider) Delete(ctx context.Context, id resource.ID) error { + functionName, statementID, err := ParsePermissionID(id) + if err != nil { + return err + } + fmt.Printf("Deleting Lambda Permission '%v'\n", statementID) + _, err = p.ctx.Lambda().RemovePermission(&awslambda.RemovePermissionInput{ + FunctionName: aws.String(functionName), + StatementId: aws.String(statementID), + }) + if err != nil { + return err + } + return nil +} diff --git a/lib/aws/provider/provider.go b/lib/aws/provider/provider.go index 062dd3380..0fb230f75 100644 --- a/lib/aws/provider/provider.go +++ b/lib/aws/provider/provider.go @@ -56,6 +56,7 @@ func NewProvider() (*Provider, error) { elasticbeanstalk.ApplicationVersionToken: elasticbeanstalk.NewApplicationVersionProvider(ctx), elasticbeanstalk.EnvironmentToken: elasticbeanstalk.NewEnvironmentProvider(ctx), lambda.FunctionToken: lambda.NewFunctionProvider(ctx), + lambda.PermissionToken: lambda.NewPermissionProvider(ctx), iam.RoleToken: iam.NewRoleProvider(ctx), s3.BucketToken: s3.NewBucketProvider(ctx), s3.ObjectToken: s3.NewObjectProvider(ctx), diff --git a/lib/aws/provider/testutil/provider.go b/lib/aws/provider/testutil/provider.go index 2173eec6f..af598b727 100644 --- a/lib/aws/provider/testutil/provider.go +++ b/lib/aws/provider/testutil/provider.go @@ -35,18 +35,25 @@ type Step []ResourceGenerator func ProviderTest(t *testing.T, resources map[string]Resource, steps []Step) { p := &providerTest{ - resources: resources, - ids: map[string]resource.ID{}, - props: map[string]*structpb.Struct{}, + resources: resources, + namesInCreationOrder: []string{}, + ids: map[string]resource.ID{}, + props: map[string]*structpb.Struct{}, } + // For each step, create or update all listed resources for _, step := range steps { for _, res := range step { - provider := resources[res.Name].Provider - token := resources[res.Name].Token + currentResource, ok := resources[res.Name] + if !ok { + t.Fatalf("expected resource to have been pre-declared: %v", res.Name) + } + provider := currentResource.Provider + token := currentResource.Token if id, ok := p.ids[res.Name]; !ok { id, props := createResource(t, res.Creator(p), provider, token) p.ids[res.Name] = resource.ID(id) + p.namesInCreationOrder = append(p.namesInCreationOrder, res.Name) p.props[res.Name] = props if id == "" { t.Fatal("expected to succesfully create resource") @@ -61,7 +68,10 @@ func ProviderTest(t *testing.T, resources map[string]Resource, steps []Step) { } } } - for name, id := range p.ids { + // Delete resources in the opposite order they were created + for i := len(p.namesInCreationOrder) - 1; i >= 0; i-- { + name := p.namesInCreationOrder[i] + id := p.ids[name] provider := resources[name].Provider token := resources[name].Token ok := deleteResource(t, string(id), provider, token) @@ -97,9 +107,10 @@ func ProviderTestSimple(t *testing.T, provider lumirpc.ResourceProviderServer, t } type providerTest struct { - resources map[string]Resource - ids map[string]resource.ID - props map[string]*structpb.Struct + resources map[string]Resource + namesInCreationOrder []string + ids map[string]resource.ID + props map[string]*structpb.Struct } func (p *providerTest) GetResourceID(name string) resource.ID { diff --git a/lib/mantle/http/api.ts b/lib/mantle/http/api.ts index 7b5f87366..658213e98 100644 --- a/lib/mantle/http/api.ts +++ b/lib/mantle/http/api.ts @@ -109,7 +109,6 @@ export class API { let restAPI = new aws.apigateway.RestAPI(prefix, { body: body }); let deployment = new aws.apigateway.Deployment(prefix + "-deployment", { restAPI: restAPI, - stageName: "Stage", }); let stage = new aws.apigateway.Stage(prefix + "-primary-stage", { deployment: deployment,