@fjall/components-infrastructure 2.12.0 → 2.13.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/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/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/resources/aws/logging/cloudTrail.d.ts +26 -1
- package/dist/lib/resources/aws/logging/cloudTrail.js +98 -9
- package/dist/lib/utils/cdkContext.d.ts +11 -0
- package/dist/lib/utils/cdkContext.js +22 -1
- package/dist/lib/utils/env.d.ts +5 -0
- package/dist/lib/utils/env.js +15 -4
- 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/package.json +4 -4
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
+
import type { AccountTrailState } from "../../utils/cdkContext.js";
|
|
2
3
|
export interface ManagementEventsTrailProps {
|
|
3
4
|
accountId: string;
|
|
4
5
|
region: string;
|
|
6
|
+
/**
|
|
7
|
+
* "active" (default) synthesises trail + bucket + CMK. "draining" omits the
|
|
8
|
+
* trail resource so CloudFormation deletes it, while the bucket + CMK keep
|
|
9
|
+
* their logical IDs and retained history. "removed" never reaches this
|
|
10
|
+
* construct — Account skips construction entirely.
|
|
11
|
+
*/
|
|
12
|
+
lifecycleState?: Exclude<AccountTrailState, "removed">;
|
|
13
|
+
/**
|
|
14
|
+
* Bucket + CMK survive stack deletion. Default true; Account passes false
|
|
15
|
+
* for development environments to avoid retained-orphan churn.
|
|
16
|
+
*/
|
|
17
|
+
retainAuditHistory?: boolean;
|
|
5
18
|
}
|
|
6
19
|
export declare class ManagementEventsTrail extends Construct {
|
|
7
20
|
constructor(scope: Construct, id: string, props: ManagementEventsTrailProps);
|
|
@@ -1,12 +1,28 @@
|
|
|
1
|
+
import { CfnOutput } from "aws-cdk-lib";
|
|
1
2
|
import { Construct } from "constructs";
|
|
3
|
+
import { ACCOUNT_TRAIL_NAME, TRAIL_BUCKET_OUTPUT_KEY, TRAIL_KEY_ARN_OUTPUT_KEY } from "@fjall/util/config";
|
|
2
4
|
import { Trail } from "../../resources/aws/logging/cloudTrail.js";
|
|
3
5
|
export class ManagementEventsTrail extends Construct {
|
|
4
6
|
constructor(scope, id, props) {
|
|
5
7
|
super(scope, id);
|
|
6
|
-
new Trail(this, "ManagementEventsTrail", {
|
|
8
|
+
const trail = new Trail(this, "ManagementEventsTrail", {
|
|
7
9
|
bucketName: `cloudtrail-management-events-${props.accountId}-${props.region}`,
|
|
8
|
-
trailName:
|
|
9
|
-
isMultiRegionTrail: true
|
|
10
|
+
trailName: ACCOUNT_TRAIL_NAME,
|
|
11
|
+
isMultiRegionTrail: true,
|
|
12
|
+
retainAuditHistory: props.retainAuditHistory ?? true,
|
|
13
|
+
omitTrail: props.lifecycleState === "draining"
|
|
14
|
+
});
|
|
15
|
+
new CfnOutput(this, "TrailBucketNameOutput", {
|
|
16
|
+
key: TRAIL_BUCKET_OUTPUT_KEY,
|
|
17
|
+
value: trail.bucket.bucketName,
|
|
18
|
+
exportName: TRAIL_BUCKET_OUTPUT_KEY,
|
|
19
|
+
description: "Per-account CloudTrail bucket, read by the org-trail migration reconciler"
|
|
20
|
+
});
|
|
21
|
+
new CfnOutput(this, "TrailKeyArnOutput", {
|
|
22
|
+
key: TRAIL_KEY_ARN_OUTPUT_KEY,
|
|
23
|
+
value: trail.encryptionKey.key.keyArn,
|
|
24
|
+
exportName: TRAIL_KEY_ARN_OUTPUT_KEY,
|
|
25
|
+
description: "Per-account CloudTrail CMK, read by the org-trail migration reconciler"
|
|
10
26
|
});
|
|
11
27
|
}
|
|
12
28
|
}
|
|
@@ -6,6 +6,7 @@ import { cronSchedule } from "../../resources/aws/messaging/index.js";
|
|
|
6
6
|
import { AccountPrincipal, Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
7
7
|
import { getConfig } from "../../utils/getConfig.js";
|
|
8
8
|
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
9
|
+
import { BACKUP_VAULT_NAME } from "@fjall/util";
|
|
9
10
|
// Backup retention constants (in days)
|
|
10
11
|
const RETENTION_PERIODS = {
|
|
11
12
|
STANDARD: 90,
|
|
@@ -21,7 +22,6 @@ const BACKUP_WINDOWS = {
|
|
|
21
22
|
COMPLETION: Duration.hours(12),
|
|
22
23
|
COMPLETION_SHORT: Duration.hours(2)
|
|
23
24
|
};
|
|
24
|
-
const BACKUP_VAULT_NAME = "backupVault";
|
|
25
25
|
// Vault lock constants
|
|
26
26
|
const VAULT_LOCK = {
|
|
27
27
|
MIN_RETENTION: Duration.days(365),
|
|
@@ -2,6 +2,7 @@ import { Construct } from "constructs";
|
|
|
2
2
|
import { CodeBuildProject } from "../../resources/aws/utilities/codeBuild.js";
|
|
3
3
|
import { BuildSpec } from "aws-cdk-lib/aws-codebuild";
|
|
4
4
|
import { LogGroup } from "../../resources/aws/logging/logGroup.js";
|
|
5
|
+
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
5
6
|
import { RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
6
7
|
import { Role } from "../../resources/aws/iam/role.js";
|
|
7
8
|
import { PolicyDocument, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
@@ -11,6 +12,7 @@ export class EcrDefaultImage extends Construct {
|
|
|
11
12
|
super(scope, id);
|
|
12
13
|
const logGroup = new LogGroup(this, `${id}LogGroup`, {
|
|
13
14
|
logGroupName: `/vpc/codebuild/${id}/`,
|
|
15
|
+
retention: RetentionDays.ONE_MONTH,
|
|
14
16
|
removalPolicy: RemovalPolicy.DESTROY
|
|
15
17
|
});
|
|
16
18
|
const ecrDefaultImageRole = new Role(this, `${id}Role`, {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Construct } from "constructs";
|
|
2
|
+
export interface OrganisationTrailProps {
|
|
3
|
+
orgId: string;
|
|
4
|
+
managementAccountId: string;
|
|
5
|
+
region: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Organisation-wide management-events trail, owned by the management account.
|
|
9
|
+
* Replaces per-account ManagementEventsTrail instances in organisation mode:
|
|
10
|
+
* AWS records every member's management events into the one bucket under
|
|
11
|
+
* AWSLogs/<orgId>/<accountId>/, and members cannot stop or alter the shadow
|
|
12
|
+
* trail AWS materialises for them.
|
|
13
|
+
*/
|
|
14
|
+
export declare class OrganisationTrail extends Construct {
|
|
15
|
+
constructor(scope: Construct, id: string, props: OrganisationTrailProps);
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CfnOutput } from "aws-cdk-lib";
|
|
2
|
+
import { Construct } from "constructs";
|
|
3
|
+
import { ORG_TRAIL_BUCKET_OUTPUT_KEY, ORGANISATION_TRAIL_NAME } from "@fjall/util/config";
|
|
4
|
+
import { Trail } from "../../resources/aws/logging/cloudTrail.js";
|
|
5
|
+
/**
|
|
6
|
+
* Organisation-wide management-events trail, owned by the management account.
|
|
7
|
+
* Replaces per-account ManagementEventsTrail instances in organisation mode:
|
|
8
|
+
* AWS records every member's management events into the one bucket under
|
|
9
|
+
* AWSLogs/<orgId>/<accountId>/, and members cannot stop or alter the shadow
|
|
10
|
+
* trail AWS materialises for them.
|
|
11
|
+
*/
|
|
12
|
+
export class OrganisationTrail extends Construct {
|
|
13
|
+
constructor(scope, id, props) {
|
|
14
|
+
super(scope, id);
|
|
15
|
+
const trail = new Trail(this, "OrganisationTrail", {
|
|
16
|
+
// "org" short form keeps the name inside S3's 63-character limit for
|
|
17
|
+
// long region names.
|
|
18
|
+
bucketName: `cloudtrail-org-management-events-${props.managementAccountId}-${props.region}`,
|
|
19
|
+
trailName: ORGANISATION_TRAIL_NAME,
|
|
20
|
+
isMultiRegionTrail: true,
|
|
21
|
+
isOrganizationTrail: true,
|
|
22
|
+
orgId: props.orgId,
|
|
23
|
+
retainAuditHistory: true
|
|
24
|
+
});
|
|
25
|
+
new CfnOutput(this, "OrganisationTrailBucketNameOutput", {
|
|
26
|
+
key: ORG_TRAIL_BUCKET_OUTPUT_KEY,
|
|
27
|
+
value: trail.bucket.bucketName,
|
|
28
|
+
exportName: ORG_TRAIL_BUCKET_OUTPUT_KEY,
|
|
29
|
+
description: "Organisation trail bucket, read by the org-trail migration reconciler"
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -18,6 +18,7 @@ export interface AccountProps extends StackProps {
|
|
|
18
18
|
export declare class Account extends Stack {
|
|
19
19
|
readonly organisationType: OrganisationType;
|
|
20
20
|
protected readonly resolvedRegion: string;
|
|
21
|
+
protected readonly accountGlobalsConfigured: boolean;
|
|
21
22
|
constructor(scope: Construct, id: string, props: AccountProps);
|
|
22
23
|
/**
|
|
23
24
|
* Whether this account receives the OIDC deploy connector (provider +
|
|
@@ -34,6 +35,14 @@ export declare class Account extends Stack {
|
|
|
34
35
|
* Deliberately separate from the OIDC/IPAM gates — the axes are independent.
|
|
35
36
|
*/
|
|
36
37
|
protected createsEcrDefaultImage(): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Whether this account owns its own management-events trail. Standalone,
|
|
40
|
+
* Platform, and member accounts do (subject to the fjallAccountTrailState
|
|
41
|
+
* decommission lifecycle); the organisation root overrides to `false`
|
|
42
|
+
* because its OrganisationTrail records this account's events too.
|
|
43
|
+
* Deliberately separate from the OIDC/ECR gates — the axes are independent.
|
|
44
|
+
*/
|
|
45
|
+
protected createsManagementEventsTrail(): boolean;
|
|
37
46
|
enableGuardDuty(props?: GuardDutyDetectorProps): GuardDutyDetector;
|
|
38
47
|
enableSecurityHub(props?: SecurityHubHubProps): SecurityHubHub;
|
|
39
48
|
enableConfigRecorder(props?: ConfigRecorderProps): ConfigRecorder;
|
|
@@ -4,7 +4,7 @@ import { ManagementEventsTrail } from "../../config/aws/cloudTrail.js";
|
|
|
4
4
|
import { OidcConnector } from "../../config/aws/oidcConnector.js";
|
|
5
5
|
import { AccountMonitoringRole } from "../../config/aws/accountMonitoringRole.js";
|
|
6
6
|
import { AccountAuditRole } from "../../config/aws/accountAuditRole.js";
|
|
7
|
-
import { CDK_CONTEXT_KEYS } from "../../utils/cdkContext.js";
|
|
7
|
+
import { CDK_CONTEXT_KEYS, resolveAccountTrailState } from "../../utils/cdkContext.js";
|
|
8
8
|
import { getConfig } from "../../utils/getConfig.js";
|
|
9
9
|
import { DisasterRecovery } from "../../config/aws/disasterRecovery.js";
|
|
10
10
|
import { S3BlockPublicAccess } from "../../config/aws/s3BlockPublicAccess.js";
|
|
@@ -18,6 +18,7 @@ import { InspectorEnablement } from "../../config/aws/inspectorEnablement.js";
|
|
|
18
18
|
export class Account extends Stack {
|
|
19
19
|
organisationType = "account";
|
|
20
20
|
resolvedRegion;
|
|
21
|
+
accountGlobalsConfigured;
|
|
21
22
|
constructor(scope, id, props) {
|
|
22
23
|
const config = getConfig();
|
|
23
24
|
const accountId = props.accountId ?? config.accountId;
|
|
@@ -59,6 +60,7 @@ export class Account extends Stack {
|
|
|
59
60
|
// (OIDC provider/role, FjallMonitoring, FjallAudit) have fixed names that
|
|
60
61
|
// collide across regions, so they are created only in the home region.
|
|
61
62
|
const accountGlobalsConfigured = this.node.tryGetContext("fjallAccountGlobalsConfigured") === "true";
|
|
63
|
+
this.accountGlobalsConfigured = accountGlobalsConfigured;
|
|
62
64
|
if (this.receivesDeployRole() &&
|
|
63
65
|
fjallOrgId &&
|
|
64
66
|
!oidcAlreadyConfigured &&
|
|
@@ -71,17 +73,28 @@ export class Account extends Stack {
|
|
|
71
73
|
if (fjallOrgId && !accountGlobalsConfigured) {
|
|
72
74
|
new AccountAuditRole(this, "AuditRole", { fjallOrgId });
|
|
73
75
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
const environment = config.environment ?? "unknown";
|
|
77
|
+
// The trail is multi-region, so a non-home-region cascade stack creating
|
|
78
|
+
// its own copy records every management event a second time — CloudTrail
|
|
79
|
+
// bills additional copies at $2.00/100k events. Home region only, like
|
|
80
|
+
// the other account globals above.
|
|
81
|
+
const trailState = resolveAccountTrailState(this.node);
|
|
82
|
+
if (!accountGlobalsConfigured &&
|
|
83
|
+
this.createsManagementEventsTrail() &&
|
|
84
|
+
trailState !== "removed") {
|
|
85
|
+
new ManagementEventsTrail(this, "CloudTrail", {
|
|
86
|
+
accountId: this.account,
|
|
87
|
+
region: this.resolvedRegion,
|
|
88
|
+
lifecycleState: trailState,
|
|
89
|
+
retainAuditHistory: environment !== "development"
|
|
90
|
+
});
|
|
91
|
+
}
|
|
78
92
|
if (this.createsEcrDefaultImage()) {
|
|
79
93
|
new EcrDefaultImage(this, "EcrDefaultImage", {
|
|
80
94
|
region: this.resolvedRegion,
|
|
81
95
|
accountId: this.account
|
|
82
96
|
});
|
|
83
97
|
}
|
|
84
|
-
const environment = config.environment ?? "unknown";
|
|
85
98
|
if (config.disasterRecoveryRegion) {
|
|
86
99
|
const isComplianceAccount = environment === "compliance";
|
|
87
100
|
if (environment === "production" || isComplianceAccount) {
|
|
@@ -124,6 +137,16 @@ export class Account extends Stack {
|
|
|
124
137
|
createsEcrDefaultImage() {
|
|
125
138
|
return true;
|
|
126
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether this account owns its own management-events trail. Standalone,
|
|
142
|
+
* Platform, and member accounts do (subject to the fjallAccountTrailState
|
|
143
|
+
* decommission lifecycle); the organisation root overrides to `false`
|
|
144
|
+
* because its OrganisationTrail records this account's events too.
|
|
145
|
+
* Deliberately separate from the OIDC/ECR gates — the axes are independent.
|
|
146
|
+
*/
|
|
147
|
+
createsManagementEventsTrail() {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
127
150
|
enableGuardDuty(props) {
|
|
128
151
|
return new GuardDutyDetector(this, "GuardDuty", props);
|
|
129
152
|
}
|
|
@@ -47,6 +47,15 @@ export declare class Organisation extends Account {
|
|
|
47
47
|
* default-ECR-image helper.
|
|
48
48
|
*/
|
|
49
49
|
protected createsEcrDefaultImage(): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* The organisation root owns the org-wide OrganisationTrail, which records
|
|
52
|
+
* this account's management events too — an ACTIVE per-account trail here
|
|
53
|
+
* would be a duplicate paid copy. Returning `false` skips Account's active
|
|
54
|
+
* synthesis; the root's own constructor instead keeps the legacy construct
|
|
55
|
+
* in draining form until the lifecycle reaches "org", so pre-existing audit
|
|
56
|
+
* storage survives the upgrade.
|
|
57
|
+
*/
|
|
58
|
+
protected createsManagementEventsTrail(): boolean;
|
|
50
59
|
private createAccountReferences;
|
|
51
60
|
private setupOrganisationFeatures;
|
|
52
61
|
private setupCostAllocationTagActivator;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import App from "../../app.js";
|
|
2
2
|
import { Account } from "./account.js";
|
|
3
3
|
import { IdentityCenter } from "../../config/aws/identityCenter.js";
|
|
4
|
+
import { ManagementEventsTrail } from "../../config/aws/cloudTrail.js";
|
|
5
|
+
import { OrganisationTrail } from "../../config/aws/organisationTrail.js";
|
|
4
6
|
import { ScpPreset } from "../../config/aws/scpPreset.js";
|
|
5
7
|
import { OrganisationResource, OrganisationAccount, CostAllocationTagActivator } from "../../resources/aws/organisation/index.js";
|
|
6
8
|
import { stripAndCamelCase } from "../../utils/stripAndCamelCase.js";
|
|
7
|
-
import { CDK_CONTEXT_KEYS } from "../../utils/cdkContext.js";
|
|
9
|
+
import { CDK_CONTEXT_KEYS, resolveAccountTrailState } from "../../utils/cdkContext.js";
|
|
8
10
|
import { getConfig } from "../../utils/getConfig.js";
|
|
9
11
|
import { extractAccountNames } from "../../utils/accountsUtils.js";
|
|
10
12
|
/**
|
|
@@ -39,10 +41,17 @@ export class Organisation extends Account {
|
|
|
39
41
|
this.accountMap[key.toLowerCase()] = value;
|
|
40
42
|
}
|
|
41
43
|
this.accountsConfig = props.accounts;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// CDK context is a `??` boundary: `-c key=` legitimately passes "", which
|
|
45
|
+
// would flow through `??`, defeat the `this.account` fallback, and break
|
|
46
|
+
// the SSO management-account exclusion downstream.
|
|
47
|
+
const orgIdCtx = this.node.tryGetContext(CDK_CONTEXT_KEYS.ORG_ID);
|
|
48
|
+
const rootIdCtx = this.node.tryGetContext(CDK_CONTEXT_KEYS.ROOT_ID);
|
|
49
|
+
const managementAccountCtx = this.node.tryGetContext(CDK_CONTEXT_KEYS.MANAGEMENT_ACCOUNT_ID);
|
|
50
|
+
const orgId = typeof orgIdCtx === "string" && orgIdCtx !== "" ? orgIdCtx : undefined;
|
|
51
|
+
const rootId = typeof rootIdCtx === "string" && rootIdCtx !== "" ? rootIdCtx : undefined;
|
|
52
|
+
const managementAccountId = (typeof managementAccountCtx === "string" && managementAccountCtx !== ""
|
|
53
|
+
? managementAccountCtx
|
|
54
|
+
: undefined) ?? this.account;
|
|
46
55
|
if (!orgId || !rootId) {
|
|
47
56
|
throw new Error("orgId and rootId must be provided via CDK context. Run deployment through the Fjall CLI.");
|
|
48
57
|
}
|
|
@@ -51,6 +60,32 @@ export class Organisation extends Account {
|
|
|
51
60
|
rootId,
|
|
52
61
|
managementAccountId
|
|
53
62
|
});
|
|
63
|
+
// Home region only: the trail is multi-region, so an org-region cascade
|
|
64
|
+
// stack creating a second org-wide trail would duplicate every paid copy
|
|
65
|
+
// across the whole organisation.
|
|
66
|
+
if (!this.accountGlobalsConfigured) {
|
|
67
|
+
new OrganisationTrail(this, "OrganisationCloudTrail", {
|
|
68
|
+
orgId,
|
|
69
|
+
managementAccountId,
|
|
70
|
+
region: this.resolvedRegion
|
|
71
|
+
});
|
|
72
|
+
// Already-deployed roots carry a per-account trail whose bucket is
|
|
73
|
+
// DESTROY + autoDeleteObjects — dropping the construct outright would
|
|
74
|
+
// delete that audit history, bypassing the acknowledgeTrailHistoryLoss
|
|
75
|
+
// gate. Keep it in DRAINING form (same "CloudTrail" construct path as
|
|
76
|
+
// Account ⇒ identical bucket/CMK logical IDs): the trail resource goes,
|
|
77
|
+
// bucket + CMK flip to RETAIN until the reconciler decommissions them
|
|
78
|
+
// behind the gate. "removed" (trailLifecycle "org") synthesises nothing.
|
|
79
|
+
const trailState = resolveAccountTrailState(this.node);
|
|
80
|
+
if (trailState !== "removed") {
|
|
81
|
+
new ManagementEventsTrail(this, "CloudTrail", {
|
|
82
|
+
accountId: this.account,
|
|
83
|
+
region: this.resolvedRegion,
|
|
84
|
+
lifecycleState: "draining",
|
|
85
|
+
retainAuditHistory: true
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
54
89
|
this.createAccountReferences(props);
|
|
55
90
|
this.setupOrganisationFeatures(props.identityCenter ?? true, managementAccountId);
|
|
56
91
|
}
|
|
@@ -70,6 +105,17 @@ export class Organisation extends Account {
|
|
|
70
105
|
createsEcrDefaultImage() {
|
|
71
106
|
return false;
|
|
72
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The organisation root owns the org-wide OrganisationTrail, which records
|
|
110
|
+
* this account's management events too — an ACTIVE per-account trail here
|
|
111
|
+
* would be a duplicate paid copy. Returning `false` skips Account's active
|
|
112
|
+
* synthesis; the root's own constructor instead keeps the legacy construct
|
|
113
|
+
* in draining form until the lifecycle reaches "org", so pre-existing audit
|
|
114
|
+
* storage survives the upgrade.
|
|
115
|
+
*/
|
|
116
|
+
createsManagementEventsTrail() {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
73
119
|
createAccountReferences(props) {
|
|
74
120
|
const allNames = extractAccountNames(props.accounts);
|
|
75
121
|
for (const accountName of allNames) {
|
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
import { Stack } from "aws-cdk-lib";
|
|
2
2
|
import * as CloudTrail from "aws-cdk-lib/aws-cloudtrail";
|
|
3
3
|
import { Construct } from "constructs";
|
|
4
|
+
import { CustomerManagedKey } from "../secrets/index.js";
|
|
4
5
|
import { S3Bucket } from "../storage/index.js";
|
|
5
6
|
interface CloudTrailProps extends CloudTrail.TrailProps {
|
|
6
7
|
bucketName: string;
|
|
8
|
+
/**
|
|
9
|
+
* Bucket + CMK survive stack deletion (RemovalPolicy.RETAIN, auto-delete
|
|
10
|
+
* off). The trail resource itself stays DESTROY regardless — a retained
|
|
11
|
+
* trail keeps logging (and charging) as an unmanaged orphan. Default false
|
|
12
|
+
* preserves the historical TrailStack behaviour.
|
|
13
|
+
*/
|
|
14
|
+
retainAuditHistory?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Draining mode: synthesise only the bucket + CMK so CloudFormation deletes
|
|
17
|
+
* the trail resource while the retained storage keeps its logical IDs (and
|
|
18
|
+
* so its history) intact.
|
|
19
|
+
*/
|
|
20
|
+
omitTrail?: boolean;
|
|
7
21
|
}
|
|
22
|
+
export declare function validateTrailProps(props: CloudTrailProps): void;
|
|
8
23
|
export declare class Trail extends Construct {
|
|
9
|
-
readonly trail
|
|
24
|
+
readonly trail?: CloudTrail.Trail;
|
|
10
25
|
readonly bucket: S3Bucket;
|
|
26
|
+
readonly encryptionKey: CustomerManagedKey;
|
|
11
27
|
constructor(scope: Construct, id: string, props: CloudTrailProps);
|
|
28
|
+
/**
|
|
29
|
+
* The CDK L2 Trail contributes no KMS key-policy statements at all — it
|
|
30
|
+
* only sets kmsKeyId on the CfnTrail (verified in aws-cdk-lib 2.251.0).
|
|
31
|
+
* These three statements are therefore the entire CloudTrail-facing key
|
|
32
|
+
* policy: management-trail and member shadow-trail encryption plus S3
|
|
33
|
+
* digest delivery. None can be narrowed to member-only encryption contexts
|
|
34
|
+
* without breaking the management trail's own delivery.
|
|
35
|
+
*/
|
|
36
|
+
private addOrganisationTrailKeyPolicy;
|
|
12
37
|
}
|
|
13
38
|
export declare class TrailStack extends Stack {
|
|
14
39
|
constructor(scope: Construct, id: string, props: CloudTrailProps);
|
|
@@ -4,46 +4,135 @@ 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
|
+
if (props.isOrganizationTrail === true) {
|
|
45
|
+
// The L2 Trail emits the organisation-wide bucket policy itself
|
|
46
|
+
// (AWSLogs/<orgId>/* delivery); granting read/write here would shadow
|
|
47
|
+
// it with a narrower per-account statement.
|
|
48
|
+
this.addOrganisationTrailKeyPolicy(props, bucketName);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
this.bucket.grantReadWrite(new ServicePrincipal("cloudtrail.amazonaws.com"));
|
|
52
|
+
}
|
|
53
|
+
if (omitTrail === true) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
26
56
|
this.trail = new CloudTrail.Trail(this, `${id}CloudTrail`, {
|
|
27
|
-
...
|
|
57
|
+
...trailProps,
|
|
28
58
|
bucket: this.bucket,
|
|
29
59
|
trailName: props.trailName || `${id}Trail`,
|
|
30
|
-
encryptionKey: encryptionKey.key
|
|
60
|
+
encryptionKey: this.encryptionKey.key
|
|
31
61
|
});
|
|
32
|
-
//
|
|
62
|
+
// Always DESTROY even when storage is retained: a RETAINED trail would
|
|
63
|
+
// keep logging (and charging) as an unmanaged orphan after stack
|
|
64
|
+
// deletion. Audit durability lives on the bucket + CMK removal policies.
|
|
33
65
|
this.trail.applyRemovalPolicy(RemovalPolicy.DESTROY);
|
|
34
66
|
// Ensure the autoDeleteObjects custom resource is fully provisioned before
|
|
35
67
|
// the trail starts writing to the bucket. Without this, a create-rollback
|
|
36
68
|
// leaves the bucket non-empty (CloudTrail writes logs immediately) and the
|
|
37
69
|
// auto-delete Lambda was never created — causing ROLLBACK_FAILED.
|
|
70
|
+
// (No-op in retain mode: auto-delete is off, so the child is absent.)
|
|
38
71
|
const autoDeleteResource = this.bucket.node.tryFindChild("AutoDeleteObjectsCustomResource");
|
|
39
72
|
if (autoDeleteResource) {
|
|
40
73
|
this.trail.node.addDependency(autoDeleteResource);
|
|
41
74
|
}
|
|
42
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The CDK L2 Trail contributes no KMS key-policy statements at all — it
|
|
78
|
+
* only sets kmsKeyId on the CfnTrail (verified in aws-cdk-lib 2.251.0).
|
|
79
|
+
* These three statements are therefore the entire CloudTrail-facing key
|
|
80
|
+
* policy: management-trail and member shadow-trail encryption plus S3
|
|
81
|
+
* digest delivery. None can be narrowed to member-only encryption contexts
|
|
82
|
+
* without breaking the management trail's own delivery.
|
|
83
|
+
*/
|
|
84
|
+
addOrganisationTrailKeyPolicy(props, bucketName) {
|
|
85
|
+
const { partition, region, account } = Stack.of(this);
|
|
86
|
+
const cloudTrailPrincipal = new ServicePrincipal("cloudtrail.amazonaws.com");
|
|
87
|
+
const managementTrailArn = `arn:${partition}:cloudtrail:${region}:${account}:trail/${props.trailName}`;
|
|
88
|
+
this.encryptionKey.key.addToResourcePolicy(new PolicyStatement({
|
|
89
|
+
sid: "AllowOrganisationTrailEncrypt",
|
|
90
|
+
principals: [cloudTrailPrincipal],
|
|
91
|
+
actions: ["kms:GenerateDataKey*"],
|
|
92
|
+
resources: ["*"],
|
|
93
|
+
conditions: {
|
|
94
|
+
StringEquals: { "aws:SourceArn": managementTrailArn },
|
|
95
|
+
// Member shadow trails encrypt under the management account's trail
|
|
96
|
+
// namespace — one management-scoped wildcard covers every member.
|
|
97
|
+
StringLike: {
|
|
98
|
+
"kms:EncryptionContext:aws:cloudtrail:arn": `arn:${partition}:cloudtrail:*:${account}:trail/*`
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}));
|
|
102
|
+
this.encryptionKey.key.addToResourcePolicy(new PolicyStatement({
|
|
103
|
+
sid: "AllowOrganisationTrailDescribeKey",
|
|
104
|
+
principals: [cloudTrailPrincipal],
|
|
105
|
+
actions: ["kms:DescribeKey"],
|
|
106
|
+
resources: ["*"],
|
|
107
|
+
conditions: {
|
|
108
|
+
StringEquals: { "aws:SourceArn": managementTrailArn }
|
|
109
|
+
}
|
|
110
|
+
}));
|
|
111
|
+
this.encryptionKey.key.addToResourcePolicy(new PolicyStatement({
|
|
112
|
+
sid: "AllowOrganisationTrailDigestDelivery",
|
|
113
|
+
principals: [cloudTrailPrincipal],
|
|
114
|
+
actions: ["kms:GenerateDataKey*", "kms:Decrypt"],
|
|
115
|
+
resources: ["*"],
|
|
116
|
+
conditions: {
|
|
117
|
+
// Digest files arrive via the bucket's default SSE-KMS. The literal
|
|
118
|
+
// bucket name (not bucket.bucketArn) avoids a Key↔Bucket
|
|
119
|
+
// dependency cycle at synth time. Two exact entries (bucket-level
|
|
120
|
+
// context + per-object contexts) — a bare `${bucketName}*` would
|
|
121
|
+
// also match sibling bucket NAMES sharing the prefix.
|
|
122
|
+
StringLike: {
|
|
123
|
+
"kms:EncryptionContext:aws:s3:arn": [
|
|
124
|
+
`arn:${partition}:s3:::${bucketName}`,
|
|
125
|
+
`arn:${partition}:s3:::${bucketName}/*`
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
43
131
|
}
|
|
44
132
|
export class TrailStack extends Stack {
|
|
45
133
|
constructor(scope, id, props) {
|
|
46
134
|
super(scope, id);
|
|
135
|
+
validateTrailProps(props);
|
|
47
136
|
new Trail(this, id, {
|
|
48
137
|
...props
|
|
49
138
|
});
|
|
@@ -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
|
@@ -26,6 +26,11 @@ type EnvConfig<T> = {
|
|
|
26
26
|
* 4. `CDK_DEFAULT_ACCOUNT` → providerAccounts lookup
|
|
27
27
|
* 5. `-c accountName=<value>` → providerAccounts lookup
|
|
28
28
|
* 6. `"unknown"` (with warning)
|
|
29
|
+
*
|
|
30
|
+
* Deliberately diverges from utils/getConfig.ts, which is App-scoped and
|
|
31
|
+
* account-aware: there `-c environment` context wins and ENVIRONMENT is a
|
|
32
|
+
* last-resort fallback. This helper is pre-App and process-wide, so it treats
|
|
33
|
+
* ENVIRONMENT as the dominant ambient signal.
|
|
29
34
|
*/
|
|
30
35
|
export declare function getEnvironment(): string;
|
|
31
36
|
/**
|
package/dist/lib/utils/env.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CDK_CONTEXT_KEYS } from "./cdkContext.js";
|
|
2
|
+
import { parseOrgConfig, resolveSynthEnvironment } from "./orgConfigParser.js";
|
|
2
3
|
import { FjallLogger } from "./validationLogger.js";
|
|
3
4
|
let cachedEnvironment;
|
|
4
5
|
/**
|
|
@@ -11,6 +12,11 @@ let cachedEnvironment;
|
|
|
11
12
|
* 4. `CDK_DEFAULT_ACCOUNT` → providerAccounts lookup
|
|
12
13
|
* 5. `-c accountName=<value>` → providerAccounts lookup
|
|
13
14
|
* 6. `"unknown"` (with warning)
|
|
15
|
+
*
|
|
16
|
+
* Deliberately diverges from utils/getConfig.ts, which is App-scoped and
|
|
17
|
+
* account-aware: there `-c environment` context wins and ENVIRONMENT is a
|
|
18
|
+
* last-resort fallback. This helper is pre-App and process-wide, so it treats
|
|
19
|
+
* ENVIRONMENT as the dominant ambient signal.
|
|
14
20
|
*/
|
|
15
21
|
export function getEnvironment() {
|
|
16
22
|
if (cachedEnvironment !== undefined) {
|
|
@@ -92,19 +98,24 @@ function parseContextArg(key) {
|
|
|
92
98
|
* Get provider accounts from orgConfig in CDK context (-c orgConfig=<json>).
|
|
93
99
|
*/
|
|
94
100
|
function getProviderAccountsFromContext() {
|
|
95
|
-
return parseOrgConfig(parseContextArg(
|
|
101
|
+
return parseOrgConfig(parseContextArg(CDK_CONTEXT_KEYS.ORG_CONFIG))
|
|
102
|
+
.providerAccounts;
|
|
96
103
|
}
|
|
97
104
|
/**
|
|
98
105
|
* Look up environment from account ID via orgConfig CDK context.
|
|
106
|
+
* Organisation-tier accounts resolve to "root" (resolveSynthEnvironment).
|
|
99
107
|
*/
|
|
100
108
|
function resolveEnvironmentFromAccountId(accountId) {
|
|
101
109
|
const accounts = getProviderAccountsFromContext();
|
|
102
|
-
|
|
110
|
+
const account = accounts.find((pa) => pa.id === accountId);
|
|
111
|
+
return account ? resolveSynthEnvironment(account) : undefined;
|
|
103
112
|
}
|
|
104
113
|
/**
|
|
105
114
|
* Look up environment from account name via orgConfig CDK context.
|
|
115
|
+
* Organisation-tier accounts resolve to "root" (resolveSynthEnvironment).
|
|
106
116
|
*/
|
|
107
117
|
function resolveEnvironmentFromAccountName(accountName) {
|
|
108
118
|
const accounts = getProviderAccountsFromContext();
|
|
109
|
-
|
|
119
|
+
const account = accounts.find((pa) => pa.name === accountName);
|
|
120
|
+
return account ? resolveSynthEnvironment(account) : undefined;
|
|
110
121
|
}
|
|
@@ -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;
|
|
@@ -5,6 +5,16 @@ export interface ParsedOrgConfig {
|
|
|
5
5
|
secondaryRegions: string[];
|
|
6
6
|
disasterRecoveryRegion?: string;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a provider account's synth-time environment. The tier/stage
|
|
10
|
+
* separation (decisions/2026-06-07-account-tier-vs-stage-separation.md) nulls
|
|
11
|
+
* the wire `environment` for organisation-tier accounts, but scaffolded org
|
|
12
|
+
* entry points gate on `config.environment === "root"` — decode the tier back
|
|
13
|
+
* to the historical "root" marker via the sanctioned `accountTier()` decoder.
|
|
14
|
+
* Workload stages pass through verbatim; a null stage on any other tier stays
|
|
15
|
+
* unresolved (callers fall back to "unknown").
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveSynthEnvironment(account: Pick<ProviderAccount, "environment" | "tier">): string | undefined;
|
|
8
18
|
/**
|
|
9
19
|
* Parse orgConfig JSON from CDK context into a validated structure.
|
|
10
20
|
*
|
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
import { VAULT_LOCK_MODES, S3_BPA_MODES } from "@fjall/util/config";
|
|
2
|
-
import { maskSensitiveOutput } from "@fjall/util";
|
|
2
|
+
import { ACCOUNT_TIERS, STRUCTURAL_ENVIRONMENTS, accountTier, maskSensitiveOutput } from "@fjall/util";
|
|
3
3
|
import { FjallLogger } from "./validationLogger.js";
|
|
4
|
+
function isProviderAccount(item) {
|
|
5
|
+
if (typeof item !== "object" || item === null)
|
|
6
|
+
return false;
|
|
7
|
+
const rec = item;
|
|
8
|
+
return (typeof rec.id === "string" &&
|
|
9
|
+
typeof rec.name === "string" &&
|
|
10
|
+
// null environment (structural accounts) must survive — rejecting it drops the account at synth.
|
|
11
|
+
(typeof rec.environment === "string" || rec.environment === null) &&
|
|
12
|
+
(rec.tier === undefined || ACCOUNT_TIERS.includes(rec.tier)) &&
|
|
13
|
+
(rec.managed === undefined || typeof rec.managed === "boolean") &&
|
|
14
|
+
(rec.oidcRoleArn === undefined || typeof rec.oidcRoleArn === "string") &&
|
|
15
|
+
(rec.vaultLock === undefined ||
|
|
16
|
+
VAULT_LOCK_MODES.includes(rec.vaultLock)) &&
|
|
17
|
+
(rec.s3BlockPublicAccess === undefined ||
|
|
18
|
+
S3_BPA_MODES.includes(rec.s3BlockPublicAccess)) &&
|
|
19
|
+
(rec.acknowledgeImmutableVaultLock === undefined ||
|
|
20
|
+
typeof rec.acknowledgeImmutableVaultLock === "boolean"));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a provider account's synth-time environment. The tier/stage
|
|
24
|
+
* separation (decisions/2026-06-07-account-tier-vs-stage-separation.md) nulls
|
|
25
|
+
* the wire `environment` for organisation-tier accounts, but scaffolded org
|
|
26
|
+
* entry points gate on `config.environment === "root"` — decode the tier back
|
|
27
|
+
* to the historical "root" marker via the sanctioned `accountTier()` decoder.
|
|
28
|
+
* Workload stages pass through verbatim; a null stage on any other tier stays
|
|
29
|
+
* unresolved (callers fall back to "unknown").
|
|
30
|
+
*/
|
|
31
|
+
export function resolveSynthEnvironment(account) {
|
|
32
|
+
if (accountTier(account) === "organisation") {
|
|
33
|
+
return STRUCTURAL_ENVIRONMENTS.ROOT;
|
|
34
|
+
}
|
|
35
|
+
return account.environment ?? undefined;
|
|
36
|
+
}
|
|
37
|
+
function describeRejectedAccount(item) {
|
|
38
|
+
if (typeof item !== "object" || item === null)
|
|
39
|
+
return "<unidentifiable>";
|
|
40
|
+
const rec = item;
|
|
41
|
+
const parts = [rec.id, rec.name].filter((v) => typeof v === "string");
|
|
42
|
+
return parts.length > 0 ? parts.join(" / ") : "<unidentifiable>";
|
|
43
|
+
}
|
|
4
44
|
/**
|
|
5
45
|
* Parse orgConfig JSON from CDK context into a validated structure.
|
|
6
46
|
*
|
|
@@ -20,28 +60,12 @@ export function parseOrgConfig(raw) {
|
|
|
20
60
|
return empty;
|
|
21
61
|
const obj = parsed;
|
|
22
62
|
const providerAccounts = Array.isArray(obj.providerAccounts)
|
|
23
|
-
? obj.providerAccounts.filter((item) =>
|
|
24
|
-
item
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"string" ||
|
|
30
|
-
item.environment === null) &&
|
|
31
|
-
(item.managed === undefined ||
|
|
32
|
-
typeof item.managed === "boolean") &&
|
|
33
|
-
(item.oidcRoleArn === undefined ||
|
|
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"))
|
|
63
|
+
? obj.providerAccounts.filter((item) => {
|
|
64
|
+
if (isProviderAccount(item))
|
|
65
|
+
return true;
|
|
66
|
+
FjallLogger.warn(`[fjall] Ignoring malformed provider account in orgConfig context: ${describeRejectedAccount(item)}`);
|
|
67
|
+
return false;
|
|
68
|
+
})
|
|
45
69
|
: [];
|
|
46
70
|
const primaryRegion = typeof obj.primaryRegion === "string" ? obj.primaryRegion : undefined;
|
|
47
71
|
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.
|
|
3
|
+
"version": "2.13.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.
|
|
67
|
-
"@fjall/util": "^2.
|
|
66
|
+
"@fjall/generator": "^2.13.0",
|
|
67
|
+
"@fjall/util": "^2.13.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": "
|
|
82
|
+
"gitHead": "5b16c5731256628f829d4168c65cf165b3516f9a"
|
|
83
83
|
}
|