@fjall/components-infrastructure 2.9.1 → 2.12.0

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.
Files changed (42) hide show
  1. package/dist/lib/config/aws/disasterRecovery.js +23 -4
  2. package/dist/lib/config/aws/ecrDefaultImage.js +2 -2
  3. package/dist/lib/config/aws/s3BlockPublicAccess.d.ts +22 -4
  4. package/dist/lib/config/aws/s3BlockPublicAccess.js +33 -13
  5. package/dist/lib/config/aws/scpPreset.js +6 -1
  6. package/dist/lib/patterns/aws/account.d.ts +15 -0
  7. package/dist/lib/patterns/aws/account.js +32 -6
  8. package/dist/lib/patterns/aws/buildkite.js +1 -1
  9. package/dist/lib/patterns/aws/clickhouseDatabase.js +5 -2
  10. package/dist/lib/patterns/aws/compute.d.ts +22 -0
  11. package/dist/lib/patterns/aws/compute.js +42 -0
  12. package/dist/lib/patterns/aws/computeEcs.d.ts +2 -1
  13. package/dist/lib/patterns/aws/computeEcs.js +67 -24
  14. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +8 -2
  15. package/dist/lib/patterns/aws/organisation.d.ts +19 -8
  16. package/dist/lib/patterns/aws/organisation.js +23 -10
  17. package/dist/lib/patterns/aws/organisationFactory.js +2 -1
  18. package/dist/lib/patterns/aws/platform.d.ts +1 -0
  19. package/dist/lib/patterns/aws/platform.js +3 -0
  20. package/dist/lib/resources/aws/backup/backupVault.js +5 -3
  21. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +1 -1
  22. package/dist/lib/resources/aws/compute/ecsConstants.js +4 -1
  23. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +2 -2
  24. package/dist/lib/resources/aws/compute/ecsRoles.js +4 -13
  25. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +5 -3
  26. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +76 -3
  27. package/dist/lib/resources/aws/compute/ecsTaskDefinition.d.ts +0 -5
  28. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +2 -20
  29. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +50 -8
  30. package/dist/lib/resources/aws/compute/ecsTypes.js +11 -0
  31. package/dist/lib/resources/aws/compute/ecsValidation.js +37 -0
  32. package/dist/lib/resources/aws/compute/lambda.d.ts +11 -0
  33. package/dist/lib/resources/aws/compute/lambda.js +23 -3
  34. package/dist/lib/resources/aws/secrets/parameter.js +3 -3
  35. package/dist/lib/resources/aws/secrets/secret.d.ts +32 -11
  36. package/dist/lib/resources/aws/secrets/secret.js +53 -0
  37. package/dist/lib/resources/aws/utilities/customResource.d.ts +4 -0
  38. package/dist/lib/resources/aws/utilities/customResource.js +4 -0
  39. package/dist/lib/utils/env.js +2 -2
  40. package/dist/lib/utils/getConfig.js +2 -2
  41. package/dist/lib/utils/orgConfigParser.js +16 -2
  42. package/package.json +4 -4
@@ -15,6 +15,17 @@ export interface LambdaFunctionProps {
15
15
  handler: string;
16
16
  lambdaDescription?: string;
17
17
  roleDescription?: string;
18
+ /**
19
+ * Fixed IAM path for the auto-generated execution role (e.g. "/fjall/").
20
+ * Used with roleName to produce an SCP-exemptable ARN.
21
+ */
22
+ rolePath?: string;
23
+ /**
24
+ * Fixed name for the auto-generated execution role. Account-global — only set
25
+ * on a Lambda confined to one (account, region) stack, else regional
26
+ * duplicates collide on the role name.
27
+ */
28
+ roleName?: string;
18
29
  runtime: Runtime;
19
30
  /** Lambda CPU architecture. Default: x86_64 */
20
31
  architecture?: Architecture;
@@ -8,9 +8,9 @@ import { EventType } from "aws-cdk-lib/aws-s3";
8
8
  import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
9
9
  import { RetentionDays } from "aws-cdk-lib/aws-logs";
10
10
  import { LogGroup } from "../logging/logGroup.js";
11
- import { Secret } from "aws-cdk-lib/aws-secretsmanager";
12
11
  import { v4 as uuid } from "uuid";
13
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ import { resolveImportedSecret } from "../secrets/index.js";
14
14
  import { toPascalCase } from "../../../utils/capitaliseString.js";
15
15
  import { resolvePrivateSubnetType } from "../../../utils/vpcUtils.js";
16
16
  import { createLambdaAlarms } from "../monitoring/index.js";
@@ -31,6 +31,24 @@ function applyRoleDescription(fn, description) {
31
31
  if (cfnRole !== undefined)
32
32
  cfnRole.description = description;
33
33
  }
34
+ /**
35
+ * Pin a fixed IAM path and/or name onto CDK's auto-generated Lambda execution
36
+ * role. Reaches the L1 CfnRole (FunctionProps exposes neither) so a stable,
37
+ * predictable ARN can be matched by an SCP exemption pattern. A fixed name is
38
+ * account-global, so only set one on a Lambda that lives in a single
39
+ * (account, region) stack — never one duplicated across regions.
40
+ */
41
+ function applyRolePathAndName(fn, rolePath, roleName) {
42
+ if (rolePath === undefined && roleName === undefined)
43
+ return;
44
+ const cfnRole = fn.role?.node.defaultChild;
45
+ if (cfnRole === undefined)
46
+ return;
47
+ if (rolePath !== undefined)
48
+ cfnRole.path = rolePath;
49
+ if (roleName !== undefined)
50
+ cfnRole.roleName = roleName;
51
+ }
34
52
  /**
35
53
  * AWS Parameters and Secrets Lambda Extension configuration.
36
54
  * @see https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html
@@ -61,6 +79,7 @@ export class SingletonFunction extends singletonFunction {
61
79
  });
62
80
  addPoliciesToRole(this, props.inlinePolicy);
63
81
  applyRoleDescription(this, props.roleDescription);
82
+ applyRolePathAndName(this, props.rolePath, props.roleName);
64
83
  }
65
84
  /**
66
85
  * The Lambda's execution role (auto-generated by CDK)
@@ -91,6 +110,7 @@ export class LambdaFunction extends Function {
91
110
  });
92
111
  addPoliciesToRole(this, props.inlinePolicy);
93
112
  applyRoleDescription(this, props.roleDescription);
113
+ applyRolePathAndName(this, props.rolePath, props.roleName);
94
114
  this.addSecretsSupport(props.secrets, props.ssmSecretsPath, props.secretsImport, props.appName, props.functionName, props.architecture);
95
115
  // Sanitise id for CloudFormation output keys (must be alphanumeric)
96
116
  const outputName = toPascalCase(id);
@@ -250,7 +270,7 @@ export class LambdaFunction extends Function {
250
270
  */
251
271
  addSecretsManagerSupport(secretsImport) {
252
272
  for (const [key, secretImport] of Object.entries(secretsImport)) {
253
- const secret = Secret.fromSecretNameV2(this, `${this.node.id}ImportedSecret${key}`, secretImport.name);
273
+ const secret = resolveImportedSecret(this, `${this.node.id}ImportedSecret${key}`, secretImport);
254
274
  this.addEnvironment(`${key}_SECRET_ARN`, secret.secretArn);
255
275
  if (secretImport.field) {
256
276
  this.addEnvironment(`${key}_SECRET_FIELD`, secretImport.field);
@@ -281,7 +301,7 @@ export class LambdaFunction extends Function {
281
301
  effect: Effect.ALLOW,
282
302
  actions: ["ssm:GetParameter", "ssm:GetParameters"],
283
303
  resources: [
284
- `arn:aws:ssm:${Stack.of(this).region}:*:parameter${scopedPath}`
304
+ `arn:${Stack.of(this).partition}:ssm:${Stack.of(this).region}:${Stack.of(this).account}:parameter${scopedPath}`
285
305
  ]
286
306
  }));
287
307
  this.addToRolePolicy(new PolicyStatement({
@@ -1,4 +1,4 @@
1
- import { aws_ssm as ssm } from "aws-cdk-lib";
1
+ import { aws_ssm as ssm, Stack } from "aws-cdk-lib";
2
2
  import { PolicyStatement } from "aws-cdk-lib/aws-iam";
3
3
  import { AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
4
4
  import { Construct } from "constructs";
@@ -73,7 +73,7 @@ export class SecureStringParameter extends Construct {
73
73
  new PolicyStatement({
74
74
  actions: ["kms:Encrypt"],
75
75
  resources: [
76
- `arn:aws:kms:${props.region}:${props.accountId}:key/${this.cmk.key.keyId}`
76
+ `arn:${Stack.of(this).partition}:kms:${props.region}:${props.accountId}:key/${this.cmk.key.keyId}`
77
77
  ]
78
78
  }),
79
79
  new PolicyStatement({
@@ -85,7 +85,7 @@ export class SecureStringParameter extends Construct {
85
85
  "logs:PutRetentionPolicy"
86
86
  ],
87
87
  resources: [
88
- `arn:aws:ssm:${props.region}:${props.accountId}:parameter${props.name}`
88
+ `arn:${Stack.of(this).partition}:ssm:${props.region}:${props.accountId}:parameter${props.name}`
89
89
  ]
90
90
  })
91
91
  ])
@@ -24,20 +24,41 @@ interface SecretProps {
24
24
  /** Set to true to import an existing secret instead of creating a new one (for replicated secrets) */
25
25
  importExisting?: boolean;
26
26
  }
27
+ /**
28
+ * Reference to an existing (externally-managed) Secrets Manager secret.
29
+ *
30
+ * Provide EXACTLY ONE of `name` (the common case — resolved to its complete ARN at
31
+ * deploy time, see {@link resolveImportedSecret}) or `arn` (a complete-ARN escape
32
+ * hatch for cross-account/region secrets the deploy identity cannot DescribeSecret).
33
+ */
27
34
  export type SecretImport = {
28
- /**
29
- * Secret ID
30
- */
35
+ /** Construct ID for the imported secret. */
31
36
  id: string;
32
- /**
33
- * Secret name
34
- */
35
- name: string;
36
- /**
37
- * Optional - may be used to import a specific field from the secret
38
- */
37
+ /** Optional — import a single JSON field from the secret. */
39
38
  field?: string;
40
- };
39
+ } & ({
40
+ name: string;
41
+ arn?: undefined;
42
+ } | {
43
+ arn: string;
44
+ name?: undefined;
45
+ });
46
+ /**
47
+ * Resolves an imported (externally-managed) Secrets Manager secret to an `ISecret`
48
+ * whose ARN is COMPLETE (with the AWS 6-char suffix) wherever possible.
49
+ *
50
+ * `Secret.fromSecretNameV2` renders a SUFFIXLESS partial ARN into the ECS container
51
+ * `valueFrom`, which real Secrets Manager rejects with AccessDenied at task launch (the
52
+ * 2026-06-04 outage). `fromSecretCompleteArn` renders the full ARN AND an exact-ARN
53
+ * `grantRead`, which authorises. The full ARN is obtained three ways, in order:
54
+ * 1. an explicit `arn` on the import (cross-account/region escape hatch);
55
+ * 2. the `fjallResolvedSecretArns` context map injected by @fjall/deploy-core, which
56
+ * DescribeSecret-resolves every name -> full ARN at deploy time;
57
+ * 3. fallback to `fromSecretNameV2` ONLY when neither is present — i.e. offline
58
+ * `cdk synth` for template generation. A real deploy ALWAYS runs deploy-core's
59
+ * resolution, so the suffixless shape never reaches a deployed task-def.
60
+ */
61
+ export declare function resolveImportedSecret(scope: Construct, id: string, secretImport: SecretImport): ISecret;
41
62
  export declare class Secret extends Construct {
42
63
  id: string;
43
64
  readonly secret: ISecret;
@@ -2,6 +2,59 @@ import { SecretValue } from "aws-cdk-lib";
2
2
  import { Secret as CdkSecret } from "aws-cdk-lib/aws-secretsmanager";
3
3
  import { Construct } from "constructs";
4
4
  import { CustomerManagedKey } from "./kms.js";
5
+ /**
6
+ * Context key carrying the deploy-time `secretName -> completeArn` map. Written by
7
+ * @fjall/deploy-core (`CdkArgumentBuilder.buildContextArgs` emits
8
+ * `-c fjallResolvedSecretArns=<json>`); read here. Keep the literal in sync with that
9
+ * canonical writer — matches the `ipamPoolId` / `orgConfig` literal-key house style.
10
+ */
11
+ const RESOLVED_SECRET_ARNS_CONTEXT_KEY = "fjallResolvedSecretArns";
12
+ function isStringRecord(value) {
13
+ return (typeof value === "object" &&
14
+ value !== null &&
15
+ !Array.isArray(value) &&
16
+ Object.values(value).every((entry) => typeof entry === "string"));
17
+ }
18
+ function readResolvedSecretArn(scope, secretName) {
19
+ const raw = scope.node.tryGetContext(RESOLVED_SECRET_ARNS_CONTEXT_KEY);
20
+ if (typeof raw !== "string" || raw === "")
21
+ return undefined;
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(raw);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ if (!isStringRecord(parsed))
30
+ return undefined;
31
+ return parsed[secretName];
32
+ }
33
+ /**
34
+ * Resolves an imported (externally-managed) Secrets Manager secret to an `ISecret`
35
+ * whose ARN is COMPLETE (with the AWS 6-char suffix) wherever possible.
36
+ *
37
+ * `Secret.fromSecretNameV2` renders a SUFFIXLESS partial ARN into the ECS container
38
+ * `valueFrom`, which real Secrets Manager rejects with AccessDenied at task launch (the
39
+ * 2026-06-04 outage). `fromSecretCompleteArn` renders the full ARN AND an exact-ARN
40
+ * `grantRead`, which authorises. The full ARN is obtained three ways, in order:
41
+ * 1. an explicit `arn` on the import (cross-account/region escape hatch);
42
+ * 2. the `fjallResolvedSecretArns` context map injected by @fjall/deploy-core, which
43
+ * DescribeSecret-resolves every name -> full ARN at deploy time;
44
+ * 3. fallback to `fromSecretNameV2` ONLY when neither is present — i.e. offline
45
+ * `cdk synth` for template generation. A real deploy ALWAYS runs deploy-core's
46
+ * resolution, so the suffixless shape never reaches a deployed task-def.
47
+ */
48
+ export function resolveImportedSecret(scope, id, secretImport) {
49
+ if (secretImport.arn !== undefined) {
50
+ return CdkSecret.fromSecretCompleteArn(scope, id, secretImport.arn);
51
+ }
52
+ const resolvedArn = readResolvedSecretArn(scope, secretImport.name);
53
+ if (resolvedArn !== undefined) {
54
+ return CdkSecret.fromSecretCompleteArn(scope, id, resolvedArn);
55
+ }
56
+ return CdkSecret.fromSecretNameV2(scope, id, secretImport.name);
57
+ }
5
58
  export class Secret extends Construct {
6
59
  id;
7
60
  secret;
@@ -7,6 +7,10 @@ interface CustomResourceProps {
7
7
  runtime: Runtime;
8
8
  roleDescription?: string;
9
9
  lambdaDescription?: string;
10
+ /** Fixed IAM path for the handler's execution role (e.g. "/fjall/"). */
11
+ rolePath?: string;
12
+ /** Fixed name for the handler's execution role (account-global — see LambdaFunctionProps). */
13
+ roleName?: string;
10
14
  inlinePolicy: PolicyStatement[];
11
15
  properties?: {
12
16
  [key: string]: string;
@@ -39,6 +39,8 @@ export class CustomResource extends Construct {
39
39
  timeout,
40
40
  lambdaDescription: props.lambdaDescription ?? `${id} lambda`,
41
41
  roleDescription: props.roleDescription ?? `${id} custom resource lambda`,
42
+ rolePath: props.rolePath,
43
+ roleName: props.roleName,
42
44
  inlinePolicy: props.inlinePolicy
43
45
  })
44
46
  : new SingletonFunction(this, `${id}Lambda`, {
@@ -47,6 +49,8 @@ export class CustomResource extends Construct {
47
49
  runtime: props.runtime,
48
50
  lambdaDescription: props.lambdaDescription ?? `${id} lambda`,
49
51
  roleDescription: props.roleDescription ?? `${id} custom resource lambda`,
52
+ rolePath: props.rolePath,
53
+ roleName: props.roleName,
50
54
  inlinePolicy: props.inlinePolicy
51
55
  });
52
56
  const provider = new Provider(this, `${id}Provider`, {
@@ -99,12 +99,12 @@ function getProviderAccountsFromContext() {
99
99
  */
100
100
  function resolveEnvironmentFromAccountId(accountId) {
101
101
  const accounts = getProviderAccountsFromContext();
102
- return accounts.find((pa) => pa.id === accountId)?.environment;
102
+ return accounts.find((pa) => pa.id === accountId)?.environment ?? undefined;
103
103
  }
104
104
  /**
105
105
  * Look up environment from account name via orgConfig CDK context.
106
106
  */
107
107
  function resolveEnvironmentFromAccountName(accountName) {
108
108
  const accounts = getProviderAccountsFromContext();
109
- return accounts.find((pa) => pa.name === accountName)?.environment;
109
+ return (accounts.find((pa) => pa.name === accountName)?.environment ?? undefined);
110
110
  }
@@ -63,7 +63,7 @@ export function getConfig(accountName) {
63
63
  if (providerAccount) {
64
64
  config.accountId = providerAccount.id;
65
65
  config.accountName = providerAccount.name;
66
- config.environment = providerAccount.environment;
66
+ config.environment = providerAccount.environment ?? "unknown";
67
67
  }
68
68
  }
69
69
  // If we still don't have an account name - try to retrieve accountId from context
@@ -75,7 +75,7 @@ export function getConfig(accountName) {
75
75
  const providerAccount = providerAccounts.find((pa) => pa.id === accountId);
76
76
  if (providerAccount) {
77
77
  config.accountName = providerAccount.name;
78
- config.environment = providerAccount.environment;
78
+ config.environment = providerAccount.environment ?? "unknown";
79
79
  }
80
80
  }
81
81
  }
@@ -1,3 +1,4 @@
1
+ import { VAULT_LOCK_MODES, S3_BPA_MODES } from "@fjall/util/config";
1
2
  import { maskSensitiveOutput } from "@fjall/util";
2
3
  import { FjallLogger } from "./validationLogger.js";
3
4
  /**
@@ -23,11 +24,24 @@ export function parseOrgConfig(raw) {
23
24
  item !== null &&
24
25
  typeof item.id === "string" &&
25
26
  typeof item.name === "string" &&
26
- typeof item.environment === "string" &&
27
+ // null environment (structural accounts) must survive — rejecting it drops the account at synth.
28
+ (typeof item.environment ===
29
+ "string" ||
30
+ item.environment === null) &&
27
31
  (item.managed === undefined ||
28
32
  typeof item.managed === "boolean") &&
29
33
  (item.oidcRoleArn === undefined ||
30
- typeof item.oidcRoleArn === "string"))
34
+ typeof item.oidcRoleArn ===
35
+ "string") &&
36
+ (item.vaultLock === undefined ||
37
+ VAULT_LOCK_MODES.includes(item.vaultLock)) &&
38
+ (item.s3BlockPublicAccess ===
39
+ undefined ||
40
+ S3_BPA_MODES.includes(item.s3BlockPublicAccess)) &&
41
+ (item.acknowledgeImmutableVaultLock ===
42
+ undefined ||
43
+ typeof item
44
+ .acknowledgeImmutableVaultLock === "boolean"))
31
45
  : [];
32
46
  const primaryRegion = typeof obj.primaryRegion === "string" ? obj.primaryRegion : undefined;
33
47
  const secondaryRegions = Array.isArray(obj.secondaryRegions)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "2.9.1",
3
+ "version": "2.12.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,8 +63,8 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@aws-sdk/client-organizations": "^3.1038.0",
66
- "@fjall/generator": "^2.9.1",
67
- "@fjall/util": "^2.9.1",
66
+ "@fjall/generator": "^2.12.0",
67
+ "@fjall/util": "^2.12.0",
68
68
  "constructs": "^10.0.0",
69
69
  "uuid": "^14.0.0"
70
70
  },
@@ -79,5 +79,5 @@
79
79
  "engines": {
80
80
  "node": ">=18.0.0"
81
81
  },
82
- "gitHead": "a97423cf3df727994364a0907fa2b5c544a86b0d"
82
+ "gitHead": "dca39a47da956d3d94c689dd782fe285d711d25e"
83
83
  }