@fjall/components-infrastructure 17.0.0 → 18.0.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.
@@ -15,6 +15,108 @@ const EXEMPT_ROLE_PATTERNS = [
15
15
  const SCP_BYTE_LIMIT = 5120;
16
16
  const IAM_POLICY_VERSION = "2012-10-17";
17
17
  const SCP_POLICY_TYPE = "SERVICE_CONTROL_POLICY";
18
+ // Region-deny exemptions: global and us-east-1-locked services evaluate
19
+ // aws:RequestedRegion as us-east-1, so a region deny without these blocks
20
+ // their consoles outright for orgs whose allowedRegions exclude us-east-1.
21
+ // Mirrors the NotAction list of AWS Control Tower's Region deny control
22
+ // (GRREGIONDENY) so future re-syncs are a mechanical diff:
23
+ // https://docs.aws.amazon.com/controltower/latest/controlreference/primary-region-deny-policy.html
24
+ // Deliberate deltas from that list:
25
+ // - "support-console:*" — Support Center console auth moves to this prefix
26
+ // on 2026-11-16 (support:* remains the API prefix); predates the CT list.
27
+ // - "purchase-orders:*" — the one aws-portal-successor billing prefix the CT
28
+ // list is missing.
29
+ // - "tag:*" — grandfathered superset of CT's "tag:GetResources"; narrowing
30
+ // a deployed deny would be a breaking tightening.
31
+ const GLOBAL_SERVICE_EXEMPTIONS = [
32
+ "a4b:*",
33
+ "access-analyzer:*",
34
+ "account:*",
35
+ "acm:*",
36
+ "activate:*",
37
+ "artifact:*",
38
+ "aws-marketplace-management:*",
39
+ "aws-marketplace:*",
40
+ "aws-portal:*",
41
+ "billing:*",
42
+ "billingconductor:*",
43
+ "budgets:*",
44
+ "ce:*",
45
+ "chatbot:*",
46
+ "chime:*",
47
+ "cloudfront:*",
48
+ "cloudtrail:LookupEvents",
49
+ "compute-optimizer:*",
50
+ "config:*",
51
+ "consoleapp:*",
52
+ "consolidatedbilling:*",
53
+ "cur:*",
54
+ "datapipeline:GetAccountLimits",
55
+ "devicefarm:*",
56
+ "directconnect:*",
57
+ "ec2:DescribeRegions",
58
+ "ec2:DescribeTransitGateways",
59
+ "ec2:DescribeVpnGateways",
60
+ "ecr-public:*",
61
+ "fms:*",
62
+ "freetier:*",
63
+ "globalaccelerator:*",
64
+ "health:*",
65
+ "iam:*",
66
+ "importexport:*",
67
+ "invoicing:*",
68
+ "iq:*",
69
+ "kms:*",
70
+ "license-manager:ListReceivedLicenses",
71
+ "lightsail:Get*",
72
+ "mobileanalytics:*",
73
+ "networkmanager:*",
74
+ "notifications-contacts:*",
75
+ "notifications:*",
76
+ "organizations:*",
77
+ "payments:*",
78
+ "pricing:*",
79
+ "purchase-orders:*",
80
+ "quicksight:DescribeAccountSubscription",
81
+ "resource-explorer-2:*",
82
+ "route53-recovery-cluster:*",
83
+ "route53-recovery-control-config:*",
84
+ "route53-recovery-readiness:*",
85
+ "route53:*",
86
+ "route53domains:*",
87
+ "s3:CreateMultiRegionAccessPoint",
88
+ "s3:DeleteMultiRegionAccessPoint",
89
+ "s3:DescribeMultiRegionAccessPointOperation",
90
+ "s3:GetAccountPublicAccessBlock",
91
+ "s3:GetBucketLocation",
92
+ "s3:GetBucketPolicyStatus",
93
+ "s3:GetBucketPublicAccessBlock",
94
+ "s3:GetMultiRegionAccessPoint",
95
+ "s3:GetMultiRegionAccessPointPolicy",
96
+ "s3:GetMultiRegionAccessPointPolicyStatus",
97
+ "s3:GetStorageLensConfiguration",
98
+ "s3:GetStorageLensDashboard",
99
+ "s3:ListAllMyBuckets",
100
+ "s3:ListMultiRegionAccessPoints",
101
+ "s3:ListStorageLensConfigurations",
102
+ "s3:PutAccountPublicAccessBlock",
103
+ "s3:PutMultiRegionAccessPointPolicy",
104
+ "savingsplans:*",
105
+ "shield:*",
106
+ "sso:*",
107
+ "sts:*",
108
+ "support-console:*",
109
+ "support:*",
110
+ "supportapp:*",
111
+ "supportplans:*",
112
+ "sustainability:*",
113
+ "tag:*",
114
+ "trustedadvisor:*",
115
+ "vendor-insights:ListEntitledSecurityProfiles",
116
+ "waf-regional:*",
117
+ "waf:*",
118
+ "wafv2:*"
119
+ ];
18
120
  function automationExemption() {
19
121
  return {
20
122
  ArnNotLike: {
@@ -62,23 +164,7 @@ function buildFoundationGuardrails(allowedRegions) {
62
164
  {
63
165
  Sid: "DenyOutsideAllowedRegions",
64
166
  Effect: "Deny",
65
- NotAction: [
66
- "iam:*",
67
- "sts:*",
68
- "cloudfront:*",
69
- "route53:*",
70
- "organizations:*",
71
- "support:*",
72
- "budgets:*",
73
- "ce:*",
74
- "waf:*",
75
- "wafv2:*",
76
- "globalaccelerator:*",
77
- "health:*",
78
- "account:*",
79
- "tag:*",
80
- "trustedadvisor:*"
81
- ],
167
+ NotAction: [...GLOBAL_SERVICE_EXEMPTIONS],
82
168
  Resource: "*",
83
169
  Condition: {
84
170
  StringNotEquals: {
@@ -22,6 +22,7 @@ export type FjallTarget = {
22
22
  readonly appName: string;
23
23
  } | {
24
24
  readonly kind: "bucket";
25
+ readonly appName: string;
25
26
  readonly bucketName: string;
26
27
  } | {
27
28
  readonly kind: "custom";
@@ -49,6 +49,14 @@ export interface S3Props {
49
49
  readonly removalPolicy?: "DESTROY" | "RETAIN";
50
50
  /** Declarative bucket-policy statements appended to the bucket's resource policy. */
51
51
  readonly resourcePolicyStatements?: ResourcePolicyStatement[];
52
+ /**
53
+ * The owning Fjall app's name, required by website-hosting buckets — their
54
+ * exports are keyed on (app, bucket) so sibling apps' identically-named
55
+ * buckets stay deployable side by side. `StorageFactory` fills it from the
56
+ * App (as `CdnFactory` does); pass it only for direct `new Storage(...)`
57
+ * construction.
58
+ */
59
+ readonly appName?: string;
52
60
  }
53
61
  export interface StorageBuildProps extends S3Props {
54
62
  readonly stackPlacement?: "storage" | "cdn" | "compute";
@@ -74,8 +74,9 @@ export class Storage extends Construct {
74
74
  this.bucket = new S3Bucket(this, `${id}Bucket`, {
75
75
  bucketName: props.bucketName,
76
76
  // The bucket nests one level below, so its own id is `${id}Bucket` — the
77
- // author's handle for `fjallBucket(<name>)` is THIS construct's id.
77
+ // author's handle for `fjallBucket(<app>, <name>)` is THIS construct's id.
78
78
  aliasTargetName: props.bucketName ?? id,
79
+ appName: props.appName,
79
80
  versioned: props.versioned,
80
81
  encryption: toBucketEncryption(props.encryption),
81
82
  encryptionKey,
@@ -175,9 +176,15 @@ export class Storage extends Construct {
175
176
  export class StorageFactory {
176
177
  static build(id, props = {}) {
177
178
  const { stackPlacement, ...s3Props } = props;
178
- const fn = (_app, scope) => {
179
+ const fn = (app, scope) => {
179
180
  validateStorageProps(s3Props);
180
- return new Storage(scope, id, s3Props);
181
+ // The App is the only place the app name is known here; website-hosting
182
+ // buckets need it for their (app, bucket)-keyed exports — the same fill
183
+ // CdnFactory performs for Cdn.
184
+ return new Storage(scope, id, {
185
+ ...s3Props,
186
+ appName: s3Props.appName ?? app.getName()
187
+ });
181
188
  };
182
189
  if (stackPlacement) {
183
190
  fn.stackPlacement = stackPlacement;
@@ -32,12 +32,14 @@ export declare function fjallCdn(appName: string): FjallAliasTarget;
32
32
  /**
33
33
  * Target helper for a Fjall S3 static-site bucket.
34
34
  *
35
- * Requires the bucket stack to publish exports `${safeBucket}WebsiteEndpoint`
36
- * and `${safeBucket}WebsiteHostedZoneId` emitted by website-enabled `S3`
37
- * storage. Naming a bucket with no such exports fails at CloudFormation
38
- * execution, exactly as `fjallApp` documents.
35
+ * `appName` names the app that owns the bucket the first segment of every
36
+ * target helper's key, exactly as `fjallApp`/`fjallCdn` take it. Requires that
37
+ * app's stack to publish exports `${safeApp}-${safeBucket}-WebsiteEndpoint`
38
+ * and `${safeApp}-${safeBucket}-WebsiteHostedZoneId` emitted by
39
+ * website-enabled `S3` storage. Naming a pair with no such exports fails at
40
+ * CloudFormation execution, exactly as `fjallApp` documents.
39
41
  */
40
- export declare function fjallBucket(bucketName: string): FjallAliasTarget;
42
+ export declare function fjallBucket(appName: string, bucketName: string): FjallAliasTarget;
41
43
  /**
42
44
  * Target helper for an arbitrary `(dnsName, hostedZoneId)` pair. Accepts
43
45
  * literal strings or CDK tokens (e.g. a `CfnOutput.value`). No
@@ -43,15 +43,18 @@ export function fjallCdn(appName) {
43
43
  /**
44
44
  * Target helper for a Fjall S3 static-site bucket.
45
45
  *
46
- * Requires the bucket stack to publish exports `${safeBucket}WebsiteEndpoint`
47
- * and `${safeBucket}WebsiteHostedZoneId` emitted by website-enabled `S3`
48
- * storage. Naming a bucket with no such exports fails at CloudFormation
49
- * execution, exactly as `fjallApp` documents.
46
+ * `appName` names the app that owns the bucket the first segment of every
47
+ * target helper's key, exactly as `fjallApp`/`fjallCdn` take it. Requires that
48
+ * app's stack to publish exports `${safeApp}-${safeBucket}-WebsiteEndpoint`
49
+ * and `${safeApp}-${safeBucket}-WebsiteHostedZoneId` emitted by
50
+ * website-enabled `S3` storage. Naming a pair with no such exports fails at
51
+ * CloudFormation execution, exactly as `fjallApp` documents.
50
52
  */
51
- export function fjallBucket(bucketName) {
52
- const resolved = resolveBucketTarget(bucketName);
53
+ export function fjallBucket(appName, bucketName) {
54
+ const resolved = resolveBucketTarget(appName, bucketName);
53
55
  return {
54
56
  kind: "bucket",
57
+ appName,
55
58
  bucketName,
56
59
  bind(record, zone) {
57
60
  return resolved.bind(record, zone);
@@ -15,8 +15,8 @@ import type { FjallTarget } from "../interfaces/domain.js";
15
15
  * | `ecsNetworking.ts` app stack | `resolveEcsTarget` | `<App>-<Compute>-AlbDnsName` |
16
16
  * | `ecsNetworking.ts` app stack | `resolveEcsTarget` | `<App>-<Compute>-AlbHostedZoneId` |
17
17
  * | `cloudFront.ts` CDN stack | `resolveCdnTarget` | `<App>CdnDistributionDomainName` |
18
- * | `s3.ts` bucket stack | `resolveBucketTarget` | `<Bucket>WebsiteEndpoint` |
19
- * | `s3.ts` bucket stack | `resolveBucketTarget` | `<Bucket>WebsiteHostedZoneId` |
18
+ * | `s3.ts` bucket stack | `resolveBucketTarget` | `<App>-<Bucket>-WebsiteEndpoint` |
19
+ * | `s3.ts` bucket stack | `resolveBucketTarget` | `<App>-<Bucket>-WebsiteHostedZoneId` |
20
20
  *
21
21
  * Both sides derive the name from `@fjall/util`'s `…ExportName` helpers, which
22
22
  * take the RAW app or bucket name and sanitise internally. Compose these
@@ -50,10 +50,13 @@ export declare function resolveCdnTarget(appName: string): IAliasRecordTarget;
50
50
  /**
51
51
  * Resolve a Fjall S3 static-site bucket to an `IAliasRecordTarget`.
52
52
  *
53
- * `bucketName` is the bucket's name as written, dots and all — the helper
54
- * strips what CloudFormation will not hold, and the producer strips the same.
53
+ * `appName` names the app whose stack exports the pairrequired because the
54
+ * export is keyed on (app, bucket), never on the bucket alone (sibling apps'
55
+ * identically-named buckets must stay deployable side by side). `bucketName`
56
+ * is the bucket's name as written, dots and all — the helper strips what
57
+ * CloudFormation will not hold, and the producer strips the same.
55
58
  */
56
- export declare function resolveBucketTarget(bucketName: string): IAliasRecordTarget;
59
+ export declare function resolveBucketTarget(appName: string, bucketName: string): IAliasRecordTarget;
57
60
  /**
58
61
  * Pure passthrough — accepts concrete DNS name + hosted-zone id (or CDK
59
62
  * tokens) and forwards them unchanged. No `Fn.importValue` wrapping.
@@ -15,8 +15,8 @@ import { albDnsExportName, albHostedZoneIdExportName, cdnDomainExportName, bucke
15
15
  * | `ecsNetworking.ts` app stack | `resolveEcsTarget` | `<App>-<Compute>-AlbDnsName` |
16
16
  * | `ecsNetworking.ts` app stack | `resolveEcsTarget` | `<App>-<Compute>-AlbHostedZoneId` |
17
17
  * | `cloudFront.ts` CDN stack | `resolveCdnTarget` | `<App>CdnDistributionDomainName` |
18
- * | `s3.ts` bucket stack | `resolveBucketTarget` | `<Bucket>WebsiteEndpoint` |
19
- * | `s3.ts` bucket stack | `resolveBucketTarget` | `<Bucket>WebsiteHostedZoneId` |
18
+ * | `s3.ts` bucket stack | `resolveBucketTarget` | `<App>-<Bucket>-WebsiteEndpoint` |
19
+ * | `s3.ts` bucket stack | `resolveBucketTarget` | `<App>-<Bucket>-WebsiteHostedZoneId` |
20
20
  *
21
21
  * Both sides derive the name from `@fjall/util`'s `…ExportName` helpers, which
22
22
  * take the RAW app or bucket name and sanitise internally. Compose these
@@ -69,12 +69,15 @@ export function resolveCdnTarget(appName) {
69
69
  /**
70
70
  * Resolve a Fjall S3 static-site bucket to an `IAliasRecordTarget`.
71
71
  *
72
- * `bucketName` is the bucket's name as written, dots and all — the helper
73
- * strips what CloudFormation will not hold, and the producer strips the same.
72
+ * `appName` names the app whose stack exports the pairrequired because the
73
+ * export is keyed on (app, bucket), never on the bucket alone (sibling apps'
74
+ * identically-named buckets must stay deployable side by side). `bucketName`
75
+ * is the bucket's name as written, dots and all — the helper strips what
76
+ * CloudFormation will not hold, and the producer strips the same.
74
77
  */
75
- export function resolveBucketTarget(bucketName) {
76
- const dnsName = Fn.importValue(bucketWebsiteEndpointExportName(bucketName));
77
- const hostedZoneId = Fn.importValue(bucketWebsiteHostedZoneIdExportName(bucketName));
78
+ export function resolveBucketTarget(appName, bucketName) {
79
+ const dnsName = Fn.importValue(bucketWebsiteEndpointExportName(appName, bucketName));
80
+ const hostedZoneId = Fn.importValue(bucketWebsiteHostedZoneIdExportName(appName, bucketName));
78
81
  return buildAliasTarget(dnsName, hostedZoneId);
79
82
  }
80
83
  /**
@@ -98,7 +101,7 @@ export function resolveTargetToDnsName(target) {
98
101
  case "cdn":
99
102
  return Fn.importValue(cdnDomainExportName(target.appName));
100
103
  case "bucket":
101
- return Fn.importValue(bucketWebsiteEndpointExportName(target.bucketName));
104
+ return Fn.importValue(bucketWebsiteEndpointExportName(target.appName, target.bucketName));
102
105
  case "custom":
103
106
  return target.dnsName;
104
107
  default: {
@@ -63,7 +63,16 @@ export interface BuildClickHouseUserDataOptions {
63
63
  serverSecretArn: string;
64
64
  };
65
65
  }
66
- export declare function generateServerConfigXml(options: BuildClickHouseUserDataOptions): string;
66
+ /**
67
+ * The subset of the user-data options that config.xml generation actually
68
+ * reads. `dataVolumeSizeGb` is deliberately absent: it shapes the boot
69
+ * SCRIPT (the launch-template refresh trigger), not the rendered server
70
+ * config — demanding it here forced the XML parity tests to invent a value
71
+ * the render never consumes. `tls` is read for presence only (it toggles
72
+ * the secure-listener block); the ARNs inside it are boot-script concerns.
73
+ */
74
+ export type ServerConfigXmlOptions = Pick<BuildClickHouseUserDataOptions, "backupBucketName" | "backupBucketRegion" | "instanceType" | "coldTier" | "tls">;
75
+ export declare function generateServerConfigXml(options: ServerConfigXmlOptions): string;
67
76
  export interface GenerateUsersConfigXmlOptions {
68
77
  schemaAdmin: ClickHouseSchemaAdmin;
69
78
  profiles: Record<string, ProfileSpec>;
@@ -37,15 +37,27 @@ export interface S3BucketProps extends BucketProps {
37
37
  publicReadAccess?: boolean;
38
38
  websiteHosting?: WebsiteHostingConfig;
39
39
  /**
40
- * Name a `fjallBucket(<name>)` alias target uses to import this bucket's
41
- * website exports. Defaults to `bucketName ?? id`, which is right for a
42
- * direct `new S3Bucket(scope, "Assets", …)` — `id` is then the author's own
43
- * handle. A wrapper that nests the bucket under a derived id MUST pass this:
44
- * `Storage` builds its bucket at `${id}Bucket`, so without it a
45
- * `fjallBucket("assets")` record would import `AssetsWebsiteEndpoint`
46
- * against an exported `AssetsBucketWebsiteEndpoint`.
40
+ * Name a `fjallBucket(<app>, <name>)` alias target uses to import this
41
+ * bucket's website exports. Defaults to `bucketName ?? id`, which is right
42
+ * for a direct `new S3Bucket(scope, "Assets", …)` — `id` is then the
43
+ * author's own handle. A wrapper that nests the bucket under a derived id
44
+ * MUST pass this: `Storage` builds its bucket at `${id}Bucket`, so without
45
+ * it a `fjallBucket(app, "assets")` record would import
46
+ * `…-Assets-WebsiteEndpoint` against an exported
47
+ * `…-AssetsBucket-WebsiteEndpoint`.
47
48
  */
48
49
  aliasTargetName?: string;
50
+ /**
51
+ * The owning Fjall app's name — the other half of the website-export key
52
+ * (`bucketWebsiteEndpointExportName(appName, bucket)`). REQUIRED whenever
53
+ * `websiteHosting` is set: export names are account+region-global and every
54
+ * scaffolded app names its website bucket identically, so an unqualified
55
+ * export makes two website-bearing apps mutually undeployable. The
56
+ * `StorageFactory` path fills this from the App automatically (as
57
+ * `CdnFactory` does for `Cdn`); only direct `new S3Bucket`/`new Storage`
58
+ * construction needs to pass it.
59
+ */
60
+ appName?: string;
49
61
  /**
50
62
  * Declarative bucket-policy statements, each appended via
51
63
  * `addToResourcePolicy`. The TLS-only `enforceSSL` deny is always applied
@@ -18,7 +18,7 @@ function toResourcePolicyPrincipal(identifier) {
18
18
  export class S3Bucket extends Bucket {
19
19
  backupVaultTier;
20
20
  constructor(scope, id, props = {}) {
21
- const { websiteHosting, backupVaultTier, resourcePolicyStatements, aliasTargetName, ...cdkProps } = props;
21
+ const { websiteHosting, backupVaultTier, resourcePolicyStatements, aliasTargetName, appName, ...cdkProps } = props;
22
22
  const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
23
23
  const versioned = props.versioned ?? shouldAutoVersion(backupVaultTier);
24
24
  const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
@@ -74,6 +74,18 @@ export class S3Bucket extends Bucket {
74
74
  Tags.of(this).add(SDK_PRE_EMPTY_TAG_KEY, "true");
75
75
  }
76
76
  if (websiteHosting) {
77
+ if (appName === undefined) {
78
+ // Refusing beats falling back to a bucket-only export name: the
79
+ // consumer (`resolveBucketTarget`) always imports the app-qualified
80
+ // pair, so an unqualified export could never be resolved — it would
81
+ // only surface at CloudFormation execution as "No export named …",
82
+ // or worse, collide with a sibling app's identically-named bucket.
83
+ throw new Error(`S3 bucket '${id}': websiteHosting requires 'appName' — the ` +
84
+ "website exports are keyed on (app, bucket) so two apps' " +
85
+ "identically-named buckets stay deployable side by side. " +
86
+ "App-managed paths (StorageFactory/CdnFactory) fill it " +
87
+ "automatically; pass appName explicitly for direct construction.");
88
+ }
77
89
  const targetBucketName = aliasTargetName ?? props.bucketName ?? id;
78
90
  // Only the export name routes through the contract helper. `safeBucket`
79
91
  // stays local so the CfnOutput logical ids are unchanged — unifying the
@@ -81,7 +93,7 @@ export class S3Bucket extends Bucket {
81
93
  const safeBucket = toPascalCase(targetBucketName.replace(/[^A-Za-z0-9-]/g, ""));
82
94
  new CfnOutput(this, `${safeBucket}WebsiteEndpoint`, {
83
95
  key: `${safeBucket}WebsiteEndpoint`,
84
- exportName: bucketWebsiteEndpointExportName(targetBucketName),
96
+ exportName: bucketWebsiteEndpointExportName(appName, targetBucketName),
85
97
  value: this.bucketWebsiteDomainName,
86
98
  description: `S3 website endpoint for ${id} (consumed by Domain alias targets)`
87
99
  });
@@ -93,7 +105,7 @@ export class S3Bucket extends Bucket {
93
105
  }
94
106
  new CfnOutput(this, `${safeBucket}WebsiteHostedZoneId`, {
95
107
  key: `${safeBucket}WebsiteHostedZoneId`,
96
- exportName: bucketWebsiteHostedZoneIdExportName(targetBucketName),
108
+ exportName: bucketWebsiteHostedZoneIdExportName(appName, targetBucketName),
97
109
  value: websiteHostedZoneId,
98
110
  description: `Route 53 hosted zone id for the S3 website endpoint of ${id}`
99
111
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "17.0.0",
3
+ "version": "18.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -80,8 +80,8 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^17.0.0",
84
- "@fjall/util": "^17.0.0",
83
+ "@fjall/generator": "^18.0.0",
84
+ "@fjall/util": "^18.0.0",
85
85
  "constructs": "^10.7.2"
86
86
  },
87
87
  "overrides": {