canopycms-cdk 0.0.66-int.84 → 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
@@ -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).
@@ -257,7 +258,18 @@ export class AssetSupport extends Construct {
257
258
  retention: props.transformLogRetention ?? logs.RetentionDays.THREE_MONTHS,
258
259
  removalPolicy: RemovalPolicy.DESTROY,
259
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
+ }
260
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,
261
273
  // Built by `pnpm run build:lambda` (lambda/asset-transform/build.mjs) -
262
274
  // esbuild bundle + a real linux/arm64 `npm install sharp` alongside it,
263
275
  // no Docker. `cdk synth`/`deploy` need that script run first; it is
@@ -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.84",
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.84"
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.84",
58
- "canopycms-auth-clerk": "0.0.66-int.84"
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
  }