@fjall/components-infrastructure 2.15.0 → 2.17.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.
@@ -32,6 +32,12 @@ function buildFoundationGuardrails(allowedRegions) {
32
32
  // Deny + NotAction denies everything except the policy-management
33
33
  // scopes a root-task unlock session needs; the AWS root-task policies
34
34
  // constrain those sessions further.
35
+ // iam:CreateLoginProfile/GetLoginProfile (root-credential recovery on
36
+ // assume-root sessions) are deliberately NOT carved out — recovery on
37
+ // this tier is a break-glass SCP detach, not a standing carve-out. Do
38
+ // not add them without revisiting the decision:
39
+ // aiDocs/troubleshooting/centralised-root-access-break-glass-runbook.md
40
+ // § Scenario 3.
35
41
  NotAction: [
36
42
  "s3:GetBucketPolicy",
37
43
  "s3:PutBucketPolicy",
@@ -214,6 +220,9 @@ function buildEncryptionAndAccess() {
214
220
  {
215
221
  Sid: "DenyIamUserCreation",
216
222
  Effect: "Deny",
223
+ // Also blocks iam:CreateLoginProfile for assume-root recovery
224
+ // sessions (they match none of EXEMPT_ROLE_PATTERNS) — deliberate;
225
+ // see the DenyRootUserActions note + break-glass runbook § Scenario 3.
217
226
  Action: [
218
227
  "iam:CreateUser",
219
228
  "iam:CreateAccessKey",
@@ -2,12 +2,12 @@ import { Construct } from "constructs";
2
2
  import { type IBucket, type EventType, type IBucketNotificationDestination, type NotificationKeyFilter } from "aws-cdk-lib/aws-s3";
3
3
  import { type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
4
4
  import type App from "../../app.js";
5
- import { BucketDeployment, type WebsiteHostingConfig } from "../../resources/aws/storage/index.js";
5
+ import { BucketDeployment, type ResourcePolicyStatement, type WebsiteHostingConfig } from "../../resources/aws/storage/index.js";
6
6
  import { type BackupTier } from "../../utils/backupTierMapping.js";
7
7
  import { type IStorage } from "./interfaces/storage.js";
8
8
  import { type IStorageConnector } from "./interfaces/connector.js";
9
9
  export { type IStorage, isStorage } from "./interfaces/storage.js";
10
- export { type WebsiteHostingConfig } from "../../resources/aws/storage/index.js";
10
+ export { type ResourcePolicyStatement, type WebsiteHostingConfig } from "../../resources/aws/storage/index.js";
11
11
  export interface CorsRule {
12
12
  readonly allowedOrigins: string[];
13
13
  readonly allowedMethods: string[];
@@ -40,6 +40,8 @@ export interface S3Props {
40
40
  readonly deployment?: S3DeploymentConfig;
41
41
  /** When true, sets RemovalPolicy.RETAIN (overriding the env-aware default). Used for imported buckets. */
42
42
  readonly retain?: boolean;
43
+ /** Declarative bucket-policy statements appended to the bucket's resource policy. */
44
+ readonly resourcePolicyStatements?: ResourcePolicyStatement[];
43
45
  }
44
46
  export interface StorageBuildProps extends S3Props {
45
47
  readonly stackPlacement?: "storage" | "cdn" | "compute";
@@ -66,6 +66,7 @@ export class Storage extends Construct {
66
66
  backupVaultTier: props.backupVaultTier,
67
67
  publicReadAccess: props.publicReadAccess,
68
68
  websiteHosting: props.websiteHosting,
69
+ resourcePolicyStatements: props.resourcePolicyStatements,
69
70
  ...(props.cors && { cors: toCorsRules(props.cors) }),
70
71
  ...(props.retain && { removalPolicy: RemovalPolicy.RETAIN })
71
72
  });
@@ -43,8 +43,13 @@ export class Ec2GracefulTerminationHandler extends Construct {
43
43
  const dataVolumeOwnerLogicalId = resolveOptionalString(props.dataVolumeOwnerLogicalId);
44
44
  this.queue = new SQSQueue(this, `${id}Queue`, {
45
45
  visibilityTimeout: QUEUE_VISIBILITY_TIMEOUT_SECONDS,
46
- deadLetterQueue: { enabled: true, maxReceiveCount: 5 }
46
+ deadLetterQueue: { enabled: true, maxReceiveCount: 5 },
47
+ // Transient instance-drain signals — no durable state. Pinned DESTROY
48
+ // (now also the SQSQueue wrapper default) so a replacing deploy of the
49
+ // parent Ec2Instance reclaims this queue + DLQ instead of orphaning them.
50
+ removalPolicy: "DESTROY"
47
51
  });
52
+ const stack = Stack.of(this);
48
53
  const ecsPolicies = ecsClusterArn !== undefined
49
54
  ? [
50
55
  new PolicyStatement({
@@ -66,6 +71,9 @@ export class Ec2GracefulTerminationHandler extends Construct {
66
71
  })
67
72
  ]
68
73
  : [];
74
+ // EIP/ENI mutations cannot be ARN-scoped (the resources are not known at
75
+ // synth), but the Lambda only ever drains instances in its own region — a
76
+ // region clamp removes the cross-region blast radius a bare "*" would allow.
69
77
  const ec2Policies = new PolicyStatement({
70
78
  effect: Effect.ALLOW,
71
79
  actions: [
@@ -74,7 +82,10 @@ export class Ec2GracefulTerminationHandler extends Construct {
74
82
  "ec2:DescribeNetworkInterfaces",
75
83
  "ec2:DetachNetworkInterface"
76
84
  ],
77
- resources: ["*"]
85
+ resources: ["*"],
86
+ conditions: {
87
+ StringEquals: { "aws:RequestedRegion": stack.region }
88
+ }
78
89
  });
79
90
  const elbReadPolicy = new PolicyStatement({
80
91
  effect: Effect.ALLOW,
@@ -94,7 +105,6 @@ export class Ec2GracefulTerminationHandler extends Construct {
94
105
  }
95
106
  }
96
107
  });
97
- const stack = Stack.of(this);
98
108
  // Account/region-scoped wildcard rather than the specific ASG ARN —
99
109
  // see PersistentDataVolume for the full deadlock writeup. Same gotcha:
100
110
  // a specific ARN creates a CFN Ref to the ASG, the Lambda's IAM Policy
@@ -1,14 +1,14 @@
1
1
  import { CfnParameter, Stack } from "aws-cdk-lib";
2
2
  import { ContainerImage } from "aws-cdk-lib/aws-ecs";
3
3
  import { Repository } from "aws-cdk-lib/aws-ecr";
4
+ import { imageTagParameterName } from "@fjall/util";
4
5
  import { DEFAULT_ECS_FALLBACK_IMAGE } from "./ecsConstants.js";
5
- import { toPascalCase } from "../../../utils/capitaliseString.js";
6
6
  function buildImageTagDescription(serviceName) {
7
7
  return `Image tag for ECS service ${serviceName}. Set by fjall deploy to the content-hash tag.`;
8
8
  }
9
9
  function getOrCreateImageTagParameter(ctx, serviceName) {
10
10
  const stack = Stack.of(ctx.scope);
11
- const paramLogicalId = `${toPascalCase(serviceName)}ImageTag`;
11
+ const paramLogicalId = imageTagParameterName(serviceName);
12
12
  const description = buildImageTagDescription(serviceName);
13
13
  const existing = stack.node.tryFindChild(paramLogicalId);
14
14
  if (existing instanceof CfnParameter) {
@@ -105,7 +105,11 @@ export class PersistentDataVolume extends Construct {
105
105
  Tags.of(this.volume).add(PERSISTENT_DATA_VOLUME_TAG_STACK_ID, Aws.STACK_ID);
106
106
  this.queue = new SQSQueue(this, `${id}Queue`, {
107
107
  visibilityTimeout: QUEUE_VISIBILITY_TIMEOUT_SECONDS,
108
- deadLetterQueue: { enabled: true, maxReceiveCount: 5 }
108
+ deadLetterQueue: { enabled: true, maxReceiveCount: 5 },
109
+ // Transient volume-attach signals — no durable state (the EBS volume
110
+ // itself is SNAPSHOT above). Pinned DESTROY (now also the SQSQueue wrapper
111
+ // default) so a replacing deploy reclaims this queue + DLQ, not orphans.
112
+ removalPolicy: "DESTROY"
109
113
  });
110
114
  const sourcePath = path.resolve(__dirname, LAUNCHING_LAMBDA_SOURCE_FILE);
111
115
  const source = readFileSync(sourcePath, "utf-8");
@@ -7,6 +7,11 @@ export interface SNSTopicProps {
7
7
  displayName?: string;
8
8
  fifo?: boolean;
9
9
  contentBasedDeduplication?: boolean;
10
+ /**
11
+ * Removal policy for the topic. Defaults to DESTROY — a topic is a transient
12
+ * fan-out medium with no durable state (see the constructor). Pass "RETAIN"
13
+ * only for a topic that must survive stack deletion.
14
+ */
10
15
  removalPolicy?: RemovalPolicyString;
11
16
  }
12
17
  export declare class SNSTopic extends Construct {
@@ -22,7 +22,13 @@ export class SNSTopic extends Construct {
22
22
  ? (props.contentBasedDeduplication ?? true)
23
23
  : undefined
24
24
  });
25
- this.topic.applyRemovalPolicy(toRemovalPolicy(props.removalPolicy));
25
+ // An SNS topic is a transient fan-out medium: it holds no durable state
26
+ // (subscriptions are re-created by the next deploy), so the wrapper defaults
27
+ // to DESTROY — deliberately NOT the env-aware production->RETAIN of
28
+ // data-bearing wrappers (S3, EventBus). RETAIN here buys no protection and
29
+ // orphans the topic on a parent construct's logical-ID churn (the same leak
30
+ // class fixed for SQS). Durable topics opt into RETAIN via props.removalPolicy.
31
+ this.topic.applyRemovalPolicy(toRemovalPolicy(props.removalPolicy ?? "DESTROY"));
26
32
  new CfnOutput(this, `${id}TopicArn`, {
27
33
  key: `${id}TopicArn`,
28
34
  value: this.topic.topicArn,
@@ -60,6 +60,12 @@ export interface SQSQueueProps {
60
60
  contentBasedDeduplication?: boolean;
61
61
  fifoThroughputLimit?: "perQueue" | "perMessageGroupId";
62
62
  deduplicationScope?: "queue" | "messageGroup";
63
+ /**
64
+ * Removal policy for the queue (and its auto-created DLQ, which tracks it).
65
+ * Defaults to DESTROY — a queue is a transient work medium (see the
66
+ * constructor). Pass "RETAIN" only for a queue whose contents are
67
+ * irreplaceable and must survive stack deletion.
68
+ */
63
69
  removalPolicy?: RemovalPolicyString;
64
70
  }
65
71
  export declare class SQSQueue extends Construct {
@@ -83,6 +83,14 @@ export class SQSQueue extends Construct {
83
83
  this.id = id;
84
84
  // Sanitise id for CloudFormation output keys (must be alphanumeric)
85
85
  const outputName = toPascalCase(id);
86
+ // SQS queues are transient work mediums: their contents (job messages,
87
+ // failed-delivery copies) regenerate from a source of truth held elsewhere
88
+ // (Postgres, the producing schedule/rule), so the wrapper defaults to
89
+ // DESTROY — deliberately NOT the env-aware production->RETAIN of
90
+ // data-bearing wrappers (S3, EventBus). RETAIN here buys no protection and
91
+ // orphans the queue on a parent construct's logical-ID churn (the prod
92
+ // orphan leak). Durable queues opt into RETAIN via props.removalPolicy.
93
+ const resolvedRemovalPolicy = toRemovalPolicy(props.removalPolicy ?? "DESTROY");
86
94
  const isFifo = props.queueType === "fifo";
87
95
  const queueName = props.queueName
88
96
  ? isFifo
@@ -110,7 +118,7 @@ export class SQSQueue extends Construct {
110
118
  fifo: isFifo,
111
119
  encryption: toEncryption(props.encryption),
112
120
  retentionPeriod: Duration.days(SQS_LIMITS.DEAD_LETTER_QUEUE.DEFAULT_RETENTION_DAYS),
113
- removalPolicy: toRemovalPolicy(props.removalPolicy)
121
+ removalPolicy: resolvedRemovalPolicy
114
122
  });
115
123
  deadLetterQueue = {
116
124
  queue: this.dlq,
@@ -156,7 +164,7 @@ export class SQSQueue extends Construct {
156
164
  deduplicationScope: isFifo
157
165
  ? toDeduplicationScope(props.deduplicationScope)
158
166
  : undefined,
159
- removalPolicy: toRemovalPolicy(props.removalPolicy)
167
+ removalPolicy: resolvedRemovalPolicy
160
168
  });
161
169
  new CfnOutput(this, `${outputName}QueueUrl`, {
162
170
  key: `${outputName}QueueUrl`,
@@ -5,6 +5,7 @@ import { getDomainExportNames } from "@fjall/util";
5
5
  import { toPascalCase, getSafeZoneName } from "../../../utils/capitaliseString.js";
6
6
  import { DelegationRole } from "../iam/delegationRole.js";
7
7
  import { applyCostAllocationTags } from "../../../utils/costAllocationTags.js";
8
+ import { resolveOrgId } from "../../../utils/cdkContext.js";
8
9
  export class HostedZoneFactory {
9
10
  static import(stack, hostedZoneId, zoneName, opts) {
10
11
  const safeZone = toPascalCase(getSafeZoneName(zoneName));
@@ -49,7 +50,17 @@ export class HostedZone extends Construct {
49
50
  value: Fn.join(",", created.hostedZoneNameServers ?? []),
50
51
  exportName: exportNames.nameservers
51
52
  });
52
- if (props.createDelegationRole !== false) {
53
+ // Org-gate: a single account has no OrganisationId export, so an org-trusting
54
+ // delegation role would leave an unresolved Fn::ImportValue and roll the stack
55
+ // back. Default follows org presence; explicit `true` opts in (and fails fast).
56
+ const inOrganisation = resolveOrgId(this.node) !== undefined;
57
+ if (props.createDelegationRole === true && !inOrganisation) {
58
+ throw new Error(`HostedZone "${props.zoneName}": createDelegationRole was requested but ` +
59
+ `this account is not part of an AWS Organization (no "orgId" context). ` +
60
+ `Cross-account DNS delegation requires an organisation — omit ` +
61
+ `createDelegationRole for single-account setups, or connect an organisation.`);
62
+ }
63
+ if (props.createDelegationRole ?? inOrganisation) {
53
64
  this.delegationRole = new DelegationRole(this, `${safeZone}DelegationRole`, {
54
65
  zoneName: props.zoneName,
55
66
  hostedZone: created,
@@ -6,6 +6,25 @@ export interface WebsiteHostingConfig {
6
6
  readonly indexDocument: string;
7
7
  readonly errorDocument?: string;
8
8
  }
9
+ /**
10
+ * A single bucket-policy statement expressed declaratively. {@link S3Bucket}
11
+ * turns each entry into an `addToResourcePolicy` call at synth.
12
+ *
13
+ * Principals are either `"*"` (anonymous / public) or an IAM ARN (account root,
14
+ * role, or user). Service and Federated principals are not yet modelled — a
15
+ * remediation that meets one must fall back to report-only rather than dropping
16
+ * it silently. Mirrors the codemod's `S3ResourcePlanSchema.resourcePolicyStatements`
17
+ * in `@fjall/generator` — the two shapes must move together.
18
+ */
19
+ export interface ResourcePolicyStatement {
20
+ readonly sid?: string;
21
+ readonly effect: "Allow" | "Deny";
22
+ readonly principals: string[];
23
+ readonly actions: string[];
24
+ /** Defaults to the bucket itself and its objects when omitted. */
25
+ readonly resources?: string[];
26
+ readonly conditions?: Record<string, Record<string, string | string[]>>;
27
+ }
9
28
  /**
10
29
  * Props for {@link S3Bucket}.
11
30
  *
@@ -17,6 +36,12 @@ export interface S3BucketProps extends BucketProps {
17
36
  backupVaultTier?: BackupTier;
18
37
  publicReadAccess?: boolean;
19
38
  websiteHosting?: WebsiteHostingConfig;
39
+ /**
40
+ * Declarative bucket-policy statements, each appended via
41
+ * `addToResourcePolicy`. The TLS-only `enforceSSL` deny is always applied
42
+ * separately and must NOT be listed here.
43
+ */
44
+ resourcePolicyStatements?: ResourcePolicyStatement[];
20
45
  }
21
46
  export declare class S3Bucket extends Bucket {
22
47
  readonly backupVaultTier?: BackupTier;
@@ -1,6 +1,7 @@
1
1
  import { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
2
2
  import { Annotations, CfnOutput, Duration, RemovalPolicy, Tags } from "aws-cdk-lib";
3
3
  import { BlockPublicAccess, Bucket } from "aws-cdk-lib/aws-s3";
4
+ import { ArnPrincipal, Effect, PolicyStatement, StarPrincipal } from "aws-cdk-lib/aws-iam";
4
5
  import { RegionInfo } from "aws-cdk-lib/region-info";
5
6
  import { toPascalCase } from "../../../utils/capitaliseString.js";
6
7
  import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
@@ -8,10 +9,15 @@ export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
8
9
  function shouldAutoVersion(tier) {
9
10
  return tier === "resilient" || tier === "enterprise";
10
11
  }
12
+ function toResourcePolicyPrincipal(identifier) {
13
+ return identifier === "*"
14
+ ? new StarPrincipal()
15
+ : new ArnPrincipal(identifier);
16
+ }
11
17
  export class S3Bucket extends Bucket {
12
18
  backupVaultTier;
13
19
  constructor(scope, id, props = {}) {
14
- const { websiteHosting, backupVaultTier, ...cdkProps } = props;
20
+ const { websiteHosting, backupVaultTier, resourcePolicyStatements, ...cdkProps } = props;
15
21
  const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
16
22
  const versioned = props.versioned ?? shouldAutoVersion(backupVaultTier);
17
23
  const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
@@ -42,6 +48,21 @@ export class S3Bucket extends Bucket {
42
48
  : props.lifecycleRules
43
49
  });
44
50
  this.backupVaultTier = backupVaultTier;
51
+ for (const statement of resourcePolicyStatements ?? []) {
52
+ this.addToResourcePolicy(new PolicyStatement({
53
+ ...(statement.sid !== undefined && { sid: statement.sid }),
54
+ effect: statement.effect === "Deny" ? Effect.DENY : Effect.ALLOW,
55
+ principals: statement.principals.map(toResourcePolicyPrincipal),
56
+ actions: statement.actions,
57
+ resources: statement.resources ?? [
58
+ this.bucketArn,
59
+ this.arnForObjects("*")
60
+ ],
61
+ ...(statement.conditions !== undefined && {
62
+ conditions: statement.conditions
63
+ })
64
+ }));
65
+ }
45
66
  if (props.autoDeleteObjects === true) {
46
67
  Annotations.of(this).addWarningV2("@fjall/components-infrastructure:s3:autoDeleteObjectsIgnored", "autoDeleteObjects: true is ignored — the CDK auto-delete custom " +
47
68
  "resource is retired (ADR D4.3). DESTROY buckets are emptied " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,8 +63,8 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@aws-sdk/client-organizations": "^3.1038.0",
66
- "@fjall/generator": "^2.15.0",
67
- "@fjall/util": "^2.15.0",
66
+ "@fjall/generator": "^2.17.0",
67
+ "@fjall/util": "^2.17.0",
68
68
  "constructs": "^10.0.0",
69
69
  "uuid": "^14.0.0"
70
70
  },
@@ -79,5 +79,5 @@
79
79
  "engines": {
80
80
  "node": ">=18.0.0"
81
81
  },
82
- "gitHead": "b2223855907cb6d467e47df073b2a5b28c684ede"
82
+ "gitHead": "21cfe1aae339e12183af2813ec81f581b9b77d49"
83
83
  }