From 5b0fe698f9b4e31a10880cb5b729a556a1c9e98b Mon Sep 17 00:00:00 2001 From: fujiwara Date: Fri, 12 Apr 2024 18:29:40 +0900 Subject: [PATCH 1/3] Support CloudFront OAC CloudFront OAC requires SourceArn support. --- README.md | 26 ++++- diff.go | 4 +- functionurl.go | 246 +++++++++++++++++++++++++------------------- functionurl_test.go | 32 +++++- 4 files changed, 197 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 494b848..f41ee90 100644 --- a/README.md +++ b/README.md @@ -568,8 +568,8 @@ When you want to deploy a private (requires AWS IAM authentication) function URL ```json { "Config": { - "AuthType": "AWS_IAM", - "Cors": { + "AuthType": "AWS_IAM", + "Cors": { "AllowOrigins": [ "*" ], @@ -600,6 +600,28 @@ When you want to deploy a private (requires AWS IAM authentication) function URL - Each elements of `Permissions` maps to [AddPermissionInput](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/lambda#AddPermissionInput) in AWS SDK Go v2. - `function_url.jsonnet` is also supported like `function.jsonnet`. +#### CloudFront origin access control (OAC) support + +CloudFront provides origin access control (OAC) for restricting access to a Lambda function URL origin. + +When you want to restrict access to a Lambda function URL origin by CloudFront, you can specify `Principal` as `cloudfront.amazonaws.com` and `SourceArn` as the ARN of the CloudFront distribution. + +See also [Restricting access to an AWS Lambda function URL origin](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html). + +```json +{ + "Config": { + "AuthType": "AWS_IAM", + }, + "Permissions": [ + { + "Principal": "cloudfront.amazonaws.com", + "SourceArn": "arn:aws:cloudfront::123456789012:distribution/XXXXXXXXX" + } + ] +} +``` + ## LICENSE MIT License diff --git a/diff.go b/diff.go index 3fe332b..0edb52d 100644 --- a/diff.go +++ b/diff.go @@ -208,8 +208,8 @@ func (app *App) diffFunctionURL(ctx context.Context, name string, opt *DiffOptio removesB = append(removesB, b...) } if ds := diff.Diff(string(removesB), string(addsB)); ds != "" { - fmt.Println(color.RedString("---")) - fmt.Println(color.GreenString("+++")) + fmt.Println(color.RedString("--- permissions")) + fmt.Println(color.GreenString("+++ permissions")) fmt.Print(coloredDiff(ds)) } diff --git a/functionurl.go b/functionurl.go index f1c5957..de7df27 100644 --- a/functionurl.go +++ b/functionurl.go @@ -10,6 +10,7 @@ import ( "os" "regexp" "sort" + "strings" "sync" "github.com/aws/aws-sdk-go-v2/aws" @@ -54,6 +55,28 @@ func (f *FunctionURL) Validate(functionName string) error { return nil } +func (fc *FunctionURL) AddPermissionInput(p *FunctionURLPermission) *lambda.AddPermissionInput { + return &lambda.AddPermissionInput{ + Action: aws.String("lambda:InvokeFunctionUrl"), + FunctionName: fc.Config.FunctionName, + Qualifier: fc.Config.Qualifier, + FunctionUrlAuthType: fc.Config.AuthType, + StatementId: aws.String(p.Sid()), + Principal: p.Principal, + PrincipalOrgID: p.PrincipalOrgID, + SourceArn: p.SourceArn, + SourceAccount: p.SourceAccount, + } +} + +func (fc *FunctionURL) RemovePermissionInput(sid string) *lambda.RemovePermissionInput { + return &lambda.RemovePermissionInput{ + FunctionName: fc.Config.FunctionName, + Qualifier: fc.Config.Qualifier, + StatementId: aws.String(sid), + } +} + type FunctionURLConfig = lambda.CreateFunctionUrlConfigInput type FunctionURLPermissions []*FunctionURLPermission @@ -84,10 +107,16 @@ type FunctionURLPermission struct { } func (p *FunctionURLPermission) Sid() string { + if p.sid != "" { + return p.sid + } else if p.StatementId != nil { + return *p.StatementId + } p.once.Do(func() { b, _ := json.Marshal(p) h := sha1.Sum(b) p.sid = fmt.Sprintf(SidFormat, h) + p.StatementId = aws.String(p.sid) }) return p.sid } @@ -107,7 +136,7 @@ type PolicyStatement struct { Condition any `json:"Condition"` } -func (ps *PolicyStatement) PrincipalAccountID() *string { +func (ps *PolicyStatement) PrincipalString() *string { if ps.Principal == nil { return nil } @@ -115,22 +144,26 @@ func (ps *PolicyStatement) PrincipalAccountID() *string { case string: return aws.String(v) case map[string]interface{}: - if v["AWS"] == nil { - return nil - } - switch vv := v["AWS"].(type) { - case string: - if a, err := arn.Parse(vv); err == nil { - return aws.String(a.AccountID) + if v["AWS"] != nil { + switch vv := v["AWS"].(type) { + case string: + if a, err := arn.Parse(vv); err == nil { + return aws.String(a.AccountID) + } + return aws.String(vv) + } + } else if v["Service"] != nil { + switch vv := v["Service"].(type) { + case string: + return aws.String(vv) } - return aws.String(vv) } } return nil } func (ps *PolicyStatement) PrincipalOrgID() *string { - principal := ps.PrincipalAccountID() + principal := ps.PrincipalString() if principal == nil || *principal != "*" { return nil } @@ -160,6 +193,37 @@ func (ps *PolicyStatement) PrincipalOrgID() *string { return nil } +func (ps *PolicyStatement) SourceArn() *string { + if ps.Condition == nil { + return nil + } + m, ok := ps.Condition.(map[string]interface{}) + if !ok { + return nil + } + if m["ArnLike"] == nil { + return nil + } + mm, ok := m["ArnLike"].(map[string]interface{}) + if !ok { + return nil + } + var sourceArn any + for k, v := range mm { + if strings.ToLower(k) == "aws:sourcearn" { + sourceArn = v + break + } + } + if sourceArn == nil { + return nil + } + if v, ok := sourceArn.(string); ok { + return aws.String(v) + } + return nil +} + func (app *App) loadFunctionUrl(path string, functionName string) (*FunctionURL, error) { f, err := loadDefinitionFile[FunctionURL](app, path, DefaultFunctionURLFilenames) if err != nil { @@ -249,96 +313,105 @@ func (app *App) deployFunctionURLPermissions(ctx context.Context, fc *FunctionUR log.Printf("[info] adding %d permissions %s", len(adds), opt.label()) if !opt.DryRun { - for _, in := range adds { - if _, err := app.lambda.AddPermission(ctx, in); err != nil { + for _, p := range adds { + if _, err := app.lambda.AddPermission(ctx, fc.AddPermissionInput(p)); err != nil { return fmt.Errorf("failed to add permission: %w", err) } - log.Printf("[info] added permission Sid: %s", *in.StatementId) + log.Printf("[info] added permission Sid: %s", p.Sid()) } } log.Printf("[info] removing %d permissions %s", len(removes), opt.label()) if !opt.DryRun { - for _, in := range removes { - if _, err := app.lambda.RemovePermission(ctx, in); err != nil { + for _, p := range removes { + if _, err := app.lambda.RemovePermission(ctx, fc.RemovePermissionInput(*p.StatementId)); err != nil { return fmt.Errorf("failed to remove permission: %w", err) } - log.Printf("[info] removed permission Sid: %s", *in.StatementId) + log.Printf("[info] removed permission Sid: %s", *p.StatementId) } } return nil } -func (app *App) calcFunctionURLPermissionsDiff(ctx context.Context, fc *FunctionURL) ([]*lambda.AddPermissionInput, []*lambda.RemovePermissionInput, error) { - fqFunctionName := fullQualifiedFunctionName(*fc.Config.FunctionName, fc.Config.Qualifier) - existsSids := []string{} - { - res, err := app.lambda.GetPolicy(ctx, &lambda.GetPolicyInput{ - FunctionName: fc.Config.FunctionName, - Qualifier: fc.Config.Qualifier, - }) - if err != nil { - var nfe *types.ResourceNotFoundException - if errors.As(err, &nfe) { - // do nothing - } else { - return nil, nil, fmt.Errorf("failed to get policy: %w", err) - } - } - if res != nil { - log.Printf("[debug] policy for %s: %s", fqFunctionName, *res.Policy) - var policy PolicyOutput - if err := json.Unmarshal([]byte(*res.Policy), &policy); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal policy: %w", err) - } - for _, s := range policy.Statement { - if s.Action != "lambda:InvokeFunctionUrl" || s.Effect != "Allow" { - // not a lambda function url policy - continue - } - existsSids = append(existsSids, s.Sid) - } - sort.Strings(existsSids) - } +func (app *App) calcFunctionURLPermissionsDiff(ctx context.Context, fc *FunctionURL) (FunctionURLPermissions, FunctionURLPermissions, error) { + existsPermissions, err := app.getFunctionURLPermissions(ctx, *fc.Config.FunctionName, fc.Config.Qualifier) + if err != nil { + return nil, nil, err } + existsSids := lo.Map(existsPermissions, func(p *FunctionURLPermission, _ int) string { + return p.Sid() + }) removeSids, addSids := lo.Difference(existsSids, fc.Permissions.Sids()) if len(removeSids) == 0 && len(addSids) == 0 { return nil, nil, nil } - var adds []*lambda.AddPermissionInput + var adds FunctionURLPermissions for _, sid := range addSids { p := fc.Permissions.Find(sid) if p == nil { // should not happen - panic(fmt.Sprintf("permission not found: %s", sid)) - } - in := &lambda.AddPermissionInput{ - Action: aws.String("lambda:InvokeFunctionUrl"), - FunctionName: fc.Config.FunctionName, - Qualifier: fc.Config.Qualifier, - FunctionUrlAuthType: fc.Config.AuthType, - StatementId: aws.String(sid), - Principal: p.Principal, - PrincipalOrgID: p.PrincipalOrgID, + panic(fmt.Sprintf("permission not found for adding: %s", sid)) } - adds = append(adds, in) + adds = append(adds, p) } - var removes []*lambda.RemovePermissionInput + var removes FunctionURLPermissions for _, sid := range removeSids { - in := &lambda.RemovePermissionInput{ - FunctionName: fc.Config.FunctionName, - Qualifier: fc.Config.Qualifier, - StatementId: aws.String(sid), + p := existsPermissions.Find(sid) + if p == nil { + // should not happen + panic(fmt.Sprintf("permission not found for removal: %s", sid)) } - removes = append(removes, in) + removes = append(removes, p) } return adds, removes, nil } +func (app *App) getFunctionURLPermissions(ctx context.Context, functionName string, qualifier *string) (FunctionURLPermissions, error) { + fqFunctionName := fullQualifiedFunctionName(functionName, qualifier) + res, err := app.lambda.GetPolicy(ctx, &lambda.GetPolicyInput{ + FunctionName: &functionName, + Qualifier: qualifier, + }) + if err != nil { + var nfe *types.ResourceNotFoundException + if errors.As(err, &nfe) { + // do nothing + } else { + return nil, fmt.Errorf("failed to get policy: %w", err) + } + } + ps := make(FunctionURLPermissions, 0) + if res != nil { + log.Printf("[debug] policy for %s: %s", fqFunctionName, *res.Policy) + var policy PolicyOutput + if err := json.Unmarshal([]byte(*res.Policy), &policy); err != nil { + return nil, fmt.Errorf("failed to unmarshal policy: %w", err) + } + for _, s := range policy.Statement { + if s.Action != "lambda:InvokeFunctionUrl" || s.Effect != "Allow" { + // not a lambda function url policy + continue + } + st, _ := json.Marshal(s) + log.Println("[debug] exists sid", s.Sid, string(st)) + ps = append(ps, &FunctionURLPermission{ + sid: s.Sid, + AddPermissionInput: lambda.AddPermissionInput{ + StatementId: aws.String(s.Sid), + Principal: s.PrincipalString(), + PrincipalOrgID: s.PrincipalOrgID(), + SourceArn: s.SourceArn(), + }, + }) + } + } + return ps, nil +} + func (app *App) initFunctionURL(ctx context.Context, fn *Function, exists bool, opt *InitOption) error { fc, err := app.lambda.GetFunctionUrlConfig(ctx, &lambda.GetFunctionUrlConfigInput{ FunctionName: fn.FunctionName, @@ -361,7 +434,7 @@ func (app *App) initFunctionURL(ctx context.Context, fn *Function, exists bool, return fmt.Errorf("failed to get function url config: %w", err) } } - fqFunctionName := fullQualifiedFunctionName(*fn.FunctionName, opt.Qualifier) + fu := &FunctionURL{ Config: &lambda.CreateFunctionUrlConfigInput{ Cors: fc.Cors, @@ -371,44 +444,11 @@ func (app *App) initFunctionURL(ctx context.Context, fn *Function, exists bool, }, } - { - res, err := app.lambda.GetPolicy(ctx, &lambda.GetPolicyInput{ - FunctionName: fn.FunctionName, - Qualifier: opt.Qualifier, - }) - if err != nil { - var nfe *types.ResourceNotFoundException - if errors.As(err, &nfe) { - // do nothing - } else { - return fmt.Errorf("failed to get policy: %w", err) - } - } - if res != nil { - log.Printf("[debug] policy for %s: %s", fqFunctionName, *res.Policy) - var policy PolicyOutput - if err := json.Unmarshal([]byte(*res.Policy), &policy); err != nil { - return fmt.Errorf("failed to unmarshal policy: %w", err) - } - for _, s := range policy.Statement { - if s.Action != "lambda:InvokeFunctionUrl" || s.Effect != "Allow" { - // not a lambda function url policy - continue - } - b, _ := marshalJSON(s) - log.Printf("[debug] statement: %s", string(b)) - pm := &FunctionURLPermission{ - AddPermissionInput: lambda.AddPermissionInput{ - Principal: s.PrincipalAccountID(), - PrincipalOrgID: s.PrincipalOrgID(), - }, - } - b, _ = marshalJSON(pm) - log.Printf("[debug] permission: %s", string(b)) - fu.Permissions = append(fu.Permissions, pm) - } - } + ps, err := app.getFunctionURLPermissions(ctx, *fn.FunctionName, opt.Qualifier) + if err != nil { + return err } + fu.Permissions = ps var name string if opt.Jsonnet { diff --git a/functionurl_test.go b/functionurl_test.go index f44f514..2d0c70d 100644 --- a/functionurl_test.go +++ b/functionurl_test.go @@ -9,11 +9,12 @@ import ( "github.com/go-test/deep" ) -var permissonsTestCases = []struct { +var permissionsTestCases = []struct { subject string statementJSON []byte expectedPrincipal *string expectedPrincipalOrgID *string + expectedSourceArn *string }{ { subject: "AuthType NONE", @@ -69,19 +70,42 @@ var permissonsTestCases = []struct { expectedPrincipal: aws.String("123456789012"), expectedPrincipalOrgID: nil, }, + { + subject: "AuthType AWS_IAM with Principal CF OAC", + statementJSON: []byte(`{ + "Action": "lambda:InvokeFunctionUrl", + "Condition": { + "ArnLike": { + "aws:SourceArn": "arn:aws:cloudfront::123456789012:distribution/ABCDEFG12345678" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "cloudfront.amazonaws.com" + }, + "Resource": "arn:aws:lambda:ap-northeast-1:123456789012:function:hello", + "Sid": "lambroll-3b135eca4b14335775cda9f947966093a57d270f" + }`), + expectedPrincipal: aws.String("cloudfront.amazonaws.com"), + expectedPrincipalOrgID: nil, + expectedSourceArn: aws.String("arn:aws:cloudfront::123456789012:distribution/ABCDEFG12345678"), + }, } func TestParseStatement(t *testing.T) { - for _, c := range permissonsTestCases { + for _, c := range permissionsTestCases { st := &lambroll.PolicyStatement{} if err := json.Unmarshal(c.statementJSON, st); err != nil { t.Errorf("%s failed to unmarshal json: %s", c.subject, err) } - if diff := deep.Equal(c.expectedPrincipal, st.PrincipalAccountID()); diff != nil { - t.Errorf("%s PrincipalAccountID diff %s", c.subject, diff) + if diff := deep.Equal(c.expectedPrincipal, st.PrincipalString()); diff != nil { + t.Errorf("%s PrincipalString diff %s", c.subject, diff) } if diff := deep.Equal(c.expectedPrincipalOrgID, st.PrincipalOrgID()); diff != nil { t.Errorf("%s PrincipalOrgID diff %s", c.subject, diff) } + if diff := deep.Equal(c.expectedSourceArn, st.SourceArn()); diff != nil { + t.Errorf("%s SourceArn diff %s", c.subject, diff) + } } } From 62013d3427337833601c99f8db1172aaf949255b Mon Sep 17 00:00:00 2001 From: fujiwara Date: Fri, 19 Apr 2024 15:29:56 +0900 Subject: [PATCH 2/3] fix readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f41ee90..90af087 100644 --- a/README.md +++ b/README.md @@ -616,12 +616,14 @@ See also [Restricting access to an AWS Lambda function URL origin](https://docs. "Permissions": [ { "Principal": "cloudfront.amazonaws.com", - "SourceArn": "arn:aws:cloudfront::123456789012:distribution/XXXXXXXXX" + "SourceArn": "arn:aws:cloudfront::123456789012:distribution/EXXXXXXXX" } ] } ``` +If you need to allow access from any CloudFront distributions in your account, you can specify `SourceArn` as `arn:aws:cloudfront::123456789012:distribution/*`. + ## LICENSE MIT License From 6ff99a121df83b4d28ad617b2746f76861a02779 Mon Sep 17 00:00:00 2001 From: fujiwara Date: Fri, 19 Apr 2024 15:46:24 +0900 Subject: [PATCH 3/3] add readme `SourceArn` as `*` is not recommended. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 90af087..1b143bb 100644 --- a/README.md +++ b/README.md @@ -624,6 +624,8 @@ See also [Restricting access to an AWS Lambda function URL origin](https://docs. If you need to allow access from any CloudFront distributions in your account, you can specify `SourceArn` as `arn:aws:cloudfront::123456789012:distribution/*`. +Specifying `SourceArn` as `*` is not recommended because it allows access from any CloudFront distribution in any AWS account. + ## LICENSE MIT License