Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
200 changes: 200 additions & 0 deletions pkg/cfres/lambda/permission.go
Original file line number Diff line number Diff line change
@@ -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, &notFound) {
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, &notFound) {
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, &notFound) {
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:
//
// <function or qualified ARN>|<Sid>
//
// 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")
}
143 changes: 143 additions & 0 deletions pkg/cfres/lambda/permission_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
3 changes: 3 additions & 0 deletions schema/pkl/lambda/permission.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading