@fjall/components-infrastructure 2.12.0 → 2.14.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 (36) hide show
  1. package/dist/lib/app.d.ts +7 -7
  2. package/dist/lib/app.js +2 -3
  3. package/dist/lib/config/aws/accountMonitoringRole.js +2 -1
  4. package/dist/lib/config/aws/cloudTrail.d.ts +13 -0
  5. package/dist/lib/config/aws/cloudTrail.js +19 -3
  6. package/dist/lib/config/aws/disasterRecovery.js +1 -1
  7. package/dist/lib/config/aws/ecrDefaultImage.js +2 -0
  8. package/dist/lib/config/aws/organisationTrail.d.ts +16 -0
  9. package/dist/lib/config/aws/organisationTrail.js +32 -0
  10. package/dist/lib/config/aws/scpPreset.js +10 -1
  11. package/dist/lib/patterns/aws/account.d.ts +9 -0
  12. package/dist/lib/patterns/aws/account.js +29 -6
  13. package/dist/lib/patterns/aws/organisation.d.ts +9 -0
  14. package/dist/lib/patterns/aws/organisation.js +51 -5
  15. package/dist/lib/patterns/aws/storage.d.ts +1 -1
  16. package/dist/lib/patterns/aws/storage.js +5 -1
  17. package/dist/lib/resources/aws/logging/cloudTrail.d.ts +48 -1
  18. package/dist/lib/resources/aws/logging/cloudTrail.js +180 -18
  19. package/dist/lib/resources/aws/messaging/eventbridge.d.ts +3 -2
  20. package/dist/lib/resources/aws/messaging/eventbridge.js +2 -2
  21. package/dist/lib/resources/aws/networking/ipamPool.js +6 -3
  22. package/dist/lib/resources/aws/networking/serviceDiscovery.d.ts +4 -3
  23. package/dist/lib/resources/aws/networking/serviceDiscovery.js +2 -3
  24. package/dist/lib/resources/aws/storage/s3.d.ts +8 -0
  25. package/dist/lib/resources/aws/storage/s3.js +19 -4
  26. package/dist/lib/utils/cdkContext.d.ts +11 -0
  27. package/dist/lib/utils/cdkContext.js +22 -1
  28. package/dist/lib/utils/env.d.ts +19 -0
  29. package/dist/lib/utils/env.js +36 -5
  30. package/dist/lib/utils/getConfig.js +32 -12
  31. package/dist/lib/utils/orgConfigParser.d.ts +10 -0
  32. package/dist/lib/utils/orgConfigParser.js +47 -23
  33. package/dist/lib/utils/removalPolicy.d.ts +15 -0
  34. package/dist/lib/utils/removalPolicy.js +32 -0
  35. package/dist/lib/utils/standardTagsAspect.js +2 -1
  36. package/package.json +4 -4
@@ -4,46 +4,208 @@ import { Construct } from "constructs";
4
4
  import { CustomerManagedKey } from "../secrets/index.js";
5
5
  import { S3Bucket } from "../storage/index.js";
6
6
  import { BucketEncryption } from "aws-cdk-lib/aws-s3";
7
- import { ServicePrincipal } from "aws-cdk-lib/aws-iam";
7
+ import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
8
+ export function validateTrailProps(props) {
9
+ if (props.isOrganizationTrail === true) {
10
+ const missingOrgId = props.orgId === undefined || props.orgId === "";
11
+ const missingTrailName = props.trailName === undefined || props.trailName === "";
12
+ if (missingOrgId || missingTrailName) {
13
+ throw new Error("Organisation trails require both orgId and trailName — without them the CDK Trail construct silently skips the org-wide bucket-policy statement and member delivery fails at runtime.");
14
+ }
15
+ if (props.retainAuditHistory !== true) {
16
+ throw new Error("Organisation trails must set retainAuditHistory: true — organisation-wide audit history must survive stack deletion.");
17
+ }
18
+ }
19
+ }
8
20
  export class Trail extends Construct {
9
21
  trail;
10
22
  bucket;
23
+ encryptionKey;
11
24
  constructor(scope, id, props) {
12
25
  super(scope, id);
13
- const encryptionKey = new CustomerManagedKey(this, `${id}CloudTrailEncryptionKey`, {
26
+ validateTrailProps(props);
27
+ const { bucketName, retainAuditHistory, omitTrail, ...trailProps } = props;
28
+ const storagePolicy = retainAuditHistory === true
29
+ ? RemovalPolicy.RETAIN
30
+ : RemovalPolicy.DESTROY;
31
+ this.encryptionKey = new CustomerManagedKey(this, `${id}CloudTrailEncryptionKey`, {
14
32
  aliasName: `cmk/cloudtrail/${id}/encryptionKey`,
15
- removalPolicy: RemovalPolicy.DESTROY
33
+ removalPolicy: storagePolicy
16
34
  });
17
35
  this.bucket = new S3Bucket(this, `${id}CloudTrailBucket`, {
18
- bucketName: props.bucketName,
36
+ bucketName,
19
37
  bucketKeyEnabled: true,
20
38
  encryption: BucketEncryption.KMS,
21
- encryptionKey: encryptionKey.key,
39
+ encryptionKey: this.encryptionKey.key,
22
40
  versioned: false,
41
+ removalPolicy: storagePolicy,
23
42
  lifecycleRules: [{ expiration: Duration.days(365), enabled: true }]
24
43
  });
25
- this.bucket.grantReadWrite(new ServicePrincipal("cloudtrail.amazonaws.com"));
44
+ const effectiveTrailName = props.trailName || `${id}Trail`;
45
+ if (props.isOrganizationTrail === true) {
46
+ // The L2 Trail emits the organisation-wide bucket policy itself
47
+ // (AWSLogs/<orgId>/* delivery); granting read/write here would shadow
48
+ // it with a narrower per-account statement.
49
+ this.addOrganisationTrailKeyPolicy(props, bucketName);
50
+ }
51
+ else {
52
+ const accountTrailArn = this.trailArn(effectiveTrailName);
53
+ this.addAccountTrailBucketPolicy(accountTrailArn);
54
+ this.addAccountTrailKeyPolicy(accountTrailArn, bucketName);
55
+ }
56
+ if (omitTrail === true) {
57
+ return;
58
+ }
26
59
  this.trail = new CloudTrail.Trail(this, `${id}CloudTrail`, {
27
- ...props,
60
+ ...trailProps,
28
61
  bucket: this.bucket,
29
- trailName: props.trailName || `${id}Trail`,
30
- encryptionKey: encryptionKey.key
62
+ trailName: effectiveTrailName,
63
+ encryptionKey: this.encryptionKey.key
31
64
  });
32
- // TODO: Revert to RemovalPolicy.RETAIN for production
65
+ // Always DESTROY even when storage is retained: a RETAINED trail would
66
+ // keep logging (and charging) as an unmanaged orphan after stack
67
+ // deletion. Audit durability lives on the bucket + CMK removal policies.
33
68
  this.trail.applyRemovalPolicy(RemovalPolicy.DESTROY);
34
- // Ensure the autoDeleteObjects custom resource is fully provisioned before
35
- // the trail starts writing to the bucket. Without this, a create-rollback
36
- // leaves the bucket non-empty (CloudTrail writes logs immediately) and the
37
- // auto-delete Lambda was never created — causing ROLLBACK_FAILED.
38
- const autoDeleteResource = this.bucket.node.tryFindChild("AutoDeleteObjectsCustomResource");
39
- if (autoDeleteResource) {
40
- this.trail.node.addDependency(autoDeleteResource);
41
- }
69
+ }
70
+ trailArn(trailName) {
71
+ const { partition, region, account } = Stack.of(this);
72
+ return `arn:${partition}:cloudtrail:${region}:${account}:trail/${trailName}`;
73
+ }
74
+ /**
75
+ * Canonical CloudTrail delivery policy (ACL probe + log write), scoped to
76
+ * this trail's ARN via aws:SourceArn so no other trail — including one in
77
+ * another account — can deliver into or probe the bucket. Emitted from
78
+ * this construct rather than left to the L2 Trail because draining mode
79
+ * (omitTrail) skips the L2 construct while the trail being deleted may
80
+ * still deliver mid-update. Fjall-prefixed sids avoid colliding with the
81
+ * sid-less statements the L2 Trail adds when it is constructed.
82
+ */
83
+ addAccountTrailBucketPolicy(trailArn) {
84
+ const { account } = Stack.of(this);
85
+ const cloudTrailPrincipal = new ServicePrincipal("cloudtrail.amazonaws.com");
86
+ this.bucket.addToResourcePolicy(new PolicyStatement({
87
+ sid: "FjallCloudTrailAclCheck",
88
+ principals: [cloudTrailPrincipal],
89
+ actions: ["s3:GetBucketAcl"],
90
+ resources: [this.bucket.bucketArn],
91
+ conditions: {
92
+ StringEquals: { "aws:SourceArn": trailArn }
93
+ }
94
+ }));
95
+ this.bucket.addToResourcePolicy(new PolicyStatement({
96
+ sid: "FjallCloudTrailWrite",
97
+ principals: [cloudTrailPrincipal],
98
+ actions: ["s3:PutObject"],
99
+ resources: [this.bucket.arnForObjects(`AWSLogs/${account}/*`)],
100
+ conditions: {
101
+ StringEquals: {
102
+ "aws:SourceArn": trailArn,
103
+ "s3:x-amz-acl": "bucket-owner-full-control"
104
+ }
105
+ }
106
+ }));
107
+ }
108
+ /**
109
+ * Conditioned replacement for the KMS half of the retired grantReadWrite:
110
+ * `Bucket.grantReadWrite` silently granted the CloudTrail principal
111
+ * unconditioned Encrypt/Decrypt/ReEncrypt on the CMK because the bucket
112
+ * carries an encryptionKey. CloudTrail's actual needs are
113
+ * kms:GenerateDataKey* + kms:DescribeKey (trail-side log encryption,
114
+ * validated at CreateTrail) plus digest delivery via the bucket's default
115
+ * SSE-KMS — without these, trail creation fails with
116
+ * InsufficientEncryptionPolicyException.
117
+ */
118
+ addAccountTrailKeyPolicy(trailArn, bucketName) {
119
+ const { partition } = Stack.of(this);
120
+ const cloudTrailPrincipal = new ServicePrincipal("cloudtrail.amazonaws.com");
121
+ this.encryptionKey.key.addToResourcePolicy(new PolicyStatement({
122
+ sid: "FjallCloudTrailKeyUse",
123
+ principals: [cloudTrailPrincipal],
124
+ actions: ["kms:GenerateDataKey*", "kms:DescribeKey"],
125
+ resources: ["*"],
126
+ conditions: {
127
+ StringEquals: { "aws:SourceArn": trailArn }
128
+ }
129
+ }));
130
+ this.encryptionKey.key.addToResourcePolicy(new PolicyStatement({
131
+ sid: "FjallCloudTrailDigestDelivery",
132
+ principals: [cloudTrailPrincipal],
133
+ actions: ["kms:GenerateDataKey*", "kms:Decrypt"],
134
+ resources: ["*"],
135
+ conditions: {
136
+ // Digest files arrive via the bucket's default SSE-KMS. Two exact
137
+ // entries (bucket-level context + per-object contexts) — a bare
138
+ // `${bucketName}*` would also match sibling bucket NAMES sharing
139
+ // the prefix.
140
+ StringLike: {
141
+ "kms:EncryptionContext:aws:s3:arn": [
142
+ `arn:${partition}:s3:::${bucketName}`,
143
+ `arn:${partition}:s3:::${bucketName}/*`
144
+ ]
145
+ }
146
+ }
147
+ }));
148
+ }
149
+ /**
150
+ * The CDK L2 Trail contributes no KMS key-policy statements at all — it
151
+ * only sets kmsKeyId on the CfnTrail (verified in aws-cdk-lib 2.251.0).
152
+ * These three statements are therefore the entire CloudTrail-facing key
153
+ * policy: management-trail and member shadow-trail encryption plus S3
154
+ * digest delivery. None can be narrowed to member-only encryption contexts
155
+ * without breaking the management trail's own delivery.
156
+ */
157
+ addOrganisationTrailKeyPolicy(props, bucketName) {
158
+ const { partition, region, account } = Stack.of(this);
159
+ const cloudTrailPrincipal = new ServicePrincipal("cloudtrail.amazonaws.com");
160
+ const managementTrailArn = `arn:${partition}:cloudtrail:${region}:${account}:trail/${props.trailName}`;
161
+ this.encryptionKey.key.addToResourcePolicy(new PolicyStatement({
162
+ sid: "AllowOrganisationTrailEncrypt",
163
+ principals: [cloudTrailPrincipal],
164
+ actions: ["kms:GenerateDataKey*"],
165
+ resources: ["*"],
166
+ conditions: {
167
+ StringEquals: { "aws:SourceArn": managementTrailArn },
168
+ // Member shadow trails encrypt under the management account's trail
169
+ // namespace — one management-scoped wildcard covers every member.
170
+ StringLike: {
171
+ "kms:EncryptionContext:aws:cloudtrail:arn": `arn:${partition}:cloudtrail:*:${account}:trail/*`
172
+ }
173
+ }
174
+ }));
175
+ this.encryptionKey.key.addToResourcePolicy(new PolicyStatement({
176
+ sid: "AllowOrganisationTrailDescribeKey",
177
+ principals: [cloudTrailPrincipal],
178
+ actions: ["kms:DescribeKey"],
179
+ resources: ["*"],
180
+ conditions: {
181
+ StringEquals: { "aws:SourceArn": managementTrailArn }
182
+ }
183
+ }));
184
+ this.encryptionKey.key.addToResourcePolicy(new PolicyStatement({
185
+ sid: "AllowOrganisationTrailDigestDelivery",
186
+ principals: [cloudTrailPrincipal],
187
+ actions: ["kms:GenerateDataKey*", "kms:Decrypt"],
188
+ resources: ["*"],
189
+ conditions: {
190
+ // Digest files arrive via the bucket's default SSE-KMS. The literal
191
+ // bucket name (not bucket.bucketArn) avoids a Key↔Bucket
192
+ // dependency cycle at synth time. Two exact entries (bucket-level
193
+ // context + per-object contexts) — a bare `${bucketName}*` would
194
+ // also match sibling bucket NAMES sharing the prefix.
195
+ StringLike: {
196
+ "kms:EncryptionContext:aws:s3:arn": [
197
+ `arn:${partition}:s3:::${bucketName}`,
198
+ `arn:${partition}:s3:::${bucketName}/*`
199
+ ]
200
+ }
201
+ }
202
+ }));
42
203
  }
43
204
  }
44
205
  export class TrailStack extends Stack {
45
206
  constructor(scope, id, props) {
46
207
  super(scope, id);
208
+ validateTrailProps(props);
47
209
  new Trail(this, id, {
48
210
  ...props
49
211
  });
@@ -14,8 +14,9 @@ export interface EventBridgeBusProps {
14
14
  /** Override the default description ("EventBus <appName> — Fjall app event bus"). */
15
15
  description?: string;
16
16
  /**
17
- * Removal policy. Default resolves via the `env()` helper (production →
18
- * RETAIN; non-prod → DESTROY) — D17 explicitly rejects `process.env.NODE_ENV`
17
+ * Removal policy. Default resolves via `envAwareRemovalPolicyDefault()`
18
+ * (production → RETAIN; other recognised stages → DESTROY; unrecognised
19
+ * values fail synth) — D17 explicitly rejects `process.env.NODE_ENV`
19
20
  * because it is not set during CDK synth in Fjall's deployment paths.
20
21
  * Buses are recreatable in non-prod; production keeps history.
21
22
  */
@@ -1,7 +1,7 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CfnOutput } from "aws-cdk-lib";
3
3
  import { EventBus } from "aws-cdk-lib/aws-events";
4
- import { env } from "../../../utils/env.js";
4
+ import { envAwareRemovalPolicyDefault } from "../../../utils/removalPolicy.js";
5
5
  import { toRemovalPolicy } from "./utils.js";
6
6
  export class EventBridgeBus extends Construct {
7
7
  id;
@@ -17,7 +17,7 @@ export class EventBridgeBus extends Construct {
17
17
  (props.appName
18
18
  ? `EventBus ${props.appName} — Fjall app event bus`
19
19
  : undefined);
20
- const removalPolicyValue = props.removalPolicy ?? env({ default: "DESTROY", production: "RETAIN" });
20
+ const removalPolicyValue = props.removalPolicy ?? envAwareRemovalPolicyDefault();
21
21
  const ownedBus = new EventBus(this, `${id}EventBus`, {
22
22
  eventBusName: props.eventBusName,
23
23
  description
@@ -8,8 +8,11 @@ import { CustomResource } from "../utilities/customResource.js";
8
8
  import getAccountId from "../../../utils/getAccountId.js";
9
9
  import { accountConstructKey, findAccountNameCollision } from "../../../utils/capitaliseString.js";
10
10
  import { FjallLogger } from "../../../utils/validationLogger.js";
11
+ import { formatIpamPairTagValue, IPAM_OPERATIONS_POOL_TAG_KEY } from "@fjall/util/aws";
11
12
  const IPAM_TAGS = {
12
- OPERATIONS_POOL: "fjall:operations:pool",
13
+ // Shared with the deploy-core readiness probe, which matches pools by this
14
+ // key + the pair value format — see @fjall/util/aws ipamTags.
15
+ OPERATIONS_POOL: IPAM_OPERATIONS_POOL_TAG_KEY,
13
16
  COST_ALLOCATION_ENVIRONMENT: "fjall:costAllocation:environment",
14
17
  COST_ALLOCATION_ACCOUNT_NAME: "fjall:costAllocation:accountName"
15
18
  };
@@ -134,14 +137,14 @@ export class IpamPool extends Construct {
134
137
  allocationResourceTags: [
135
138
  {
136
139
  key: IPAM_TAGS.OPERATIONS_POOL,
137
- value: `${accountId}-${region}`
140
+ value: formatIpamPairTagValue(accountId, region)
138
141
  }
139
142
  ],
140
143
  autoImport: true,
141
144
  tags: [
142
145
  {
143
146
  key: IPAM_TAGS.OPERATIONS_POOL,
144
- value: `${accountId}-${region}`
147
+ value: formatIpamPairTagValue(accountId, region)
145
148
  },
146
149
  {
147
150
  key: IPAM_TAGS.COST_ALLOCATION_ACCOUNT_NAME,
@@ -18,9 +18,10 @@ export interface ServiceDiscoveryNamespaceProps {
18
18
  /** Override the default description (`"ServiceDiscovery <name> — Fjall private DNS namespace"`). */
19
19
  description?: string;
20
20
  /**
21
- * Removal policy. Default resolves via the `env()` helper (production →
22
- * RETAIN; non-prod DESTROY) matches the convention codified in D17 of
23
- * the EventBridge promotion design. NODE_ENV is intentionally NOT consulted
21
+ * Removal policy. Default resolves via `envAwareRemovalPolicyDefault()`
22
+ * (productionRETAIN; other recognised stages DESTROY; unrecognised
23
+ * values fail synth) matches the convention codified in D17 of the
24
+ * EventBridge promotion design. NODE_ENV is intentionally NOT consulted
24
25
  * because it is unset during CDK synth in Fjall's deployment paths.
25
26
  */
26
27
  removalPolicy?: "DESTROY" | "RETAIN";
@@ -1,8 +1,7 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CfnOutput, Duration } from "aws-cdk-lib";
3
3
  import { PrivateDnsNamespace, DnsRecordType } from "aws-cdk-lib/aws-servicediscovery";
4
- import { env } from "../../../utils/env.js";
5
- import { toRemovalPolicy } from "../../../utils/removalPolicy.js";
4
+ import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
6
5
  export class ServiceDiscoveryNamespace extends Construct {
7
6
  id;
8
7
  #namespace;
@@ -12,7 +11,7 @@ export class ServiceDiscoveryNamespace extends Construct {
12
11
  this.id = id;
13
12
  const description = props.description ??
14
13
  `ServiceDiscovery ${props.name} — Fjall private DNS namespace`;
15
- const removalPolicyValue = props.removalPolicy ?? env({ default: "DESTROY", production: "RETAIN" });
14
+ const removalPolicyValue = props.removalPolicy ?? envAwareRemovalPolicyDefault();
16
15
  const namespace = new PrivateDnsNamespace(this, `${id}Namespace`, {
17
16
  vpc: props.vpc,
18
17
  name: props.name,
@@ -1,10 +1,18 @@
1
1
  import { Bucket, type BucketProps } from "aws-cdk-lib/aws-s3";
2
2
  import { type Construct } from "constructs";
3
3
  import { type BackupTier } from "../../../utils/backupTierMapping.js";
4
+ export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
4
5
  export interface WebsiteHostingConfig {
5
6
  readonly indexDocument: string;
6
7
  readonly errorDocument?: string;
7
8
  }
9
+ /**
10
+ * Props for {@link S3Bucket}.
11
+ *
12
+ * `autoDeleteObjects` is accepted for source compatibility but always
13
+ * overridden to `false` (ADR D4.3) — passing `true` raises a synth-time
14
+ * warning rather than a compile error so older scaffolds keep building.
15
+ */
8
16
  export interface S3BucketProps extends BucketProps {
9
17
  backupVaultTier?: BackupTier;
10
18
  publicReadAccess?: boolean;
@@ -1,7 +1,10 @@
1
- import { CfnOutput, Duration, RemovalPolicy } from "aws-cdk-lib";
1
+ import { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
2
+ import { Annotations, CfnOutput, Duration, RemovalPolicy, Tags } from "aws-cdk-lib";
2
3
  import { BlockPublicAccess, Bucket } from "aws-cdk-lib/aws-s3";
3
4
  import { RegionInfo } from "aws-cdk-lib/region-info";
4
5
  import { toPascalCase } from "../../../utils/capitaliseString.js";
6
+ import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
7
+ export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
5
8
  function shouldAutoVersion(tier) {
6
9
  return tier === "resilient" || tier === "enterprise";
7
10
  }
@@ -10,13 +13,16 @@ export class S3Bucket extends Bucket {
10
13
  constructor(scope, id, props = {}) {
11
14
  const { websiteHosting, backupVaultTier, ...cdkProps } = props;
12
15
  const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
13
- const isRetained = props.removalPolicy === RemovalPolicy.RETAIN;
14
16
  const versioned = props.versioned ?? shouldAutoVersion(backupVaultTier);
17
+ const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
15
18
  super(scope, id, {
16
19
  ...cdkProps,
17
20
  enforceSSL: true,
18
- autoDeleteObjects: !isRetained,
19
- removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY,
21
+ // autoDeleteObjects retired (ADR D4.3): the CDK custom resource's deny
22
+ // policy is the quarantine mechanism — destroy-time emptying is done
23
+ // SDK-side by deploy-core's pre-empty pass, keyed off the marker tag.
24
+ autoDeleteObjects: false,
25
+ removalPolicy,
20
26
  publicReadAccess: isPublic,
21
27
  ...(isPublic && {
22
28
  blockPublicAccess: new BlockPublicAccess({
@@ -36,6 +42,15 @@ export class S3Bucket extends Bucket {
36
42
  : props.lifecycleRules
37
43
  });
38
44
  this.backupVaultTier = backupVaultTier;
45
+ if (props.autoDeleteObjects === true) {
46
+ Annotations.of(this).addWarningV2("@fjall/components-infrastructure:s3:autoDeleteObjectsIgnored", "autoDeleteObjects: true is ignored — the CDK auto-delete custom " +
47
+ "resource is retired (ADR D4.3). DESTROY buckets are emptied " +
48
+ "SDK-side by deploy-core's pre-empty pass via the " +
49
+ `${SDK_PRE_EMPTY_TAG_KEY} marker tag. Remove the prop.`);
50
+ }
51
+ if (removalPolicy === RemovalPolicy.DESTROY) {
52
+ Tags.of(this).add(SDK_PRE_EMPTY_TAG_KEY, "true");
53
+ }
39
54
  if (websiteHosting) {
40
55
  const safeBucket = toPascalCase((props.bucketName ?? id).replace(/[^A-Za-z0-9-]/g, ""));
41
56
  new CfnOutput(this, `${safeBucket}WebsiteEndpoint`, {
@@ -1,9 +1,20 @@
1
1
  import type { Node } from "constructs";
2
+ import { ACCOUNT_TRAIL_STATES, type AccountTrailState } from "@fjall/util/config";
2
3
  export declare const CDK_CONTEXT_KEYS: {
3
4
  readonly ORG_ID: "orgId";
4
5
  readonly ROOT_ID: "rootId";
5
6
  readonly MANAGEMENT_ACCOUNT_ID: "managementAccountId";
7
+ readonly ORG_CONFIG: "orgConfig";
8
+ readonly ACCOUNT_TRAIL_STATE: "fjallAccountTrailState";
6
9
  };
10
+ export { ACCOUNT_TRAIL_STATES, type AccountTrailState };
11
+ /**
12
+ * Resolves the per-account management-events-trail lifecycle state from CDK
13
+ * context. Unset (or the `-c key=` empty-string boundary) means `active`.
14
+ * An unrecognised value throws rather than defaulting: a typo arriving
15
+ * mid-decommission must fail the synth, not silently re-create a trail.
16
+ */
17
+ export declare function resolveAccountTrailState(node: Node): AccountTrailState;
7
18
  export declare const DEFAULT_ORG_ID: "default";
8
19
  export declare function resolveOrgId(node: Node, fallback: string): string;
9
20
  export declare function resolveOrgId(node: Node): string | undefined;
@@ -1,8 +1,29 @@
1
+ import { ACCOUNT_TRAIL_STATES } from "@fjall/util/config";
1
2
  export const CDK_CONTEXT_KEYS = {
2
3
  ORG_ID: "orgId",
3
4
  ROOT_ID: "rootId",
4
- MANAGEMENT_ACCOUNT_ID: "managementAccountId"
5
+ MANAGEMENT_ACCOUNT_ID: "managementAccountId",
6
+ ORG_CONFIG: "orgConfig",
7
+ ACCOUNT_TRAIL_STATE: "fjallAccountTrailState"
5
8
  };
9
+ export { ACCOUNT_TRAIL_STATES };
10
+ /**
11
+ * Resolves the per-account management-events-trail lifecycle state from CDK
12
+ * context. Unset (or the `-c key=` empty-string boundary) means `active`.
13
+ * An unrecognised value throws rather than defaulting: a typo arriving
14
+ * mid-decommission must fail the synth, not silently re-create a trail.
15
+ */
16
+ export function resolveAccountTrailState(node) {
17
+ const raw = node.tryGetContext(CDK_CONTEXT_KEYS.ACCOUNT_TRAIL_STATE);
18
+ if (raw === undefined || raw === "") {
19
+ return "active";
20
+ }
21
+ if (typeof raw === "string" &&
22
+ ACCOUNT_TRAIL_STATES.includes(raw)) {
23
+ return raw;
24
+ }
25
+ throw new Error(`${CDK_CONTEXT_KEYS.ACCOUNT_TRAIL_STATE} must be one of ${ACCOUNT_TRAIL_STATES.join(", ")}; received "${String(raw)}"`);
26
+ }
6
27
  export const DEFAULT_ORG_ID = "default";
7
28
  export function resolveOrgId(node, fallback) {
8
29
  const raw = node.tryGetContext(CDK_CONTEXT_KEYS.ORG_ID);
@@ -16,6 +16,12 @@
16
16
  type EnvConfig<T> = {
17
17
  default: T;
18
18
  } & Partial<Record<string, T>>;
19
+ /**
20
+ * Sentinel returned by `getEnvironment()` when no environment signal exists.
21
+ * Consumers that need to distinguish "no signal" from "unrecognised value"
22
+ * (e.g. `envAwareRemovalPolicyDefault()`) compare against this.
23
+ */
24
+ export declare const UNKNOWN_ENVIRONMENT = "unknown";
19
25
  /**
20
26
  * Resolve the current deployment environment without App dependency.
21
27
  *
@@ -26,8 +32,21 @@ type EnvConfig<T> = {
26
32
  * 4. `CDK_DEFAULT_ACCOUNT` → providerAccounts lookup
27
33
  * 5. `-c accountName=<value>` → providerAccounts lookup
28
34
  * 6. `"unknown"` (with warning)
35
+ *
36
+ * Deliberately diverges from utils/getConfig.ts, which is App-scoped and
37
+ * account-aware: there `-c environment` context wins and ENVIRONMENT is a
38
+ * last-resort fallback. This helper is pre-App and process-wide, so it treats
39
+ * ENVIRONMENT as the dominant ambient signal.
29
40
  */
30
41
  export declare function getEnvironment(): string;
42
+ /**
43
+ * True when the environment came from an explicit operator signal
44
+ * (`ENVIRONMENT` env var or `-c environment=`) rather than account-lookup
45
+ * inference or the no-signal fallback. Lets consumers distinguish an
46
+ * explicitly-supplied literal "unknown" from the `UNKNOWN_ENVIRONMENT`
47
+ * sentinel meaning "no signal at all".
48
+ */
49
+ export declare function hasExplicitEnvironmentSignal(): boolean;
31
50
  /**
32
51
  * Resolve an environment-specific value at CDK synth time.
33
52
  *
@@ -1,5 +1,12 @@
1
- import { parseOrgConfig } from "./orgConfigParser.js";
1
+ import { CDK_CONTEXT_KEYS } from "./cdkContext.js";
2
+ import { parseOrgConfig, resolveSynthEnvironment } from "./orgConfigParser.js";
2
3
  import { FjallLogger } from "./validationLogger.js";
4
+ /**
5
+ * Sentinel returned by `getEnvironment()` when no environment signal exists.
6
+ * Consumers that need to distinguish "no signal" from "unrecognised value"
7
+ * (e.g. `envAwareRemovalPolicyDefault()`) compare against this.
8
+ */
9
+ export const UNKNOWN_ENVIRONMENT = "unknown";
3
10
  let cachedEnvironment;
4
11
  /**
5
12
  * Resolve the current deployment environment without App dependency.
@@ -11,6 +18,11 @@ let cachedEnvironment;
11
18
  * 4. `CDK_DEFAULT_ACCOUNT` → providerAccounts lookup
12
19
  * 5. `-c accountName=<value>` → providerAccounts lookup
13
20
  * 6. `"unknown"` (with warning)
21
+ *
22
+ * Deliberately diverges from utils/getConfig.ts, which is App-scoped and
23
+ * account-aware: there `-c environment` context wins and ENVIRONMENT is a
24
+ * last-resort fallback. This helper is pre-App and process-wide, so it treats
25
+ * ENVIRONMENT as the dominant ambient signal.
14
26
  */
15
27
  export function getEnvironment() {
16
28
  if (cachedEnvironment !== undefined) {
@@ -48,9 +60,23 @@ export function getEnvironment() {
48
60
  // 5. Fallback
49
61
  FjallLogger.warn("[fjall] Could not determine environment. " +
50
62
  "Set ENVIRONMENT env var or pass -c environment=<value>.");
51
- cachedEnvironment = "unknown";
63
+ cachedEnvironment = UNKNOWN_ENVIRONMENT;
52
64
  return cachedEnvironment;
53
65
  }
66
+ /**
67
+ * True when the environment came from an explicit operator signal
68
+ * (`ENVIRONMENT` env var or `-c environment=`) rather than account-lookup
69
+ * inference or the no-signal fallback. Lets consumers distinguish an
70
+ * explicitly-supplied literal "unknown" from the `UNKNOWN_ENVIRONMENT`
71
+ * sentinel meaning "no signal at all".
72
+ */
73
+ export function hasExplicitEnvironmentSignal() {
74
+ const envVar = process.env.ENVIRONMENT;
75
+ if (envVar !== undefined && envVar !== "")
76
+ return true;
77
+ const ctxValue = parseContextArg("environment");
78
+ return ctxValue !== undefined && ctxValue !== "";
79
+ }
54
80
  /**
55
81
  * Resolve an environment-specific value at CDK synth time.
56
82
  *
@@ -92,19 +118,24 @@ function parseContextArg(key) {
92
118
  * Get provider accounts from orgConfig in CDK context (-c orgConfig=<json>).
93
119
  */
94
120
  function getProviderAccountsFromContext() {
95
- return parseOrgConfig(parseContextArg("orgConfig")).providerAccounts;
121
+ return parseOrgConfig(parseContextArg(CDK_CONTEXT_KEYS.ORG_CONFIG))
122
+ .providerAccounts;
96
123
  }
97
124
  /**
98
125
  * Look up environment from account ID via orgConfig CDK context.
126
+ * Organisation-tier accounts resolve to "root" (resolveSynthEnvironment).
99
127
  */
100
128
  function resolveEnvironmentFromAccountId(accountId) {
101
129
  const accounts = getProviderAccountsFromContext();
102
- return accounts.find((pa) => pa.id === accountId)?.environment ?? undefined;
130
+ const account = accounts.find((pa) => pa.id === accountId);
131
+ return account ? resolveSynthEnvironment(account) : undefined;
103
132
  }
104
133
  /**
105
134
  * Look up environment from account name via orgConfig CDK context.
135
+ * Organisation-tier accounts resolve to "root" (resolveSynthEnvironment).
106
136
  */
107
137
  function resolveEnvironmentFromAccountName(accountName) {
108
138
  const accounts = getProviderAccountsFromContext();
109
- return (accounts.find((pa) => pa.name === accountName)?.environment ?? undefined);
139
+ const account = accounts.find((pa) => pa.name === accountName);
140
+ return account ? resolveSynthEnvironment(account) : undefined;
110
141
  }
@@ -1,5 +1,6 @@
1
1
  import App from "../app.js";
2
- import { parseOrgConfig } from "./orgConfigParser.js";
2
+ import { CDK_CONTEXT_KEYS } from "./cdkContext.js";
3
+ import { parseOrgConfig, resolveSynthEnvironment } from "./orgConfigParser.js";
3
4
  import { findAccountNameCollision } from "./capitaliseString.js";
4
5
  import { FjallLogger } from "./validationLogger.js";
5
6
  export function getConfig(accountName) {
@@ -14,7 +15,7 @@ export function getConfig(accountName) {
14
15
  if (!process.env.AWS_REGION)
15
16
  FjallLogger.warn("AWS_REGION is not set, defaulting to us-east-1");
16
17
  // Primary: read org config from CDK context (injected by CLI/worker)
17
- const orgConfigRaw = app.node.tryGetContext("orgConfig");
18
+ const orgConfigRaw = app.node.tryGetContext(CDK_CONTEXT_KEYS.ORG_CONFIG);
18
19
  if (typeof orgConfigRaw !== "string") {
19
20
  FjallLogger.warn("No orgConfig context provided — org-level config (accounts, regions) will be empty");
20
21
  }
@@ -53,41 +54,60 @@ export function getConfig(accountName) {
53
54
  // If unspecified account name - try to retrieve from context
54
55
  if (!accountName) {
55
56
  const contextAccountName = app.node.tryGetContext("accountName");
56
- if (typeof contextAccountName === "string") {
57
+ if (typeof contextAccountName === "string" && contextAccountName !== "") {
57
58
  accountName = contextAccountName;
58
59
  }
59
60
  }
60
61
  // If we have an account name - look for associated provider account
62
+ let stageIsStructurallyNull = false;
61
63
  if (accountName) {
62
64
  const providerAccount = providerAccounts.find((pa) => pa.name === accountName);
63
65
  if (providerAccount) {
64
- config.accountId = providerAccount.id;
65
- config.accountName = providerAccount.name;
66
- config.environment = providerAccount.environment ?? "unknown";
66
+ stageIsStructurallyNull = applyProviderAccount(config, providerAccount);
67
67
  }
68
68
  }
69
69
  // If we still don't have an account name - try to retrieve accountId from context
70
70
  if (!config.accountName) {
71
71
  const accountId = app.node.tryGetContext("accountId");
72
72
  // If we find an accountId - retrieve the associated account from config
73
- if (typeof accountId === "string") {
73
+ if (typeof accountId === "string" && accountId !== "") {
74
74
  config.accountId = accountId;
75
75
  const providerAccount = providerAccounts.find((pa) => pa.id === accountId);
76
76
  if (providerAccount) {
77
- config.accountName = providerAccount.name;
78
- config.environment = providerAccount.environment ?? "unknown";
77
+ stageIsStructurallyNull = applyProviderAccount(config, providerAccount);
79
78
  }
80
79
  }
81
80
  }
82
- // Check for environment from context (highest priority)
81
+ // Check for environment from context (highest priority). Resolution order
82
+ // deliberately diverges from utils/env.ts getEnvironment() (pre-App,
83
+ // process-wide: ENVIRONMENT first): here explicit `-c environment` context
84
+ // wins, and the ENVIRONMENT env var is a last-resort fallback that must
85
+ // never override an account-resolved stage — including a structurally-null
86
+ // one (organisation/platform tiers carry no workload stage).
83
87
  const contextEnvironment = app.node.tryGetContext("environment");
84
- if (typeof contextEnvironment === "string") {
88
+ if (typeof contextEnvironment === "string" && contextEnvironment !== "") {
85
89
  config.environment = contextEnvironment;
86
90
  }
87
- else if (process.env.ENVIRONMENT && config.environment === "unknown") {
91
+ else if (process.env.ENVIRONMENT &&
92
+ config.environment === "unknown" &&
93
+ !stageIsStructurallyNull) {
88
94
  // Fall back to ENVIRONMENT variable if no other source found
89
95
  config.environment = process.env.ENVIRONMENT;
90
96
  }
91
97
  return config;
92
98
  }
99
+ /**
100
+ * Copy a resolved provider account onto the config. The organisation tier
101
+ * resolves to "root" (via resolveSynthEnvironment) so scaffolded org gates
102
+ * fire; other tiers carry their stage verbatim. Returns true when the
103
+ * environment stays unresolved (structurally-null stage) so the caller can
104
+ * suppress the ENVIRONMENT env-var fallback.
105
+ */
106
+ function applyProviderAccount(config, providerAccount) {
107
+ config.accountId = providerAccount.id;
108
+ config.accountName = providerAccount.name;
109
+ const resolved = resolveSynthEnvironment(providerAccount);
110
+ config.environment = resolved ?? "unknown";
111
+ return resolved === undefined;
112
+ }
93
113
  export default getConfig;