canopycms-cdk 0.0.66-int.83 → 0.0.66-int.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constructs/asset-support.d.ts +60 -3
- package/dist/constructs/asset-support.js +90 -7
- package/dist/constructs/cms-distribution.d.ts +9 -3
- package/dist/constructs/cms-distribution.js +16 -1
- package/dist/constructs/cms-service.d.ts +33 -1
- package/dist/constructs/cms-service.js +12 -0
- package/dist/constructs/lambda-execution-role.d.ts +60 -0
- package/dist/constructs/lambda-execution-role.js +90 -0
- package/package.json +6 -6
|
@@ -96,6 +96,32 @@ export interface AssetSupportProps {
|
|
|
96
96
|
* never affected.
|
|
97
97
|
*/
|
|
98
98
|
readonly transformOutputRetention?: Duration;
|
|
99
|
+
/**
|
|
100
|
+
* Execution role for the transform Lambda (default: CDK creates one).
|
|
101
|
+
*
|
|
102
|
+
* Set this when the role's ARN has to be computable WITHOUT a reference to
|
|
103
|
+
* this construct - the case that motivated the prop is an asset bucket in a
|
|
104
|
+
* different AWS account from the compute, where the resource-policy half of
|
|
105
|
+
* the cross-account grant must be written in the bucket's own stack and needs
|
|
106
|
+
* the principal as a plain string. Reading `transformFunction.role` across an
|
|
107
|
+
* account boundary does not give you that: CDK emits `Fn::GetStackOutput`, a
|
|
108
|
+
* CDK-CLI-only intrinsic resolved at deploy time, so the coupling is
|
|
109
|
+
* invisible to CloudFormation and unusable by any deploy path that is not
|
|
110
|
+
* `cdk deploy`. Create a deterministically NAMED role instead and both stacks
|
|
111
|
+
* can compute `arn:aws:iam::<account>:role/<name>` from literals, with
|
|
112
|
+
* nothing crossing between them.
|
|
113
|
+
*
|
|
114
|
+
* A named IAM role means the consuming stack needs `CAPABILITY_NAMED_IAM`,
|
|
115
|
+
* and cannot be replaced in place without a rename - that trade is yours to
|
|
116
|
+
* make here, which is the point of taking a role rather than a name.
|
|
117
|
+
*
|
|
118
|
+
* `iam.Role`, not `iam.IRole`, ON PURPOSE - an imported role silently
|
|
119
|
+
* discards the managed policies this construct has to re-attach. See
|
|
120
|
+
* `attachLambdaExecutionPolicies` (./lambda-execution-role) for what CDK
|
|
121
|
+
* drops when a role is passed, and why the narrower type is what makes the
|
|
122
|
+
* compensation reliable.
|
|
123
|
+
*/
|
|
124
|
+
readonly transformRole?: iam.Role;
|
|
99
125
|
/**
|
|
100
126
|
* Name for the transform Lambda's CloudWatch log group (default:
|
|
101
127
|
* `/canopycms/<stackName>/transform`). Deliberately NOT
|
|
@@ -217,13 +243,17 @@ export declare class AssetSupport extends Construct {
|
|
|
217
243
|
/** Effective advisory upload-size cap (see `AssetSupportProps.maxUploadBytes`). */
|
|
218
244
|
readonly maxUploadBytes: number;
|
|
219
245
|
private readonly behaviors;
|
|
220
|
-
/** Distributions `attachTo` has already wired -- see that method's duplicate check. */
|
|
221
|
-
private readonly attachedDistributions;
|
|
222
246
|
constructor(scope: Construct, id: string, props: AssetSupportProps);
|
|
223
247
|
private buildBehaviors;
|
|
224
248
|
/**
|
|
225
249
|
* The two CloudFront behavior configs this system needs.
|
|
226
250
|
*
|
|
251
|
+
* If you use this rather than `attachTo` -- which now takes an `overrides`
|
|
252
|
+
* parameter, so needing per-behavior options is no longer a reason to fall
|
|
253
|
+
* back here -- you own the ordering, and you should assert it: read the
|
|
254
|
+
* SYNTHESIZED template's `CacheBehaviors` array index, not your own source
|
|
255
|
+
* object, since the property is about emitted order.
|
|
256
|
+
*
|
|
227
257
|
* Prefer `attachTo(distribution)` or `CanopyCmsDistribution`'s
|
|
228
258
|
* `assetSupport` prop, which attach these to a distribution in the only
|
|
229
259
|
* safe order automatically. This method exists as the escape hatch for a
|
|
@@ -250,6 +280,33 @@ export declare class AssetSupport extends Construct {
|
|
|
250
280
|
* 0x2A, `t` = 0x74), so alphabetizing the keys reproduces exactly this
|
|
251
281
|
* failure, with no synth or deploy error to catch it.
|
|
252
282
|
*
|
|
283
|
+
* `overrides` is merged into BOTH behaviors, and exists because without it
|
|
284
|
+
* this method is unusable by exactly the adopters who most need the ordering
|
|
285
|
+
* guarantee. A distribution that runs a viewer-request function on every
|
|
286
|
+
* behavior - tier basic-auth, most commonly - needs the asset behaviors to
|
|
287
|
+
* carry that same `functionAssociations`, or `/assets/*` is anonymously
|
|
288
|
+
* readable on an authenticated tier. Before this parameter existed such an
|
|
289
|
+
* adopter had to fall back to `assetBehaviors()` plus two hand-ordered
|
|
290
|
+
* `addBehavior` calls: the precise shape this method was added to eliminate,
|
|
291
|
+
* re-entered while believing ordering was handled upstream, which is worse
|
|
292
|
+
* than never having had the method. (`responseHeadersPolicy` is the same
|
|
293
|
+
* story for a repo with a shared security-headers policy.)
|
|
294
|
+
*
|
|
295
|
+
* Merged into both rather than per-behavior on purpose: applying one set to
|
|
296
|
+
* both is what preserves the ordering guarantee as the only thing this
|
|
297
|
+
* method decides. A caller who genuinely needs the two behaviors to differ
|
|
298
|
+
* has left this method's remit and should use `assetBehaviors()` - and keep
|
|
299
|
+
* their own ordering assertion.
|
|
300
|
+
*
|
|
301
|
+
* Typed `Partial<AddBehaviorOptions>` because that is exactly what
|
|
302
|
+
* `addBehavior(pattern, origin, behaviorOptions?)` accepts - `origin` is a
|
|
303
|
+
* POSITIONAL argument there, so `BehaviorOptions` (which is
|
|
304
|
+
* `AddBehaviorOptions` plus `origin`) would let a caller pass a key the call
|
|
305
|
+
* silently ignores. Measured, because the first version of this comment
|
|
306
|
+
* claimed the opposite: widening the parameter and passing an `origin`
|
|
307
|
+
* override changes nothing in the emitted template, so this narrowing
|
|
308
|
+
* prevents a confusing no-op rather than a broken origin group.
|
|
309
|
+
*
|
|
253
310
|
* Needs a concrete `cloudfront.Distribution` - `addBehavior` is an instance
|
|
254
311
|
* method on that class, not on `IDistribution` (what an imported/looked-up
|
|
255
312
|
* distribution reference gives you). For a distribution built entirely
|
|
@@ -258,7 +315,7 @@ export declare class AssetSupport extends Construct {
|
|
|
258
315
|
* directly instead and list `/assets/t/*` before `/assets/*` yourself - see
|
|
259
316
|
* `AssetCloudFrontBehaviors`'s doc comment.
|
|
260
317
|
*/
|
|
261
|
-
attachTo(distribution: cloudfront.Distribution): void;
|
|
318
|
+
attachTo(distribution: cloudfront.Distribution, overrides?: Partial<cloudfront.AddBehaviorOptions>): void;
|
|
262
319
|
/**
|
|
263
320
|
* Grant the CMS/editor principal (the CMS Lambda's role, typically) the
|
|
264
321
|
* exact permissions `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts)
|
|
@@ -3,6 +3,7 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { Construct } from 'constructs';
|
|
5
5
|
import { Duration, RemovalPolicy, Stack, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3, } from 'aws-cdk-lib';
|
|
6
|
+
import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
|
|
6
7
|
// This package (`canopycms-cdk`) is `"type": "module"`, so its compiled
|
|
7
8
|
// output is real ESM - `__dirname` is not a global there. Derive it from
|
|
8
9
|
// `import.meta.url` instead (mirrors ../../lambda/asset-transform/build.mjs).
|
|
@@ -111,6 +112,24 @@ const TRANSFORM_OUTPUT_RETENTION = Duration.days(180);
|
|
|
111
112
|
const TRANSFORM_CACHE_MIN_TTL = Duration.seconds(0);
|
|
112
113
|
const TRANSFORM_CACHE_DEFAULT_TTL = Duration.days(1);
|
|
113
114
|
const TRANSFORM_CACHE_MAX_TTL = Duration.days(365);
|
|
115
|
+
/**
|
|
116
|
+
* Distributions that already carry the asset behaviors, tracked at MODULE level
|
|
117
|
+
* rather than per instance.
|
|
118
|
+
*
|
|
119
|
+
* Per-instance (the first version of this) only caught the same `AssetSupport`
|
|
120
|
+
* attaching twice. Two `AssetSupport` constructs attaching to one distribution
|
|
121
|
+
* slipped through and synthesized
|
|
122
|
+
* ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'] - the identical
|
|
123
|
+
* duplicate-path-pattern deploy failure the guard exists to convert into a
|
|
124
|
+
* synth error. There is no legitimate form of that: both instances attach the
|
|
125
|
+
* same two patterns, so the second is always wrong regardless of which
|
|
126
|
+
* construct owns it.
|
|
127
|
+
*
|
|
128
|
+
* A `WeakSet` keyed on the distribution object, so it holds no reference that
|
|
129
|
+
* would outlive the construct tree and cannot leak across CDK apps in a test
|
|
130
|
+
* process.
|
|
131
|
+
*/
|
|
132
|
+
const distributionsWithAssetBehaviors = new WeakSet();
|
|
114
133
|
/**
|
|
115
134
|
* The two CloudFront path patterns the asset system's behaviors are keyed
|
|
116
135
|
* under, derived from `PREFIXES` above (the single source of truth for these
|
|
@@ -156,8 +175,6 @@ export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
|
|
|
156
175
|
export class AssetSupport extends Construct {
|
|
157
176
|
constructor(scope, id, props) {
|
|
158
177
|
super(scope, id);
|
|
159
|
-
/** Distributions `attachTo` has already wired -- see that method's duplicate check. */
|
|
160
|
-
this.attachedDistributions = new WeakSet();
|
|
161
178
|
// Fail closed before anything else: refuse to build a stack around a
|
|
162
179
|
// transform Lambda whose code asset was never verified to contain the
|
|
163
180
|
// linux/arm64 sharp binary. Without this, `pnpm test` (whose
|
|
@@ -241,7 +258,18 @@ export class AssetSupport extends Construct {
|
|
|
241
258
|
retention: props.transformLogRetention ?? logs.RetentionDays.THREE_MONTHS,
|
|
242
259
|
removalPolicy: RemovalPolicy.DESTROY,
|
|
243
260
|
});
|
|
261
|
+
// Re-attach what CDK silently drops for a caller-supplied role. MUST run
|
|
262
|
+
// for every passed role - see that function's doc comment. This function
|
|
263
|
+
// is not VPC-attached, so it takes basic execution only; the test suite
|
|
264
|
+
// pins the absence of the VPC-ENI policy here specifically, so that this
|
|
265
|
+
// call site and the CMS Lambda's cannot be collapsed into one.
|
|
266
|
+
if (props.transformRole) {
|
|
267
|
+
attachLambdaExecutionPolicies(props.transformRole, { vpc: false });
|
|
268
|
+
}
|
|
244
269
|
this.transformFunction = new lambda.Function(this, 'TransformFunction', {
|
|
270
|
+
// Default (unset) leaves CDK to create the execution role, with its own
|
|
271
|
+
// managed policies intact. See `transformRole`'s doc comment.
|
|
272
|
+
role: props.transformRole,
|
|
245
273
|
// Built by `pnpm run build:lambda` (lambda/asset-transform/build.mjs) -
|
|
246
274
|
// esbuild bundle + a real linux/arm64 `npm install sharp` alongside it,
|
|
247
275
|
// no Docker. `cdk synth`/`deploy` need that script run first; it is
|
|
@@ -346,6 +374,12 @@ export class AssetSupport extends Construct {
|
|
|
346
374
|
/**
|
|
347
375
|
* The two CloudFront behavior configs this system needs.
|
|
348
376
|
*
|
|
377
|
+
* If you use this rather than `attachTo` -- which now takes an `overrides`
|
|
378
|
+
* parameter, so needing per-behavior options is no longer a reason to fall
|
|
379
|
+
* back here -- you own the ordering, and you should assert it: read the
|
|
380
|
+
* SYNTHESIZED template's `CacheBehaviors` array index, not your own source
|
|
381
|
+
* object, since the property is about emitted order.
|
|
382
|
+
*
|
|
349
383
|
* Prefer `attachTo(distribution)` or `CanopyCmsDistribution`'s
|
|
350
384
|
* `assetSupport` prop, which attach these to a distribution in the only
|
|
351
385
|
* safe order automatically. This method exists as the escape hatch for a
|
|
@@ -374,6 +408,33 @@ export class AssetSupport extends Construct {
|
|
|
374
408
|
* 0x2A, `t` = 0x74), so alphabetizing the keys reproduces exactly this
|
|
375
409
|
* failure, with no synth or deploy error to catch it.
|
|
376
410
|
*
|
|
411
|
+
* `overrides` is merged into BOTH behaviors, and exists because without it
|
|
412
|
+
* this method is unusable by exactly the adopters who most need the ordering
|
|
413
|
+
* guarantee. A distribution that runs a viewer-request function on every
|
|
414
|
+
* behavior - tier basic-auth, most commonly - needs the asset behaviors to
|
|
415
|
+
* carry that same `functionAssociations`, or `/assets/*` is anonymously
|
|
416
|
+
* readable on an authenticated tier. Before this parameter existed such an
|
|
417
|
+
* adopter had to fall back to `assetBehaviors()` plus two hand-ordered
|
|
418
|
+
* `addBehavior` calls: the precise shape this method was added to eliminate,
|
|
419
|
+
* re-entered while believing ordering was handled upstream, which is worse
|
|
420
|
+
* than never having had the method. (`responseHeadersPolicy` is the same
|
|
421
|
+
* story for a repo with a shared security-headers policy.)
|
|
422
|
+
*
|
|
423
|
+
* Merged into both rather than per-behavior on purpose: applying one set to
|
|
424
|
+
* both is what preserves the ordering guarantee as the only thing this
|
|
425
|
+
* method decides. A caller who genuinely needs the two behaviors to differ
|
|
426
|
+
* has left this method's remit and should use `assetBehaviors()` - and keep
|
|
427
|
+
* their own ordering assertion.
|
|
428
|
+
*
|
|
429
|
+
* Typed `Partial<AddBehaviorOptions>` because that is exactly what
|
|
430
|
+
* `addBehavior(pattern, origin, behaviorOptions?)` accepts - `origin` is a
|
|
431
|
+
* POSITIONAL argument there, so `BehaviorOptions` (which is
|
|
432
|
+
* `AddBehaviorOptions` plus `origin`) would let a caller pass a key the call
|
|
433
|
+
* silently ignores. Measured, because the first version of this comment
|
|
434
|
+
* claimed the opposite: widening the parameter and passing an `origin`
|
|
435
|
+
* override changes nothing in the emitted template, so this narrowing
|
|
436
|
+
* prevents a confusing no-op rather than a broken origin group.
|
|
437
|
+
*
|
|
377
438
|
* Needs a concrete `cloudfront.Distribution` - `addBehavior` is an instance
|
|
378
439
|
* method on that class, not on `IDistribution` (what an imported/looked-up
|
|
379
440
|
* distribution reference gives you). For a distribution built entirely
|
|
@@ -382,7 +443,7 @@ export class AssetSupport extends Construct {
|
|
|
382
443
|
* directly instead and list `/assets/t/*` before `/assets/*` yourself - see
|
|
383
444
|
* `AssetCloudFrontBehaviors`'s doc comment.
|
|
384
445
|
*/
|
|
385
|
-
attachTo(distribution) {
|
|
446
|
+
attachTo(distribution, overrides) {
|
|
386
447
|
// `addBehavior` does not dedupe, and these calls bypass
|
|
387
448
|
// `CanopyCmsDistribution`'s own synth-time guard because they run after
|
|
388
449
|
// that distribution is constructed. So attaching twice -- passing the
|
|
@@ -391,18 +452,40 @@ export class AssetSupport extends Construct {
|
|
|
391
452
|
// ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'] and fails at
|
|
392
453
|
// deploy time when CloudFront rejects the duplicate patterns. Refuse it
|
|
393
454
|
// here instead, where the message can say which two routes collided.
|
|
394
|
-
if (
|
|
455
|
+
if (distributionsWithAssetBehaviors.has(distribution)) {
|
|
395
456
|
throw new Error(`AssetSupport: attachTo() was already called for this distribution. Each pattern ` +
|
|
396
457
|
`would be attached twice and CloudFront rejects duplicate path patterns at deploy ` +
|
|
397
458
|
`time. This usually means the distribution was given CanopyCmsDistribution's ` +
|
|
398
459
|
`\`assetSupport\` prop (which calls attachTo for you) as well as an explicit ` +
|
|
399
460
|
`attachTo() call -- keep one.`);
|
|
400
461
|
}
|
|
401
|
-
|
|
462
|
+
distributionsWithAssetBehaviors.add(distribution);
|
|
463
|
+
// Drop explicitly-`undefined` keys before merging. A spread copies own
|
|
464
|
+
// enumerable keys INCLUDING ones whose value is undefined, so
|
|
465
|
+
// `{ ...transformRest, ...{ cachePolicy: undefined } }` deletes the
|
|
466
|
+
// construct's choice and lets CDK substitute its own default - which is a
|
|
467
|
+
// DIFFERENT default. Measured: `cachePolicy: undefined` swaps this
|
|
468
|
+
// behavior's custom policy (TRANSFORM_CACHE_MIN_TTL = 0, which exists
|
|
469
|
+
// solely to stop the oversized-output redirect loop documented above) for
|
|
470
|
+
// the managed CACHING_OPTIMIZED and its 1-second min TTL; and
|
|
471
|
+
// `viewerProtocolPolicy: undefined` downgrades BOTH behaviors from
|
|
472
|
+
// redirect-to-https to allow-all, serving assets over plain HTTP.
|
|
473
|
+
//
|
|
474
|
+
// This is not a contrived input - it is what forwarding optional props
|
|
475
|
+
// produces: `attachTo(dist, { cachePolicy: props.maybePolicy })` with the
|
|
476
|
+
// prop unset. It typechecks (every AddBehaviorOptions field is already
|
|
477
|
+
// optional), synthesizes and deploys clean.
|
|
478
|
+
const definedOverrides = Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value !== undefined));
|
|
402
479
|
const { origin: transformOrigin, ...transformRest } = this.behaviors.assetsTransform;
|
|
403
480
|
const { origin: assetsOrigin, ...assetsRest } = this.behaviors.assets;
|
|
404
|
-
distribution.addBehavior(ASSETS_TRANSFORM_PATH_PATTERN, transformOrigin,
|
|
405
|
-
|
|
481
|
+
distribution.addBehavior(ASSETS_TRANSFORM_PATH_PATTERN, transformOrigin, {
|
|
482
|
+
...transformRest,
|
|
483
|
+
...definedOverrides,
|
|
484
|
+
});
|
|
485
|
+
distribution.addBehavior(ASSETS_PATH_PATTERN, assetsOrigin, {
|
|
486
|
+
...assetsRest,
|
|
487
|
+
...definedOverrides,
|
|
488
|
+
});
|
|
406
489
|
}
|
|
407
490
|
/**
|
|
408
491
|
* Grant the CMS/editor principal (the CMS Lambda's role, typically) the
|
|
@@ -66,9 +66,15 @@ export interface CanopyCmsDistributionProps {
|
|
|
66
66
|
* Attach `AssetSupport`'s CloudFront behaviors (`/assets/*` and
|
|
67
67
|
* `/assets/t/*`) to the distribution this construct builds, in the only
|
|
68
68
|
* safe order - see `AssetSupport.attachTo()`'s doc comment for why the
|
|
69
|
-
* order matters.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
69
|
+
* order matters. Calls `assetSupport.attachTo(distribution)` for you right
|
|
70
|
+
* after construction, with nothing to forget.
|
|
71
|
+
*
|
|
72
|
+
* NOT fully equivalent to calling `attachTo` yourself: this path cannot pass
|
|
73
|
+
* that method's `overrides` argument. If the asset behaviors need
|
|
74
|
+
* per-behavior options - a viewer-request function for tier auth being the
|
|
75
|
+
* motivating case, since without it `/assets/*` is anonymously readable on
|
|
76
|
+
* an authenticated tier - drop this prop and call
|
|
77
|
+
* `assetSupport.attachTo(this.distribution, { ... })` after construction.
|
|
72
78
|
*
|
|
73
79
|
* Prefer this over passing `assetSupport.assetBehaviors()` through
|
|
74
80
|
* `additionalBehaviors` by hand - that stays available as an escape hatch
|
|
@@ -20,7 +20,21 @@ import { ASSETS_PATH_PATTERN, ASSETS_TRANSFORM_PATH_PATTERN, ASSET_BEHAVIOR_SPRE
|
|
|
20
20
|
function mergeBehaviors(defaults, caller, attachingAssetSupport) {
|
|
21
21
|
if (!caller)
|
|
22
22
|
return defaults;
|
|
23
|
-
|
|
23
|
+
// Compare NORMALIZED keys here too. CloudFront treats a leading '/' as
|
|
24
|
+
// optional, so a caller passing `'_next/static/*'` did not displace this
|
|
25
|
+
// construct's own `'/_next/static/*'` default and both were emitted -- two
|
|
26
|
+
// behaviors CloudFront reads as the same pattern, which it rejects at
|
|
27
|
+
// deploy. Same reason the hazard checks below normalize.
|
|
28
|
+
const callerPatterns = new Set(Object.keys(caller).map(normalizePathPattern));
|
|
29
|
+
const uncollided = Object.fromEntries(Object.entries(defaults).filter(([pattern]) => !callerPatterns.has(normalizePathPattern(pattern))));
|
|
30
|
+
const normalizedCallerKeys = Object.keys(caller).map(normalizePathPattern);
|
|
31
|
+
const duplicateCallerPattern = normalizedCallerKeys.find((pattern, i) => normalizedCallerKeys.indexOf(pattern) !== i);
|
|
32
|
+
if (duplicateCallerPattern !== undefined) {
|
|
33
|
+
throw new Error(`CanopyCmsDistribution: additionalBehaviors has two keys that CloudFront reads as the ` +
|
|
34
|
+
`same path pattern (${JSON.stringify(duplicateCallerPattern)} with and without its ` +
|
|
35
|
+
`leading '/'). CloudFront rejects duplicate path patterns at deploy time. Keep one ` +
|
|
36
|
+
`spelling.`);
|
|
37
|
+
}
|
|
24
38
|
const merged = { ...uncollided, ...caller };
|
|
25
39
|
assertNoAssetBehaviorOrderingHazards(merged, attachingAssetSupport);
|
|
26
40
|
return merged;
|
|
@@ -68,6 +82,7 @@ function mergeBehaviors(defaults, caller, attachingAssetSupport) {
|
|
|
68
82
|
function normalizePathPattern(pattern) {
|
|
69
83
|
return pattern.startsWith('/') ? pattern.slice(1) : pattern;
|
|
70
84
|
}
|
|
85
|
+
/** See the three-hazard list documented above `normalizePathPattern`. */
|
|
71
86
|
function assertNoAssetBehaviorOrderingHazards(merged, attachingAssetSupport) {
|
|
72
87
|
const keys = Object.keys(merged).map(normalizePathPattern);
|
|
73
88
|
const assetsPattern = normalizePathPattern(ASSETS_PATH_PATTERN);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Construct } from 'constructs';
|
|
2
|
-
import { Duration, RemovalPolicy, aws_ec2 as ec2, aws_efs as efs, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_logs as logs } from 'aws-cdk-lib';
|
|
2
|
+
import { Duration, RemovalPolicy, aws_ec2 as ec2, aws_efs as efs, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_logs as logs } from 'aws-cdk-lib';
|
|
3
3
|
import type { IBucket } from 'aws-cdk-lib/aws-s3';
|
|
4
4
|
/**
|
|
5
5
|
* Default CMS Lambda timeout.
|
|
@@ -168,6 +168,38 @@ export interface CanopyCmsServiceProps {
|
|
|
168
168
|
* default name would collide).
|
|
169
169
|
*/
|
|
170
170
|
workerLogGroupName?: string;
|
|
171
|
+
/**
|
|
172
|
+
* Execution role for the CMS Lambda (default: CDK creates one).
|
|
173
|
+
*
|
|
174
|
+
* Set this when the role's ARN has to be computable WITHOUT a reference to
|
|
175
|
+
* this construct - the motivating case is an asset bucket in a different AWS
|
|
176
|
+
* account from the compute, where the resource-policy half of the
|
|
177
|
+
* cross-account grant must be written in the bucket's own stack and needs the
|
|
178
|
+
* principal as a plain string. Reading `lambdaFunction.role` across an
|
|
179
|
+
* account boundary does not give you that: CDK emits `Fn::GetStackOutput`, a
|
|
180
|
+
* CDK-CLI-only intrinsic resolved at deploy time, so the coupling is
|
|
181
|
+
* invisible to CloudFormation and unusable by any deploy path that is not
|
|
182
|
+
* `cdk deploy`. Create a deterministically NAMED role instead and both stacks
|
|
183
|
+
* can compute `arn:aws:iam::<account>:role/<name>` from literals, with
|
|
184
|
+
* nothing crossing between them.
|
|
185
|
+
*
|
|
186
|
+
* A named IAM role means the consuming stack needs `CAPABILITY_NAMED_IAM`,
|
|
187
|
+
* and cannot be replaced in place without a rename - that trade is yours to
|
|
188
|
+
* make here, which is the point of taking a role rather than a name.
|
|
189
|
+
*
|
|
190
|
+
* This Lambda is VPC-attached, which makes the compensation in
|
|
191
|
+
* `attachLambdaExecutionPolicies` (./lambda-execution-role) load-bearing
|
|
192
|
+
* rather than cosmetic: CDK discards `AWSLambdaVPCAccessExecutionRole` for a
|
|
193
|
+
* passed role, and without it the function cannot create ENIs and so cannot
|
|
194
|
+
* start - after synthesizing and deploying clean. The construct re-attaches
|
|
195
|
+
* it, which is also why this is `iam.Role` and not `iam.IRole` (an imported
|
|
196
|
+
* role would drop it again, silently). Everything else survives a passed role
|
|
197
|
+
* untouched: the EFS access-point statements, the `cmsLogGroup` write grant
|
|
198
|
+
* and the `assetBucket` grants all land on it.
|
|
199
|
+
*
|
|
200
|
+
* The EC2 worker has its own instance role and is unaffected by this prop.
|
|
201
|
+
*/
|
|
202
|
+
lambdaRole?: iam.Role;
|
|
171
203
|
/**
|
|
172
204
|
* Retention for the CMS Lambda's CloudWatch log group (default: three
|
|
173
205
|
* months / 90 days).
|
|
@@ -2,6 +2,7 @@ import * as path from 'node:path';
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { Construct } from 'constructs';
|
|
4
4
|
import { Duration, RemovalPolicy, Stack, aws_ec2 as ec2, aws_efs as efs, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_s3_assets as s3assets, aws_logs as logs, } from 'aws-cdk-lib';
|
|
5
|
+
import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
|
|
5
6
|
// This package (`canopycms-cdk`) is `"type": "module"`, so its compiled
|
|
6
7
|
// output is real ESM - `__dirname` is not a global there. Found while
|
|
7
8
|
// fixing this construct's B1 deploy blocker below: the worker asset path a
|
|
@@ -404,8 +405,19 @@ export class CanopyCmsService extends Construct {
|
|
|
404
405
|
removalPolicy: RemovalPolicy.DESTROY,
|
|
405
406
|
});
|
|
406
407
|
this.timeout = props.timeout ?? DEFAULT_CMS_LAMBDA_TIMEOUT;
|
|
408
|
+
// Re-attach what CDK silently drops for a caller-supplied role. MUST run
|
|
409
|
+
// for every passed role, and `vpc: true` here is the load-bearing part:
|
|
410
|
+
// this function is VPC-attached, so a role without
|
|
411
|
+
// AWSLambdaVPCAccessExecutionRole cannot create ENIs and the Lambda cannot
|
|
412
|
+
// start, having deployed clean. See that function's doc comment.
|
|
413
|
+
if (props.lambdaRole) {
|
|
414
|
+
attachLambdaExecutionPolicies(props.lambdaRole, { vpc: true });
|
|
415
|
+
}
|
|
407
416
|
this.lambdaFunction = new lambda.DockerImageFunction(this, 'CmsFunction', {
|
|
408
417
|
code: props.cmsDockerImage,
|
|
418
|
+
// Default (unset) leaves CDK to create the execution role, with its own
|
|
419
|
+
// managed policies intact. See `lambdaRole`'s doc comment.
|
|
420
|
+
role: props.lambdaRole,
|
|
409
421
|
memorySize: props.memorySize ?? 2048,
|
|
410
422
|
timeout: this.timeout,
|
|
411
423
|
reservedConcurrentExecutions: props.reservedConcurrency ?? 10,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { aws_iam as iam } from 'aws-cdk-lib';
|
|
2
|
+
export interface LambdaExecutionPolicyOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Whether the function this role belongs to is VPC-attached (i.e. was given
|
|
5
|
+
* a `vpc` prop). Mirrors the `props.vpc &&` condition in the CDK snippet
|
|
6
|
+
* above rather than being decided here, so the two cannot drift.
|
|
7
|
+
*
|
|
8
|
+
* Required rather than defaulted: getting it wrong on a VPC function
|
|
9
|
+
* produces a Lambda that cannot start, and a default would let a new call
|
|
10
|
+
* site inherit the wrong answer by saying nothing.
|
|
11
|
+
*/
|
|
12
|
+
readonly vpc: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Re-attach what CDK drops when a `lambda.Function` is handed a pre-built
|
|
16
|
+
* execution role. Call this for every caller-supplied role, alongside passing
|
|
17
|
+
* that role to the function.
|
|
18
|
+
*
|
|
19
|
+
* The contract this maintains is deliberately the strong one: **passing a role
|
|
20
|
+
* yields the same effective permissions as letting the construct create one.**
|
|
21
|
+
* The weaker alternative - attach only what each function demonstrably still
|
|
22
|
+
* needs - is a judgment call that goes stale silently. Concretely, neither
|
|
23
|
+
* Lambda in this package strictly needs `AWSLambdaBasicExecutionRole` today:
|
|
24
|
+
* both log to a custom-named group (`/canopycms/<stackName>/...`), that
|
|
25
|
+
* policy's `logs:CreateLogStream`/`logs:PutLogEvents` are scoped to
|
|
26
|
+
* `arn:aws:logs:*:*:log-group:/aws/lambda/*:*` and so grant nothing for such a
|
|
27
|
+
* group, and an explicit `logGroup.grantWrite()` is what actually enables
|
|
28
|
+
* their logging. Attaching it regardless costs one managed-policy ARN and
|
|
29
|
+
* means the first change to either function's logging cannot quietly leave
|
|
30
|
+
* passed-role deployments behind.
|
|
31
|
+
*
|
|
32
|
+
* WHY THE PARAMETER IS THE CONCRETE `iam.Role` AND NOT `iam.IRole`, which is
|
|
33
|
+
* the type a CDK prop would normally take: `addManagedPolicy` does nothing
|
|
34
|
+
* useful on an imported role, in two distinct ways, neither of which surfaces
|
|
35
|
+
* an error.
|
|
36
|
+
*
|
|
37
|
+
* - `ImmutableRole.addManagedPolicy(_policy) {}` - an empty method body. This
|
|
38
|
+
* is what `Role.fromRoleArn` returns for a CROSS-ACCOUNT role, or for any
|
|
39
|
+
* role imported with `mutable: false`.
|
|
40
|
+
* - `ImportedRole.addManagedPolicy` (the same-account, still-mutable case)
|
|
41
|
+
* attaches the policy only if it exposes `attachToRole`, which
|
|
42
|
+
* `ManagedPolicy.fromAwsManagedPolicyName` does not - so it takes the
|
|
43
|
+
* warning branch and emits nothing.
|
|
44
|
+
*
|
|
45
|
+
* Both confirmed on a synthesized template: no `AWS::IAM::Role` and no
|
|
46
|
+
* `AWS::IAM::Policy` resource appears in either case. So typed `IRole`, this
|
|
47
|
+
* function would silently no-op for every imported role and ship exactly the
|
|
48
|
+
* cannot-start Lambda it exists to prevent. There is no runtime guard
|
|
49
|
+
* available either - `ImmutableRole.addToPrincipalPolicy` returns
|
|
50
|
+
* `statementAdded: true` while emitting nothing, so the one signal CDK offers
|
|
51
|
+
* lies. Narrowing the type moves the failure to COMPILE time instead, which is
|
|
52
|
+
* the earliest it can be caught; `attachTo(distribution:
|
|
53
|
+
* cloudfront.Distribution)` in asset-support.ts narrows to a concrete class for
|
|
54
|
+
* the same kind of reason.
|
|
55
|
+
*
|
|
56
|
+
* Residual, documented rather than guarded because of its rarity: a role
|
|
57
|
+
* produced under `Role.customizeRoles` carries a precreated `ImportedRole` that
|
|
58
|
+
* `addManagedPolicy` delegates to, landing on the warning branch above.
|
|
59
|
+
*/
|
|
60
|
+
export declare function attachLambdaExecutionPolicies(role: iam.Role, options: LambdaExecutionPolicyOptions): void;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { aws_iam as iam } from 'aws-cdk-lib';
|
|
2
|
+
/**
|
|
3
|
+
* The two AWS managed policies CDK's `lambda.Function` attaches to an
|
|
4
|
+
* execution role IT creates, and silently DISCARDS when one is passed in.
|
|
5
|
+
*
|
|
6
|
+
* Verbatim from `aws-cdk-lib/aws-lambda/lib/function.js` (checked against
|
|
7
|
+
* 2.265.0 here, and independently against 2.267.0 by the adopter who filed
|
|
8
|
+
* this):
|
|
9
|
+
*
|
|
10
|
+
* ```js
|
|
11
|
+
* managedPolicies.push(ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'))
|
|
12
|
+
* props.vpc && managedPolicies.push(ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'))
|
|
13
|
+
* this.role = props.role || new iam.Role(this, 'ServiceRole', { assumedBy: ..., managedPolicies })
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The array is built unconditionally and then reaches ONLY the `||` fallback.
|
|
17
|
+
* Pass a role and both entries are dropped with nothing logged. Confirmed on a
|
|
18
|
+
* synthesized template, not inferred: a VPC-attached function given a bare role
|
|
19
|
+
* emits an `AWS::IAM::Role` whose `ManagedPolicyArns` is absent entirely.
|
|
20
|
+
*
|
|
21
|
+
* For the VPC-attached CMS Lambda that is not a degradation, it is fatal and
|
|
22
|
+
* invisible: without `AWSLambdaVPCAccessExecutionRole` the function cannot
|
|
23
|
+
* create ENIs, so it cannot start at all - while synthesizing and deploying
|
|
24
|
+
* perfectly clean and failing only at invoke, a long way from the cause. Which
|
|
25
|
+
* is why every `role` prop in this package routes through here.
|
|
26
|
+
*
|
|
27
|
+
* NOT affected by passing a role, so deliberately not re-applied below (all
|
|
28
|
+
* three verified in the same CDK source):
|
|
29
|
+
*
|
|
30
|
+
* - EFS access-point statements - `config.policies.forEach(p => this.role?.addToPrincipalPolicy(p))`.
|
|
31
|
+
* - `initialPolicy` - `this.role.addToPrincipalPolicy(statement)` per statement.
|
|
32
|
+
* - Every `grant*` call, because `this.grantPrincipal = this.role`. That covers
|
|
33
|
+
* `cmsLogGroup`/`transformLogGroup`'s `grantWrite` and all asset-bucket
|
|
34
|
+
* grants, which land on the passed role exactly as they would on a created
|
|
35
|
+
* one.
|
|
36
|
+
*/
|
|
37
|
+
const BASIC_EXECUTION_POLICY = 'service-role/AWSLambdaBasicExecutionRole';
|
|
38
|
+
const VPC_ACCESS_POLICY = 'service-role/AWSLambdaVPCAccessExecutionRole';
|
|
39
|
+
/**
|
|
40
|
+
* Re-attach what CDK drops when a `lambda.Function` is handed a pre-built
|
|
41
|
+
* execution role. Call this for every caller-supplied role, alongside passing
|
|
42
|
+
* that role to the function.
|
|
43
|
+
*
|
|
44
|
+
* The contract this maintains is deliberately the strong one: **passing a role
|
|
45
|
+
* yields the same effective permissions as letting the construct create one.**
|
|
46
|
+
* The weaker alternative - attach only what each function demonstrably still
|
|
47
|
+
* needs - is a judgment call that goes stale silently. Concretely, neither
|
|
48
|
+
* Lambda in this package strictly needs `AWSLambdaBasicExecutionRole` today:
|
|
49
|
+
* both log to a custom-named group (`/canopycms/<stackName>/...`), that
|
|
50
|
+
* policy's `logs:CreateLogStream`/`logs:PutLogEvents` are scoped to
|
|
51
|
+
* `arn:aws:logs:*:*:log-group:/aws/lambda/*:*` and so grant nothing for such a
|
|
52
|
+
* group, and an explicit `logGroup.grantWrite()` is what actually enables
|
|
53
|
+
* their logging. Attaching it regardless costs one managed-policy ARN and
|
|
54
|
+
* means the first change to either function's logging cannot quietly leave
|
|
55
|
+
* passed-role deployments behind.
|
|
56
|
+
*
|
|
57
|
+
* WHY THE PARAMETER IS THE CONCRETE `iam.Role` AND NOT `iam.IRole`, which is
|
|
58
|
+
* the type a CDK prop would normally take: `addManagedPolicy` does nothing
|
|
59
|
+
* useful on an imported role, in two distinct ways, neither of which surfaces
|
|
60
|
+
* an error.
|
|
61
|
+
*
|
|
62
|
+
* - `ImmutableRole.addManagedPolicy(_policy) {}` - an empty method body. This
|
|
63
|
+
* is what `Role.fromRoleArn` returns for a CROSS-ACCOUNT role, or for any
|
|
64
|
+
* role imported with `mutable: false`.
|
|
65
|
+
* - `ImportedRole.addManagedPolicy` (the same-account, still-mutable case)
|
|
66
|
+
* attaches the policy only if it exposes `attachToRole`, which
|
|
67
|
+
* `ManagedPolicy.fromAwsManagedPolicyName` does not - so it takes the
|
|
68
|
+
* warning branch and emits nothing.
|
|
69
|
+
*
|
|
70
|
+
* Both confirmed on a synthesized template: no `AWS::IAM::Role` and no
|
|
71
|
+
* `AWS::IAM::Policy` resource appears in either case. So typed `IRole`, this
|
|
72
|
+
* function would silently no-op for every imported role and ship exactly the
|
|
73
|
+
* cannot-start Lambda it exists to prevent. There is no runtime guard
|
|
74
|
+
* available either - `ImmutableRole.addToPrincipalPolicy` returns
|
|
75
|
+
* `statementAdded: true` while emitting nothing, so the one signal CDK offers
|
|
76
|
+
* lies. Narrowing the type moves the failure to COMPILE time instead, which is
|
|
77
|
+
* the earliest it can be caught; `attachTo(distribution:
|
|
78
|
+
* cloudfront.Distribution)` in asset-support.ts narrows to a concrete class for
|
|
79
|
+
* the same kind of reason.
|
|
80
|
+
*
|
|
81
|
+
* Residual, documented rather than guarded because of its rarity: a role
|
|
82
|
+
* produced under `Role.customizeRoles` carries a precreated `ImportedRole` that
|
|
83
|
+
* `addManagedPolicy` delegates to, landing on the warning branch above.
|
|
84
|
+
*/
|
|
85
|
+
export function attachLambdaExecutionPolicies(role, options) {
|
|
86
|
+
role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName(BASIC_EXECUTION_POLICY));
|
|
87
|
+
if (options.vpc) {
|
|
88
|
+
role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName(VPC_ACCESS_POLICY));
|
|
89
|
+
}
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canopycms-cdk",
|
|
3
|
-
"version": "0.0.66-int.
|
|
3
|
+
"version": "0.0.66-int.85",
|
|
4
4
|
"description": "AWS CDK constructs and EC2 worker for CanopyCMS deployment",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"aws-cdk-lib": "^2.260.0",
|
|
32
32
|
"constructs": "^10.0.0",
|
|
33
|
-
"canopycms": "0.0.66-int.
|
|
33
|
+
"canopycms": "0.0.66-int.85"
|
|
34
34
|
},
|
|
35
35
|
"peerDependenciesMeta": {
|
|
36
36
|
"aws-cdk-lib": {
|
|
@@ -54,16 +54,16 @@
|
|
|
54
54
|
"tsx": "^4.19.0",
|
|
55
55
|
"typescript": "^5.6.3",
|
|
56
56
|
"vitest": "^4.1.0",
|
|
57
|
-
"canopycms": "0.0.66-int.
|
|
58
|
-
"canopycms
|
|
57
|
+
"canopycms-auth-clerk": "0.0.66-int.85",
|
|
58
|
+
"canopycms": "0.0.66-int.85"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
61
|
"build": "rm -rf dist && tsc && node ../../scripts/add-js-extensions.mjs dist",
|
|
62
62
|
"build:worker": "esbuild worker/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=worker/dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"",
|
|
63
63
|
"build:lambda": "node lambda/asset-transform/build.mjs",
|
|
64
64
|
"build:test-fixtures": "pnpm run build:worker && node lambda/asset-transform/build.mjs --skip-native",
|
|
65
|
-
"lint": "eslint src/ 'lambda/**/*.ts' 'canary/**/*.ts' 'worker/**/*.ts'",
|
|
65
|
+
"lint": "eslint src/ 'lambda/**/*.ts' 'canary/**/*.ts' 'worker/**/*.ts' 'test-support/**/*.ts'",
|
|
66
66
|
"test": "pnpm run build:test-fixtures && vitest run",
|
|
67
|
-
"typecheck": "tsc --noEmit && tsc --noEmit -p lambda/tsconfig.json && tsc --noEmit -p canary/tsconfig.json && tsc --noEmit -p worker/tsconfig.json"
|
|
67
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p lambda/tsconfig.json && tsc --noEmit -p canary/tsconfig.json && tsc --noEmit -p worker/tsconfig.json && tsc --noEmit -p test-support/tsconfig.json"
|
|
68
68
|
}
|
|
69
69
|
}
|