@fjall/components-infrastructure 2.11.1 → 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.
@@ -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
+ }
@@ -1,9 +1,27 @@
1
1
  import { Construct } from "constructs";
2
+ export interface S3BlockPublicAccessProps {
3
+ /**
4
+ * Account whose account-level S3 Block Public Access is managed. Taken as a
5
+ * prop rather than read from `Stack.of(this).account` — that is a synth token
6
+ * that never matches a provider-account id when looking up config.
7
+ */
8
+ accountId: string;
9
+ }
2
10
  /**
3
- * Enables S3 Block Public Access at the account level via a Custom Resource.
4
- * All four public access flags are set to true (block all public access).
5
- * Stack deletion does NOT revert this setting security features are preserved.
11
+ * Manages account-level S3 Block Public Access via a Custom Resource.
12
+ *
13
+ * Off by default: Fjall observes-and-flags exposed buckets through the posture
14
+ * layer rather than hard-blocking at the account level. Set
15
+ * `s3BlockPublicAccess: "enforced"` on a provider account to switch all four
16
+ * flags on. The flags ride in the custom resource's properties (not hardcoded
17
+ * in the handler) so a config change reconciles on the next deploy. Stack
18
+ * deletion is a no-op — the account-level setting is left as-is.
19
+ *
20
+ * The handler runs under a fixed `/fjall/FjallS3BpaManager` role so the
21
+ * DenyS3PublicAccessChanges SCP can exempt its PutAccountPublicAccessBlock by
22
+ * ARN pattern. The role name is account-global, so this construct is created
23
+ * only in an account's home region (see Account's accountGlobals gate).
6
24
  */
7
25
  export declare class S3BlockPublicAccess extends Construct {
8
- constructor(scope: Construct, id: string);
26
+ constructor(scope: Construct, id: string, props: S3BlockPublicAccessProps);
9
27
  }
@@ -1,23 +1,41 @@
1
1
  import { Duration } from "aws-cdk-lib";
2
2
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
3
  import { Runtime } from "aws-cdk-lib/aws-lambda";
4
- import { Stack } from "aws-cdk-lib";
5
4
  import { Construct } from "constructs";
6
5
  import { CustomResource } from "../../resources/aws/utilities/customResource.js";
6
+ import { getConfig } from "../../utils/getConfig.js";
7
7
  /**
8
- * Enables S3 Block Public Access at the account level via a Custom Resource.
9
- * All four public access flags are set to true (block all public access).
10
- * Stack deletion does NOT revert this setting security features are preserved.
8
+ * Manages account-level S3 Block Public Access via a Custom Resource.
9
+ *
10
+ * Off by default: Fjall observes-and-flags exposed buckets through the posture
11
+ * layer rather than hard-blocking at the account level. Set
12
+ * `s3BlockPublicAccess: "enforced"` on a provider account to switch all four
13
+ * flags on. The flags ride in the custom resource's properties (not hardcoded
14
+ * in the handler) so a config change reconciles on the next deploy. Stack
15
+ * deletion is a no-op — the account-level setting is left as-is.
16
+ *
17
+ * The handler runs under a fixed `/fjall/FjallS3BpaManager` role so the
18
+ * DenyS3PublicAccessChanges SCP can exempt its PutAccountPublicAccessBlock by
19
+ * ARN pattern. The role name is account-global, so this construct is created
20
+ * only in an account's home region (see Account's accountGlobals gate).
11
21
  */
12
22
  export class S3BlockPublicAccess extends Construct {
13
- constructor(scope, id) {
23
+ constructor(scope, id, props) {
14
24
  super(scope, id);
25
+ const account = getConfig().providerAccounts.find((pa) => pa.id === props.accountId);
26
+ const enforce = (account?.s3BlockPublicAccess ?? "off") === "enforced";
15
27
  new CustomResource(this, "S3BlockPublicAccess", {
16
28
  runtime: Runtime.NODEJS_22_X,
17
29
  timeout: Duration.minutes(5),
18
- lambdaDescription: "Enables S3 Block Public Access at account level",
30
+ lambdaDescription: "Manages S3 Block Public Access at account level",
31
+ rolePath: "/fjall/",
32
+ roleName: "FjallS3BpaManager",
19
33
  properties: {
20
- AccountId: Stack.of(this).account
34
+ AccountId: props.accountId,
35
+ BlockPublicAcls: String(enforce),
36
+ IgnorePublicAcls: String(enforce),
37
+ BlockPublicPolicy: String(enforce),
38
+ RestrictPublicBuckets: String(enforce)
21
39
  },
22
40
  inlinePolicy: [
23
41
  new PolicyStatement({
@@ -32,20 +50,22 @@ export class S3BlockPublicAccess extends Construct {
32
50
  inlineCode: `
33
51
  const { S3ControlClient, PutPublicAccessBlockCommand } = require('@aws-sdk/client-s3-control');
34
52
 
53
+ const toBool = (v) => v === 'true' || v === true;
54
+
35
55
  exports.handler = async (event) => {
36
56
  const physicalResourceId = event.PhysicalResourceId || event.LogicalResourceId || 's3-block-public-access';
37
57
  if (event.RequestType === 'Delete') {
38
58
  return { PhysicalResourceId: physicalResourceId };
39
59
  }
40
- const accountId = event.ResourceProperties.AccountId;
60
+ const props = event.ResourceProperties;
41
61
  const client = new S3ControlClient({});
42
62
  await client.send(new PutPublicAccessBlockCommand({
43
- AccountId: accountId,
63
+ AccountId: props.AccountId,
44
64
  PublicAccessBlockConfiguration: {
45
- BlockPublicAcls: true,
46
- IgnorePublicAcls: true,
47
- BlockPublicPolicy: true,
48
- RestrictPublicBuckets: true
65
+ BlockPublicAcls: toBool(props.BlockPublicAcls),
66
+ IgnorePublicAcls: toBool(props.IgnorePublicAcls),
67
+ BlockPublicPolicy: toBool(props.BlockPublicPolicy),
68
+ RestrictPublicBuckets: toBool(props.RestrictPublicBuckets)
49
69
  }
50
70
  }));
51
71
  return { PhysicalResourceId: physicalResourceId };
@@ -5,7 +5,12 @@ import { OrganisationPolicy } from "../../resources/aws/organisation/organisatio
5
5
  // default path.
6
6
  const EXEMPT_ROLE_PATTERNS = [
7
7
  "arn:aws:iam::*:role/fjall/FjallDeploy*",
8
- "arn:aws:iam::*:role/OrganizationAccountAccessRole"
8
+ "arn:aws:iam::*:role/OrganizationAccountAccessRole",
9
+ // The account-level S3 Block Public Access manager Lambda re-applies BPA on
10
+ // every deploy; its PutAccountPublicAccessBlock must survive
11
+ // DenyS3PublicAccessChanges. Pathed under /fjall/ so a member admin cannot
12
+ // forge an exempt role at the default path.
13
+ "arn:aws:iam::*:role/fjall/FjallS3BpaManager*"
9
14
  ];
10
15
  const SCP_BYTE_LIMIT = 5120;
11
16
  const IAM_POLICY_VERSION = "2012-10-17";
@@ -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 +
@@ -27,6 +28,21 @@ export declare class Account extends Stack {
27
28
  * independent, and Platform must take one but not the other.
28
29
  */
29
30
  protected receivesDeployRole(): boolean;
31
+ /**
32
+ * Whether this account creates the default-ECR-image helper (the CodeBuild
33
+ * project that seeds a placeholder application image). App-hosting accounts
34
+ * need it; the organisation root runs no workloads and overrides to `false`.
35
+ * Deliberately separate from the OIDC/IPAM gates — the axes are independent.
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;
30
46
  enableGuardDuty(props?: GuardDutyDetectorProps): GuardDutyDetector;
31
47
  enableSecurityHub(props?: SecurityHubHubProps): SecurityHubHub;
32
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,15 +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
- });
78
- new EcrDefaultImage(this, "EcrDefaultImage", {
79
- region: this.resolvedRegion,
80
- accountId: this.account
81
- });
82
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
+ }
92
+ if (this.createsEcrDefaultImage()) {
93
+ new EcrDefaultImage(this, "EcrDefaultImage", {
94
+ region: this.resolvedRegion,
95
+ accountId: this.account
96
+ });
97
+ }
83
98
  if (config.disasterRecoveryRegion) {
84
99
  const isComplianceAccount = environment === "compliance";
85
100
  if (environment === "production" || isComplianceAccount) {
@@ -95,7 +110,12 @@ export class Account extends Stack {
95
110
  exportName: "Environment",
96
111
  description: "Environment type for this account (e.g., production, staging, development)"
97
112
  });
98
- new S3BlockPublicAccess(this, "S3BlockPublicAccess");
113
+ // Account-level S3 Block Public Access is account-global and runs under a
114
+ // fixed-name role, so (like the OIDC/Monitoring/Audit globals above) it is
115
+ // created only in the home region to avoid a cross-region role collision.
116
+ if (!accountGlobalsConfigured) {
117
+ new S3BlockPublicAccess(this, "S3BlockPublicAccess", { accountId });
118
+ }
99
119
  new EbsDefaultEncryption(this, "EbsDefaultEncryption");
100
120
  }
101
121
  /**
@@ -108,6 +128,25 @@ export class Account extends Stack {
108
128
  receivesDeployRole() {
109
129
  return this.constructor === Account;
110
130
  }
131
+ /**
132
+ * Whether this account creates the default-ECR-image helper (the CodeBuild
133
+ * project that seeds a placeholder application image). App-hosting accounts
134
+ * need it; the organisation root runs no workloads and overrides to `false`.
135
+ * Deliberately separate from the OIDC/IPAM gates — the axes are independent.
136
+ */
137
+ createsEcrDefaultImage() {
138
+ return true;
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
+ }
111
150
  enableGuardDuty(props) {
112
151
  return new GuardDutyDetector(this, "GuardDuty", props);
113
152
  }
@@ -1,21 +1,21 @@
1
- import { type Environment, Stack, type StackProps } from "aws-cdk-lib";
1
+ import { type Environment } from "aws-cdk-lib";
2
+ import { type Construct } from "constructs";
3
+ import { Account, type AccountProps } from "./account.js";
2
4
  import { type CustomPermissionSets } from "../../config/aws/identityCenter.js";
3
5
  import { ScpPreset } from "../../config/aws/scpPreset.js";
4
6
  import type { ScpPresetProps } from "../../config/aws/scpPreset.js";
5
7
  import { OrganisationResource } from "../../resources/aws/organisation/index.js";
6
- type ExtendedStackProps = Omit<StackProps, "env"> & {
7
- env?: Required<Pick<Environment, "region" | "account">> & Partial<Omit<Environment, "region" | "account">>;
8
- };
9
8
  export type AccountsConfig = {
10
9
  readonly [key: string]: readonly string[] | string | AccountsConfig;
11
10
  };
12
- export interface OrganisationProps extends ExtendedStackProps {
11
+ export interface OrganisationProps extends AccountProps {
13
12
  organisationName: string;
14
13
  accounts: AccountsConfig;
15
14
  orgEmail: string;
16
15
  accountIds?: Record<string, string>;
17
16
  identityCenter?: boolean;
18
17
  allowedRegions?: string[];
18
+ env?: Required<Pick<Environment, "region" | "account">> & Partial<Omit<Environment, "region" | "account">>;
19
19
  }
20
20
  /**
21
21
  * Organisation CDK stack.
@@ -27,14 +27,35 @@ export interface OrganisationProps extends ExtendedStackProps {
27
27
  * - Identity Centre configuration
28
28
  * - Cost allocation tag auto-activation (daily Lambda)
29
29
  */
30
- export declare class Organisation extends Stack {
30
+ export declare class Organisation extends Account {
31
31
  readonly organisationType: "organisation";
32
32
  private org;
33
33
  private accountRefs;
34
34
  private accountMap;
35
35
  private accountsConfig;
36
36
  private identityCenter?;
37
- constructor(id: string, props: OrganisationProps);
37
+ constructor(scope: Construct, id: string, props: OrganisationProps);
38
+ /**
39
+ * The organisation root's OIDC deploy connector is owned by the customer-run
40
+ * Quick-Create CloudFormation stack, never the inherited Account connector.
41
+ * Returning `false` is also the short-circuit first clause of Account's OIDC
42
+ * gate, so no `OidcConnector` is synthesised on the root stack.
43
+ */
44
+ protected receivesDeployRole(): boolean;
45
+ /**
46
+ * The organisation root runs no application workloads, so it needs no
47
+ * default-ECR-image helper.
48
+ */
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;
38
59
  private createAccountReferences;
39
60
  private setupOrganisationFeatures;
40
61
  private setupCostAllocationTagActivator;
@@ -45,4 +66,3 @@ export declare class Organisation extends Stack {
45
66
  getAccounts(): Record<string, string>;
46
67
  enableScps(props: ScpPresetProps): ScpPreset;
47
68
  }
48
- export {};
@@ -1,10 +1,12 @@
1
- import { Stack } from "aws-cdk-lib";
2
1
  import App from "../../app.js";
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
  /**
@@ -17,23 +19,20 @@ import { extractAccountNames } from "../../utils/accountsUtils.js";
17
19
  * - Identity Centre configuration
18
20
  * - Cost allocation tag auto-activation (daily Lambda)
19
21
  */
20
- export class Organisation extends Stack {
22
+ export class Organisation extends Account {
21
23
  organisationType = "organisation";
22
24
  org;
23
25
  accountRefs = [];
24
26
  accountMap;
25
27
  accountsConfig;
26
28
  identityCenter;
27
- constructor(id, props) {
29
+ constructor(scope, id, props) {
28
30
  const config = getConfig();
29
- const env = props.env ?? {
30
- region: config.region,
31
- account: config.accountId ?? ""
32
- };
33
- if (!env.account) {
34
- throw new Error("Organisation requires an account ID. Provide it via env.account or ensure CDK context includes accountId.");
31
+ const accountId = props.accountId ?? props.env?.account ?? config.accountId;
32
+ if (!accountId) {
33
+ throw new Error("Organisation requires an account ID. Provide it via env.account, accountId, or ensure CDK context includes accountId.");
35
34
  }
36
- super(App.getInstance(), id, { ...props, env });
35
+ super(scope, id, { ...props, accountId });
37
36
  // Normalise account map keys to lowercase for case-insensitive lookups.
38
37
  // providerAccounts stores names as lowercase; ACCOUNTS config uses PascalCase.
39
38
  const rawMap = props.accountIds ?? config.accountIds ?? {};
@@ -42,10 +41,17 @@ export class Organisation extends Stack {
42
41
  this.accountMap[key.toLowerCase()] = value;
43
42
  }
44
43
  this.accountsConfig = props.accounts;
45
- const orgId = this.node.tryGetContext(CDK_CONTEXT_KEYS.ORG_ID);
46
- const rootId = this.node.tryGetContext(CDK_CONTEXT_KEYS.ROOT_ID);
47
- const managementAccountId = this.node.tryGetContext(CDK_CONTEXT_KEYS.MANAGEMENT_ACCOUNT_ID) ??
48
- 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;
49
55
  if (!orgId || !rootId) {
50
56
  throw new Error("orgId and rootId must be provided via CDK context. Run deployment through the Fjall CLI.");
51
57
  }
@@ -54,9 +60,62 @@ export class Organisation extends Stack {
54
60
  rootId,
55
61
  managementAccountId
56
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
+ }
57
89
  this.createAccountReferences(props);
58
90
  this.setupOrganisationFeatures(props.identityCenter ?? true, managementAccountId);
59
91
  }
92
+ /**
93
+ * The organisation root's OIDC deploy connector is owned by the customer-run
94
+ * Quick-Create CloudFormation stack, never the inherited Account connector.
95
+ * Returning `false` is also the short-circuit first clause of Account's OIDC
96
+ * gate, so no `OidcConnector` is synthesised on the root stack.
97
+ */
98
+ receivesDeployRole() {
99
+ return false;
100
+ }
101
+ /**
102
+ * The organisation root runs no application workloads, so it needs no
103
+ * default-ECR-image helper.
104
+ */
105
+ createsEcrDefaultImage() {
106
+ return false;
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
+ }
60
119
  createAccountReferences(props) {
61
120
  const allNames = extractAccountNames(props.accounts);
62
121
  for (const accountName of allNames) {
@@ -1,3 +1,4 @@
1
+ import App from "../../app.js";
1
2
  import { Organisation } from "./organisation.js";
2
3
  import { Platform } from "./platform.js";
3
4
  import { Account } from "./account.js";
@@ -5,7 +6,7 @@ export class OrganisationFactory {
5
6
  static build(id, props) {
6
7
  switch (props.type) {
7
8
  case "organisation":
8
- return new Organisation(id, props);
9
+ return new Organisation(App.getInstance(), id, props);
9
10
  case "platform":
10
11
  return (scope) => new Platform(scope, id, props);
11
12
  case "account":
@@ -15,6 +15,17 @@ export interface LambdaFunctionProps {
15
15
  handler: string;
16
16
  lambdaDescription?: string;
17
17
  roleDescription?: string;
18
+ /**
19
+ * Fixed IAM path for the auto-generated execution role (e.g. "/fjall/").
20
+ * Used with roleName to produce an SCP-exemptable ARN.
21
+ */
22
+ rolePath?: string;
23
+ /**
24
+ * Fixed name for the auto-generated execution role. Account-global — only set
25
+ * on a Lambda confined to one (account, region) stack, else regional
26
+ * duplicates collide on the role name.
27
+ */
28
+ roleName?: string;
18
29
  runtime: Runtime;
19
30
  /** Lambda CPU architecture. Default: x86_64 */
20
31
  architecture?: Architecture;
@@ -31,6 +31,24 @@ function applyRoleDescription(fn, description) {
31
31
  if (cfnRole !== undefined)
32
32
  cfnRole.description = description;
33
33
  }
34
+ /**
35
+ * Pin a fixed IAM path and/or name onto CDK's auto-generated Lambda execution
36
+ * role. Reaches the L1 CfnRole (FunctionProps exposes neither) so a stable,
37
+ * predictable ARN can be matched by an SCP exemption pattern. A fixed name is
38
+ * account-global, so only set one on a Lambda that lives in a single
39
+ * (account, region) stack — never one duplicated across regions.
40
+ */
41
+ function applyRolePathAndName(fn, rolePath, roleName) {
42
+ if (rolePath === undefined && roleName === undefined)
43
+ return;
44
+ const cfnRole = fn.role?.node.defaultChild;
45
+ if (cfnRole === undefined)
46
+ return;
47
+ if (rolePath !== undefined)
48
+ cfnRole.path = rolePath;
49
+ if (roleName !== undefined)
50
+ cfnRole.roleName = roleName;
51
+ }
34
52
  /**
35
53
  * AWS Parameters and Secrets Lambda Extension configuration.
36
54
  * @see https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html
@@ -61,6 +79,7 @@ export class SingletonFunction extends singletonFunction {
61
79
  });
62
80
  addPoliciesToRole(this, props.inlinePolicy);
63
81
  applyRoleDescription(this, props.roleDescription);
82
+ applyRolePathAndName(this, props.rolePath, props.roleName);
64
83
  }
65
84
  /**
66
85
  * The Lambda's execution role (auto-generated by CDK)
@@ -91,6 +110,7 @@ export class LambdaFunction extends Function {
91
110
  });
92
111
  addPoliciesToRole(this, props.inlinePolicy);
93
112
  applyRoleDescription(this, props.roleDescription);
113
+ applyRolePathAndName(this, props.rolePath, props.roleName);
94
114
  this.addSecretsSupport(props.secrets, props.ssmSecretsPath, props.secretsImport, props.appName, props.functionName, props.architecture);
95
115
  // Sanitise id for CloudFormation output keys (must be alphanumeric)
96
116
  const outputName = toPascalCase(id);
@@ -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: 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
+ /**
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
- const encryptionKey = new CustomerManagedKey(this, `${id}CloudTrailEncryptionKey`, {
26
+ validateTrailProps(props);
27
+ const { bucketName, retainAuditHistory, omitTrail, ...trailProps } = props;
28
+ const storagePolicy = retainAuditHistory === true
29
+ ? RemovalPolicy.RETAIN
30
+ : RemovalPolicy.DESTROY;
31
+ this.encryptionKey = new CustomerManagedKey(this, `${id}CloudTrailEncryptionKey`, {
14
32
  aliasName: `cmk/cloudtrail/${id}/encryptionKey`,
15
- removalPolicy: RemovalPolicy.DESTROY
33
+ removalPolicy: storagePolicy
16
34
  });
17
35
  this.bucket = new S3Bucket(this, `${id}CloudTrailBucket`, {
18
- bucketName: props.bucketName,
36
+ bucketName,
19
37
  bucketKeyEnabled: true,
20
38
  encryption: BucketEncryption.KMS,
21
- encryptionKey: encryptionKey.key,
39
+ encryptionKey: this.encryptionKey.key,
22
40
  versioned: false,
41
+ removalPolicy: storagePolicy,
23
42
  lifecycleRules: [{ expiration: Duration.days(365), enabled: true }]
24
43
  });
25
- this.bucket.grantReadWrite(new ServicePrincipal("cloudtrail.amazonaws.com"));
44
+ 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
- ...props,
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
- // TODO: Revert to RemovalPolicy.RETAIN for production
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
  });
@@ -7,6 +7,10 @@ interface CustomResourceProps {
7
7
  runtime: Runtime;
8
8
  roleDescription?: string;
9
9
  lambdaDescription?: string;
10
+ /** Fixed IAM path for the handler's execution role (e.g. "/fjall/"). */
11
+ rolePath?: string;
12
+ /** Fixed name for the handler's execution role (account-global — see LambdaFunctionProps). */
13
+ roleName?: string;
10
14
  inlinePolicy: PolicyStatement[];
11
15
  properties?: {
12
16
  [key: string]: string;
@@ -39,6 +39,8 @@ export class CustomResource extends Construct {
39
39
  timeout,
40
40
  lambdaDescription: props.lambdaDescription ?? `${id} lambda`,
41
41
  roleDescription: props.roleDescription ?? `${id} custom resource lambda`,
42
+ rolePath: props.rolePath,
43
+ roleName: props.roleName,
42
44
  inlinePolicy: props.inlinePolicy
43
45
  })
44
46
  : new SingletonFunction(this, `${id}Lambda`, {
@@ -47,6 +49,8 @@ export class CustomResource extends Construct {
47
49
  runtime: props.runtime,
48
50
  lambdaDescription: props.lambdaDescription ?? `${id} lambda`,
49
51
  roleDescription: props.roleDescription ?? `${id} custom resource lambda`,
52
+ rolePath: props.rolePath,
53
+ roleName: props.roleName,
50
54
  inlinePolicy: props.inlinePolicy
51
55
  });
52
56
  const provider = new Provider(this, `${id}Provider`, {
@@ -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);
@@ -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
  /**
@@ -1,4 +1,5 @@
1
- import { parseOrgConfig } from "./orgConfigParser.js";
1
+ import { CDK_CONTEXT_KEYS } from "./cdkContext.js";
2
+ import { parseOrgConfig, resolveSynthEnvironment } from "./orgConfigParser.js";
2
3
  import { FjallLogger } from "./validationLogger.js";
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("orgConfig")).providerAccounts;
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
- return accounts.find((pa) => pa.id === accountId)?.environment;
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
- return accounts.find((pa) => pa.name === accountName)?.environment;
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 { parseOrgConfig } from "./orgConfigParser.js";
2
+ import { CDK_CONTEXT_KEYS } from "./cdkContext.js";
3
+ import { parseOrgConfig, resolveSynthEnvironment } from "./orgConfigParser.js";
3
4
  import { findAccountNameCollision } from "./capitaliseString.js";
4
5
  import { FjallLogger } from "./validationLogger.js";
5
6
  export function getConfig(accountName) {
@@ -14,7 +15,7 @@ export function getConfig(accountName) {
14
15
  if (!process.env.AWS_REGION)
15
16
  FjallLogger.warn("AWS_REGION is not set, defaulting to us-east-1");
16
17
  // Primary: read org config from CDK context (injected by CLI/worker)
17
- const orgConfigRaw = app.node.tryGetContext("orgConfig");
18
+ const orgConfigRaw = app.node.tryGetContext(CDK_CONTEXT_KEYS.ORG_CONFIG);
18
19
  if (typeof orgConfigRaw !== "string") {
19
20
  FjallLogger.warn("No orgConfig context provided — org-level config (accounts, regions) will be empty");
20
21
  }
@@ -53,41 +54,60 @@ export function getConfig(accountName) {
53
54
  // If unspecified account name - try to retrieve from context
54
55
  if (!accountName) {
55
56
  const contextAccountName = app.node.tryGetContext("accountName");
56
- if (typeof contextAccountName === "string") {
57
+ if (typeof contextAccountName === "string" && contextAccountName !== "") {
57
58
  accountName = contextAccountName;
58
59
  }
59
60
  }
60
61
  // If we have an account name - look for associated provider account
62
+ let stageIsStructurallyNull = false;
61
63
  if (accountName) {
62
64
  const providerAccount = providerAccounts.find((pa) => pa.name === accountName);
63
65
  if (providerAccount) {
64
- config.accountId = providerAccount.id;
65
- config.accountName = providerAccount.name;
66
- config.environment = providerAccount.environment;
66
+ stageIsStructurallyNull = applyProviderAccount(config, providerAccount);
67
67
  }
68
68
  }
69
69
  // If we still don't have an account name - try to retrieve accountId from context
70
70
  if (!config.accountName) {
71
71
  const accountId = app.node.tryGetContext("accountId");
72
72
  // If we find an accountId - retrieve the associated account from config
73
- if (typeof accountId === "string") {
73
+ if (typeof accountId === "string" && accountId !== "") {
74
74
  config.accountId = accountId;
75
75
  const providerAccount = providerAccounts.find((pa) => pa.id === accountId);
76
76
  if (providerAccount) {
77
- config.accountName = providerAccount.name;
78
- config.environment = providerAccount.environment;
77
+ stageIsStructurallyNull = applyProviderAccount(config, providerAccount);
79
78
  }
80
79
  }
81
80
  }
82
- // Check for environment from context (highest priority)
81
+ // Check for environment from context (highest priority). Resolution order
82
+ // deliberately diverges from utils/env.ts getEnvironment() (pre-App,
83
+ // process-wide: ENVIRONMENT first): here explicit `-c environment` context
84
+ // wins, and the ENVIRONMENT env var is a last-resort fallback that must
85
+ // never override an account-resolved stage — including a structurally-null
86
+ // one (organisation/platform tiers carry no workload stage).
83
87
  const contextEnvironment = app.node.tryGetContext("environment");
84
- if (typeof contextEnvironment === "string") {
88
+ if (typeof contextEnvironment === "string" && contextEnvironment !== "") {
85
89
  config.environment = contextEnvironment;
86
90
  }
87
- else if (process.env.ENVIRONMENT && config.environment === "unknown") {
91
+ else if (process.env.ENVIRONMENT &&
92
+ config.environment === "unknown" &&
93
+ !stageIsStructurallyNull) {
88
94
  // Fall back to ENVIRONMENT variable if no other source found
89
95
  config.environment = process.env.ENVIRONMENT;
90
96
  }
91
97
  return config;
92
98
  }
99
+ /**
100
+ * Copy a resolved provider account onto the config. The organisation tier
101
+ * resolves to "root" (via resolveSynthEnvironment) so scaffolded org gates
102
+ * fire; other tiers carry their stage verbatim. Returns true when the
103
+ * environment stays unresolved (structurally-null stage) so the caller can
104
+ * suppress the ENVIRONMENT env-var fallback.
105
+ */
106
+ function applyProviderAccount(config, providerAccount) {
107
+ config.accountId = providerAccount.id;
108
+ config.accountName = providerAccount.name;
109
+ const resolved = resolveSynthEnvironment(providerAccount);
110
+ config.environment = resolved ?? "unknown";
111
+ return resolved === undefined;
112
+ }
93
113
  export default getConfig;
@@ -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
- import { VAULT_LOCK_MODES } from "@fjall/util/config";
2
- import { maskSensitiveOutput } from "@fjall/util";
1
+ import { VAULT_LOCK_MODES, S3_BPA_MODES } from "@fjall/util/config";
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,22 +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) => typeof item === "object" &&
24
- item !== null &&
25
- typeof item.id === "string" &&
26
- typeof item.name === "string" &&
27
- typeof item.environment === "string" &&
28
- (item.managed === undefined ||
29
- typeof item.managed === "boolean") &&
30
- (item.oidcRoleArn === undefined ||
31
- typeof item.oidcRoleArn ===
32
- "string") &&
33
- (item.vaultLock === undefined ||
34
- VAULT_LOCK_MODES.includes(item.vaultLock)) &&
35
- (item.acknowledgeImmutableVaultLock ===
36
- undefined ||
37
- typeof item
38
- .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
+ })
39
69
  : [];
40
70
  const primaryRegion = typeof obj.primaryRegion === "string" ? obj.primaryRegion : undefined;
41
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.11.1",
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.11.1",
67
- "@fjall/util": "^2.11.1",
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": "69823c3d7f2eacba419657464381119c5b5b5fd6"
82
+ "gitHead": "5b16c5731256628f829d4168c65cf165b3516f9a"
83
83
  }