From ded7192b578294364fb85bbb43bd78d3f99222cc Mon Sep 17 00:00:00 2001 From: Jeroen Soeters Date: Fri, 21 Aug 2026 20:29:06 -0700 Subject: [PATCH] fix(lambda): discover permissions on published versions and aliases A Lambda function keeps a separate resource policy per qualifier: statements added against the bare function name live on the unqualified policy, while statements added against a version's or alias's ARN live on that qualifier's policy. The CloudControl list handler only returns the unqualified policy's statements, so a permission granted against a qualified ARN, the shape API Gateway and Lambda@Edge wiring uses, was invisible to discovery. List now goes through a custom provisioner that walks every policy scope via the Lambda control plane (GetPolicy on the function, each published version, and each alias) and emits the same statement-ARN|Sid composite id the CloudControl CRUD path produces. The client carries the discovery-grade retry budget registry dispatch bypasses. --- CHANGELOG.md | 8 ++ pkg/cfres/lambda/permission.go | 200 ++++++++++++++++++++++++++++ pkg/cfres/lambda/permission_test.go | 143 ++++++++++++++++++++ schema/pkl/lambda/permission.pkl | 3 + 4 files changed, 354 insertions(+) create mode 100644 pkg/cfres/lambda/permission.go create mode 100644 pkg/cfres/lambda/permission_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2508f603..49cc3356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -398,6 +398,14 @@ Requires formae >= 0.89.0. ### Fixed +- `AWS::Lambda::Permission` discovery now finds permissions attached to a + published version or an alias, not only those on the bare function. Lambda + keeps a separate resource policy per qualifier and the CloudControl list + only returns the unqualified one, so a permission granted against a + version's or alias's ARN (the shape API Gateway and Lambda@Edge wiring + uses) was invisible to discovery. The plugin now walks every policy scope + through the Lambda control plane. + - An `AWS::CertificateManager::Certificate` with `subjectAlternativeNames` no longer plans a destructive replace on every reconcile. ACM injects the certificate's primary `domainName` into the subject alternative names it diff --git a/pkg/cfres/lambda/permission.go b/pkg/cfres/lambda/permission.go new file mode 100644 index 00000000..e1b32a56 --- /dev/null +++ b/pkg/cfres/lambda/permission.go @@ -0,0 +1,200 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package lambda + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/retry" + awslambda "github.com/aws/aws-sdk-go-v2/service/lambda" + lambdatypes "github.com/aws/aws-sdk-go-v2/service/lambda/types" + + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" + "github.com/platform-engineering-labs/formae-plugin-aws/pkg/cfres/prov" + "github.com/platform-engineering-labs/formae-plugin-aws/pkg/cfres/registry" + "github.com/platform-engineering-labs/formae-plugin-aws/pkg/config" +) + +// Permission is a custom provisioner for AWS::Lambda::Permission that only +// overrides List. A Lambda function keeps a separate resource policy per +// qualifier: statements added against the bare function name live on the +// unqualified policy, while statements added against a published version's +// or an alias's ARN live on that qualifier's policy. CloudControl's list +// handler only returns the unqualified policy's statements, so permissions +// attached to a version or alias are invisible to it. Discovery therefore +// enumerates every policy scope through the Lambda control plane. All other +// operations fall through to CloudControl. +type Permission struct { + cfg *config.Config +} + +var _ prov.Provisioner = &Permission{} + +func init() { + registry.Register("AWS::Lambda::Permission", + []resource.Operation{resource.OperationList}, + func(cfg *config.Config) prov.Provisioner { + return &Permission{cfg: cfg} + }) +} + +// lambdaPermissionClient is the narrow Lambda-SDK subset used by List. +type lambdaPermissionClient interface { + GetPolicy(ctx context.Context, params *awslambda.GetPolicyInput, optFns ...func(*awslambda.Options)) (*awslambda.GetPolicyOutput, error) + ListVersionsByFunction(ctx context.Context, params *awslambda.ListVersionsByFunctionInput, optFns ...func(*awslambda.Options)) (*awslambda.ListVersionsByFunctionOutput, error) + ListAliases(ctx context.Context, params *awslambda.ListAliasesInput, optFns ...func(*awslambda.Options)) (*awslambda.ListAliasesOutput, error) +} + +func (p *Permission) List(ctx context.Context, request *resource.ListRequest) (*resource.ListResult, error) { + awsCfg, err := p.cfg.ToAwsConfig(ctx) + if err != nil { + return nil, fmt.Errorf("loading AWS config: %w", err) + } + // Discovery has no operator-level retry loop around List, and registry + // dispatch bypasses the ccx retry budget, so give this client the same + // discovery-grade budget ccx uses (10 attempts, backoff capped at 30s) + // instead of the SDK default of 3 attempts. + client := awslambda.NewFromConfig(awsCfg, func(o *awslambda.Options) { + o.Retryer = retry.NewStandard(func(so *retry.StandardOptions) { + so.MaxAttempts = 10 + so.MaxBackoff = 30 * time.Second + }) + }) + return p.listWithClient(ctx, client, request) +} + +func (p *Permission) listWithClient(ctx context.Context, client lambdaPermissionClient, request *resource.ListRequest) (*resource.ListResult, error) { + functionName, ok := request.AdditionalProperties["FunctionName"] + if !ok || functionName == "" { + return nil, fmt.Errorf("AWS::Lambda::Permission list requires FunctionName filter") + } + + // Every qualifier scope that can carry its own resource policy: the bare + // function, each published version, and each alias. $LATEST shares the + // unqualified policy and is skipped to avoid double-listing it. + qualifiers := []string{""} + var marker *string + for { + versions, err := client.ListVersionsByFunction(ctx, &awslambda.ListVersionsByFunctionInput{ + FunctionName: aws.String(functionName), + Marker: marker, + }) + if err != nil { + // Treat a missing parent as an empty list rather than a discovery + // failure: the function may have been deleted between the list + // operation being queued and the call landing. + var notFound *lambdatypes.ResourceNotFoundException + if errors.As(err, ¬Found) { + return &resource.ListResult{NativeIDs: []string{}}, nil + } + return nil, fmt.Errorf("listing versions of function %s: %w", functionName, err) + } + for _, v := range versions.Versions { + if v.Version == nil || *v.Version == "$LATEST" { + continue + } + qualifiers = append(qualifiers, *v.Version) + } + if versions.NextMarker == nil { + break + } + marker = versions.NextMarker + } + aliases, err := client.ListAliases(ctx, &awslambda.ListAliasesInput{FunctionName: aws.String(functionName)}) + if err != nil { + var notFound *lambdatypes.ResourceNotFoundException + if errors.As(err, ¬Found) { + return &resource.ListResult{NativeIDs: []string{}}, nil + } + return nil, fmt.Errorf("listing aliases of function %s: %w", functionName, err) + } + for _, a := range aliases.Aliases { + if a.Name != nil { + qualifiers = append(qualifiers, *a.Name) + } + } + + nativeIDs := []string{} + for _, qualifier := range qualifiers { + input := &awslambda.GetPolicyInput{FunctionName: aws.String(functionName)} + if qualifier != "" { + input.Qualifier = aws.String(qualifier) + } + policy, err := client.GetPolicy(ctx, input) + if err != nil { + // A scope without any permission statements has no policy at all. + var notFound *lambdatypes.ResourceNotFoundException + if errors.As(err, ¬Found) { + continue + } + return nil, fmt.Errorf("reading policy of function %s qualifier %q: %w", functionName, qualifier, err) + } + if policy.Policy == nil { + continue + } + ids, err := nativeIDsFromPolicy(*policy.Policy) + if err != nil { + return nil, fmt.Errorf("parsing policy of function %s qualifier %q: %w", functionName, qualifier, err) + } + nativeIDs = append(nativeIDs, ids...) + } + + return &resource.ListResult{NativeIDs: nativeIDs}, nil +} + +// nativeIDsFromPolicy extracts one composite native id per policy statement. +// The id mirrors the CloudControl CRUD path for Permission: +// +// | +// +// where the ARN is the statement's Resource, which Lambda always writes as +// the ARN of the scope the permission was added to. Discovery must produce +// the same shape or inventory lookups diverge between discovered and managed +// permissions. +func nativeIDsFromPolicy(policyJSON string) ([]string, error) { + var doc struct { + Statement []struct { + Sid string `json:"Sid"` + Resource any `json:"Resource"` + } `json:"Statement"` + } + if err := json.Unmarshal([]byte(policyJSON), &doc); err != nil { + return nil, err + } + ids := []string{} + for _, stmt := range doc.Statement { + arn, ok := stmt.Resource.(string) + if !ok || arn == "" || stmt.Sid == "" { + continue + } + ids = append(ids, fmt.Sprintf("%s|%s", arn, stmt.Sid)) + } + return ids, nil +} + +func (p *Permission) Create(ctx context.Context, request *resource.CreateRequest) (*resource.CreateResult, error) { + return nil, fmt.Errorf("create is handled by CloudControl for AWS::Lambda::Permission") +} + +func (p *Permission) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) { + return nil, fmt.Errorf("read is handled by CloudControl for AWS::Lambda::Permission") +} + +func (p *Permission) Update(ctx context.Context, request *resource.UpdateRequest) (*resource.UpdateResult, error) { + return nil, fmt.Errorf("update is handled by CloudControl for AWS::Lambda::Permission") +} + +func (p *Permission) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) { + return nil, fmt.Errorf("delete is handled by CloudControl for AWS::Lambda::Permission") +} + +func (p *Permission) Status(ctx context.Context, request *resource.StatusRequest) (*resource.StatusResult, error) { + return nil, fmt.Errorf("status is handled by CloudControl for AWS::Lambda::Permission") +} diff --git a/pkg/cfres/lambda/permission_test.go b/pkg/cfres/lambda/permission_test.go new file mode 100644 index 00000000..f5cb75f4 --- /dev/null +++ b/pkg/cfres/lambda/permission_test.go @@ -0,0 +1,143 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +//go:build unit + +package lambda + +import ( + "context" + "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" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const vrefFunctionArn = "arn:aws:lambda:us-east-1:111122223333:function:my-function" + +type mockPermissionClient struct { + // policies maps qualifier ("" for the unqualified function scope) to the + // policy JSON GetPolicy returns; a missing key returns ResourceNotFound. + policies map[string]string + versionPages [][]lambdatypes.FunctionConfiguration + aliases []lambdatypes.AliasConfiguration + listErr error +} + +func (m *mockPermissionClient) GetPolicy(_ context.Context, in *awslambda.GetPolicyInput, _ ...func(*awslambda.Options)) (*awslambda.GetPolicyOutput, error) { + q := "" + if in.Qualifier != nil { + q = *in.Qualifier + } + policy, ok := m.policies[q] + if !ok { + return nil, &lambdatypes.ResourceNotFoundException{} + } + return &awslambda.GetPolicyOutput{Policy: aws.String(policy)}, nil +} + +func (m *mockPermissionClient) ListVersionsByFunction(_ context.Context, in *awslambda.ListVersionsByFunctionInput, _ ...func(*awslambda.Options)) (*awslambda.ListVersionsByFunctionOutput, error) { + if m.listErr != nil { + return nil, m.listErr + } + page := 0 + if in.Marker != nil { + page = 1 + } + out := &awslambda.ListVersionsByFunctionOutput{} + if page < len(m.versionPages) { + out.Versions = m.versionPages[page] + } + if page == 0 && len(m.versionPages) > 1 { + out.NextMarker = aws.String("next") + } + return out, nil +} + +func (m *mockPermissionClient) ListAliases(_ context.Context, _ *awslambda.ListAliasesInput, _ ...func(*awslambda.Options)) (*awslambda.ListAliasesOutput, error) { + if m.listErr != nil { + return nil, m.listErr + } + return &awslambda.ListAliasesOutput{Aliases: m.aliases}, nil +} + +func policyDoc(sid, resourceArn string) string { + return `{"Version":"2012-10-17","Id":"default","Statement":[{"Sid":"` + sid + `","Effect":"Allow","Principal":{"Service":"s3.amazonaws.com"},"Action":"lambda:InvokeFunction","Resource":"` + resourceArn + `"}]}` +} + +func TestLambdaPermissionList(t *testing.T) { + perm := &Permission{} + request := &resource.ListRequest{AdditionalProperties: map[string]string{"FunctionName": "my-function"}} + + t.Run("emits ids across function, version, and alias policy scopes", func(t *testing.T) { + client := &mockPermissionClient{ + policies: map[string]string{ + "": policyDoc("fn-sid", vrefFunctionArn), + "1": policyDoc("v1-sid", vrefFunctionArn+":1"), + "live": policyDoc("alias-sid", vrefFunctionArn+":live"), + }, + versionPages: [][]lambdatypes.FunctionConfiguration{{ + {Version: aws.String("$LATEST")}, + {Version: aws.String("1")}, + }}, + aliases: []lambdatypes.AliasConfiguration{{Name: aws.String("live")}}, + } + result, err := perm.listWithClient(context.Background(), client, request) + require.NoError(t, err) + assert.ElementsMatch(t, []string{ + vrefFunctionArn + "|fn-sid", + vrefFunctionArn + ":1|v1-sid", + vrefFunctionArn + ":live|alias-sid", + }, result.NativeIDs) + }) + + t.Run("walks every version page", func(t *testing.T) { + client := &mockPermissionClient{ + policies: map[string]string{ + "2": policyDoc("v2-sid", vrefFunctionArn+":2"), + }, + versionPages: [][]lambdatypes.FunctionConfiguration{ + {{Version: aws.String("1")}}, + {{Version: aws.String("2")}}, + }, + } + result, err := perm.listWithClient(context.Background(), client, request) + require.NoError(t, err) + assert.Equal(t, []string{vrefFunctionArn + ":2|v2-sid"}, result.NativeIDs) + }) + + t.Run("skips scopes without a policy", func(t *testing.T) { + client := &mockPermissionClient{ + policies: map[string]string{"1": policyDoc("v1-sid", vrefFunctionArn+":1")}, + versionPages: [][]lambdatypes.FunctionConfiguration{{{Version: aws.String("1")}}}, + } + result, err := perm.listWithClient(context.Background(), client, request) + require.NoError(t, err) + assert.Equal(t, []string{vrefFunctionArn + ":1|v1-sid"}, result.NativeIDs) + }) + + t.Run("requires the FunctionName filter", func(t *testing.T) { + _, err := perm.listWithClient(context.Background(), &mockPermissionClient{}, &resource.ListRequest{ + AdditionalProperties: map[string]string{}, + }) + assert.Error(t, err) + }) + + t.Run("treats a deleted function as an empty list", func(t *testing.T) { + client := &mockPermissionClient{listErr: &lambdatypes.ResourceNotFoundException{}} + result, err := perm.listWithClient(context.Background(), client, request) + require.NoError(t, err) + assert.Empty(t, result.NativeIDs) + }) + + t.Run("propagates other errors", func(t *testing.T) { + client := &mockPermissionClient{listErr: &lambdatypes.TooManyRequestsException{}} + _, err := perm.listWithClient(context.Background(), client, request) + assert.Error(t, err) + }) +} diff --git a/schema/pkl/lambda/permission.pkl b/schema/pkl/lambda/permission.pkl index fe3dc77a..656a9fd3 100644 --- a/schema/pkl/lambda/permission.pkl +++ b/schema/pkl/lambda/permission.pkl @@ -17,6 +17,9 @@ typealias FunctionUrlAuthType = "AWS_IAM"|"NONE" type = module.type identifier = "Id" parent = "AWS::Lambda::Function" + // Discovery goes through a custom List provisioner that walks every + // policy scope (function, published versions, aliases); the CloudControl + // list handler only returns the unqualified function policy. listParam = new formae.ListProperty { parentProperty = "FunctionName" listParameter = "FunctionName" } } open class Permission extends formae.Resource {