Skip to content

fix: render Distribution functionAssociations; grant lambda:InvokeFunction for public Function URLs - #159

Merged
so0k merged 5 commits into
mainfrom
fix/edge-function-associations-and-function-url
Aug 16, 2026
Merged

fix: render Distribution functionAssociations; grant lambda:InvokeFunction for public Function URLs#159
so0k merged 5 commits into
mainfrom
fix/edge-function-associations-and-function-url

Conversation

@so0k

@so0k so0k commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #99, fixes #50, fixes #98.

edge: render functionAssociations and survive function replacement (#99, #50)

  • Distribution._renderDefaultCacheBehavior now renders functionAssociations into the provider's function_association blocks (event_type + function_arn). Since default and ordered cache behaviors share this render path, the fix covers defaultBehavior, additionalBehaviors, and addBehavior() alike.
  • Added synth-time validation: at most one function association per FunctionEventType per behavior (CloudFront's actual constraint).
  • edge.Function now sets lifecycle { create_before_destroy = true } on the aws_cloudfront_function resource, so a name-forced replacement no longer fails with FunctionInUse 409 while attached to a distribution (per the workaround note on Distribution: functionAssociations not rendered to Terraform JSON #99).
  • The ${$${ escaping issue from aws/edge: CDN FunctionAssociation does not work #50 was already fixed on main for FunctionCode.fromFile (since v0.1.0); this PR adds regression tests for it, including a synth-level assertion that the escaped code survives into cdk.tf.json. fromInline intentionally does not escape (inline code is often built from cdktn tokens); its doc comment now spells out the $${ escaping rule for literal ${.

compute: public Function URLs no longer 403 (#98)

  • FunctionUrl with authType: NONE now adds the second required permission — lambda:InvokeFunction — alongside lambda:InvokeFunctionUrl.
  • Better than the unconditioned grant suggested in the issue: the provider's aws_lambda_permission exposes invoked_via_function_url, which maps 1:1 to the lambda:InvokedViaFunctionUrl condition key the AWS Console uses in its FunctionURLInvokeAllowPublicAccess statement. The new grant is therefore scoped to invocations arriving via the Function URL only. Exposed as a new optional invokedViaFunctionUrl prop on Permission.

Note: #98 is unrelated to the two edge issues but was bundled per request; it lives in its own commit.

Tests

  • Unit: new test/aws/edge/function.test.ts (escaping regression incl. synth-level, create_before_destroy), extended test/aws/edge/distribution.test.ts (function_association rendered on both default and ordered behaviors, duplicate-event-type validation on both eager and lazy paths), new test/aws/compute/function-url.test.ts (both permissions for NONE, none for AWS_IAM, alias-qualified URL).
  • Integ: new integ/aws/edge/apps/distribution-function.ts + TestDistributionFunction (ported from upstream aws-cdk integ.distribution-function.ts). It asserts the deployed distribution's DefaultCacheBehavior.FunctionAssociations via the CloudFront API — the assertion that would have caught Distribution: functionAssociations not rendered to Terraform JSON #99 — plus a TestFunction invocation of the associated function.
  • Live runs: make nodejs-function-url (validates FunctionUrl with AuthType.NONE missing lambda:InvokeFunction permission — 403 on all Function URLs #98 end-to-end: public URL returns 200) and make distribution-function — results below.

Live integ results (account 694710432912, us-east-1, 2026-08-16):

--- PASS: TestNodeJsFunctionUrl (139.09s)
--- PASS: TestDistributionFunction (429.54s)

Follow-up found during review

Alias.functionArn returns the alias invoke_arn (the API-Gateway-style invocation ARN) instead of the alias ARN, which makes any addPermission() on an Alias emit an invalid aws_lambda_permission.function_name. Pre-existing, not addressed here; filed separately.

so0k added 2 commits August 16, 2026 13:21
…s before destroy

Distribution silently dropped functionAssociations: the prop was declared
but never rendered into the aws_cloudfront_distribution function_association
blocks, for the default and ordered cache behaviors alike. Render it, and
validate at most one association per event type per behavior.

Set create_before_destroy on aws_cloudfront_function so a name-forced
replacement no longer fails with FunctionInUse while the function is
attached to a distribution.

Add regression tests for FunctionCode.fromFile ${ -> $${ escaping
(including a synth-level assertion) and document that fromInline
intentionally passes ${ through for token interpolation.

Port the upstream aws-cdk integ.distribution-function.ts integration test;
it asserts the deployed distribution's FunctionAssociations via the
CloudFront API. Verified live: TestDistributionFunction passed.

Closes #99
Closes #50
FunctionUrl with authType NONE only added lambda:InvokeFunctionUrl, so
every public Function URL returned 403 AccessDeniedException. AWS requires
a second lambda:InvokeFunction statement (the console's
FunctionURLInvokeAllowPublicAccess).

The grant is scoped with the lambda:InvokedViaFunctionUrl condition key via
the provider's invoked_via_function_url argument, exposed as a new optional
invokedViaFunctionUrl prop on Permission, so it only applies to invocations
arriving through the Function URL.

Verified live: TestNodeJsFunctionUrl passed (public URL returns 200).

Closes #98
Comment thread src/aws/edge/distribution.ts
@sakul-learning

Copy link
Copy Markdown
Contributor

Review findings with reproduction steps

1. Distribution snapshots default-behavior functionAssociations during construction

src/aws/edge/distribution.ts constructs defaultCacheBehavior immediately, and renderFunctionAssociations() maps the supplied array to a new provider array at that point. If an association is attached to the caller-owned array after the Distribution constructor finishes, the synthesized default cache behavior silently omits it.

Minimal reproduction:

const associations: edge.FunctionAssociation[] = [];
const fn = new edge.Function(stack, "ViewerRequestFn", {
  nameSuffix: "viewer-request",
  code: edge.FunctionCode.fromInline("function handler(event) { return event.request; }"),
});

new edge.Distribution(stack, "Distribution", {
  defaultBehavior: {
    origin,
    functionAssociations: associations,
  },
});

// Attach after Distribution construction.
associations.push({
  function: fn,
  eventType: edge.FunctionEventType.VIEWER_REQUEST,
});

const synthesized = Testing.synth(stack);

Expected: default_cache_behavior.function_association contains the viewer-request function.

Actual at this head: the block is absent because renderFunctionAssociations([]) already returned undefined during construction.

distribution.addBehavior() is not affected: ordered behaviors are read from additionalBehaviors by the existing lazy producer at synthesis. The focused fix can defer only the default behavior's nested functionAssociation value. Per #116, the lazy producer must invoke the generated cloudfrontDistributionDefaultCacheBehaviorFunctionAssociationToTerraform() mapper so nested camelCase fields become Terraform's event_type and function_arn keys.

A durable regression test can construct with an empty association array, push after construction, synthesize, and assert the two snake_case fields.

2. Function({ autoPublish: false }) can be associated even though it never reaches LIVE

src/aws/edge/function.ts forwards autoPublish: false to aws_cloudfront_function.publish, while Distribution.renderFunctionAssociations() unconditionally emits that function's ARN. This produces a configuration that synthesizes successfully but cannot be applied: CloudFront only permits cache behaviors to reference functions in the LIVE stage.

Minimal reproduction:

const fn = new edge.Function(stack, "DevelopmentFn", {
  nameSuffix: "development-only",
  autoPublish: false,
  code: edge.FunctionCode.fromInline("function handler(event) { return event.request; }"),
});

new edge.Distribution(stack, "Distribution", {
  defaultBehavior: {
    origin,
    functionAssociations: [{
      function: fn,
      eventType: edge.FunctionEventType.VIEWER_REQUEST,
    }],
  },
});

const synthesized = Testing.synth(stack);

The synthesized output contains both:

  • aws_cloudfront_function.publish = false, leaving the function in DEVELOPMENT; and
  • a distribution function_association that references that function.

Applying that output asks AWS to associate a non-LIVE function. AWS documents the constraint here: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/publish-function.html

Please fail fast when a locally created edge.Function is known to have autoPublish: false, while leaving imported/general IFunction publication state as inherently unverifiable. A regression test should assert that the detectable local combination is rejected before deployment.

…ject unpublished functions

Address PR #159 review findings:

- The default cache behavior's function_association is now produced via
  Lazy.anyValue so associations pushed onto a caller-held array after the
  Distribution constructor are still rendered at synth. The producer wraps
  each element with the generated ...FunctionAssociationToTerraform mapper
  (lazy tokens bypass the struct mapper, see #116). Duplicate-event-type
  validation for the default behavior consequently moves to synth time.

- Associating a locally-created edge.Function with autoPublish: false now
  fails fast at synth for both default and ordered behaviors: CloudFront
  only allows LIVE-stage functions in cache behaviors, so the config would
  synth fine and fail at apply. Imported/general IFunction implementations
  are unverifiable and left alone. Exposed via a jsii-internal _autoPublish
  member on Function.

Re-verified live: TestDistributionFunction passed after the change.
@so0k

so0k commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Both findings addressed in 27d35c0.

1. Eager default-behavior rendering (this thread + the constructor comment): default_cache_behavior.function_association is now produced via Lazy.anyValue, so associations pushed onto a caller-held array after the Distribution constructor completes are picked up at synth. Per #116, the producer wraps each element with the generated cloudfrontDistributionDefaultCacheBehaviorFunctionAssociationToTerraform() mapper so the nested keys reach Terraform as event_type/function_arn (the ordered-behavior path needs no change — its whole-behavior mapper already recurses into function_association). Regression test added exactly as suggested: construct with an empty caller-held array, push after construction, synth, assert both snake_case fields. Consequence: duplicate-event-type validation for the default behavior now surfaces at synth instead of construction (tests updated accordingly).

2. autoPublish: false + association: now fails fast at synth for locally-created edge.Functions, for both default and ordered behaviors, with an error naming the construct path and explaining CloudFront's LIVE-stage requirement. Imported/general IFunction implementations are left alone as inherently unverifiable (note: edge.Function has no static import method yet — the test covers this with a structural IFunction; worth revisiting when fromLookup/fromAttributes lands). The flag is exposed via a jsii-@internal _autoPublish member, following the _logGroup precedent in compute/function.ts.

One deliberate trade-off to flag: the guard is a hard synth error with no escape hatch, so a user who sets autoPublish: false and publishes out-of-band (CLI/external automation) can no longer associate that function. That matches the "fail fast on the detectable local combination" ask; if an opt-out is ever wanted, it should be an explicit skipPublishCheck-style flag rather than loosening the default. Since this turns a previously-synthesizing config into a synth-time error, it may deserve a release-note line.

Re-verified live after the change: TestDistributionFunction PASS (361.8s, account 694710432912, us-east-1).

Comment thread src/aws/edge/distribution.ts Outdated
Comment thread test/aws/edge/distribution.test.ts
@sakul-learning

Copy link
Copy Markdown
Contributor

I need to correct my earlier request on src/aws/edge/distribution.ts: I asked for a hard synth-time rejection whenever a locally created edge.Function has autoPublish: false. Apologies — based on vincenthsh's comments about separately managed function stages, that requirement was too strong and I was wrong to prescribe it as the fix.

The current check infers the function's eventual LIVE state solely from the L2's autoPublish setting. That does not account for cases where publication/stage ownership is intentionally managed elsewhere in the infrastructure or delivery process and the distribution is associated only after the function is confirmed LIVE.

I also want to narrow my subsequent example: “create the function, manually publish it with the AWS CLI, then associate it” is not a realistic default IaC workflow and should not be normalized as an ordinary silent path. A separately managed function stage should be treated as exceptional and made visible to the consumer—whether through an explicit acknowledgment of that ownership, a warning, or another deliberate contract.

Rather than prescribing the mechanism, could you please reconsider this part of the implementation around the broader stage-lifecycle cases raised by vincenthsh: publication performed by another resource or pipeline, and distributions created only after the function is known to be LIVE? The API should prevent accidental association of an unpublished function without ruling out intentionally separate stage management.

…ledgeable warning

Per review, the hard synth failure was too restrictive for intentionally
separate stage management (publication by a pipeline or a later apply,
distribution created only after the function is LIVE).

Associating a locally-created edge.Function with autoPublish: false now
emits an Annotations warning with the stable id prefix
[terraconstructs/aws-edge:unpublishedFunctionAssociation] instead of
throwing. Consumers acknowledge intentional out-of-band publication with a
new skipPublishCheck flag on the association (skipPermissions-style
contract); the acknowledgment is per association, so other accidental
unpublished associations still warn. Imported functions remain unchecked.

Warnings emitted inside the Lazy behavior producers are captured by the
synth manifest (prepareStack resolves lazies before annotation collection;
verified empirically) and deduped across the double resolution pass.

TODO(#161) tracks migrating to an id-based addWarningV2/acknowledgeWarning
Annotations facade.
@so0k

so0k commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Reworked in 4d78a07 — the hard failure is gone, replaced by an acknowledgeable warning.

New behavior: associating a locally-created edge.Function with autoPublish: false now emits a construct-tree warning (Annotations.addWarning) prefixed with a stable id — [terraconstructs/aws-edge:unpublishedFunctionAssociation] — instead of failing synth. Consumers who intentionally manage publication elsewhere acknowledge it per association:

functionAssociations: [{
  function: fn,
  eventType: FunctionEventType.VIEWER_REQUEST,
  skipPublishCheck: true, // publication managed out-of-band
}]

The acknowledgment is deliberately per-association (contract modeled on skipPermissions): a different unpublished function accidentally associated later still warns. Imported IFunctions remain unchecked/unwarned. Duplicate-event-type stays a hard error.

On "could another resource in the construct tree publish it?" — verified against the provider (aws 6.58.0): there is no separate publish resource (aws_cloudfront_function carries the only publish argument; no aws_cloudfront_function_publish/version/association resource exists). So in-tree publication by another resource isn't expressible in Terraform today. The realistic separate-stage paths are: flipping autoPublish on the same construct in a later apply, out-of-band publication (aws cloudfront publish-function from a pipeline, or a terraform_data local-exec), a split-stack ordering where the distribution deploys after the function is confirmed LIVE, or importing the already-published function (which the check already exempts). skipPublishCheck is the acknowledgment for all of these. For reference, upstream aws-cdk exposes autoPublish and performs no validation or warning at all on association.

Why not acknowledgeWarning-style suppression: cdktn's Annotations is a pre-V2 copy of aws-cdk core — addWarning/addInfo/addError only, no id-based warnings, no acknowledgment mechanism (addError is escapable only globally via CDKTF_CONTINUE_SYNTH_ON_ERROR_ANNOTATIONS). Filed #161 to port upstream's addWarningV2/acknowledgeWarning shape into a TerraConstructs Annotations façade (it would also retire the four existing addWarningV2 TODOs in ecr-repository.ts/iam/role.ts); the warning here carries the stable id prefix so migrating it is mechanical once that lands.

Implementation notes: warnings are emitted inside the lazy behavior producers and land reliably in the synth manifest (prepareStack resolves lazies before the synthesizer's annotation-collection walk — verified empirically with a standalone App.synth() probe), deduped across the double resolution pass, and covered by tests for: default + ordered behaviors, late-pushed associations, per-association suppression (mixed acknowledged/unacknowledged case), and the no-warning cases.

Comment thread src/aws/edge/function.ts
Comment thread src/aws/edge/distribution.ts Outdated
The unpublished-function check used `fa.function instanceof Function`,
which silently skips the warning when the associated function comes from a
duplicate installed copy of this library. Add the repo's cross-package
runtime-identification pattern (AwsStack.isAwsStack / Role.isRole):
a Symbol.for marker on Function.prototype and a static Function.isFunction
type guard, and use it in Distribution.renderFunctionAssociations.

@sakul-learning sakul-learning left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at f9a23d1.

src/aws/edge/distribution.ts now lazily renders default-cache functionAssociations through the generated provider mapper, so associations attached after construction are retained with the required event_type and function_arn fields. Duplicate event types remain rejected. Locally created unpublished functions produce an acknowledgeable warning rather than blocking separately managed publication, and Function.isFunction() now uses the repository’s Symbol.for(...) marker pattern so that advisory check survives compatible duplicate package copies.

The public Function URL path also grants the required lambda:InvokeFunction permission while restricting it to invocations through the Function URL.

The focused distribution suite passed 16/16 tests with 3/3 snapshots. JSII compilation and ESLint both pass. No blocking correctness, security, compatibility, or artifact-value findings remain.

@so0k
so0k merged commit c2bd9c7 into main Aug 16, 2026
13 checks passed
@so0k
so0k deleted the fix/edge-function-associations-and-function-url branch August 16, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants