Skip to content

Commit c2bd9c7

Browse files
authored
Merge pull request #159 from TerraConstructs/fix/edge-function-associations-and-function-url
fix: render Distribution functionAssociations; grant lambda:InvokeFunction for public Function URLs
2 parents 7ca7dc3 + f9a23d1 commit c2bd9c7

15 files changed

Lines changed: 1081 additions & 10 deletions

‎integ/aws/edge/Makefile‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ distribution-policies: ## Test Distribution Policies
2121
go test -v -timeout 45m ./... -run ^TestDistributionPolicies$
2222
.PHONY: distribution-policies
2323

24+
distribution-function: ## Test Distribution Function association
25+
go test -v -timeout 45m ./... -run ^TestDistributionFunction$
26+
.PHONY: distribution-function
27+
2428
service-with-http-namespace: ## Test CloudMap Service with HTTP Namespace
2529
go test -v -timeout 30m ./... -run ^TestServiceWithHttpNamespace$
2630
.PHONY: service-with-http-namespace

‎integ/aws/edge/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Test Targets:
1515
kvs-jwt-verify Test Edge function for KVS JWT verify
1616
multi-zone-acm-pub-cert Test Multi Zone ACM Public Certificate
1717
distribution-policies Test Distribution Policies
18+
distribution-function Test Distribution Function association
19+
service-with-http-namespace Test CloudMap Service with HTTP Namespace
1820

1921
Other Targets:
2022
help Print out every target with a description
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// https://github.com/aws/aws-cdk/blob/7926560f0a150d8fd39d0775df5259621b8068ae/packages/@aws-cdk-testing/framework-integ/test/aws-cloudfront/test/integ.distribution-function.ts
2+
import { cloudfrontDistribution } from "@cdktn/provider-aws";
3+
import { App, LocalBackend } from "cdktn";
4+
import { aws } from "../../../../src";
5+
6+
const environmentName = process.env.ENVIRONMENT_NAME ?? "test";
7+
const region = process.env.AWS_REGION ?? "us-east-1";
8+
const outdir = process.env.OUT_DIR ?? "cdktf.out";
9+
const stackName = process.env.STACK_NAME ?? "distribution-function";
10+
11+
// https://github.com/aws/aws-cdk/blob/17b12f2aa7a2b519a6e802bf79d3099f2fcd7851/packages/@aws-cdk-testing/framework-integ/test/aws-cloudfront/test/test-origin.ts
12+
/** Used for testing common Origin functionality */
13+
class TestOrigin extends aws.edge.OriginBase {
14+
constructor(domainName: string, props: aws.edge.OriginProps = {}) {
15+
super(domainName, props);
16+
}
17+
protected renderCustomOriginConfig():
18+
| cloudfrontDistribution.CloudfrontDistributionOriginCustomOriginConfig
19+
| undefined {
20+
return {
21+
httpPort: 80,
22+
httpsPort: 443,
23+
originProtocolPolicy: aws.edge.OriginProtocolPolicy.HTTPS_ONLY,
24+
originSslProtocols: [aws.edge.OriginSslPolicy.TLS_V1_2],
25+
};
26+
}
27+
}
28+
29+
const app = new App({
30+
outdir,
31+
});
32+
const stack = new aws.AwsStack(app, stackName, {
33+
gridUUID: "g12345678-1234",
34+
environmentName,
35+
providerConfig: {
36+
region,
37+
},
38+
});
39+
40+
new LocalBackend(stack, {
41+
path: `${stackName}.tfstate`,
42+
});
43+
44+
// Viewer-request function that stamps a marker header on the request so the
45+
// association's effect is directly observable (via TestFunction and, once
46+
// deployed, on the actual viewer response echoed back by the origin).
47+
const cfFunction = new aws.edge.Function(stack, "Function", {
48+
nameSuffix: "distribution-function",
49+
code: aws.edge.FunctionCode.fromInline(
50+
`function handler(event) {
51+
var request = event.request;
52+
request.headers['x-distribution-function'] = { value: 'true' };
53+
return request;
54+
}`,
55+
),
56+
registerOutputs: true,
57+
outputName: "function",
58+
});
59+
60+
new aws.edge.Distribution(stack, "Dist", {
61+
defaultBehavior: {
62+
origin: new TestOrigin("www.example.com"),
63+
cachePolicy: aws.edge.ManagedCachePolicy.CACHING_DISABLED,
64+
functionAssociations: [
65+
{
66+
function: cfFunction,
67+
eventType: aws.edge.FunctionEventType.VIEWER_REQUEST,
68+
},
69+
],
70+
},
71+
registerOutputs: true,
72+
outputName: "distribution",
73+
});
74+
75+
app.synth();

‎integ/aws/edge/edge_test.go‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7+
"strings"
78
"testing"
89
"time"
910

1011
"github.com/aws/aws-sdk-go-v2/aws"
12+
cftypes "github.com/aws/aws-sdk-go-v2/service/cloudfront/types"
1113
"github.com/aws/aws-sdk-go-v2/service/servicediscovery/types"
1214
"github.com/stretchr/testify/require"
1315
"github.com/terraconstructs/base/integ"
@@ -58,6 +60,14 @@ func TestDistributionPolicies(t *testing.T) {
5860
})
5961
}
6062

63+
// Run the apps/distribution-function.ts integration test
64+
// ref: https://github.com/TerraConstructs/base/issues/50
65+
// ref: https://github.com/TerraConstructs/base/issues/99
66+
func TestDistributionFunction(t *testing.T) {
67+
envVars := executors.EnvMap(os.Environ())
68+
runEdgeIntegrationTest(t, "distribution-function", "us-east-1", envVars, validateDistributionFunction)
69+
}
70+
6171
// Test the apps/service-with-http-namespace.ts app
6272
// ref: https://github.com/aws/aws-cdk/blob/v2.233.0/packages/@aws-cdk-testing/framework-integ/test/aws-servicediscovery/test/integ.service-with-http-namespace.lit.ts
6373
func TestServiceWithHttpNamespace(t *testing.T) {
@@ -187,6 +197,73 @@ func validateURLRewriteFunction(t *testing.T, workingDir string, _awsRegion stri
187197
}
188198
}
189199

200+
// validateDistributionFunction verifies the aws.edge.Distribution's
201+
// defaultBehavior functionAssociations wiring from apps/distribution-function.ts:
202+
// it waits for the distribution to deploy, then confirms the associated
203+
// viewer-request CloudFront Function actually runs by exercising the
204+
// TestFunction API and asserting the marker header it stamps on the request.
205+
func validateDistributionFunction(t *testing.T, workingDir string, awsRegion string) {
206+
// Load the Terraform Options saved by the earlier deploy_terraform stage
207+
terraformOptions := test_structure.LoadTerraformOptions(t, workingDir)
208+
209+
distributionId := util.LoadOutputAttribute(t, terraformOptions, "distribution", "id")
210+
util.WaitForDistributionDeployed(t, awsRegion, distributionId, 10, 10*time.Second)
211+
212+
functionName := util.LoadOutputAttribute(t, terraformOptions, "function", "name")
213+
214+
// Assert the deployed distribution config actually carries the
215+
// viewer-request FunctionAssociation for the function -- this is the
216+
// direct regression check for #99/#50 (a dropped association would
217+
// still let the distribution deploy and the TestFunction call below
218+
// would still succeed, since TestFunction invokes the function by name
219+
// independent of any distribution).
220+
dist, err := util.GetDistributionE(t, awsRegion, distributionId)
221+
require.NoError(t, err)
222+
functionAssociations := dist.DistributionConfig.DefaultCacheBehavior.FunctionAssociations
223+
require.NotNil(t, functionAssociations)
224+
require.EqualValues(t, 1, aws.ToInt32(functionAssociations.Quantity))
225+
require.Len(t, functionAssociations.Items, 1)
226+
require.Equal(t, cftypes.EventTypeViewerRequest, functionAssociations.Items[0].EventType)
227+
functionArn := aws.ToString(functionAssociations.Items[0].FunctionARN)
228+
require.NotEmpty(t, functionArn)
229+
require.True(t, strings.HasSuffix(functionArn, functionName),
230+
"expected FunctionARN %q to end with function name %q", functionArn, functionName)
231+
232+
functionStage := "LIVE"
233+
testEvent := &util.CloudFrontFunctionEvent{
234+
Version: "1.0",
235+
Context: util.Context{
236+
DistributionDomainName: "d111111abcdef8.cloudfront.net",
237+
DistributionID: distributionId,
238+
EventType: "viewer-request",
239+
RequestID: "test-request-id",
240+
},
241+
Viewer: util.Viewer{
242+
IP: "1.2.3.4",
243+
},
244+
Request: &util.Request{
245+
Method: "GET",
246+
URI: "/",
247+
Querystring: util.ValueObject{},
248+
Headers: util.ValueObject{
249+
"host": util.ValueEntry{Value: "d111111abcdef8.cloudfront.net"},
250+
},
251+
},
252+
}
253+
util.TestCloudFrontFunctionWithCustomValidation(t, functionName, functionStage, *testEvent,
254+
func(r *util.CloudFrontTestFunctionResult) error {
255+
if r.Output == nil {
256+
return fmt.Errorf("got nil Output response")
257+
}
258+
return integ.AssertE(r.Output, []integ.Assertion{
259+
{
260+
Path: "request.headers.\"x-distribution-function\".value",
261+
ExpectedRegexp: strPtr("^true$"),
262+
},
263+
})
264+
})
265+
}
266+
190267
// validateJwtVerifyFunction with testevents
191268
func validateJwtVerifyFunction(t *testing.T, workingDir string, _awsRegion string) {
192269
// Load the Terraform Options saved by the earlier deploy_terraform stage

‎src/aws/compute/function-base.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ export abstract class LambdaFunctionBase
372372
sourceArn: permission.sourceArn ?? sourceArn,
373373
principalOrgId: permission.organizationId ?? principalOrgID,
374374
functionUrlAuthType: permission.functionUrlAuthType,
375+
invokedViaFunctionUrl: permission.invokedViaFunctionUrl,
375376
});
376377
}
377378

‎src/aws/compute/function-permission.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,18 @@ export interface Permission {
8888
* @default - No functionUrlAuthType
8989
*/
9090
readonly functionUrlAuthType?: FunctionUrlAuthType;
91+
92+
/**
93+
* Restricts this permission to only apply to invocations that go through a
94+
* Lambda Function URL (i.e. adds the `lambda:InvokedViaFunctionUrl`
95+
* condition key to the generated resource policy statement).
96+
*
97+
* This is used, for example, to scope the `lambda:InvokeFunction`
98+
* permission that is required (in addition to `lambda:InvokeFunctionUrl`)
99+
* for a public (`FunctionUrlAuthType.NONE`) function URL, mirroring the
100+
* `FunctionURLInvokeAllowPublicAccess` statement the AWS Console adds.
101+
*
102+
* @default - no lambda:InvokedViaFunctionUrl condition is added to the statement
103+
*/
104+
readonly invokedViaFunctionUrl?: boolean;
91105
}

‎src/aws/compute/function-url.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,27 @@ export class FunctionUrl extends AwsConstructBase implements IFunctionUrl {
260260
this.functionArn = this.resource.functionArn;
261261
this.function = props.function;
262262

263-
if (props.authType === FunctionUrlAuthType.NONE) {
263+
if (this.authType === FunctionUrlAuthType.NONE) {
264264
props.function.addPermission("invoke-function-url", {
265265
principal: new iam.AnyPrincipal(),
266266
action: "lambda:InvokeFunctionUrl",
267267
functionUrlAuthType: props.authType,
268268
});
269+
// A public (authType NONE) Function URL also requires a standalone
270+
// lambda:InvokeFunction grant - lambda:InvokeFunctionUrl alone is not
271+
// sufficient and unauthenticated callers otherwise receive a 403.
272+
// This mirrors the "FunctionURLInvokeAllowPublicAccess" statement the AWS
273+
// Console/CLI add automatically, which is scoped down using the
274+
// lambda:InvokedViaFunctionUrl condition key so this permission only
275+
// applies to invocations made through the function URL (not direct
276+
// lambda:InvokeFunction calls). The `invoked_via_function_url`
277+
// argument on aws_lambda_permission maps 1:1 to that condition key.
278+
// See: https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html
279+
props.function.addPermission("invoke-function-url-via-invoke", {
280+
principal: new iam.AnyPrincipal(),
281+
action: "lambda:InvokeFunction",
282+
invokedViaFunctionUrl: true,
283+
});
269284
}
270285
this.functionUrlOutputs = {
271286
url: this.url,

‎src/aws/edge/distribution.ts‎

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@ import {
55
dataAwsCloudfrontOriginRequestPolicy,
66
dataAwsCloudfrontResponseHeadersPolicy,
77
} from "@cdktn/provider-aws";
8-
import { IResolvable, Token, Lazy } from "cdktn";
8+
import { Annotations, IResolvable, Token, Lazy } from "cdktn";
99
import { Construct } from "constructs";
10-
import { ICertificate, IOrigin, FunctionAssociation } from ".";
10+
import {
11+
ICertificate,
12+
IOrigin,
13+
FunctionAssociation,
14+
FunctionEventType,
15+
} from ".";
16+
// aliased to avoid shadowing the global `Function` constructor
17+
import { Function as CloudFrontFunction } from "./function";
1118
import { Duration } from "../../duration";
1219
import { ArnFormat } from "../arn";
1320
import {
@@ -243,6 +250,7 @@ export class Distribution extends AwsConstructBase implements IDistribution {
243250

244251
private readonly errorResponses: ErrorResponse[];
245252
private readonly certificate?: ICertificate;
253+
private readonly warnedUnpublishedFunctions = new Set<string>();
246254

247255
constructor(scope: Construct, name: string, props: DistributionProps) {
248256
super(scope, name, props);
@@ -297,11 +305,30 @@ export class Distribution extends AwsConstructBase implements IDistribution {
297305
),
298306
),
299307
}),
300-
defaultCacheBehavior: this._renderDefaultCacheBehavior({
301-
pathPattern: "*", // ignored for Default Cache Behavior
302-
targetOriginId: defaultOriginId,
303-
...props.defaultBehavior,
304-
}),
308+
defaultCacheBehavior: {
309+
...this._renderDefaultCacheBehavior({
310+
pathPattern: "*", // ignored for Default Cache Behavior
311+
targetOriginId: defaultOriginId,
312+
...props.defaultBehavior,
313+
// rendered lazily below so associations pushed onto a caller-held
314+
// array *after* construction are still picked up at synth time
315+
functionAssociations: undefined,
316+
}),
317+
functionAssociation: Lazy.anyValue(
318+
{
319+
produce: () =>
320+
this.renderFunctionAssociations(
321+
props.defaultBehavior.functionAssociations,
322+
)?.map((fa) =>
323+
// Lazy producers need additional xxxToTerraform wrap
324+
cloudfrontDistribution.cloudfrontDistributionDefaultCacheBehaviorFunctionAssociationToTerraform(
325+
fa,
326+
),
327+
),
328+
},
329+
{ omitEmptyArray: true },
330+
),
331+
},
305332
orderedCacheBehavior: Lazy.anyValue(
306333
{
307334
produce: () =>
@@ -480,9 +507,67 @@ export class Distribution extends AwsConstructBase implements IDistribution {
480507
smoothStreaming: props.smoothStreaming,
481508
viewerProtocolPolicy:
482509
props.viewerProtocolPolicy ?? ViewerProtocolPolicy.ALLOW_ALL,
510+
functionAssociation: this.renderFunctionAssociations(
511+
props.functionAssociations,
512+
),
483513
};
484514
}
485515

516+
/**
517+
* Renders the `functionAssociation` blocks for a cache behavior from the
518+
* given `FunctionAssociation`s.
519+
*
520+
* CloudFront allows at most one function association per `FunctionEventType`
521+
* for each cache behavior.
522+
*
523+
* @internal
524+
*/
525+
private renderFunctionAssociations(
526+
functionAssociations?: FunctionAssociation[],
527+
):
528+
| cloudfrontDistribution.CloudfrontDistributionDefaultCacheBehaviorFunctionAssociation[]
529+
| undefined {
530+
if (!functionAssociations || functionAssociations.length === 0) {
531+
return undefined;
532+
}
533+
const eventTypes = new Set<FunctionEventType>();
534+
for (const fa of functionAssociations) {
535+
if (eventTypes.has(fa.eventType)) {
536+
throw new Error(
537+
`Only one function association is allowed per event type, got multiple for event type ${fa.eventType}`,
538+
);
539+
}
540+
eventTypes.add(fa.eventType);
541+
// Only locally-created `Function`s are verifiable here - imported/general
542+
// `IFunction` implementations may or may not be published, so leave them
543+
// alone. CloudFront only allows LIVE-stage (published) functions to be
544+
// associated with a distribution's cache behaviors.
545+
// Lazy producers resolve more than once per synth (prepareStack +
546+
// final render), so dedupe to avoid stacking identical warnings on
547+
// this node's metadata.
548+
if (
549+
CloudFrontFunction.isFunction(fa.function) &&
550+
!fa.function._autoPublish &&
551+
!fa.skipPublishCheck &&
552+
!this.warnedUnpublishedFunctions.has(fa.function.node.path)
553+
) {
554+
this.warnedUnpublishedFunctions.add(fa.function.node.path);
555+
// TODO(https://github.com/TerraConstructs/base/issues/161): switch to
556+
// Annotations.addWarningV2()/acknowledgeWarning() once the Annotations
557+
// facade lands, using the id prefix below as the warning's stable id.
558+
Annotations.of(this).addWarning(
559+
`[terraconstructs/aws-edge:unpublishedFunctionAssociation] Function '${fa.function.node.path}' is associated with a cache behavior but was created with autoPublish: false; ` +
560+
"CloudFront only allows LIVE-stage functions in cache behaviors, so this will fail at apply time unless the function is published out of band. " +
561+
"Set skipPublishCheck: true on the association to acknowledge.",
562+
);
563+
}
564+
}
565+
return functionAssociations.map((fa) => ({
566+
eventType: fa.eventType,
567+
functionArn: fa.function.functionArn,
568+
}));
569+
}
570+
486571
private renderRestrictions(geoRestriction?: GeoRestriction) {
487572
return geoRestriction
488573
? {

0 commit comments

Comments
 (0)