@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.
- package/dist/lib/app.d.ts +7 -7
- package/dist/lib/app.js +2 -3
- package/dist/lib/config/aws/accountMonitoringRole.js +2 -1
- package/dist/lib/config/aws/cloudTrail.d.ts +13 -0
- package/dist/lib/config/aws/cloudTrail.js +19 -3
- package/dist/lib/config/aws/disasterRecovery.js +1 -1
- package/dist/lib/config/aws/ecrDefaultImage.js +2 -0
- package/dist/lib/config/aws/organisationTrail.d.ts +16 -0
- package/dist/lib/config/aws/organisationTrail.js +32 -0
- package/dist/lib/config/aws/scpPreset.js +10 -1
- package/dist/lib/patterns/aws/account.d.ts +9 -0
- package/dist/lib/patterns/aws/account.js +29 -6
- package/dist/lib/patterns/aws/organisation.d.ts +9 -0
- package/dist/lib/patterns/aws/organisation.js +51 -5
- package/dist/lib/patterns/aws/storage.d.ts +1 -1
- package/dist/lib/patterns/aws/storage.js +5 -1
- package/dist/lib/resources/aws/logging/cloudTrail.d.ts +48 -1
- package/dist/lib/resources/aws/logging/cloudTrail.js +180 -18
- package/dist/lib/resources/aws/messaging/eventbridge.d.ts +3 -2
- package/dist/lib/resources/aws/messaging/eventbridge.js +2 -2
- package/dist/lib/resources/aws/networking/ipamPool.js +6 -3
- package/dist/lib/resources/aws/networking/serviceDiscovery.d.ts +4 -3
- package/dist/lib/resources/aws/networking/serviceDiscovery.js +2 -3
- package/dist/lib/resources/aws/storage/s3.d.ts +8 -0
- package/dist/lib/resources/aws/storage/s3.js +19 -4
- package/dist/lib/utils/cdkContext.d.ts +11 -0
- package/dist/lib/utils/cdkContext.js +22 -1
- package/dist/lib/utils/env.d.ts +19 -0
- package/dist/lib/utils/env.js +36 -5
- package/dist/lib/utils/getConfig.js +32 -12
- package/dist/lib/utils/orgConfigParser.d.ts +10 -0
- package/dist/lib/utils/orgConfigParser.js +47 -23
- package/dist/lib/utils/removalPolicy.d.ts +15 -0
- package/dist/lib/utils/removalPolicy.js +32 -0
- package/dist/lib/utils/standardTagsAspect.js +2 -1
- 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
|
-
|
|
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:
|
|
33
|
+
removalPolicy: storagePolicy
|
|
16
34
|
});
|
|
17
35
|
this.bucket = new S3Bucket(this, `${id}CloudTrailBucket`, {
|
|
18
|
-
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
|
-
|
|
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
|
-
...
|
|
60
|
+
...trailProps,
|
|
28
61
|
bucket: this.bucket,
|
|
29
|
-
trailName:
|
|
30
|
-
encryptionKey: encryptionKey.key
|
|
62
|
+
trailName: effectiveTrailName,
|
|
63
|
+
encryptionKey: this.encryptionKey.key
|
|
31
64
|
});
|
|
32
|
-
//
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
18
|
-
* RETAIN;
|
|
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 {
|
|
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 ??
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Removal policy. Default resolves via `envAwareRemovalPolicyDefault()`
|
|
22
|
+
* (production → RETAIN; 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 {
|
|
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 ??
|
|
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 {
|
|
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:
|
|
19
|
-
|
|
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);
|
package/dist/lib/utils/env.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/lib/utils/env.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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;
|