@fjall/components-infrastructure 2.25.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 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
- super();
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) {
@@ -39,6 +39,7 @@ export class AccountMonitoringRole extends Construct {
39
39
  actions: [
40
40
  "ecs:DescribeServices",
41
41
  "ecs:DescribeTasks",
42
+ "ecs:DescribeTaskDefinition",
42
43
  "ecs:ListTasks",
43
44
  "ecs:DescribeClusters",
44
45
  "ecs:ListServices"
@@ -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
- assumedBy: new iam.FederatedPrincipal(providerArn, {
100
- StringEquals: { [`${issuerDomain}:aud`]: "sts.amazonaws.com" },
101
- StringLike: {
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 optimiseQuery = [
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
- ].join("; ");
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
- const logGroupArn = `arn:${partition}:logs:${region}:${account}:log-group:/ecs/${ctx.props.clusterName}*`;
28
- executionRole.addToPolicy(new PolicyStatement({
29
- effect: Effect.ALLOW,
30
- actions: [
31
- "logs:CreateLogStream",
32
- "logs:PutLogEvents",
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
- export declare const OPTIMISE_MV_TABLES: readonly ["metrics_hourly_mv", "metrics_daily_mv", "response_time_quantiles_hourly_mv", "deployment_duration_quantiles_daily_mv", "log_severity_hourly_mv", "compliance_score_daily_mv", "ai_usage_daily_mv", "finding_daily_aggregate", "insight_pattern_dismissals"];
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
- /** Username from the snapshot (required when restoring from snapshot to reset password) */
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;
@@ -10,7 +10,8 @@ import { addProxyCfnOutput } from "./rdsProxyOutput.js";
10
10
  import { getDatabaseInsightsRetention } from "./index.js";
11
11
  import { RDS_DEFAULTS } from "./rdsDefaults.js";
12
12
  import { createRdsAlarms } from "../monitoring/index.js";
13
- 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";
14
15
  export class RdsAurora extends Construct {
15
16
  connections;
16
17
  constructId;
@@ -48,6 +49,13 @@ export class RdsAurora extends Construct {
48
49
  });
49
50
  }
50
51
  else {
52
+ warnIfSnapshotUsernameAssumed({
53
+ scope: this,
54
+ databaseName: this.databaseNameValue,
55
+ snapshotIdentifier: props.snapshotIdentifier,
56
+ snapshotUsername: props.snapshotUsername,
57
+ assumedUsername: username
58
+ });
51
59
  this.databaseCredentials = new Secret(this, `${this.databaseNameValue}Credentials`, {
52
60
  secretName: ResourceNaming.credentialsSecretName(id),
53
61
  generateSecretString: {
@@ -169,12 +177,11 @@ export class RdsAurora extends Construct {
169
177
  if (proxyEnabled) {
170
178
  this.addProxy(props, clusterSecurityGroup);
171
179
  }
172
- // Secret rotation enabled by default (opt-out with secretRotation: false)
173
- if (!props.isGlobalSecondary) {
174
- const secretRotationDisabled = props.credentials?.secretRotation === false;
175
- if (!secretRotationDisabled) {
176
- this.addSecretRotation(props);
177
- }
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);
178
185
  }
179
186
  if (props.alertsTopic && props.alarms !== false) {
180
187
  createRdsAlarms({
@@ -265,13 +272,13 @@ export class RdsAurora extends Construct {
265
272
  }
266
273
  addProxyCfnOutput(this, this.constructId, this.databaseNameValue, this.databaseProxy);
267
274
  }
268
- addSecretRotation(props) {
275
+ addSecretRotation(rotationConfig) {
269
276
  addMultiUserSecretRotation({
270
277
  scope: this,
271
278
  databaseName: this.databaseNameValue,
272
279
  constructId: this.constructId,
273
280
  engineConfig: this.engineConfig,
274
- credentialsConfig: props.credentials,
281
+ rotationConfig,
275
282
  databaseSecret: this.databaseCredentials.secret,
276
283
  target: this.databaseCluster,
277
284
  vpc: this.databaseCluster.vpc
@@ -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 CredentialsConfig } from "../../../utils/databaseTypes.js";
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
- credentialsConfig: CredentialsConfig | undefined;
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 rotationConfig = params.credentialsConfig?.secretRotation;
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
- /** Username from the snapshot (required when restoring from snapshot to reset password) */
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;
@@ -10,8 +10,8 @@ import { CustomerManagedKey, Secret } from "../secrets/index.js";
10
10
  import { CustomResource } from "../utilities/customResource.js";
11
11
  import { getDatabaseInsightsRetention } from "./index.js";
12
12
  import { RDS_DEFAULTS } from "./rdsDefaults.js";
13
- import { DEFAULT_POSTGRES_ENGINE_CONFIG, resolveDatabaseInsights, resolveStorageEncryptionKey, resolvePerformanceInsightsKey, addMultiUserSecretRotation } from "./rdsHelpers.js";
14
- 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";
15
15
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
16
16
  import { addProxyCfnOutput } from "./rdsProxyOutput.js";
17
17
  import { createRdsAlarms } from "../monitoring/index.js";
@@ -45,10 +45,11 @@ export class RdsInstance extends Construct {
45
45
  const proxyEnabled = props.proxy !== undefined && props.proxy !== false;
46
46
  this.clientPort = proxyEnabled ? engineDefaultPort : this.port;
47
47
  this.addDatabase(props);
48
- // Secret rotation enabled by default (opt-out with secretRotation: false)
49
- const secretRotationDisabled = props.credentials?.secretRotation === false;
50
- if (!secretRotationDisabled) {
51
- this.rotateSecret(props);
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);
52
53
  }
53
54
  if (props.proxy !== undefined && props.proxy !== false) {
54
55
  this.addProxy(props);
@@ -72,6 +73,13 @@ export class RdsInstance extends Construct {
72
73
  const username = props.snapshotIdentifier && props.snapshotUsername
73
74
  ? props.snapshotUsername
74
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
+ });
75
83
  this.databaseCredentials = new Secret(this, `${this.databaseNameValue}Credentials`, {
76
84
  secretName: ResourceNaming.credentialsSecretName(this.constructId),
77
85
  generateSecretString: {
@@ -165,13 +173,13 @@ export class RdsInstance extends Construct {
165
173
  });
166
174
  }
167
175
  }
168
- rotateSecret(props) {
176
+ rotateSecret(rotationConfig) {
169
177
  this.masterSecret = addMultiUserSecretRotation({
170
178
  scope: this,
171
179
  databaseName: this.databaseNameValue,
172
180
  constructId: this.constructId,
173
181
  engineConfig: this.engineConfig,
174
- credentialsConfig: props.credentials,
182
+ rotationConfig,
175
183
  databaseSecret: this.databaseCredentials.secret,
176
184
  target: this.database,
177
185
  vpc: this.vpc
@@ -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.25.0",
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.25.0",
68
- "@fjall/util": "^2.25.0",
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": "7c1a329184064aefa557c2c09de0965c4f8cd4fb"
83
+ "gitHead": "921ecd4f65f52c17037a8a24834d7e4c196b9347"
83
84
  }