@fjall/components-infrastructure 2.24.0 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/app.js +11 -1
- package/dist/lib/config/aws/accountMonitoringRole.js +1 -0
- package/dist/lib/config/aws/oidcConnector.d.ts +9 -0
- package/dist/lib/config/aws/oidcConnector.js +71 -8
- package/dist/lib/config/aws/scpPreset.d.ts +14 -0
- package/dist/lib/config/aws/scpPreset.js +75 -0
- package/dist/lib/patterns/aws/account.js +2 -1
- package/dist/lib/patterns/aws/clickhouseDatabase.js +14 -7
- package/dist/lib/patterns/aws/organisation.d.ts +9 -0
- package/dist/lib/patterns/aws/organisation.js +21 -1
- package/dist/lib/resources/aws/compute/ecsRoles.js +6 -10
- package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +7 -3
- package/dist/lib/resources/aws/database/clickhouseConstants.js +6 -2
- package/dist/lib/resources/aws/database/rdsAurora.d.ts +7 -1
- package/dist/lib/resources/aws/database/rdsAurora.js +50 -15
- package/dist/lib/resources/aws/database/rdsAuroraGlobal.d.ts +1 -1
- package/dist/lib/resources/aws/database/rdsDefaults.d.ts +0 -2
- package/dist/lib/resources/aws/database/rdsDefaults.js +0 -2
- package/dist/lib/resources/aws/database/rdsHelpers.d.ts +23 -2
- package/dist/lib/resources/aws/database/rdsHelpers.js +23 -5
- package/dist/lib/resources/aws/database/rdsInstance.d.ts +7 -1
- package/dist/lib/resources/aws/database/rdsInstance.js +40 -11
- package/dist/lib/utils/cdkContext.d.ts +1 -0
- package/dist/lib/utils/cdkContext.js +2 -1
- package/dist/lib/utils/databaseTypes.d.ts +14 -0
- package/dist/lib/utils/databaseTypes.js +10 -0
- package/dist/lib/utils/manifestWriter.d.ts +7 -0
- package/dist/lib/utils/manifestWriter.js +9 -0
- package/package.json +5 -4
package/dist/lib/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { App as CdkApp, Aspects, Tags } from "aws-cdk-lib";
|
|
2
2
|
import { maskSensitiveOutput } from "@fjall/util";
|
|
3
|
+
import { RDS_PREVENT_RENDERING_DEPRECATED_CREDENTIALS } from "aws-cdk-lib/cx-api";
|
|
3
4
|
import { Vpc } from "./resources/aws/networking/vpc.js";
|
|
4
5
|
import { createBastion } from "./patterns/aws/bastionFactory.js";
|
|
5
6
|
import { AwsStack } from "./resources/index.js";
|
|
@@ -44,7 +45,12 @@ export class App extends CdkApp {
|
|
|
44
45
|
resourceInventory;
|
|
45
46
|
manifestCollector;
|
|
46
47
|
constructor(name, options) {
|
|
47
|
-
|
|
48
|
+
// Without this flag DatabaseClusterFromSnapshot renders a second, orphaned
|
|
49
|
+
// credentials secret alongside snapshotCredentials. Constructor context has
|
|
50
|
+
// the lowest precedence, so an app's cdk.json can still override it.
|
|
51
|
+
super({
|
|
52
|
+
context: { [RDS_PREVENT_RENDERING_DEPRECATED_CREDENTIALS]: true }
|
|
53
|
+
});
|
|
48
54
|
App.instance = this;
|
|
49
55
|
this.name = name ?? "FjallApp";
|
|
50
56
|
this.stackPrefix = toPascalCase(this.name);
|
|
@@ -118,6 +124,10 @@ export class App extends CdkApp {
|
|
|
118
124
|
App.instance.stackPrefix = toPascalCase(name);
|
|
119
125
|
// Reinitialise standard tags with the new name
|
|
120
126
|
App.instance.initialiseStandardTags();
|
|
127
|
+
// Rename the collector in place, or the manifest's top-level appName
|
|
128
|
+
// keeps the constructor-time name ("FjallApp" when getConfig() ran
|
|
129
|
+
// before getApp()). In-place rename preserves collected services.
|
|
130
|
+
App.instance.manifestCollector.setAppName(name);
|
|
121
131
|
}
|
|
122
132
|
// If options provided but network not yet initialised, initialise it now
|
|
123
133
|
if (options?.network === false) {
|
|
@@ -10,6 +10,15 @@ export interface OidcConnectorProps {
|
|
|
10
10
|
* Absent ⇒ no boundary (today's behaviour — backward-compatible).
|
|
11
11
|
*/
|
|
12
12
|
securityTier?: GovernancePreset;
|
|
13
|
+
/**
|
|
14
|
+
* Workload STAGE of the connected account (the wire `environment` value —
|
|
15
|
+
* never the account TIER axis). When `"development"`, the connector
|
|
16
|
+
* additionally provisions the dev-tier surface (fast-dev-environments plan
|
|
17
|
+
* §4.2 G3): the `FjallDevDeploy`/`FjallDevProvisioner`/`FjallDevSyncWriter`
|
|
18
|
+
* roles and the `FjallDevBoundary` permissions boundary. Absent or any
|
|
19
|
+
* other stage ⇒ deploy role only.
|
|
20
|
+
*/
|
|
21
|
+
accountStage?: string;
|
|
13
22
|
}
|
|
14
23
|
export declare class OidcConnector extends Construct {
|
|
15
24
|
readonly deployRoleArn: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { SECURITY_TIER_TO_BOUNDARY, deployBoundaryName } from "@fjall/generator";
|
|
2
|
-
import { CfnOutput, Duration } from "aws-cdk-lib";
|
|
1
|
+
import { DEV_BOUNDARY_POLICY_NAME, DEV_DEPLOY_POLICY_NAME, DEV_PROVISIONER_POLICY_NAME, DEV_SYNC_WRITER_POLICY_NAME, SECURITY_TIER_TO_BOUNDARY, buildDevBoundaryStatements, buildDevDeployPolicyStatements, buildDevDeployTrustStatements, buildDevProvisionerPolicyStatements, buildDevProvisionerTrustStatements, buildDevSyncWriterPolicyStatements, buildDevSyncWriterTrustStatements, buildNarrowedDeployTrustStatements, deployBoundaryName, devDeployRoleName, devProvisionerRoleName, devSyncWriterRoleName } from "@fjall/generator";
|
|
2
|
+
import { CfnOutput, Duration, Stack } from "aws-cdk-lib";
|
|
3
3
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
4
|
import { Runtime } from "aws-cdk-lib/aws-lambda";
|
|
5
5
|
import { Construct } from "constructs";
|
|
@@ -52,6 +52,21 @@ exports.handler = async (event) => {
|
|
|
52
52
|
}
|
|
53
53
|
return { PhysicalResourceId: arn, Data: { ProviderArn: arn } };
|
|
54
54
|
};`;
|
|
55
|
+
/**
|
|
56
|
+
* Renders an SSoT trust document verbatim onto a role via the L1 escape
|
|
57
|
+
* hatch. An L2 `FederatedPrincipal` can express only ONE trust statement, but
|
|
58
|
+
* the G4 narrowed trust is a condition SPLIT (each condition class its own
|
|
59
|
+
* Allow statement — conditions inside one statement AND together), and the
|
|
60
|
+
* three-rendering parity fixture compares the rendered document against the
|
|
61
|
+
* `@fjall/generator` SSoT byte-for-byte.
|
|
62
|
+
*/
|
|
63
|
+
function applyTrustDocument(role, statements) {
|
|
64
|
+
const cfnRole = role.node.defaultChild;
|
|
65
|
+
cfnRole.assumeRolePolicyDocument = {
|
|
66
|
+
Version: "2012-10-17",
|
|
67
|
+
Statement: statements
|
|
68
|
+
};
|
|
69
|
+
}
|
|
55
70
|
export class OidcConnector extends Construct {
|
|
56
71
|
deployRoleArn;
|
|
57
72
|
constructor(scope, id, props) {
|
|
@@ -92,16 +107,18 @@ export class OidcConnector extends Construct {
|
|
|
92
107
|
statements: SECURITY_TIER_TO_BOUNDARY[props.securityTier].map((statement) => iam.PolicyStatement.fromJson(statement))
|
|
93
108
|
})
|
|
94
109
|
: undefined;
|
|
110
|
+
const trustParams = {
|
|
111
|
+
fjallOrgId: props.fjallOrgId,
|
|
112
|
+
providerArn,
|
|
113
|
+
issuerDomain
|
|
114
|
+
};
|
|
95
115
|
const deployRole = new Role(this, "DeployRole", {
|
|
96
116
|
roleName: `FjallDeploy${props.fjallOrgId}`,
|
|
97
117
|
path: "/fjall/",
|
|
98
118
|
maxSessionDuration: Duration.hours(1),
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
[`${issuerDomain}:sub`]: `org:${props.fjallOrgId}:*`
|
|
103
|
-
}
|
|
104
|
-
}, "sts:AssumeRoleWithWebIdentity"),
|
|
119
|
+
// Placeholder — applyTrustDocument below replaces the whole rendered
|
|
120
|
+
// AssumeRolePolicyDocument with the SSoT's G4 condition split.
|
|
121
|
+
assumedBy: new iam.FederatedPrincipal(providerArn, {}, "sts:AssumeRoleWithWebIdentity"),
|
|
105
122
|
managedPolicies: [
|
|
106
123
|
iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")
|
|
107
124
|
],
|
|
@@ -109,7 +126,53 @@ export class OidcConnector extends Construct {
|
|
|
109
126
|
permissionsBoundary: deployBoundary
|
|
110
127
|
})
|
|
111
128
|
});
|
|
129
|
+
applyTrustDocument(deployRole, buildNarrowedDeployTrustStatements(trustParams));
|
|
112
130
|
this.deployRoleArn = deployRole.roleArn;
|
|
131
|
+
// G3 dev-tier surface — development-STAGE accounts only. Every document
|
|
132
|
+
// comes verbatim from the @fjall/generator SSoT (three-rendering parity
|
|
133
|
+
// with the Quick-Create yaml's IsDevelopmentStage-conditional resources
|
|
134
|
+
// and the webapp dev-gate-remediate pass).
|
|
135
|
+
if (props.accountStage === "development") {
|
|
136
|
+
const accountId = Stack.of(this).account;
|
|
137
|
+
new ManagedPolicy(this, "DevBoundary", {
|
|
138
|
+
managedPolicyName: DEV_BOUNDARY_POLICY_NAME,
|
|
139
|
+
description: "Fjall dev-tier permissions boundary — ceiling for the per-slot task roles the dev provisioner creates",
|
|
140
|
+
statements: buildDevBoundaryStatements({ accountId }).map((statement) => iam.PolicyStatement.fromJson(statement))
|
|
141
|
+
});
|
|
142
|
+
const devDeployRole = new Role(this, "DevDeployRole", {
|
|
143
|
+
roleName: devDeployRoleName(props.fjallOrgId),
|
|
144
|
+
assumedBy: new iam.FederatedPrincipal(providerArn, {}, "sts:AssumeRoleWithWebIdentity"),
|
|
145
|
+
inlinePolicies: {
|
|
146
|
+
[DEV_DEPLOY_POLICY_NAME]: iam.PolicyDocument.fromJson({
|
|
147
|
+
Version: "2012-10-17",
|
|
148
|
+
Statement: buildDevDeployPolicyStatements({ accountId })
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
applyTrustDocument(devDeployRole, buildDevDeployTrustStatements({ ...trustParams, accountId }));
|
|
153
|
+
const devProvisionerRole = new Role(this, "DevProvisionerRole", {
|
|
154
|
+
roleName: devProvisionerRoleName(props.fjallOrgId),
|
|
155
|
+
assumedBy: new iam.FederatedPrincipal(providerArn, {}, "sts:AssumeRoleWithWebIdentity"),
|
|
156
|
+
inlinePolicies: {
|
|
157
|
+
[DEV_PROVISIONER_POLICY_NAME]: iam.PolicyDocument.fromJson({
|
|
158
|
+
Version: "2012-10-17",
|
|
159
|
+
Statement: buildDevProvisionerPolicyStatements({ accountId })
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
applyTrustDocument(devProvisionerRole, buildDevProvisionerTrustStatements(trustParams));
|
|
164
|
+
const devSyncWriterRole = new Role(this, "DevSyncWriterRole", {
|
|
165
|
+
roleName: devSyncWriterRoleName(props.fjallOrgId),
|
|
166
|
+
assumedBy: new iam.FederatedPrincipal(providerArn, {}, "sts:AssumeRoleWithWebIdentity"),
|
|
167
|
+
inlinePolicies: {
|
|
168
|
+
[DEV_SYNC_WRITER_POLICY_NAME]: iam.PolicyDocument.fromJson({
|
|
169
|
+
Version: "2012-10-17",
|
|
170
|
+
Statement: buildDevSyncWriterPolicyStatements({ accountId })
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
applyTrustDocument(devSyncWriterRole, buildDevSyncWriterTrustStatements(trustParams));
|
|
175
|
+
}
|
|
113
176
|
new CfnOutput(this, "OidcDeployRoleArn", {
|
|
114
177
|
key: "OidcDeployRoleArn",
|
|
115
178
|
value: deployRole.roleArn,
|
|
@@ -19,3 +19,17 @@ export declare class ScpPreset extends Construct {
|
|
|
19
19
|
private createRootScps;
|
|
20
20
|
private createHardenedScps;
|
|
21
21
|
}
|
|
22
|
+
export interface DevIsolationScpProps {
|
|
23
|
+
developmentOuId: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The dev-gate SCP backstop (fast dev-environments G6): denies organisation
|
|
27
|
+
* management and cross-account role assumption from every account in the
|
|
28
|
+
* development OU, independent of the ScpPreset security level — foundation
|
|
29
|
+
* orgs get it too. Attached automatically by the Organisation pattern when
|
|
30
|
+
* the deploy engine threads the development OU id via CDK context
|
|
31
|
+
* (CDK_CONTEXT_KEYS.DEV_OU_ID); never instantiated from customer templates.
|
|
32
|
+
*/
|
|
33
|
+
export declare class DevIsolationScp extends Construct {
|
|
34
|
+
constructor(scope: Construct, id: string, props: DevIsolationScpProps);
|
|
35
|
+
}
|
|
@@ -268,6 +268,59 @@ function buildCostControls() {
|
|
|
268
268
|
]
|
|
269
269
|
};
|
|
270
270
|
}
|
|
271
|
+
function buildDevIsolationGuardrails() {
|
|
272
|
+
return {
|
|
273
|
+
Version: IAM_POLICY_VERSION,
|
|
274
|
+
Statement: [
|
|
275
|
+
{
|
|
276
|
+
Sid: "DenyOrganisationsManagement",
|
|
277
|
+
Effect: "Deny",
|
|
278
|
+
// Every mutating organizations: verb; Describe*/List* stay allowed so
|
|
279
|
+
// read-only org probes (e.g. DescribeOrganization from in-account
|
|
280
|
+
// tooling) keep working.
|
|
281
|
+
Action: [
|
|
282
|
+
"organizations:Accept*",
|
|
283
|
+
"organizations:Attach*",
|
|
284
|
+
"organizations:Cancel*",
|
|
285
|
+
"organizations:Close*",
|
|
286
|
+
"organizations:Create*",
|
|
287
|
+
"organizations:Decline*",
|
|
288
|
+
"organizations:Delete*",
|
|
289
|
+
"organizations:Deregister*",
|
|
290
|
+
"organizations:Detach*",
|
|
291
|
+
"organizations:Disable*",
|
|
292
|
+
"organizations:Enable*",
|
|
293
|
+
"organizations:Invite*",
|
|
294
|
+
"organizations:Leave*",
|
|
295
|
+
"organizations:Move*",
|
|
296
|
+
"organizations:Put*",
|
|
297
|
+
"organizations:Register*",
|
|
298
|
+
"organizations:Remove*",
|
|
299
|
+
"organizations:Tag*",
|
|
300
|
+
"organizations:Untag*",
|
|
301
|
+
"organizations:Update*"
|
|
302
|
+
],
|
|
303
|
+
Resource: "*",
|
|
304
|
+
Condition: automationExemption()
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
Sid: "DenyCrossAccountAssumeRole",
|
|
308
|
+
Effect: "Deny",
|
|
309
|
+
Action: ["sts:AssumeRole", "sts:AssumeRoot"],
|
|
310
|
+
Resource: "*",
|
|
311
|
+
Condition: {
|
|
312
|
+
StringNotEquals: {
|
|
313
|
+
// IAM policy variable, substituted per-request by AWS — the
|
|
314
|
+
// resource-perimeter pattern: deny assumption of any role whose
|
|
315
|
+
// account differs from the calling principal's own.
|
|
316
|
+
"aws:ResourceAccount": "${aws:PrincipalAccount}"
|
|
317
|
+
},
|
|
318
|
+
...automationExemption()
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
]
|
|
322
|
+
};
|
|
323
|
+
}
|
|
271
324
|
function validateByteLimit(name, policy) {
|
|
272
325
|
const json = JSON.stringify(policy);
|
|
273
326
|
const byteLength = Buffer.byteLength(json, "utf-8");
|
|
@@ -335,3 +388,25 @@ export class ScpPreset extends Construct {
|
|
|
335
388
|
}
|
|
336
389
|
}
|
|
337
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* The dev-gate SCP backstop (fast dev-environments G6): denies organisation
|
|
393
|
+
* management and cross-account role assumption from every account in the
|
|
394
|
+
* development OU, independent of the ScpPreset security level — foundation
|
|
395
|
+
* orgs get it too. Attached automatically by the Organisation pattern when
|
|
396
|
+
* the deploy engine threads the development OU id via CDK context
|
|
397
|
+
* (CDK_CONTEXT_KEYS.DEV_OU_ID); never instantiated from customer templates.
|
|
398
|
+
*/
|
|
399
|
+
export class DevIsolationScp extends Construct {
|
|
400
|
+
constructor(scope, id, props) {
|
|
401
|
+
super(scope, id);
|
|
402
|
+
const policy = buildDevIsolationGuardrails();
|
|
403
|
+
validateByteLimit("DevIsolation", policy);
|
|
404
|
+
new OrganisationPolicy(this, "DevIsolation", {
|
|
405
|
+
name: "fjall-dev-isolation",
|
|
406
|
+
policyType: SCP_POLICY_TYPE,
|
|
407
|
+
content: policy,
|
|
408
|
+
description: "Denies organisation management and cross-account role assumption from development accounts",
|
|
409
|
+
targetIds: [props.developmentOuId]
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
@@ -61,12 +61,14 @@ export class Account extends Stack {
|
|
|
61
61
|
// collide across regions, so they are created only in the home region.
|
|
62
62
|
const accountGlobalsConfigured = this.node.tryGetContext("fjallAccountGlobalsConfigured") === "true";
|
|
63
63
|
this.accountGlobalsConfigured = accountGlobalsConfigured;
|
|
64
|
+
const environment = config.environment ?? "unknown";
|
|
64
65
|
if (this.receivesDeployRole() &&
|
|
65
66
|
fjallOrgId &&
|
|
66
67
|
!oidcAlreadyConfigured &&
|
|
67
68
|
!accountGlobalsConfigured) {
|
|
68
69
|
new OidcConnector(this, "OidcConnector", {
|
|
69
70
|
fjallOrgId,
|
|
71
|
+
accountStage: environment,
|
|
70
72
|
...(props.securityTier !== undefined && {
|
|
71
73
|
securityTier: props.securityTier
|
|
72
74
|
})
|
|
@@ -78,7 +80,6 @@ export class Account extends Stack {
|
|
|
78
80
|
if (fjallOrgId && !accountGlobalsConfigured) {
|
|
79
81
|
new AccountAuditRole(this, "AuditRole", { fjallOrgId });
|
|
80
82
|
}
|
|
81
|
-
const environment = config.environment ?? "unknown";
|
|
82
83
|
// The trail is multi-region, so a non-home-region cascade stack creating
|
|
83
84
|
// its own copy records every management event a second time — CloudTrail
|
|
84
85
|
// bills additional copies at $2.00/100k events. Home region only, like
|
|
@@ -292,10 +292,10 @@ export class ClickHouseDatabase extends Construct {
|
|
|
292
292
|
const dataAz = Stack.of(this).availabilityZones[0];
|
|
293
293
|
const clickHouseHost = this.getHostEndpoint();
|
|
294
294
|
const backupDestUrl = `https://${backupBucket.bucketName}.s3.${Stack.of(this).region}.amazonaws.com/backup/`;
|
|
295
|
-
const
|
|
295
|
+
const optimiseStatements = [
|
|
296
296
|
...REPLACING_MERGE_TREE_TABLES.map((table) => `OPTIMIZE TABLE ${CLICKHOUSE_DATABASE_NAME}.${table} FINAL`),
|
|
297
297
|
...OPTIMISE_MV_TABLES.map((table) => `OPTIMIZE TABLE ${CLICKHOUSE_DATABASE_NAME}.${table}`)
|
|
298
|
-
]
|
|
298
|
+
];
|
|
299
299
|
const adminSecret = expectDefined(userSecrets.get(schemaAdmin.name), `schemaAdmin '${schemaAdmin.name}' secret not minted.`);
|
|
300
300
|
const sidecarTlsPreamble = tlsActive
|
|
301
301
|
? `set -eu && printf '%s\\n' "$CLICKHOUSE_CA_CERT" > /tmp/ca.crt && printf '%s' '<?xml version="1.0"?><config><openSSL><client><caConfig>/tmp/ca.crt</caConfig><verificationMode>strict</verificationMode><loadDefaultCAFile>false</loadDefaultCAFile><invalidCertificateHandler><name>RejectCertificateHandler</name></invalidCertificateHandler></client></openSSL></config>' > /tmp/clickhouse-client.xml && `
|
|
@@ -306,6 +306,17 @@ export class ClickHouseDatabase extends Construct {
|
|
|
306
306
|
const sidecarTlsSecrets = tlsActive && tlsCaSecret !== undefined
|
|
307
307
|
? { CLICKHOUSE_CA_CERT: EcsSecret.fromSecretsManager(tlsCaSecret) }
|
|
308
308
|
: {};
|
|
309
|
+
// One clickhouse-client invocation per statement: a single multi-statement
|
|
310
|
+
// --query aborts on the first failure, so one missing table (a migration
|
|
311
|
+
// deploy-order window, or a dropped table lingering in the constants)
|
|
312
|
+
// would permanently skip every optimise after it. Failures are logged and
|
|
313
|
+
// the task still exits non-zero so they stay visible in the task history.
|
|
314
|
+
const optimiseClient = `clickhouse-client --host ${clickHouseHost} --port ${nativePort} --user ${schemaAdmin.name}${sidecarTlsClientArgs}`;
|
|
315
|
+
const optimiseScript = [
|
|
316
|
+
"FAILED=0",
|
|
317
|
+
...optimiseStatements.map((stmt) => `${optimiseClient} --query "${stmt}" || { echo "optimise failed: ${stmt}"; FAILED=1; }`),
|
|
318
|
+
'exit "$FAILED"'
|
|
319
|
+
].join("; ");
|
|
309
320
|
const scheduledTasks = [];
|
|
310
321
|
if (optimiseEnabled) {
|
|
311
322
|
scheduledTasks.push({
|
|
@@ -314,11 +325,7 @@ export class ClickHouseDatabase extends Construct {
|
|
|
314
325
|
image: ContainerImage.fromRegistry(CLICKHOUSE_IMAGE),
|
|
315
326
|
cpu: OPTIMISE_TASK_CPU_UNITS,
|
|
316
327
|
memoryLimitMiB: OPTIMISE_TASK_MEMORY_MIB,
|
|
317
|
-
command: [
|
|
318
|
-
"sh",
|
|
319
|
-
"-c",
|
|
320
|
-
`${sidecarTlsPreamble}clickhouse-client --host ${clickHouseHost} --port ${nativePort} --user ${schemaAdmin.name}${sidecarTlsClientArgs} --query "${optimiseQuery};"`
|
|
321
|
-
],
|
|
328
|
+
command: ["sh", "-c", `${sidecarTlsPreamble}${optimiseScript}`],
|
|
322
329
|
secrets: {
|
|
323
330
|
CLICKHOUSE_PASSWORD: EcsSecret.fromSecretsManager(adminSecret.secret, "password"),
|
|
324
331
|
...sidecarTlsSecrets
|
|
@@ -41,6 +41,15 @@ export declare class Organisation extends Account {
|
|
|
41
41
|
private accountsConfig;
|
|
42
42
|
private identityCenter?;
|
|
43
43
|
constructor(scope: Construct, id: string, props: OrganisationProps);
|
|
44
|
+
/**
|
|
45
|
+
* Dev-gate SCP backstop (G6): attached whenever the deploy engine resolves a
|
|
46
|
+
* development OU and threads its id via context — independent of enableScps,
|
|
47
|
+
* so foundation-security orgs are covered too. Context absent (no dev OU, or
|
|
48
|
+
* a pre-G6 engine) synthesises nothing; a later synth without the context
|
|
49
|
+
* removes a previously attached policy, which is consistent — no dev OU
|
|
50
|
+
* means no development accounts left to isolate.
|
|
51
|
+
*/
|
|
52
|
+
private setupDevIsolationScp;
|
|
44
53
|
/**
|
|
45
54
|
* The organisation root's OIDC deploy connector is owned by the customer-run
|
|
46
55
|
* Quick-Create CloudFormation stack, never the inherited Account connector.
|
|
@@ -3,7 +3,7 @@ import { Account } from "./account.js";
|
|
|
3
3
|
import { IdentityCenter } from "../../config/aws/identityCenter.js";
|
|
4
4
|
import { ManagementEventsTrail } from "../../config/aws/cloudTrail.js";
|
|
5
5
|
import { OrganisationTrail } from "../../config/aws/organisationTrail.js";
|
|
6
|
-
import { ScpPreset } from "../../config/aws/scpPreset.js";
|
|
6
|
+
import { DevIsolationScp, ScpPreset } from "../../config/aws/scpPreset.js";
|
|
7
7
|
import { OrganisationResource, OrganisationAccount, CostAllocationTagActivator } from "../../resources/aws/organisation/index.js";
|
|
8
8
|
import { Schedule } from "../../resources/aws/messaging/schedule.js";
|
|
9
9
|
import { stripAndCamelCase } from "../../utils/stripAndCamelCase.js";
|
|
@@ -95,6 +95,26 @@ export class Organisation extends Account {
|
|
|
95
95
|
}
|
|
96
96
|
this.createAccountReferences(props);
|
|
97
97
|
this.setupOrganisationFeatures(props.identityCentre, managementAccountId);
|
|
98
|
+
this.setupDevIsolationScp();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Dev-gate SCP backstop (G6): attached whenever the deploy engine resolves a
|
|
102
|
+
* development OU and threads its id via context — independent of enableScps,
|
|
103
|
+
* so foundation-security orgs are covered too. Context absent (no dev OU, or
|
|
104
|
+
* a pre-G6 engine) synthesises nothing; a later synth without the context
|
|
105
|
+
* removes a previously attached policy, which is consistent — no dev OU
|
|
106
|
+
* means no development accounts left to isolate.
|
|
107
|
+
*/
|
|
108
|
+
setupDevIsolationScp() {
|
|
109
|
+
const devOuIdCtx = this.node.tryGetContext(CDK_CONTEXT_KEYS.DEV_OU_ID);
|
|
110
|
+
const devOuId = typeof devOuIdCtx === "string" && devOuIdCtx !== ""
|
|
111
|
+
? devOuIdCtx
|
|
112
|
+
: undefined;
|
|
113
|
+
if (devOuId !== undefined) {
|
|
114
|
+
new DevIsolationScp(this, "DevIsolationScp", {
|
|
115
|
+
developmentOuId: devOuId
|
|
116
|
+
});
|
|
117
|
+
}
|
|
98
118
|
}
|
|
99
119
|
/**
|
|
100
120
|
* The organisation root's OIDC deploy connector is owned by the customer-run
|
|
@@ -24,16 +24,12 @@ export function createExecutionRole(ctx, serviceName) {
|
|
|
24
24
|
resources: ["*"]
|
|
25
25
|
}));
|
|
26
26
|
const { partition, region, account } = Stack.of(ctx.scope);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"logs:CreateLogGroup"
|
|
34
|
-
],
|
|
35
|
-
resources: [logGroupArn, `${logGroupArn}:*`]
|
|
36
|
-
}));
|
|
27
|
+
// Gotcha: no manual logs grant here. The task definition's AwsLogDriver
|
|
28
|
+
// binds an explicit LogGroup construct (CFN auto-named — there is NO
|
|
29
|
+
// `/ecs/{cluster}/...` group; that shape is only the stream prefix), and
|
|
30
|
+
// `AwsLogDriver.bind()` auto-grants CreateLogStream/PutLogEvents on the
|
|
31
|
+
// real group's ARN. A hand-rolled `/ecs/{cluster}*` statement grants
|
|
32
|
+
// access to log groups that do not exist.
|
|
37
33
|
// Gotcha: do NOT add a manual `secretsImport` grant here. `addContainer({ secrets })`
|
|
38
34
|
// auto-grants `secret.grantRead(executionRole)` on the resolved complete ARN (exact, no
|
|
39
35
|
// wildcard). A manual bare/`-*` statement is the 2026-06-04 outage shape and is dead.
|
|
@@ -150,7 +150,7 @@ export declare const OPTIMISE_FINAL_SCHEDULE = "rate(6 hours)";
|
|
|
150
150
|
/** Tables requiring periodic OPTIMIZE FINAL (ReplacingMergeTree only).
|
|
151
151
|
* Keep in sync with REPLACING_MERGE_TREE_TABLES in
|
|
152
152
|
* webapp/app/.server/lib/clickhouse/tenantQuery/tableConstants.ts (auto-FINAL). */
|
|
153
|
-
export declare const REPLACING_MERGE_TREE_TABLES: readonly ["application_metrics", "cost_records", "log_fingerprints", "insights", "asset_inventory"];
|
|
153
|
+
export declare const REPLACING_MERGE_TREE_TABLES: readonly ["application_metrics", "cost_records", "log_events", "log_fingerprints", "insights", "asset_inventory"];
|
|
154
154
|
/** Subdirectory on the EBS volume for server config files (must match CDK volume mount). */
|
|
155
155
|
export declare const CLICKHOUSE_CONFIG_SUBDIR = "server-config.d";
|
|
156
156
|
/** Subdirectory on the EBS volume for users config files (must match CDK volume mount). */
|
|
@@ -166,8 +166,12 @@ export declare const CLICKHOUSE_CLOUDMAP_SERVICE_NAME = "clickhouse";
|
|
|
166
166
|
export declare const CLICKHOUSE_SERVER_CONTAINER_NAME = "clickhouse";
|
|
167
167
|
/** Materialised views that benefit from periodic OPTIMIZE to reduce part count at read time.
|
|
168
168
|
* These are not ReplacingMergeTree (no dedup needed) but un-merged parts force
|
|
169
|
-
* read-time aggregation which degrades query performance.
|
|
170
|
-
|
|
169
|
+
* read-time aggregation which degrades query performance.
|
|
170
|
+
*
|
|
171
|
+
* Coupled with webapp/clickhouse-init: every name here must exist in the live
|
|
172
|
+
* schema. A migration that DROPs a table must remove it here in the same
|
|
173
|
+
* change (log_severity_hourly_mv was dropped by 012-log-events-dedup.sql). */
|
|
174
|
+
export declare const OPTIMISE_MV_TABLES: readonly ["metrics_hourly_mv", "metrics_daily_mv", "response_time_quantiles_hourly_mv", "deployment_duration_quantiles_daily_mv", "compliance_score_daily_mv", "ai_usage_daily_mv", "finding_daily_aggregate", "insight_pattern_dismissals"];
|
|
171
175
|
/** Resource allocation for the lightweight optimise task. */
|
|
172
176
|
export declare const OPTIMISE_TASK_MEMORY_MIB = 256;
|
|
173
177
|
export declare const OPTIMISE_TASK_CPU_UNITS = 256;
|
|
@@ -159,6 +159,7 @@ export const OPTIMISE_FINAL_SCHEDULE = "rate(6 hours)";
|
|
|
159
159
|
export const REPLACING_MERGE_TREE_TABLES = [
|
|
160
160
|
"application_metrics",
|
|
161
161
|
"cost_records",
|
|
162
|
+
"log_events",
|
|
162
163
|
"log_fingerprints",
|
|
163
164
|
"insights",
|
|
164
165
|
"asset_inventory"
|
|
@@ -178,13 +179,16 @@ export const CLICKHOUSE_CLOUDMAP_SERVICE_NAME = "clickhouse";
|
|
|
178
179
|
export const CLICKHOUSE_SERVER_CONTAINER_NAME = "clickhouse";
|
|
179
180
|
/** Materialised views that benefit from periodic OPTIMIZE to reduce part count at read time.
|
|
180
181
|
* These are not ReplacingMergeTree (no dedup needed) but un-merged parts force
|
|
181
|
-
* read-time aggregation which degrades query performance.
|
|
182
|
+
* read-time aggregation which degrades query performance.
|
|
183
|
+
*
|
|
184
|
+
* Coupled with webapp/clickhouse-init: every name here must exist in the live
|
|
185
|
+
* schema. A migration that DROPs a table must remove it here in the same
|
|
186
|
+
* change (log_severity_hourly_mv was dropped by 012-log-events-dedup.sql). */
|
|
182
187
|
export const OPTIMISE_MV_TABLES = [
|
|
183
188
|
"metrics_hourly_mv",
|
|
184
189
|
"metrics_daily_mv",
|
|
185
190
|
"response_time_quantiles_hourly_mv",
|
|
186
191
|
"deployment_duration_quantiles_daily_mv",
|
|
187
|
-
"log_severity_hourly_mv",
|
|
188
192
|
"compliance_score_daily_mv",
|
|
189
193
|
"ai_usage_daily_mv",
|
|
190
194
|
"finding_daily_aggregate",
|
|
@@ -28,7 +28,12 @@ interface RdsProps {
|
|
|
28
28
|
deletionProtection?: boolean;
|
|
29
29
|
/** ARN or identifier of DB cluster snapshot to restore from */
|
|
30
30
|
snapshotIdentifier?: string;
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Master username of the snapshot being restored. Must match the snapshot's
|
|
33
|
+
* actual master user — the post-restore password reset applies to that user
|
|
34
|
+
* and the new credentials secret bakes this name in. When omitted, falls
|
|
35
|
+
* back to credentials.username or the engine default and synth warns.
|
|
36
|
+
*/
|
|
32
37
|
snapshotUsername?: string;
|
|
33
38
|
/** Allow access from VPC CIDR (avoids cross-stack cyclic dependencies with Lambda) */
|
|
34
39
|
allowVpcAccess?: boolean;
|
|
@@ -56,6 +61,7 @@ export declare class RdsAurora extends Construct implements IConnectable {
|
|
|
56
61
|
connections: Connections;
|
|
57
62
|
private readonly constructId;
|
|
58
63
|
private port;
|
|
64
|
+
private clientPort;
|
|
59
65
|
private engineConfig;
|
|
60
66
|
private databaseCluster;
|
|
61
67
|
private databaseProxy?;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
1
|
+
import { Annotations, Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
2
|
+
import { ENGINE_DEFAULT_DATABASE_PORTS } from "@fjall/generator";
|
|
2
3
|
import { Connections, Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
|
|
3
4
|
import { AuroraPostgresEngineVersion, CaCertificate, ClusterInstance, Credentials, DatabaseCluster, DatabaseClusterFromSnapshot, DatabaseClusterEngine, DatabaseProxy, ParameterGroup, ProxyTarget, SnapshotCredentials } from "aws-cdk-lib/aws-rds";
|
|
4
5
|
import { Construct } from "constructs";
|
|
@@ -9,11 +10,13 @@ import { addProxyCfnOutput } from "./rdsProxyOutput.js";
|
|
|
9
10
|
import { getDatabaseInsightsRetention } from "./index.js";
|
|
10
11
|
import { RDS_DEFAULTS } from "./rdsDefaults.js";
|
|
11
12
|
import { createRdsAlarms } from "../monitoring/index.js";
|
|
12
|
-
import { DEFAULT_POSTGRES_ENGINE_CONFIG, resolveDatabaseInsights, resolveStorageEncryptionKey, resolvePerformanceInsightsKey, addMultiUserSecretRotation } from "./rdsHelpers.js";
|
|
13
|
+
import { DEFAULT_POSTGRES_ENGINE_CONFIG, resolveDatabaseInsights, resolveStorageEncryptionKey, resolvePerformanceInsightsKey, addMultiUserSecretRotation, warnIfSnapshotUsernameAssumed } from "./rdsHelpers.js";
|
|
14
|
+
import { resolveSecretRotation } from "../../../utils/databaseTypes.js";
|
|
13
15
|
export class RdsAurora extends Construct {
|
|
14
16
|
connections;
|
|
15
17
|
constructId;
|
|
16
18
|
port;
|
|
19
|
+
clientPort;
|
|
17
20
|
engineConfig;
|
|
18
21
|
databaseCluster;
|
|
19
22
|
databaseProxy;
|
|
@@ -23,10 +26,16 @@ export class RdsAurora extends Construct {
|
|
|
23
26
|
constructor(scope, id, props) {
|
|
24
27
|
super(scope, id);
|
|
25
28
|
this.constructId = id;
|
|
26
|
-
this.port = props.port ?? RDS_DEFAULTS.DEFAULT_PORT;
|
|
27
29
|
this.databaseNameValue = props.databaseName ?? id;
|
|
28
30
|
// PostgreSQL fallback for direct usage - ensure engine and engineConfig match
|
|
29
31
|
this.engineConfig = props.engineConfig ?? DEFAULT_POSTGRES_ENGINE_CONFIG;
|
|
32
|
+
const engineDefaultPort = ENGINE_DEFAULT_DATABASE_PORTS[this.engineConfig.family];
|
|
33
|
+
this.port = props.port ?? engineDefaultPort;
|
|
34
|
+
// RDS Proxy listens exclusively on the engine-default port (CreateDBProxy
|
|
35
|
+
// has no port parameter), so the client-facing surface must advertise it
|
|
36
|
+
// whenever a proxy fronts the cluster.
|
|
37
|
+
const proxyEnabled = props.proxy !== undefined && props.proxy !== false;
|
|
38
|
+
this.clientPort = proxyEnabled ? engineDefaultPort : this.port;
|
|
30
39
|
const { piEnabled, piConfig } = resolveDatabaseInsights(props.databaseInsights);
|
|
31
40
|
// Username priority: snapshotUsername > credentials.username > engine default
|
|
32
41
|
const username = props.snapshotIdentifier && props.snapshotUsername
|
|
@@ -40,6 +49,13 @@ export class RdsAurora extends Construct {
|
|
|
40
49
|
});
|
|
41
50
|
}
|
|
42
51
|
else {
|
|
52
|
+
warnIfSnapshotUsernameAssumed({
|
|
53
|
+
scope: this,
|
|
54
|
+
databaseName: this.databaseNameValue,
|
|
55
|
+
snapshotIdentifier: props.snapshotIdentifier,
|
|
56
|
+
snapshotUsername: props.snapshotUsername,
|
|
57
|
+
assumedUsername: username
|
|
58
|
+
});
|
|
43
59
|
this.databaseCredentials = new Secret(this, `${this.databaseNameValue}Credentials`, {
|
|
44
60
|
secretName: ResourceNaming.credentialsSecretName(id),
|
|
45
61
|
generateSecretString: {
|
|
@@ -158,16 +174,14 @@ export class RdsAurora extends Construct {
|
|
|
158
174
|
this.cfnCluster.masterUserPassword = undefined;
|
|
159
175
|
this.cfnCluster.databaseName = undefined;
|
|
160
176
|
}
|
|
161
|
-
const proxyEnabled = props.proxy !== undefined && props.proxy !== false;
|
|
162
177
|
if (proxyEnabled) {
|
|
163
178
|
this.addProxy(props, clusterSecurityGroup);
|
|
164
179
|
}
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
180
|
+
// The wired multi-user rotation cannot complete until the master secret is
|
|
181
|
+
// populated with valid superuser credentials — see addMultiUserSecretRotation.
|
|
182
|
+
const rotationConfig = resolveSecretRotation(props.credentials);
|
|
183
|
+
if (!props.isGlobalSecondary && rotationConfig !== undefined) {
|
|
184
|
+
this.addSecretRotation(rotationConfig);
|
|
171
185
|
}
|
|
172
186
|
if (props.alertsTopic && props.alarms !== false) {
|
|
173
187
|
createRdsAlarms({
|
|
@@ -214,18 +228,26 @@ export class RdsAurora extends Construct {
|
|
|
214
228
|
});
|
|
215
229
|
});
|
|
216
230
|
}
|
|
217
|
-
addProxy(props,
|
|
231
|
+
addProxy(props, clusterSecurityGroup) {
|
|
218
232
|
if (!props.proxy)
|
|
219
233
|
return;
|
|
220
234
|
const proxyConfig = props.proxy;
|
|
221
235
|
const vpcSubnets = proxyConfig.vpcSubnets ?? {
|
|
222
236
|
subnetType: SubnetType.PRIVATE_WITH_EGRESS
|
|
223
237
|
};
|
|
238
|
+
const proxySecurityGroup = new SecurityGroup(this, `${this.databaseNameValue}ProxySecurityGroup`, {
|
|
239
|
+
vpc: props.vpc,
|
|
240
|
+
description: `Security group for RDS Proxy ${this.databaseNameValue}`
|
|
241
|
+
});
|
|
242
|
+
clusterSecurityGroup.addIngressRule(proxySecurityGroup, Port.tcp(this.port), "Allow RDS Proxy to connect to Aurora cluster");
|
|
243
|
+
if (props.allowVpcAccess) {
|
|
244
|
+
proxySecurityGroup.addIngressRule(Peer.ipv4(props.vpc.vpcCidrBlock), Port.tcp(this.clientPort), "Allow access from VPC CIDR");
|
|
245
|
+
}
|
|
224
246
|
this.databaseProxy = new DatabaseProxy(this, `${this.databaseNameValue}DatabaseProxy`, {
|
|
225
247
|
dbProxyName: ResourceNaming.proxyName(this.constructId),
|
|
226
248
|
proxyTarget: ProxyTarget.fromCluster(this.databaseCluster),
|
|
227
249
|
secrets: [this.databaseCredentials.secret],
|
|
228
|
-
securityGroups: [
|
|
250
|
+
securityGroups: [proxySecurityGroup],
|
|
229
251
|
vpc: props.vpc,
|
|
230
252
|
vpcSubnets,
|
|
231
253
|
requireTLS: proxyConfig.requireTLS ?? true,
|
|
@@ -235,15 +257,28 @@ export class RdsAurora extends Construct {
|
|
|
235
257
|
maxConnectionsPercent: proxyConfig.maxConnections,
|
|
236
258
|
maxIdleConnectionsPercent: proxyConfig.maxIdleConnections
|
|
237
259
|
});
|
|
260
|
+
// The proxy is now the client-facing surface: grantConnect /
|
|
261
|
+
// allowDefaultPortFrom must open the PROXY security group on the proxy's
|
|
262
|
+
// fixed engine-default listener port, not the cluster SG on the DB port.
|
|
263
|
+
this.connections = new Connections({
|
|
264
|
+
securityGroups: [proxySecurityGroup],
|
|
265
|
+
defaultPort: Port.tcp(this.clientPort)
|
|
266
|
+
});
|
|
267
|
+
if (this.port !== this.clientPort) {
|
|
268
|
+
Annotations.of(this).addWarning(`Database '${this.databaseNameValue}' listens on port ${this.port}, but its ` +
|
|
269
|
+
`RDS Proxy accepts client connections only on ${this.clientPort} (the engine ` +
|
|
270
|
+
`default — AWS does not allow configuring the proxy listener port). Fjall ` +
|
|
271
|
+
`advertises ${this.clientPort}; direct-to-database access uses ${this.port}.`);
|
|
272
|
+
}
|
|
238
273
|
addProxyCfnOutput(this, this.constructId, this.databaseNameValue, this.databaseProxy);
|
|
239
274
|
}
|
|
240
|
-
addSecretRotation(
|
|
275
|
+
addSecretRotation(rotationConfig) {
|
|
241
276
|
addMultiUserSecretRotation({
|
|
242
277
|
scope: this,
|
|
243
278
|
databaseName: this.databaseNameValue,
|
|
244
279
|
constructId: this.constructId,
|
|
245
280
|
engineConfig: this.engineConfig,
|
|
246
|
-
|
|
281
|
+
rotationConfig,
|
|
247
282
|
databaseSecret: this.databaseCredentials.secret,
|
|
248
283
|
target: this.databaseCluster,
|
|
249
284
|
vpc: this.databaseCluster.vpc
|
|
@@ -257,7 +292,7 @@ export class RdsAurora extends Construct {
|
|
|
257
292
|
return this.databaseCluster.clusterEndpoint.hostname;
|
|
258
293
|
}
|
|
259
294
|
getHostPort() {
|
|
260
|
-
return String(this.
|
|
295
|
+
return String(this.clientPort);
|
|
261
296
|
}
|
|
262
297
|
getCredentials() {
|
|
263
298
|
return this.databaseCredentials;
|
|
@@ -43,7 +43,7 @@ interface RdsAuroraGlobalProps {
|
|
|
43
43
|
monitoringInterval?: Duration;
|
|
44
44
|
/** Preferred maintenance window. Default: "Sat:12:30-Sat:20:30" */
|
|
45
45
|
preferredMaintenanceWindow?: string;
|
|
46
|
-
/** Database port. Default:
|
|
46
|
+
/** Database port. Default: engine default (5432 postgresql / 3306 mysql) */
|
|
47
47
|
port?: number;
|
|
48
48
|
databaseInsights?: DatabaseInsightsConfig | false;
|
|
49
49
|
deletionProtection?: boolean;
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { Duration } from "aws-cdk-lib";
|
|
2
2
|
export declare const RDS_DEFAULTS: Readonly<{
|
|
3
|
-
/** Default database port: FJALL on phone keypad */
|
|
4
|
-
readonly DEFAULT_PORT: 35255;
|
|
5
3
|
/** Saturday window in UTC — minimises weekday impact for scheduled maintenance */
|
|
6
4
|
readonly PREFERRED_MAINTENANCE_WINDOW: "Sat:12:30-Sat:20:30";
|
|
7
5
|
/** 1 minute — balances monitoring granularity against CloudWatch costs */
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { Duration } from "aws-cdk-lib";
|
|
2
2
|
export const RDS_DEFAULTS = Object.freeze({
|
|
3
|
-
/** Default database port: FJALL on phone keypad */
|
|
4
|
-
DEFAULT_PORT: 35255,
|
|
5
3
|
/** Saturday window in UTC — minimises weekday impact for scheduled maintenance */
|
|
6
4
|
PREFERRED_MAINTENANCE_WINDOW: "Sat:12:30-Sat:20:30",
|
|
7
5
|
/** 1 minute — balances monitoring granularity against CloudWatch costs */
|
|
@@ -3,7 +3,7 @@ import { type IVpc, type IConnectable } from "aws-cdk-lib/aws-ec2";
|
|
|
3
3
|
import { type ISecret } from "aws-cdk-lib/aws-secretsmanager";
|
|
4
4
|
import type { Construct } from "constructs";
|
|
5
5
|
import { Secret } from "../secrets/index.js";
|
|
6
|
-
import { type DatabaseInsightsConfig, type EngineConfig, type EncryptionKeySpec, type
|
|
6
|
+
import { type DatabaseInsightsConfig, type EngineConfig, type EncryptionKeySpec, type SecretRotationConfig } from "../../../utils/databaseTypes.js";
|
|
7
7
|
/** Default PostgreSQL engine configuration used by both Aurora and Instance constructs. */
|
|
8
8
|
export declare const DEFAULT_POSTGRES_ENGINE_CONFIG: EngineConfig;
|
|
9
9
|
/**
|
|
@@ -23,16 +23,37 @@ export declare function resolveDatabaseInsights(databaseInsights: DatabaseInsigh
|
|
|
23
23
|
piEnabled: boolean;
|
|
24
24
|
piConfig: DatabaseInsightsConfig | undefined;
|
|
25
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Warn when a snapshot restore bakes an assumed username into the new
|
|
28
|
+
* credentials secret. The post-restore password reset applies to the
|
|
29
|
+
* snapshot's ACTUAL master user, so if that user differs from the assumed
|
|
30
|
+
* username the secret pairs a valid password with the wrong user — synth and
|
|
31
|
+
* deploy stay green while every consumer fails at connect time.
|
|
32
|
+
* Shared between RdsAurora and RdsInstance.
|
|
33
|
+
*/
|
|
34
|
+
export declare function warnIfSnapshotUsernameAssumed(params: {
|
|
35
|
+
scope: Construct;
|
|
36
|
+
databaseName: string;
|
|
37
|
+
snapshotIdentifier: string | undefined;
|
|
38
|
+
snapshotUsername: string | undefined;
|
|
39
|
+
assumedUsername: string;
|
|
40
|
+
}): void;
|
|
26
41
|
/**
|
|
27
42
|
* Add multi-user secret rotation to a database construct.
|
|
28
43
|
* Shared between RdsAurora and RdsInstance.
|
|
44
|
+
*
|
|
45
|
+
* The master secret is created empty (a generated string bound to no database
|
|
46
|
+
* user), so rotation attempts fail at the master-secret read until it is
|
|
47
|
+
* populated with valid superuser credentials as JSON
|
|
48
|
+
* (`{"username": ..., "password": ...}` plus connection keys). A synth-time
|
|
49
|
+
* warning surfaces this to anyone opting in.
|
|
29
50
|
*/
|
|
30
51
|
export declare function addMultiUserSecretRotation(params: {
|
|
31
52
|
scope: Construct;
|
|
32
53
|
databaseName: string;
|
|
33
54
|
constructId: string;
|
|
34
55
|
engineConfig: EngineConfig;
|
|
35
|
-
|
|
56
|
+
rotationConfig: SecretRotationConfig;
|
|
36
57
|
databaseSecret: ISecret;
|
|
37
58
|
target: IConnectable;
|
|
38
59
|
vpc: IVpc;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Duration } from "aws-cdk-lib";
|
|
1
|
+
import { Annotations, Duration } from "aws-cdk-lib";
|
|
2
2
|
import { SecretRotation, SecretRotationApplication } from "aws-cdk-lib/aws-secretsmanager";
|
|
3
3
|
import { CustomerManagedKey, Secret } from "../secrets/index.js";
|
|
4
4
|
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
@@ -57,18 +57,36 @@ export function resolveDatabaseInsights(databaseInsights) {
|
|
|
57
57
|
: undefined;
|
|
58
58
|
return { piEnabled, piConfig };
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Warn when a snapshot restore bakes an assumed username into the new
|
|
62
|
+
* credentials secret. The post-restore password reset applies to the
|
|
63
|
+
* snapshot's ACTUAL master user, so if that user differs from the assumed
|
|
64
|
+
* username the secret pairs a valid password with the wrong user — synth and
|
|
65
|
+
* deploy stay green while every consumer fails at connect time.
|
|
66
|
+
* Shared between RdsAurora and RdsInstance.
|
|
67
|
+
*/
|
|
68
|
+
export function warnIfSnapshotUsernameAssumed(params) {
|
|
69
|
+
if (!params.snapshotIdentifier || params.snapshotUsername) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
Annotations.of(params.scope).addWarningV2("@fjall/components-infrastructure:rdsSnapshotUsernameAssumed", `Restoring '${params.databaseName}' from snapshot without snapshotUsername — the credentials secret assumes username '${params.assumedUsername}'. The post-restore password reset targets the snapshot's actual master user; if that is not '${params.assumedUsername}', applications reading the secret will fail to authenticate. Set snapshotUsername to the snapshot's master username.`);
|
|
73
|
+
}
|
|
60
74
|
/**
|
|
61
75
|
* Add multi-user secret rotation to a database construct.
|
|
62
76
|
* Shared between RdsAurora and RdsInstance.
|
|
77
|
+
*
|
|
78
|
+
* The master secret is created empty (a generated string bound to no database
|
|
79
|
+
* user), so rotation attempts fail at the master-secret read until it is
|
|
80
|
+
* populated with valid superuser credentials as JSON
|
|
81
|
+
* (`{"username": ..., "password": ...}` plus connection keys). A synth-time
|
|
82
|
+
* warning surfaces this to anyone opting in.
|
|
63
83
|
*/
|
|
64
84
|
export function addMultiUserSecretRotation(params) {
|
|
65
|
-
const
|
|
66
|
-
const rotationPeriod = (typeof rotationConfig === "object" &&
|
|
67
|
-
rotationConfig?.automaticallyAfter) ||
|
|
68
|
-
Duration.days(30);
|
|
85
|
+
const rotationPeriod = params.rotationConfig.automaticallyAfter ?? Duration.days(30);
|
|
69
86
|
const masterSecret = new Secret(params.scope, `${params.databaseName}MasterSecret`, {
|
|
70
87
|
secretName: ResourceNaming.masterSecretName(params.constructId)
|
|
71
88
|
});
|
|
89
|
+
Annotations.of(params.scope).addWarningV2("@fjall/components-infrastructure:rdsRotationMasterSecretUnpopulated", `Secret rotation for '${params.databaseName}' cannot complete until the master secret '${ResourceNaming.masterSecretName(params.constructId)}' is populated with valid superuser credentials as JSON — until then every rotation attempt fails and the database password stays unchanged.`);
|
|
72
90
|
new SecretRotation(params.scope, `${params.databaseName}SecretRotation`, {
|
|
73
91
|
application: MULTI_USER_ROTATION_APPLICATIONS[params.engineConfig.family],
|
|
74
92
|
secret: params.databaseSecret,
|
|
@@ -43,7 +43,12 @@ interface RdsProps {
|
|
|
43
43
|
iamAuthentication?: boolean;
|
|
44
44
|
/** ARN or identifier of DB instance snapshot to restore from */
|
|
45
45
|
snapshotIdentifier?: string;
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Master username of the snapshot being restored. Must match the snapshot's
|
|
48
|
+
* actual master user — the post-restore password reset applies to that user
|
|
49
|
+
* and the new credentials secret bakes this name in. When omitted, falls
|
|
50
|
+
* back to credentials.username or the engine default and synth warns.
|
|
51
|
+
*/
|
|
47
52
|
snapshotUsername?: string;
|
|
48
53
|
/** SNS topic for alarm notifications. Required for alarm creation. */
|
|
49
54
|
alertsTopic?: ITopic;
|
|
@@ -57,6 +62,7 @@ export declare class RdsInstance extends Construct implements IConnectable {
|
|
|
57
62
|
databaseSecurityGroup: SecurityGroup;
|
|
58
63
|
vpc: IVpc;
|
|
59
64
|
private port;
|
|
65
|
+
private clientPort;
|
|
60
66
|
private engineConfig;
|
|
61
67
|
private databaseCredentials;
|
|
62
68
|
private database;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
1
|
+
import { Annotations, Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
2
|
+
import { ENGINE_DEFAULT_DATABASE_PORTS } from "@fjall/generator";
|
|
2
3
|
import { Connections, InstanceType, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
|
|
3
4
|
import { CaCertificate, Credentials, DatabaseInstance, DatabaseInstanceFromSnapshot, DatabaseInstanceEngine, DatabaseInstanceReadReplica, DatabaseProxy, ParameterGroup, PostgresEngineVersion, ProxyTarget, SnapshotCredentials, StorageType } from "aws-cdk-lib/aws-rds";
|
|
4
5
|
import { Runtime } from "aws-cdk-lib/aws-lambda";
|
|
@@ -9,8 +10,8 @@ import { CustomerManagedKey, Secret } from "../secrets/index.js";
|
|
|
9
10
|
import { CustomResource } from "../utilities/customResource.js";
|
|
10
11
|
import { getDatabaseInsightsRetention } from "./index.js";
|
|
11
12
|
import { RDS_DEFAULTS } from "./rdsDefaults.js";
|
|
12
|
-
import { DEFAULT_POSTGRES_ENGINE_CONFIG, resolveDatabaseInsights, resolveStorageEncryptionKey, resolvePerformanceInsightsKey, addMultiUserSecretRotation } from "./rdsHelpers.js";
|
|
13
|
-
import { isCMKRequested } from "../../../utils/databaseTypes.js";
|
|
13
|
+
import { DEFAULT_POSTGRES_ENGINE_CONFIG, resolveDatabaseInsights, resolveStorageEncryptionKey, resolvePerformanceInsightsKey, addMultiUserSecretRotation, warnIfSnapshotUsernameAssumed } from "./rdsHelpers.js";
|
|
14
|
+
import { resolveSecretRotation, isCMKRequested } from "../../../utils/databaseTypes.js";
|
|
14
15
|
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
15
16
|
import { addProxyCfnOutput } from "./rdsProxyOutput.js";
|
|
16
17
|
import { createRdsAlarms } from "../monitoring/index.js";
|
|
@@ -19,6 +20,7 @@ export class RdsInstance extends Construct {
|
|
|
19
20
|
databaseSecurityGroup;
|
|
20
21
|
vpc;
|
|
21
22
|
port;
|
|
23
|
+
clientPort;
|
|
22
24
|
engineConfig;
|
|
23
25
|
databaseCredentials;
|
|
24
26
|
database;
|
|
@@ -31,16 +33,23 @@ export class RdsInstance extends Construct {
|
|
|
31
33
|
constructor(scope, id, props) {
|
|
32
34
|
super(scope, id);
|
|
33
35
|
this.constructId = id;
|
|
34
|
-
this.port = props.port ?? RDS_DEFAULTS.DEFAULT_PORT;
|
|
35
36
|
this.vpc = props.vpc;
|
|
36
37
|
this.databaseNameValue = props.databaseName ?? id.replace("Rds", "");
|
|
37
38
|
// PostgreSQL fallback for direct usage - ensure engine and engineConfig match
|
|
38
39
|
this.engineConfig = props.engineConfig ?? DEFAULT_POSTGRES_ENGINE_CONFIG;
|
|
40
|
+
const engineDefaultPort = ENGINE_DEFAULT_DATABASE_PORTS[this.engineConfig.family];
|
|
41
|
+
this.port = props.port ?? engineDefaultPort;
|
|
42
|
+
// RDS Proxy listens exclusively on the engine-default port (CreateDBProxy
|
|
43
|
+
// has no port parameter), so the client-facing surface must advertise it
|
|
44
|
+
// whenever a proxy fronts the database.
|
|
45
|
+
const proxyEnabled = props.proxy !== undefined && props.proxy !== false;
|
|
46
|
+
this.clientPort = proxyEnabled ? engineDefaultPort : this.port;
|
|
39
47
|
this.addDatabase(props);
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
// The wired multi-user rotation cannot complete until the master secret is
|
|
49
|
+
// populated with valid superuser credentials — see addMultiUserSecretRotation.
|
|
50
|
+
const rotationConfig = resolveSecretRotation(props.credentials);
|
|
51
|
+
if (rotationConfig !== undefined) {
|
|
52
|
+
this.rotateSecret(rotationConfig);
|
|
44
53
|
}
|
|
45
54
|
if (props.proxy !== undefined && props.proxy !== false) {
|
|
46
55
|
this.addProxy(props);
|
|
@@ -64,6 +73,13 @@ export class RdsInstance extends Construct {
|
|
|
64
73
|
const username = props.snapshotIdentifier && props.snapshotUsername
|
|
65
74
|
? props.snapshotUsername
|
|
66
75
|
: (props.credentials?.username ?? this.engineConfig.defaultUsername);
|
|
76
|
+
warnIfSnapshotUsernameAssumed({
|
|
77
|
+
scope: this,
|
|
78
|
+
databaseName: this.databaseNameValue,
|
|
79
|
+
snapshotIdentifier: props.snapshotIdentifier,
|
|
80
|
+
snapshotUsername: props.snapshotUsername,
|
|
81
|
+
assumedUsername: username
|
|
82
|
+
});
|
|
67
83
|
this.databaseCredentials = new Secret(this, `${this.databaseNameValue}Credentials`, {
|
|
68
84
|
secretName: ResourceNaming.credentialsSecretName(this.constructId),
|
|
69
85
|
generateSecretString: {
|
|
@@ -157,13 +173,13 @@ export class RdsInstance extends Construct {
|
|
|
157
173
|
});
|
|
158
174
|
}
|
|
159
175
|
}
|
|
160
|
-
rotateSecret(
|
|
176
|
+
rotateSecret(rotationConfig) {
|
|
161
177
|
this.masterSecret = addMultiUserSecretRotation({
|
|
162
178
|
scope: this,
|
|
163
179
|
databaseName: this.databaseNameValue,
|
|
164
180
|
constructId: this.constructId,
|
|
165
181
|
engineConfig: this.engineConfig,
|
|
166
|
-
|
|
182
|
+
rotationConfig,
|
|
167
183
|
databaseSecret: this.databaseCredentials.secret,
|
|
168
184
|
target: this.database,
|
|
169
185
|
vpc: this.vpc
|
|
@@ -196,6 +212,19 @@ export class RdsInstance extends Construct {
|
|
|
196
212
|
maxConnectionsPercent: proxyConfig.maxConnections,
|
|
197
213
|
maxIdleConnectionsPercent: proxyConfig.maxIdleConnections
|
|
198
214
|
});
|
|
215
|
+
// The proxy is now the client-facing surface: grantConnect /
|
|
216
|
+
// allowDefaultPortFrom must open the PROXY security group on the proxy's
|
|
217
|
+
// fixed engine-default listener port, not the database SG on the DB port.
|
|
218
|
+
this.connections = new Connections({
|
|
219
|
+
securityGroups: [this.databaseProxySecurityGroup],
|
|
220
|
+
defaultPort: Port.tcp(this.clientPort)
|
|
221
|
+
});
|
|
222
|
+
if (this.port !== this.clientPort) {
|
|
223
|
+
Annotations.of(this).addWarning(`Database '${this.databaseNameValue}' listens on port ${this.port}, but its ` +
|
|
224
|
+
`RDS Proxy accepts client connections only on ${this.clientPort} (the engine ` +
|
|
225
|
+
`default — AWS does not allow configuring the proxy listener port). Fjall ` +
|
|
226
|
+
`advertises ${this.clientPort}; direct-to-database access uses ${this.port}.`);
|
|
227
|
+
}
|
|
199
228
|
addProxyCfnOutput(this, this.constructId, this.databaseNameValue, this.databaseProxy);
|
|
200
229
|
}
|
|
201
230
|
addReadReplica(props) {
|
|
@@ -312,7 +341,7 @@ exports.handler = async (event) => {
|
|
|
312
341
|
}
|
|
313
342
|
}
|
|
314
343
|
getHostPort() {
|
|
315
|
-
return String(this.
|
|
344
|
+
return String(this.clientPort);
|
|
316
345
|
}
|
|
317
346
|
getCredentials() {
|
|
318
347
|
return this.databaseCredentials;
|
|
@@ -6,6 +6,7 @@ export declare const CDK_CONTEXT_KEYS: {
|
|
|
6
6
|
readonly MANAGEMENT_ACCOUNT_ID: "managementAccountId";
|
|
7
7
|
readonly ORG_CONFIG: "orgConfig";
|
|
8
8
|
readonly ACCOUNT_TRAIL_STATE: "fjallAccountTrailState";
|
|
9
|
+
readonly DEV_OU_ID: "fjallDevOuId";
|
|
9
10
|
};
|
|
10
11
|
export { ACCOUNT_TRAIL_STATES, type AccountTrailState };
|
|
11
12
|
/**
|
|
@@ -4,7 +4,8 @@ export const CDK_CONTEXT_KEYS = {
|
|
|
4
4
|
ROOT_ID: "rootId",
|
|
5
5
|
MANAGEMENT_ACCOUNT_ID: "managementAccountId",
|
|
6
6
|
ORG_CONFIG: "orgConfig",
|
|
7
|
-
ACCOUNT_TRAIL_STATE: "fjallAccountTrailState"
|
|
7
|
+
ACCOUNT_TRAIL_STATE: "fjallAccountTrailState",
|
|
8
|
+
DEV_OU_ID: "fjallDevOuId"
|
|
8
9
|
};
|
|
9
10
|
export { ACCOUNT_TRAIL_STATES };
|
|
10
11
|
/**
|
|
@@ -33,11 +33,25 @@ export interface ReadReplicaConfig {
|
|
|
33
33
|
}
|
|
34
34
|
export interface CredentialsConfig {
|
|
35
35
|
username?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Opt-in multi-user secret rotation. Rotation resources are created only
|
|
38
|
+
* when a configuration object is passed (`{}` accepts the 30-day default);
|
|
39
|
+
* omitting the property leaves rotation off. Rotation cannot complete until
|
|
40
|
+
* the generated master secret is populated with working superuser
|
|
41
|
+
* credentials — a synth-time warning is emitted when enabled. `false` is
|
|
42
|
+
* accepted for backwards compatibility and behaves like omission.
|
|
43
|
+
*/
|
|
36
44
|
secretRotation?: SecretRotationConfig | false;
|
|
37
45
|
}
|
|
38
46
|
export interface SecretRotationConfig {
|
|
39
47
|
automaticallyAfter?: Duration;
|
|
40
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the opt-in rotation gate once at the construct boundary. Returns
|
|
51
|
+
* the rotation config only when the caller passed a configuration object;
|
|
52
|
+
* `false` and omission both resolve to `undefined` (rotation off).
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveSecretRotation(credentials: CredentialsConfig | undefined): SecretRotationConfig | undefined;
|
|
41
55
|
export interface EncryptionConfig {
|
|
42
56
|
storageKey?: EncryptionKeySpec;
|
|
43
57
|
}
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the opt-in rotation gate once at the construct boundary. Returns
|
|
3
|
+
* the rotation config only when the caller passed a configuration object;
|
|
4
|
+
* `false` and omission both resolve to `undefined` (rotation off).
|
|
5
|
+
*/
|
|
6
|
+
export function resolveSecretRotation(credentials) {
|
|
7
|
+
return typeof credentials?.secretRotation === "object"
|
|
8
|
+
? credentials.secretRotation
|
|
9
|
+
: undefined;
|
|
10
|
+
}
|
|
1
11
|
// Type guard to check if the encryption key spec is AWS managed.
|
|
2
12
|
export function isAwsManagedKey(spec) {
|
|
3
13
|
return (spec !== undefined &&
|
|
@@ -74,6 +74,13 @@ export declare class ManifestCollector {
|
|
|
74
74
|
* Get the application name.
|
|
75
75
|
*/
|
|
76
76
|
getAppName(): string;
|
|
77
|
+
/**
|
|
78
|
+
* Rename in place after a late `App.getApp(name)`. `getConfig()` before
|
|
79
|
+
* `getApp()` constructs the App singleton nameless ("FjallApp"); re-keying
|
|
80
|
+
* via `getManifestCollector(newName)` instead would discard any services
|
|
81
|
+
* already collected under the old name.
|
|
82
|
+
*/
|
|
83
|
+
setAppName(appName: string): void;
|
|
77
84
|
}
|
|
78
85
|
/**
|
|
79
86
|
* Write the manifest file to cdk.out.
|
|
@@ -105,6 +105,15 @@ export class ManifestCollector {
|
|
|
105
105
|
getAppName() {
|
|
106
106
|
return this.appName;
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Rename in place after a late `App.getApp(name)`. `getConfig()` before
|
|
110
|
+
* `getApp()` constructs the App singleton nameless ("FjallApp"); re-keying
|
|
111
|
+
* via `getManifestCollector(newName)` instead would discard any services
|
|
112
|
+
* already collected under the old name.
|
|
113
|
+
*/
|
|
114
|
+
setAppName(appName) {
|
|
115
|
+
this.appName = appName;
|
|
116
|
+
}
|
|
108
117
|
}
|
|
109
118
|
/**
|
|
110
119
|
* Compute SHA-256 hash of a CloudFormation template file.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/components-infrastructure",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.27.0",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1045.0",
|
|
53
|
+
"@aws-sdk/client-iam": "^3.1038.0",
|
|
53
54
|
"@aws-sdk/client-identitystore": "^3.1038.0",
|
|
54
55
|
"@peculiar/x509": "2.0.0",
|
|
55
56
|
"@types/aws-lambda": "^8.10.161",
|
|
@@ -64,8 +65,8 @@
|
|
|
64
65
|
},
|
|
65
66
|
"dependencies": {
|
|
66
67
|
"@aws-sdk/client-organizations": "^3.1038.0",
|
|
67
|
-
"@fjall/generator": "^2.
|
|
68
|
-
"@fjall/util": "^2.
|
|
68
|
+
"@fjall/generator": "^2.27.0",
|
|
69
|
+
"@fjall/util": "^2.27.0",
|
|
69
70
|
"constructs": "^10.6.0"
|
|
70
71
|
},
|
|
71
72
|
"overrides": {
|
|
@@ -79,5 +80,5 @@
|
|
|
79
80
|
"engines": {
|
|
80
81
|
"node": ">=18.0.0"
|
|
81
82
|
},
|
|
82
|
-
"gitHead": "
|
|
83
|
+
"gitHead": "921ecd4f65f52c17037a8a24834d7e4c196b9347"
|
|
83
84
|
}
|