@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.
- package/dist/lib/config/aws/cloudTrail.d.ts +13 -0
- package/dist/lib/config/aws/cloudTrail.js +19 -3
- package/dist/lib/config/aws/disasterRecovery.js +1 -1
- package/dist/lib/config/aws/ecrDefaultImage.js +2 -0
- package/dist/lib/config/aws/organisationTrail.d.ts +16 -0
- package/dist/lib/config/aws/organisationTrail.js +32 -0
- package/dist/lib/config/aws/s3BlockPublicAccess.d.ts +22 -4
- package/dist/lib/config/aws/s3BlockPublicAccess.js +33 -13
- package/dist/lib/config/aws/scpPreset.js +6 -1
- package/dist/lib/patterns/aws/account.d.ts +16 -0
- package/dist/lib/patterns/aws/account.js +49 -10
- package/dist/lib/patterns/aws/organisation.d.ts +28 -8
- package/dist/lib/patterns/aws/organisation.js +74 -15
- package/dist/lib/patterns/aws/organisationFactory.js +2 -1
- package/dist/lib/resources/aws/compute/lambda.d.ts +11 -0
- package/dist/lib/resources/aws/compute/lambda.js +20 -0
- package/dist/lib/resources/aws/logging/cloudTrail.d.ts +26 -1
- package/dist/lib/resources/aws/logging/cloudTrail.js +98 -9
- package/dist/lib/resources/aws/utilities/customResource.d.ts +4 -0
- package/dist/lib/resources/aws/utilities/customResource.js +4 -0
- 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 +48 -18
- 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
|
+
}
|
|
@@ -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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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: "
|
|
30
|
+
lambdaDescription: "Manages S3 Block Public Access at account level",
|
|
31
|
+
rolePath: "/fjall/",
|
|
32
|
+
roleName: "FjallS3BpaManager",
|
|
19
33
|
properties: {
|
|
20
|
-
AccountId:
|
|
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
|
|
60
|
+
const props = event.ResourceProperties;
|
|
41
61
|
const client = new S3ControlClient({});
|
|
42
62
|
await client.send(new PutPublicAccessBlockCommand({
|
|
43
|
-
AccountId:
|
|
63
|
+
AccountId: props.AccountId,
|
|
44
64
|
PublicAccessBlockConfiguration: {
|
|
45
|
-
BlockPublicAcls:
|
|
46
|
-
IgnorePublicAcls:
|
|
47
|
-
BlockPublicPolicy:
|
|
48
|
-
RestrictPublicBuckets:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
30
|
-
|
|
31
|
-
account
|
|
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(
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
});
|
|
@@ -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);
|
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;
|
|
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;
|
|
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
|
-
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) =>
|
|
24
|
-
item
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
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
|
}
|