@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
package/dist/lib/app.d.ts CHANGED
@@ -72,10 +72,11 @@ export interface IAppOptions {
72
72
  *
73
73
  * - `name?` overrides the AWS `EventBus.Name` (defaults to the app name).
74
74
  * - `removalPolicy?` overrides the env-resolved default. Default resolves
75
- * via `env({ default: "DESTROY", production: "RETAIN" })` per D17 NOT
76
- * `process.env.NODE_ENV` (which is not set during CDK synth in Fjall's
77
- * deployment paths). The shape is the normalised string union per
78
- * D18(d), NOT the raw CDK `RemovalPolicy` enum.
75
+ * via `envAwareRemovalPolicyDefault()` per D17 (production → RETAIN,
76
+ * other recognised stages DESTROY, unrecognised values fail synth)
77
+ * NOT `process.env.NODE_ENV` (which is not set during CDK synth in
78
+ * Fjall's deployment paths). The shape is the normalised string union
79
+ * per D18(d), NOT the raw CDK `RemovalPolicy` enum.
79
80
  */
80
81
  eventBus?: {
81
82
  name?: string;
@@ -175,9 +176,8 @@ export declare class App extends CdkApp {
175
176
  * Buses are recreatable in non-prod; the env-resolved default keeps prod
176
177
  * history. The override (`App.getApp({ eventBus: { name?, removalPolicy? } })`)
177
178
  * is consulted first; absent override falls back to the app name and the
178
- * `env({ default: "DESTROY", production: "RETAIN" })` resolution per D17.
179
- * NODE_ENV is not consulted — it is not set during CDK synth in Fjall's
180
- * deployment paths.
179
+ * `envAwareRemovalPolicyDefault()` resolution per D17. NODE_ENV is not
180
+ * consulted — it is not set during CDK synth in Fjall's deployment paths.
181
181
  */
182
182
  getEventBus(): EventBusMessaging;
183
183
  /**
package/dist/lib/app.js CHANGED
@@ -277,9 +277,8 @@ export class App extends CdkApp {
277
277
  * Buses are recreatable in non-prod; the env-resolved default keeps prod
278
278
  * history. The override (`App.getApp({ eventBus: { name?, removalPolicy? } })`)
279
279
  * is consulted first; absent override falls back to the app name and the
280
- * `env({ default: "DESTROY", production: "RETAIN" })` resolution per D17.
281
- * NODE_ENV is not consulted — it is not set during CDK synth in Fjall's
282
- * deployment paths.
280
+ * `envAwareRemovalPolicyDefault()` resolution per D17. NODE_ENV is not
281
+ * consulted — it is not set during CDK synth in Fjall's deployment paths.
283
282
  */
284
283
  getEventBus() {
285
284
  if (this.defaultEventBus) {
@@ -1,3 +1,4 @@
1
+ import { ACCOUNT_MONITORING_ROLE_NAME } from "@fjall/util/aws";
1
2
  import { CfnOutput } from "aws-cdk-lib";
2
3
  import { Role, AccountPrincipal, PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
3
4
  import { Construct } from "constructs";
@@ -16,7 +17,7 @@ export class AccountMonitoringRole extends Construct {
16
17
  constructor(scope, id, props) {
17
18
  super(scope, id);
18
19
  this.role = new Role(this, "Role", {
19
- roleName: "FjallMonitoring",
20
+ roleName: ACCOUNT_MONITORING_ROLE_NAME,
20
21
  path: "/",
21
22
  assumedBy: new AccountPrincipal(FJALL_PLATFORM_ACCOUNT_ID),
22
23
  description: "Cross-account monitoring role for the Fjall platform. Grants read access to CloudWatch, ECS, RDS, S3, Lambda, ALB, Logs, and Cost Explorer.",
@@ -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: "managementEvents",
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
+ }
@@ -29,7 +29,16 @@ function buildFoundationGuardrails(allowedRegions) {
29
29
  {
30
30
  Sid: "DenyRootUserActions",
31
31
  Effect: "Deny",
32
- Action: "*",
32
+ // Deny + NotAction denies everything except the policy-management
33
+ // scopes a root-task unlock session needs; the AWS root-task policies
34
+ // constrain those sessions further.
35
+ NotAction: [
36
+ "s3:GetBucketPolicy",
37
+ "s3:PutBucketPolicy",
38
+ "s3:DeleteBucketPolicy",
39
+ "sqs:GetQueueAttributes",
40
+ "sqs:SetQueueAttributes"
41
+ ],
33
42
  Resource: "*",
34
43
  Condition: {
35
44
  StringLike: {
@@ -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
- new ManagementEventsTrail(this, "CloudTrail", {
75
- accountId: this.account,
76
- region: this.resolvedRegion
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
- const orgId = this.node.tryGetContext(CDK_CONTEXT_KEYS.ORG_ID);
43
- const rootId = this.node.tryGetContext(CDK_CONTEXT_KEYS.ROOT_ID);
44
- const managementAccountId = this.node.tryGetContext(CDK_CONTEXT_KEYS.MANAGEMENT_ACCOUNT_ID) ??
45
- this.account;
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) {
@@ -38,7 +38,7 @@ export interface S3Props {
38
38
  readonly backupVaultTier?: BackupTier;
39
39
  readonly cors?: CorsRule[];
40
40
  readonly deployment?: S3DeploymentConfig;
41
- /** When true, sets RemovalPolicy.RETAIN and disables autoDeleteObjects. Used for imported buckets. */
41
+ /** When true, sets RemovalPolicy.RETAIN (overriding the env-aware default). Used for imported buckets. */
42
42
  readonly retain?: boolean;
43
43
  }
44
44
  export interface StorageBuildProps extends S3Props {
@@ -21,7 +21,11 @@ function toHttpMethod(method) {
21
21
  DELETE: HttpMethods.DELETE,
22
22
  HEAD: HttpMethods.HEAD
23
23
  };
24
- return methodMap[method] ?? HttpMethods.GET;
24
+ const mapped = methodMap[method];
25
+ if (mapped === undefined) {
26
+ throw new Error(`Unsupported CORS method "${method}" — expected one of: ${Object.keys(methodMap).join(", ")}.`);
27
+ }
28
+ return mapped;
25
29
  }
26
30
  function toCorsRules(cors) {
27
31
  if (!cors)
@@ -1,14 +1,61 @@
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). The trail
10
+ * resource itself stays DESTROY regardless — a retained trail keeps
11
+ * logging (and charging) as an unmanaged orphan. Default false preserves
12
+ * 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: CloudTrail.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
+ private trailArn;
29
+ /**
30
+ * Canonical CloudTrail delivery policy (ACL probe + log write), scoped to
31
+ * this trail's ARN via aws:SourceArn so no other trail — including one in
32
+ * another account — can deliver into or probe the bucket. Emitted from
33
+ * this construct rather than left to the L2 Trail because draining mode
34
+ * (omitTrail) skips the L2 construct while the trail being deleted may
35
+ * still deliver mid-update. Fjall-prefixed sids avoid colliding with the
36
+ * sid-less statements the L2 Trail adds when it is constructed.
37
+ */
38
+ private addAccountTrailBucketPolicy;
39
+ /**
40
+ * Conditioned replacement for the KMS half of the retired grantReadWrite:
41
+ * `Bucket.grantReadWrite` silently granted the CloudTrail principal
42
+ * unconditioned Encrypt/Decrypt/ReEncrypt on the CMK because the bucket
43
+ * carries an encryptionKey. CloudTrail's actual needs are
44
+ * kms:GenerateDataKey* + kms:DescribeKey (trail-side log encryption,
45
+ * validated at CreateTrail) plus digest delivery via the bucket's default
46
+ * SSE-KMS — without these, trail creation fails with
47
+ * InsufficientEncryptionPolicyException.
48
+ */
49
+ private addAccountTrailKeyPolicy;
50
+ /**
51
+ * The CDK L2 Trail contributes no KMS key-policy statements at all — it
52
+ * only sets kmsKeyId on the CfnTrail (verified in aws-cdk-lib 2.251.0).
53
+ * These three statements are therefore the entire CloudTrail-facing key
54
+ * policy: management-trail and member shadow-trail encryption plus S3
55
+ * digest delivery. None can be narrowed to member-only encryption contexts
56
+ * without breaking the management trail's own delivery.
57
+ */
58
+ private addOrganisationTrailKeyPolicy;
12
59
  }
13
60
  export declare class TrailStack extends Stack {
14
61
  constructor(scope: Construct, id: string, props: CloudTrailProps);