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.
@@ -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 (this.attachedDistributions.has(distribution)) {
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
- this.attachedDistributions.add(distribution);
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, transformRest);
405
- distribution.addBehavior(ASSETS_PATH_PATTERN, assetsOrigin, assetsRest);
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. Equivalent to calling
70
- * `assetSupport.attachTo(distribution)` yourself right after construction,
71
- * but with nothing to forget.
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
- const uncollided = Object.fromEntries(Object.entries(defaults).filter(([pattern]) => !(pattern in caller)));
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.83",
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.83"
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.83",
58
- "canopycms-auth-clerk": "0.0.66-int.83"
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
  }