@fjall/components-infrastructure 29.0.0 → 31.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.
package/dist/lib/app.d.ts CHANGED
@@ -55,7 +55,10 @@ export interface IAppOptions {
55
55
  /**
56
56
  * Backup configuration for automatic AWS Backup enrolment.
57
57
  * - Object with tier: Tags all resources with `fjall:disasterRecovery:tier`
58
- * - false: Explicitly disabled (no backup tag)
58
+ * and sets the app backup tier that resource defaults derive from — S3
59
+ * buckets version by default at `resilient`/`enterprise`; a per-bucket
60
+ * `backupVaultTier` overrides the tier for that bucket.
61
+ * - false: Explicitly disabled (no backup tag, no tier-derived defaults)
59
62
  */
60
63
  backup?: {
61
64
  tier: BackupTier;
@@ -107,14 +110,24 @@ export declare class App extends CdkApp {
107
110
  private bastion?;
108
111
  private networkDisabled;
109
112
  private globalTags;
113
+ private backupTier?;
114
+ private backupTierObserved;
110
115
  private aspectApplied;
111
116
  private resourceInventory?;
112
117
  private manifestCollector;
113
118
  private constructor();
114
- private applyBackupTag;
119
+ private applyBackupTier;
115
120
  private initialiseTunnel;
116
121
  private initialiseNetwork;
117
122
  getName(): string;
123
+ /**
124
+ * The app-level backup tier from `backup: { tier }`, or undefined when no
125
+ * backup is declared. Factories thread it to resources whose defaults derive
126
+ * from it (S3 versioning); a per-resource `backupVaultTier` overrides it.
127
+ * Reading it pins the tier: a `backup` applied on a later `getApp` call
128
+ * throws, so no resource can carry the tier's tag without its defaults.
129
+ */
130
+ getBackupTier(): BackupTier | undefined;
118
131
  static getApp(name?: string, options?: IAppOptions): App;
119
132
  static getInstance(name?: string, options?: IAppOptions): App;
120
133
  /**
@@ -345,10 +358,10 @@ export declare class App extends CdkApp {
345
358
  * Add a storage resource (S3) to the default storage stack using the factory pattern.
346
359
  *
347
360
  * @example
348
- * // Private bucket (default)
349
- * const assets = app.addStorage(StorageFactory.build("Assets", {
350
- * versioned: true
351
- * }));
361
+ * // Private bucket (default). Versioning follows the app backup tier
362
+ * // (`backup: { tier }` — on at resilient/enterprise); `versioned` or a
363
+ * // per-bucket `backupVaultTier` overrides it.
364
+ * const assets = app.addStorage(StorageFactory.build("Assets"));
352
365
  *
353
366
  * @example
354
367
  * // Website bucket
package/dist/lib/app.js CHANGED
@@ -48,6 +48,8 @@ export class App extends CdkApp {
48
48
  bastion;
49
49
  networkDisabled = false;
50
50
  globalTags = {};
51
+ backupTier;
52
+ backupTierObserved = false;
51
53
  aspectApplied = false;
52
54
  resourceInventory;
53
55
  manifestCollector;
@@ -64,9 +66,9 @@ export class App extends CdkApp {
64
66
  // Initialise manifest collector for build-time service discovery
65
67
  this.manifestCollector = getManifestCollector(this.name);
66
68
  this.initialiseStandardTags();
67
- // Apply backup tier tag if configured
69
+ // Apply backup tier if configured
68
70
  if (options?.backup && typeof options.backup === "object") {
69
- this.applyBackupTag(options.backup.tier);
71
+ this.applyBackupTier(options.backup.tier);
70
72
  }
71
73
  // Initialise network immediately if configured
72
74
  if (options?.network === false) {
@@ -83,7 +85,14 @@ export class App extends CdkApp {
83
85
  this.eventBusOverride = options.eventBus;
84
86
  }
85
87
  }
86
- applyBackupTag(tier) {
88
+ applyBackupTier(tier) {
89
+ if (this.backupTierObserved) {
90
+ throw new Error(`Backup tier "${tier}" applied after a resource already resolved the app's tier: ` +
91
+ `that resource would carry the tier's disaster-recovery tag without its versioning default. ` +
92
+ `Pass \`backup\` on the first App.getApp("${this.name}") call, before addStorage(), ` +
93
+ `addBuildkite() or a Payload pattern.`);
94
+ }
95
+ this.backupTier = tier;
87
96
  this.globalTags[BACKUP_TIER_TAG_KEY] = BACKUP_TIER_TAG_MAP[tier];
88
97
  }
89
98
  initialiseTunnel(config) {
@@ -109,6 +118,17 @@ export class App extends CdkApp {
109
118
  getName() {
110
119
  return this.name;
111
120
  }
121
+ /**
122
+ * The app-level backup tier from `backup: { tier }`, or undefined when no
123
+ * backup is declared. Factories thread it to resources whose defaults derive
124
+ * from it (S3 versioning); a per-resource `backupVaultTier` overrides it.
125
+ * Reading it pins the tier: a `backup` applied on a later `getApp` call
126
+ * throws, so no resource can carry the tier's tag without its defaults.
127
+ */
128
+ getBackupTier() {
129
+ this.backupTierObserved = true;
130
+ return this.backupTier;
131
+ }
112
132
  static getApp(name, options) {
113
133
  return App.getInstance(name, options);
114
134
  }
@@ -145,11 +165,11 @@ export class App extends CdkApp {
145
165
  !App.instance.networkDisabled) {
146
166
  App.instance.initialiseNetwork(options.network);
147
167
  }
148
- // Apply backup tag if configured and not already set
168
+ // Apply backup tier if configured and not already set
149
169
  if (options?.backup &&
150
170
  typeof options.backup === "object" &&
151
- !App.instance.globalTags[BACKUP_TIER_TAG_KEY]) {
152
- App.instance.applyBackupTag(options.backup.tier);
171
+ App.instance.backupTier === undefined) {
172
+ App.instance.applyBackupTier(options.backup.tier);
153
173
  }
154
174
  // Initialise tunnel if configured and not already created
155
175
  if (options?.tunnel && !App.instance.bastion) {
@@ -557,10 +577,10 @@ export class App extends CdkApp {
557
577
  * Add a storage resource (S3) to the default storage stack using the factory pattern.
558
578
  *
559
579
  * @example
560
- * // Private bucket (default)
561
- * const assets = app.addStorage(StorageFactory.build("Assets", {
562
- * versioned: true
563
- * }));
580
+ * // Private bucket (default). Versioning follows the app backup tier
581
+ * // (`backup: { tier }` — on at resilient/enterprise); `versioned` or a
582
+ * // per-bucket `backupVaultTier` overrides it.
583
+ * const assets = app.addStorage(StorageFactory.build("Assets"));
564
584
  *
565
585
  * @example
566
586
  * // Website bucket
@@ -0,0 +1,124 @@
1
+ import { type BoundaryStatement, type GovernancePreset } from "@fjall/generator";
2
+ import { Construct } from "constructs";
3
+ /**
4
+ * The deploy-role permissions boundary is an ACCOUNT-GLOBAL IAM singleton, not
5
+ * a stack-owned resource. Once `cdk bootstrap --custom-permissions-boundary`
6
+ * caps a `cfn-exec-role` with it, the policy has a referrer OUTSIDE every
7
+ * stack: a native `AWS::IAM::ManagedPolicy` can then never be deleted
8
+ * (`DeleteConflict` → DELETE_FAILED for the account stack AND the customer's
9
+ * Quick-Create stack), while `DeletionPolicy: Retain` breaks the next
10
+ * re-connect (`CreatePolicy` → `EntityAlreadyExists`). This custom resource is
11
+ * the adopt-or-create shape the OIDC provider already uses: it publishes the
12
+ * SSoT tier document by name, converges an existing policy in place
13
+ * (`CreatePolicyVersion` + `SetAsDefault`), and ACKs Delete without touching
14
+ * the policy — end-of-life removal is deploy-core's reference-counted sweep
15
+ * (`deleteDeployBoundaryIfUnreferenced`) once no role is bounded by it.
16
+ *
17
+ * The writer is a confused deputy by construction — CloudFormation invokes it
18
+ * under whatever principal drives the stack, including the bounded deploy
19
+ * role — so it trusts NOTHING in the request: it publishes only a document
20
+ * whose canonical digest is one of the SSoT tiers, and it never drops a Deny
21
+ * ACTION the live document carries (the tiers are cumulative, so a tier
22
+ * upgrade is self-service and a downgrade is an administrator action,
23
+ * matching the opt-out semantics). The ratchet compares Deny action sets, not
24
+ * statement scope: a default hand-published outside fjall that is tighter
25
+ * only by Resource or Condition converges back to the SSoT tier on the next
26
+ * deploy — the tier, not the hand edit, is the contract. Its execution role
27
+ * can act on the one policy ARN only.
28
+ */
29
+ export declare const DEPLOY_BOUNDARY_RESOURCE_TYPE = "Custom::FjallDeployBoundary";
30
+ /**
31
+ * Execution-role description of the singleton's writer Lambda. The role is
32
+ * deliberately NOT bounded: a writer capped by the boundary it maintains could
33
+ * never re-version it (FjallDenyBoundaryPolicyTamper denies exactly that).
34
+ */
35
+ export declare const DEPLOY_BOUNDARY_WRITER_ROLE_DESCRIPTION = "Fjall deploy boundary writer";
36
+ /**
37
+ * Set at `CreatePolicy` only — IAM descriptions are immutable — and
38
+ * byte-identical in the Quick-Create twin so an adopted policy reads the same
39
+ * whichever renderer created it.
40
+ */
41
+ export declare const DEPLOY_BOUNDARY_DESCRIPTION = "Fjall deploy-role permissions boundary - hard cap on the OIDC deploy role and the CDK cfn-exec-role";
42
+ /** Every IAM action the writer holds — on the one boundary policy ARN, never `*`. */
43
+ export declare const DEPLOY_BOUNDARY_WRITER_ACTIONS: string[];
44
+ /** CloudFormation's inline-code ceiling (`ZipFile` / `Code.fromInline`). */
45
+ export declare const DEPLOY_BOUNDARY_CODE_LIMIT_BYTES = 4096;
46
+ /**
47
+ * Writer Lambda budget — one converge is at most eight IAM calls. Coupled
48
+ * with the Quick-Create twin's `Timeout`; the template parity test pins both.
49
+ */
50
+ export declare const DEPLOY_BOUNDARY_WRITER_TIMEOUT_SECONDS = 60;
51
+ /**
52
+ * CloudFormation's own wait for the writer's response — a lost response must
53
+ * fail the stack in minutes, not the default hour. Coupled with the twin's
54
+ * `ServiceTimeout`; the template parity test pins both.
55
+ */
56
+ export declare const DEPLOY_BOUNDARY_SERVICE_TIMEOUT_SECONDS = 300;
57
+ export interface DeployBoundaryDocument {
58
+ Version: "2012-10-17";
59
+ Statement: BoundaryStatement[];
60
+ }
61
+ export interface PolicyTag {
62
+ Key: string;
63
+ Value: string;
64
+ }
65
+ export declare function deployBoundaryDocument(tier: GovernancePreset): DeployBoundaryDocument;
66
+ /**
67
+ * Mirrors the Quick-Create twin's per-resource Tags block (the CDK account
68
+ * stack tags natively-rendered resources itself; a policy the writer creates
69
+ * through the SDK must carry them explicitly).
70
+ */
71
+ export declare function deployBoundaryPolicyTags(fjallOrgId: string): PolicyTag[];
72
+ export declare function boundaryDocumentDigest(document: unknown): string;
73
+ /**
74
+ * The allow-list the writer embeds. A tier added to `GOVERNANCE_PRESETS`
75
+ * without a digest here fails typecheck (`satisfies Record<GovernancePreset>`),
76
+ * and the Quick-Create twin pins these literals byte-for-byte.
77
+ */
78
+ export declare const SECURITY_TIER_DIGESTS: {
79
+ foundation: string;
80
+ compliance: string;
81
+ hardened: string;
82
+ };
83
+ /**
84
+ * Handler algorithm shared VERBATIM by both renderers (the Quick-Create twin
85
+ * embeds this exact text inside its own cfn-response wrapper; the template
86
+ * parity test pins the bytes, and both wrappers must stay under
87
+ * `DEPLOY_BOUNDARY_CODE_LIMIT_BYTES`, which is why the algorithm carries no
88
+ * comments of its own). Node 24 runtime — `@aws-sdk/client-iam` and `crypto`
89
+ * come from the Lambda runtime, nothing is bundled.
90
+ *
91
+ * `ALLOWED` is the digest allow-list (SSoT tiers only); `denied` is the
92
+ * loosening ratchet — the requested document must deny every ACTION the live
93
+ * one denies (the tiers are cumulative, so a tier upgrade always passes and a
94
+ * downgrade always fails). It compares action sets only: a live default that
95
+ * is tighter by Resource, Condition or `NotAction` converges back to the tier.
96
+ * The live no-op check runs BEFORE the tier gate: a CloudFormation rollback
97
+ * re-presents the previous release's document, whose digest a newer writer no
98
+ * longer allow-lists — when it already IS the live default nothing is
99
+ * published, so the confused-deputy guard loses nothing and the rollback
100
+ * lands. The create race is retried once (`retried`), so IAM read-after-write
101
+ * lag surfaces as a failure instead of spinning to the Lambda timeout.
102
+ * Five is IAM's per-policy version ceiling: at five, the oldest non-default
103
+ * version is dropped before the new default is published.
104
+ * `GetPolicyVersion` returns the document URL-encoded, hence the decode.
105
+ */
106
+ export declare const DEPLOY_BOUNDARY_HANDLER_CORE: string;
107
+ /**
108
+ * CDK rendering — the provider framework turns a resolved object into the
109
+ * SUCCESS response and a thrown error into FAILED with the message as Reason.
110
+ * Delete is an ACK: the policy may still cap cfn-exec roles the stack never
111
+ * owned, and the next connect adopts it.
112
+ */
113
+ export declare const DEPLOY_BOUNDARY_HANDLER: string;
114
+ export interface DeployBoundarySingletonProps {
115
+ fjallOrgId: string;
116
+ /** Governance tier selecting the boundary CONTENT (`SECURITY_TIER_TO_BOUNDARY`). */
117
+ tier: GovernancePreset;
118
+ }
119
+ export declare class DeployBoundarySingleton extends Construct {
120
+ readonly policyName: string;
121
+ /** `Fn::GetAtt` on the writer — referencing it orders the role after the policy. */
122
+ readonly policyArn: string;
123
+ constructor(scope: Construct, id: string, props: DeployBoundarySingletonProps);
124
+ }
@@ -0,0 +1,257 @@
1
+ import { SECURITY_TIER_TO_BOUNDARY, deployBoundaryName } from "@fjall/generator";
2
+ import { Aws, Duration } from "aws-cdk-lib";
3
+ import * as iam from "aws-cdk-lib/aws-iam";
4
+ import { Runtime } from "aws-cdk-lib/aws-lambda";
5
+ import { Construct } from "constructs";
6
+ import { createHash } from "node:crypto";
7
+ import { CustomResource } from "../../resources/aws/utilities/customResource.js";
8
+ /**
9
+ * The deploy-role permissions boundary is an ACCOUNT-GLOBAL IAM singleton, not
10
+ * a stack-owned resource. Once `cdk bootstrap --custom-permissions-boundary`
11
+ * caps a `cfn-exec-role` with it, the policy has a referrer OUTSIDE every
12
+ * stack: a native `AWS::IAM::ManagedPolicy` can then never be deleted
13
+ * (`DeleteConflict` → DELETE_FAILED for the account stack AND the customer's
14
+ * Quick-Create stack), while `DeletionPolicy: Retain` breaks the next
15
+ * re-connect (`CreatePolicy` → `EntityAlreadyExists`). This custom resource is
16
+ * the adopt-or-create shape the OIDC provider already uses: it publishes the
17
+ * SSoT tier document by name, converges an existing policy in place
18
+ * (`CreatePolicyVersion` + `SetAsDefault`), and ACKs Delete without touching
19
+ * the policy — end-of-life removal is deploy-core's reference-counted sweep
20
+ * (`deleteDeployBoundaryIfUnreferenced`) once no role is bounded by it.
21
+ *
22
+ * The writer is a confused deputy by construction — CloudFormation invokes it
23
+ * under whatever principal drives the stack, including the bounded deploy
24
+ * role — so it trusts NOTHING in the request: it publishes only a document
25
+ * whose canonical digest is one of the SSoT tiers, and it never drops a Deny
26
+ * ACTION the live document carries (the tiers are cumulative, so a tier
27
+ * upgrade is self-service and a downgrade is an administrator action,
28
+ * matching the opt-out semantics). The ratchet compares Deny action sets, not
29
+ * statement scope: a default hand-published outside fjall that is tighter
30
+ * only by Resource or Condition converges back to the SSoT tier on the next
31
+ * deploy — the tier, not the hand edit, is the contract. Its execution role
32
+ * can act on the one policy ARN only.
33
+ */
34
+ export const DEPLOY_BOUNDARY_RESOURCE_TYPE = "Custom::FjallDeployBoundary";
35
+ /**
36
+ * Execution-role description of the singleton's writer Lambda. The role is
37
+ * deliberately NOT bounded: a writer capped by the boundary it maintains could
38
+ * never re-version it (FjallDenyBoundaryPolicyTamper denies exactly that).
39
+ */
40
+ export const DEPLOY_BOUNDARY_WRITER_ROLE_DESCRIPTION = "Fjall deploy boundary writer";
41
+ /**
42
+ * Set at `CreatePolicy` only — IAM descriptions are immutable — and
43
+ * byte-identical in the Quick-Create twin so an adopted policy reads the same
44
+ * whichever renderer created it.
45
+ */
46
+ export const DEPLOY_BOUNDARY_DESCRIPTION = "Fjall deploy-role permissions boundary - hard cap on the OIDC deploy role and the CDK cfn-exec-role";
47
+ /** Every IAM action the writer holds — on the one boundary policy ARN, never `*`. */
48
+ export const DEPLOY_BOUNDARY_WRITER_ACTIONS = [
49
+ "iam:GetPolicy",
50
+ "iam:GetPolicyVersion",
51
+ "iam:ListPolicyVersions",
52
+ "iam:CreatePolicy",
53
+ "iam:CreatePolicyVersion",
54
+ "iam:DeletePolicyVersion",
55
+ "iam:TagPolicy"
56
+ ];
57
+ /** CloudFormation's inline-code ceiling (`ZipFile` / `Code.fromInline`). */
58
+ export const DEPLOY_BOUNDARY_CODE_LIMIT_BYTES = 4096;
59
+ /**
60
+ * Writer Lambda budget — one converge is at most eight IAM calls. Coupled
61
+ * with the Quick-Create twin's `Timeout`; the template parity test pins both.
62
+ */
63
+ export const DEPLOY_BOUNDARY_WRITER_TIMEOUT_SECONDS = 60;
64
+ /**
65
+ * CloudFormation's own wait for the writer's response — a lost response must
66
+ * fail the stack in minutes, not the default hour. Coupled with the twin's
67
+ * `ServiceTimeout`; the template parity test pins both.
68
+ */
69
+ export const DEPLOY_BOUNDARY_SERVICE_TIMEOUT_SECONDS = 300;
70
+ export function deployBoundaryDocument(tier) {
71
+ return { Version: "2012-10-17", Statement: SECURITY_TIER_TO_BOUNDARY[tier] };
72
+ }
73
+ /**
74
+ * Mirrors the Quick-Create twin's per-resource Tags block (the CDK account
75
+ * stack tags natively-rendered resources itself; a policy the writer creates
76
+ * through the SDK must carry them explicitly).
77
+ */
78
+ export function deployBoundaryPolicyTags(fjallOrgId) {
79
+ return [
80
+ { Key: "fjall:managed", Value: "true" },
81
+ { Key: "fjall:org-id", Value: fjallOrgId },
82
+ { Key: "fjall:costAllocation:owner", Value: fjallOrgId },
83
+ { Key: "fjall:costAllocation:service", Value: "OidcConnector" },
84
+ { Key: "fjall:costAllocation:environment", Value: "root" }
85
+ ];
86
+ }
87
+ /**
88
+ * Canonical form for digesting: keys sorted recursively, a one-element array
89
+ * collapsed to its scalar (IAM treats `Action: ["x"]` and `Action: "x"` as
90
+ * the same document, and returns whichever spelling it stored). MUST stay in
91
+ * lockstep with `canonical` inside the handler — the handler-execution tests
92
+ * pin both against the same fixtures.
93
+ */
94
+ function canonicalise(value) {
95
+ if (Array.isArray(value)) {
96
+ return value.length === 1
97
+ ? canonicalise(value[0])
98
+ : value.map((entry) => canonicalise(entry));
99
+ }
100
+ if (value !== null && typeof value === "object") {
101
+ const record = value;
102
+ const sorted = {};
103
+ for (const key of Object.keys(record).sort()) {
104
+ sorted[key] = canonicalise(record[key]);
105
+ }
106
+ return sorted;
107
+ }
108
+ return value;
109
+ }
110
+ export function boundaryDocumentDigest(document) {
111
+ return createHash("sha256")
112
+ .update(JSON.stringify(canonicalise(document)))
113
+ .digest("hex");
114
+ }
115
+ /**
116
+ * The allow-list the writer embeds. A tier added to `GOVERNANCE_PRESETS`
117
+ * without a digest here fails typecheck (`satisfies Record<GovernancePreset>`),
118
+ * and the Quick-Create twin pins these literals byte-for-byte.
119
+ */
120
+ export const SECURITY_TIER_DIGESTS = {
121
+ foundation: boundaryDocumentDigest(deployBoundaryDocument("foundation")),
122
+ compliance: boundaryDocumentDigest(deployBoundaryDocument("compliance")),
123
+ hardened: boundaryDocumentDigest(deployBoundaryDocument("hardened"))
124
+ };
125
+ const ALLOWED_DIGEST_LITERALS = Object.values(SECURITY_TIER_DIGESTS)
126
+ .map((digest) => `'${digest}'`)
127
+ .join(", ");
128
+ /**
129
+ * Handler algorithm shared VERBATIM by both renderers (the Quick-Create twin
130
+ * embeds this exact text inside its own cfn-response wrapper; the template
131
+ * parity test pins the bytes, and both wrappers must stay under
132
+ * `DEPLOY_BOUNDARY_CODE_LIMIT_BYTES`, which is why the algorithm carries no
133
+ * comments of its own). Node 24 runtime — `@aws-sdk/client-iam` and `crypto`
134
+ * come from the Lambda runtime, nothing is bundled.
135
+ *
136
+ * `ALLOWED` is the digest allow-list (SSoT tiers only); `denied` is the
137
+ * loosening ratchet — the requested document must deny every ACTION the live
138
+ * one denies (the tiers are cumulative, so a tier upgrade always passes and a
139
+ * downgrade always fails). It compares action sets only: a live default that
140
+ * is tighter by Resource, Condition or `NotAction` converges back to the tier.
141
+ * The live no-op check runs BEFORE the tier gate: a CloudFormation rollback
142
+ * re-presents the previous release's document, whose digest a newer writer no
143
+ * longer allow-lists — when it already IS the live default nothing is
144
+ * published, so the confused-deputy guard loses nothing and the rollback
145
+ * lands. The create race is retried once (`retried`), so IAM read-after-write
146
+ * lag surfaces as a failure instead of spinning to the Lambda timeout.
147
+ * Five is IAM's per-policy version ceiling: at five, the oldest non-default
148
+ * version is dropped before the new default is published.
149
+ * `GetPolicyVersion` returns the document URL-encoded, hence the decode.
150
+ */
151
+ export const DEPLOY_BOUNDARY_HANDLER_CORE = `const { IAMClient, GetPolicyCommand, GetPolicyVersionCommand, CreatePolicyCommand, CreatePolicyVersionCommand, ListPolicyVersionsCommand, DeletePolicyVersionCommand } = require('@aws-sdk/client-iam');
152
+ const { createHash } = require('crypto');
153
+ const ALLOWED = [${ALLOWED_DIGEST_LITERALS}];
154
+
155
+ function canonical(value) {
156
+ if (Array.isArray(value)) return value.length === 1 ? canonical(value[0]) : value.map(canonical);
157
+ if (value && typeof value === 'object') {
158
+ const out = {};
159
+ for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]);
160
+ return out;
161
+ }
162
+ return value;
163
+ }
164
+ const digest = (doc) => createHash('sha256').update(JSON.stringify(canonical(doc))).digest('hex');
165
+ const denied = (doc) => new Set([].concat(doc.Statement).filter((s) => s.Effect === 'Deny').flatMap((s) => [].concat(s.Action || [])));
166
+ const reason = (err) => String(err && err.name ? err.name + ': ' + err.message : err).slice(0, 512);
167
+
168
+ async function converge(iam, props, retried) {
169
+ const doc = props.PolicyDocument;
170
+ const wanted = digest(doc);
171
+ const arn = props.PolicyArn;
172
+ const body = JSON.stringify(doc);
173
+ let policy;
174
+ try {
175
+ policy = (await iam.send(new GetPolicyCommand({ PolicyArn: arn }))).Policy;
176
+ } catch (err) {
177
+ if (err.name !== 'NoSuchEntityException') throw err;
178
+ }
179
+ const live = policy && JSON.parse(decodeURIComponent((await iam.send(new GetPolicyVersionCommand({ PolicyArn: arn, VersionId: policy.DefaultVersionId }))).PolicyVersion.Document));
180
+ if (live && digest(live) === wanted) return arn;
181
+ if (!ALLOWED.includes(wanted)) throw new Error('PolicyDocument is not a Fjall boundary tier (digest ' + wanted + ')');
182
+ if (!live) {
183
+ try {
184
+ await iam.send(new CreatePolicyCommand({ PolicyName: props.PolicyName, Description: props.Description, PolicyDocument: body, Tags: props.PolicyTags }));
185
+ return arn;
186
+ } catch (err) {
187
+ if (err.name !== 'EntityAlreadyExistsException' || retried) throw err;
188
+ return converge(iam, props, true);
189
+ }
190
+ }
191
+ const keep = denied(doc);
192
+ const lost = [...denied(live)].find((action) => !keep.has(action));
193
+ if (lost) throw new Error('Refusing to loosen the live boundary (it denies ' + lost + '). Loosening is an administrator action: iam create-policy-version --set-as-default, then retry.');
194
+ const versions = (await iam.send(new ListPolicyVersionsCommand({ PolicyArn: arn }))).Versions || [];
195
+ if (versions.length >= 5) {
196
+ const oldest = versions.filter((v) => !v.IsDefaultVersion).sort((a, b) => new Date(a.CreateDate) - new Date(b.CreateDate))[0];
197
+ await iam.send(new DeletePolicyVersionCommand({ PolicyArn: arn, VersionId: oldest.VersionId }));
198
+ }
199
+ await iam.send(new CreatePolicyVersionCommand({ PolicyArn: arn, PolicyDocument: body, SetAsDefault: true }));
200
+ return arn;
201
+ }
202
+ `;
203
+ /**
204
+ * CDK rendering — the provider framework turns a resolved object into the
205
+ * SUCCESS response and a thrown error into FAILED with the message as Reason.
206
+ * Delete is an ACK: the policy may still cap cfn-exec roles the stack never
207
+ * owned, and the next connect adopts it.
208
+ */
209
+ export const DEPLOY_BOUNDARY_HANDLER = `${DEPLOY_BOUNDARY_HANDLER_CORE}
210
+ exports.handler = async (event) => {
211
+ const props = event.ResourceProperties;
212
+ const physicalId = event.PhysicalResourceId || props.PolicyArn;
213
+ if (event.RequestType === 'Delete') return { PhysicalResourceId: physicalId };
214
+ try {
215
+ const arn = await converge(new IAMClient({}), props);
216
+ return { PhysicalResourceId: arn, Data: { PolicyArn: arn } };
217
+ } catch (err) {
218
+ throw new Error(reason(err));
219
+ }
220
+ };`;
221
+ export class DeployBoundarySingleton extends Construct {
222
+ policyName;
223
+ /** `Fn::GetAtt` on the writer — referencing it orders the role after the policy. */
224
+ policyArn;
225
+ constructor(scope, id, props) {
226
+ super(scope, id);
227
+ this.policyName = deployBoundaryName(props.fjallOrgId);
228
+ const policyArn = `arn:${Aws.PARTITION}:iam::${Aws.ACCOUNT_ID}:policy/${this.policyName}`;
229
+ const writer = new CustomResource(this, "Policy", {
230
+ runtime: Runtime.NODEJS_24_X,
231
+ timeout: Duration.seconds(DEPLOY_BOUNDARY_WRITER_TIMEOUT_SECONDS),
232
+ serviceTimeout: Duration.seconds(DEPLOY_BOUNDARY_SERVICE_TIMEOUT_SECONDS),
233
+ lambdaDescription: "Adopt-or-create the account-global Fjall deploy-role permissions boundary",
234
+ roleDescription: DEPLOY_BOUNDARY_WRITER_ROLE_DESCRIPTION,
235
+ resourceType: DEPLOY_BOUNDARY_RESOURCE_TYPE,
236
+ inlinePolicy: [
237
+ new iam.PolicyStatement({
238
+ effect: iam.Effect.ALLOW,
239
+ actions: DEPLOY_BOUNDARY_WRITER_ACTIONS,
240
+ resources: [policyArn]
241
+ })
242
+ ],
243
+ inlineCode: DEPLOY_BOUNDARY_HANDLER,
244
+ // Key order mirrors the Quick-Create twin — the document last, so the
245
+ // console shows the identity before the ceiling (the template parity
246
+ // test pins the twin's order).
247
+ properties: {
248
+ PolicyName: this.policyName,
249
+ PolicyArn: policyArn,
250
+ Description: DEPLOY_BOUNDARY_DESCRIPTION,
251
+ PolicyTags: deployBoundaryPolicyTags(props.fjallOrgId),
252
+ PolicyDocument: deployBoundaryDocument(props.tier)
253
+ }
254
+ });
255
+ this.policyArn = writer.resource.getAtt("PolicyArn").toString();
256
+ }
257
+ }
@@ -2,6 +2,7 @@ export * from "./identityCenter.js";
2
2
  export * from "./identityCentreConfig.js";
3
3
  export * from "./ipam.js";
4
4
  export * from "./ecrDefaultImage.js";
5
+ export * from "./deployBoundarySingleton.js";
5
6
  export * from "./oidcConnector.js";
6
7
  export * from "./platform.js";
7
8
  export * from "./accountMonitoringRole.js";
@@ -2,6 +2,7 @@ export * from "./identityCenter.js";
2
2
  export * from "./identityCentreConfig.js";
3
3
  export * from "./ipam.js";
4
4
  export * from "./ecrDefaultImage.js";
5
+ export * from "./deployBoundarySingleton.js";
5
6
  export * from "./oidcConnector.js";
6
7
  export * from "./platform.js";
7
8
  export * from "./accountMonitoringRole.js";
@@ -5,12 +5,28 @@ export interface OidcConnectorProps {
5
5
  fjallOrgId: string;
6
6
  /**
7
7
  * Governance tier selecting the deploy-role permissions-boundary CONTENT.
8
- * When set, a `FjallDeployBoundary${fjallOrgId}` customer-managed policy is
9
- * created and attached to the deploy role as its permissions boundary — the
10
- * hard cap that survives CDK's assume-role hand-off (unlike a session policy).
11
- * Absent no boundary (today's behaviour backward-compatible).
8
+ * The connector ALWAYS publishes the `FjallDeployBoundary${fjallOrgId}`
9
+ * customer-managed policy (adopt-or-create an account-global singleton
10
+ * shared with the Quick-Create stack and the bootstrapped cfn-exec roles)
11
+ * and attaches it to the deploy role as its
12
+ * permissions boundary — the hard cap that survives CDK's assume-role
13
+ * hand-off (unlike a session policy). Absent ⇒ `DEFAULT_DEPLOY_BOUNDARY_TIER`
14
+ * (the ceiling the Quick-Create template ships), so a CDK-rendered account
15
+ * never carries a looser cap than a template-connected one. Set it only to
16
+ * select a DIFFERENT tier; `deployRoleBoundary: false` is the sole path to
17
+ * an uncapped role, and combining the two throws at synth.
12
18
  */
13
19
  securityTier?: GovernancePreset;
20
+ /**
21
+ * Opt-out of the deploy-role permissions boundary. `false` renders no
22
+ * `FjallDeployBoundary${fjallOrgId}` policy and leaves the deploy role
23
+ * uncapped. Absent or `true` ⇒ the boundary renders at `securityTier` or
24
+ * the default. Mirrors the Quick-Create template's
25
+ * `FjallPermissionsBoundary` parameter: both surfaces default ON and opt
26
+ * OUT, so "no boundary" is always a visible choice in the account's own
27
+ * infrastructure code, never a consequence of leaving a prop unset.
28
+ */
29
+ deployRoleBoundary?: boolean;
14
30
  /**
15
31
  * Workload STAGE of the connected account (the wire `environment` value —
16
32
  * never the account TIER axis). When `"development"`, the connector
@@ -1,4 +1,4 @@
1
- import { DEV_BOUNDARY_POLICY_NAME, DEV_DEPLOY_POLICY_NAME, DEV_PROVISIONER_POLICY_NAME, DEV_SYNC_WRITER_POLICY_NAME, FJALL_OIDC_AUDIENCE, FJALL_OIDC_ISSUER_DOMAIN, FJALL_OIDC_PLACEHOLDER_THUMBPRINT, SECURITY_TIER_TO_BOUNDARY, buildDevBoundaryStatements, buildDevDeployPolicyStatements, buildDevDeployTrustStatements, buildDevProvisionerPolicyStatements, buildDevProvisionerTrustStatements, buildDevSyncWriterPolicyStatements, buildDevSyncWriterTrustStatements, buildNarrowedDeployTrustStatements, deployBoundaryName, devDeployRoleName, devProvisionerRoleName, devSyncWriterRoleName } from "@fjall/generator";
1
+ import { DEFAULT_DEPLOY_BOUNDARY_TIER, DEPLOY_ROLE_PATH, DEV_BOUNDARY_POLICY_NAME, DEV_DEPLOY_POLICY_NAME, DEV_PROVISIONER_POLICY_NAME, DEV_SYNC_WRITER_POLICY_NAME, FJALL_OIDC_AUDIENCE, FJALL_OIDC_ISSUER_DOMAIN, FJALL_OIDC_PLACEHOLDER_THUMBPRINT, buildDevBoundaryStatements, buildDevDeployPolicyStatements, buildDevDeployTrustStatements, buildDevProvisionerPolicyStatements, buildDevProvisionerTrustStatements, buildDevSyncWriterPolicyStatements, buildDevSyncWriterTrustStatements, buildNarrowedDeployTrustStatements, deployRoleName, devDeployRoleName, devProvisionerRoleName, devSyncWriterRoleName } from "@fjall/generator";
2
2
  import { Aws, 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";
@@ -6,6 +6,7 @@ import { Construct } from "constructs";
6
6
  import { ManagedPolicy } from "../../resources/aws/iam/managedPolicy.js";
7
7
  import { Role } from "../../resources/aws/iam/role.js";
8
8
  import { CustomResource } from "../../resources/aws/utilities/customResource.js";
9
+ import { DeployBoundarySingleton } from "./deployBoundarySingleton.js";
9
10
  /**
10
11
  * The `https://fjall.io` OIDC provider is an account-global IAM singleton
11
12
  * (one per account+URL). Both this construct AND the Quick-Create template can
@@ -67,6 +68,21 @@ function applyTrustDocument(role, statements) {
67
68
  Statement: statements
68
69
  };
69
70
  }
71
+ /**
72
+ * Default-on: an absent tier resolves to the SSoT default, `deployRoleBoundary:
73
+ * false` is the only route to no boundary, and naming a tier alongside the
74
+ * opt-out is a contradiction that fails at synth rather than silently picking
75
+ * a side.
76
+ */
77
+ function resolveDeployBoundaryTier(props) {
78
+ if (props.deployRoleBoundary === false) {
79
+ if (props.securityTier !== undefined) {
80
+ throw new Error(`OidcConnector: securityTier "${props.securityTier}" selects the deploy-role boundary content, but deployRoleBoundary: false renders no boundary — remove one of them`);
81
+ }
82
+ return undefined;
83
+ }
84
+ return props.securityTier ?? DEFAULT_DEPLOY_BOUNDARY_TIER;
85
+ }
70
86
  export class OidcConnector extends Construct {
71
87
  deployRoleArn;
72
88
  constructor(scope, id, props) {
@@ -104,15 +120,16 @@ export class OidcConnector extends Construct {
104
120
  const providerArn = providerResource.resource
105
121
  .getAtt("ProviderArn")
106
122
  .toString();
107
- // Permissions boundary (hard cap). Created here — in the account-connection
108
- // stack that runs BEFORE any bootstrap/deploy — because
123
+ // Permissions boundary (hard cap). Published here — in the account-
124
+ // connection stack that runs BEFORE any bootstrap/deploy — because
109
125
  // `cdk bootstrap --custom-permissions-boundary <name>` LOOKS THE POLICY UP
110
- // by name; it never creates it. So the policy must exist by name first.
111
- const deployBoundary = props.securityTier !== undefined
112
- ? new ManagedPolicy(this, "DeployBoundary", {
113
- managedPolicyName: deployBoundaryName(props.fjallOrgId),
114
- description: "Fjall deploy-role permissions boundary — hard cap on the OIDC deploy role and the CDK cfn-exec-role",
115
- statements: SECURITY_TIER_TO_BOUNDARY[props.securityTier].map((statement) => iam.PolicyStatement.fromJson(statement))
126
+ // by name; it never creates it. Account-global adopt-or-create, not a
127
+ // stack-owned ManagedPolicy: see DeployBoundarySingleton for why.
128
+ const boundaryTier = resolveDeployBoundaryTier(props);
129
+ const deployBoundary = boundaryTier !== undefined
130
+ ? new DeployBoundarySingleton(this, "DeployBoundary", {
131
+ fjallOrgId: props.fjallOrgId,
132
+ tier: boundaryTier
116
133
  })
117
134
  : undefined;
118
135
  // Concrete when env.account is set, a token otherwise — both render. Feeds
@@ -131,8 +148,8 @@ export class OidcConnector extends Construct {
131
148
  })
132
149
  };
133
150
  const deployRole = new Role(this, "DeployRole", {
134
- roleName: `FjallDeploy${props.fjallOrgId}`,
135
- path: "/fjall/",
151
+ roleName: deployRoleName(props.fjallOrgId),
152
+ path: DEPLOY_ROLE_PATH,
136
153
  maxSessionDuration: Duration.hours(1),
137
154
  // Placeholder — applyTrustDocument below replaces the whole rendered
138
155
  // AssumeRolePolicyDocument with the SSoT's G4 condition split.
@@ -141,7 +158,7 @@ export class OidcConnector extends Construct {
141
158
  iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")
142
159
  ],
143
160
  ...(deployBoundary !== undefined && {
144
- permissionsBoundary: deployBoundary
161
+ permissionsBoundary: iam.ManagedPolicy.fromManagedPolicyArn(this, "DeployBoundaryRef", deployBoundary.policyArn)
145
162
  })
146
163
  });
147
164
  applyTrustDocument(deployRole, buildNarrowedDeployTrustStatements(trustParams));
@@ -1,10 +1,12 @@
1
+ import { DEPLOY_ROLE_PATH, deployRoleName } from "@fjall/generator";
1
2
  import { Construct } from "constructs";
2
3
  import { OrganisationPolicy } from "../../resources/aws/organisation/organisationPolicy.js";
3
4
  // Path prefix `/fjall/` is load-bearing: without it, a member-account admin
4
5
  // could bypass protect-* SCPs by creating a `FjallDeploy*` role at the
5
- // default path.
6
+ // default path. Derived from the generator SSoT so the exemption, the role the
7
+ // connector renders and the boundary's self-protection name the same role.
6
8
  const EXEMPT_ROLE_PATTERNS = [
7
- "arn:aws:iam::*:role/fjall/FjallDeploy*",
9
+ `arn:aws:iam::*:role${DEPLOY_ROLE_PATH}${deployRoleName("*")}`,
8
10
  "arn:aws:iam::*:role/OrganizationAccountAccessRole",
9
11
  // The account-level S3 Block Public Access manager Lambda re-applies BPA on
10
12
  // every deploy; its PutAccountPublicAccessBlock must survive
@@ -21,12 +21,30 @@ export interface AccountProps extends StackProps {
21
21
  * Governance tier selecting the deploy-role permissions-boundary CONTENT
22
22
  * (Phase E, seam 1). Forwarded to the account's `OidcConnector`, which
23
23
  * creates `FjallDeployBoundary${fjallOrgId}` from
24
- * `SECURITY_TIER_TO_BOUNDARY[securityTier]`. Undefined ⇒ no boundary
25
- * (backward-compatible). Sourced from the generated `account/infrastructure.ts`
26
- * (the `--security` dial baked in at generation time the tier is not
27
- * persisted anywhere the deploy flow could read it).
24
+ * `SECURITY_TIER_TO_BOUNDARY[securityTier]`. Undefined ⇒ the boundary still
25
+ * renders, at `DEFAULT_DEPLOY_BOUNDARY_TIER` (the ceiling the Quick-Create
26
+ * template ships) the tier only ever changes WHICH ceiling, never whether
27
+ * there is one (`deployRoleBoundary: false` does that). Sourced from the
28
+ * generated `account/infrastructure.ts` (the `--security` dial baked in at
29
+ * generation time — the tier is not persisted anywhere the deploy flow could
30
+ * read it).
28
31
  */
29
32
  securityTier?: GovernancePreset;
33
+ /**
34
+ * Opt-out of the deploy-role permissions boundary, forwarded to the
35
+ * account's `OidcConnector`. `false` renders no boundary policy and an
36
+ * uncapped FjallDeploy role; absent or `true` ⇒ the boundary renders at
37
+ * `securityTier` or the default. Combining `false` with a `securityTier`
38
+ * throws at synth. Sourced from the generated `account/infrastructure.ts`,
39
+ * same as `securityTier`.
40
+ *
41
+ * Deliberately NOT named `permissionsBoundary`: `AccountProps` extends CDK's
42
+ * `StackProps`, whose `permissionsBoundary` is a different mechanism (a
43
+ * `PermissionsBoundary` applied to EVERY role in the stack — the shape the
44
+ * deferred E3b "app roles inherit the boundary" work would use). This knob
45
+ * governs only the FjallDeploy role.
46
+ */
47
+ deployRoleBoundary?: boolean;
30
48
  /**
31
49
  * Delivery endpoints for the account's shared alarm topic. Undefined ⇒
32
50
  * the topic synthesises with no subscribers and every alarm transition is
@@ -122,6 +122,9 @@ export class Account extends Stack {
122
122
  ...(props.securityTier !== undefined && {
123
123
  securityTier: props.securityTier
124
124
  }),
125
+ ...(props.deployRoleBoundary !== undefined && {
126
+ deployRoleBoundary: props.deployRoleBoundary
127
+ }),
125
128
  ...(props.machineTrustMode !== undefined && {
126
129
  machineTrustMode: props.machineTrustMode
127
130
  })
@@ -1,8 +1,14 @@
1
1
  import { type IVpc } from "aws-cdk-lib/aws-ec2";
2
2
  import { Construct } from "constructs";
3
+ import { type BackupTier } from "../../../utils/backupTierMapping.js";
3
4
  import { type BuildkitePropsInput } from "./schema.js";
4
5
  export type BuildkiteConstructProps = BuildkitePropsInput & {
5
6
  readonly vpc: IVpc;
7
+ /**
8
+ * The owning app's backup tier — `BuildkiteFactory` fills it from the App
9
+ * so the artefact bucket's versioning default follows the tier.
10
+ */
11
+ readonly appBackupTier?: BackupTier;
6
12
  };
7
13
  /**
8
14
  * Self-hosted Buildkite agent fleet on the pinned Elastic CI Stack AMIs —
@@ -37,7 +37,7 @@ export class Buildkite extends Construct {
37
37
  autoScalingGroupName;
38
38
  constructor(scope, id, props) {
39
39
  super(scope, id);
40
- const { vpc, ...plainProps } = props;
40
+ const { vpc, appBackupTier, ...plainProps } = props;
41
41
  const config = validateBuildkiteProps(plainProps);
42
42
  // Deliberate asymmetry: the agent token is load-bearing (boot + scaler),
43
43
  // so underivable throws; the api-key hook and per-pipeline job secrets
@@ -88,10 +88,14 @@ export class Buildkite extends Construct {
88
88
  owner: config.costAllocationOwner
89
89
  })
90
90
  });
91
- const artifactBucket = new S3Bucket(this, `${id}ArtifactBucket`);
91
+ // Artefacts are write-once keys, so versioning follows the app backup tier
92
+ // at negligible cost; the bucket is otherwise scratch output and keeps the
93
+ // wrapper's env-aware default (DESTROY + pre-empty off prod).
94
+ const artifactBucket = new S3Bucket(this, `${id}ArtifactBucket`, {
95
+ appBackupTier
96
+ });
92
97
  // Secrets (SSH deploy keys, git credentials) must survive stack teardown
93
- // and accidental overwrite; the artifact bucket is scratch output and
94
- // keeps the wrapper's env-aware default (DESTROY + pre-empty off prod).
98
+ // and accidental overwrite.
95
99
  const managedSecretsBucket = new S3Bucket(this, `${id}ManagedSecretsBucket`, { versioned: true, removalPolicy: RemovalPolicy.RETAIN });
96
100
  if (fjallApiKeyParameterName !== undefined ||
97
101
  pipelineSecretsPrefix !== undefined) {
@@ -10,10 +10,10 @@ import type { BuildkitePropsInput } from "./schema.js";
10
10
  */
11
11
  export type BuildkiteFactoryFn = (app: App, scope: Construct) => Buildkite;
12
12
  /**
13
- * Resolve construct props from plain-data input: VPC injection plus the three
13
+ * Resolve construct props from plain-data input: VPC injection plus the
14
14
  * app-level defaults (cost-allocation environment, shared alarm topic,
15
- * applicationId). Single source for both `App.addBuildkite` entry paths
16
- * (design § D2) — explicit props always win.
15
+ * applicationId, backup tier). Single source for both `App.addBuildkite`
16
+ * entry paths (design § D2) — explicit props always win.
17
17
  */
18
18
  export declare function resolveBuildkiteConstructProps(app: App, props: BuildkitePropsInput): BuildkiteConstructProps;
19
19
  /**
@@ -3,10 +3,10 @@ import { UNKNOWN_ENVIRONMENT } from "../../../utils/env.js";
3
3
  import { getConfig } from "../../../utils/getConfig.js";
4
4
  import { Buildkite } from "./buildkite.js";
5
5
  /**
6
- * Resolve construct props from plain-data input: VPC injection plus the three
6
+ * Resolve construct props from plain-data input: VPC injection plus the
7
7
  * app-level defaults (cost-allocation environment, shared alarm topic,
8
- * applicationId). Single source for both `App.addBuildkite` entry paths
9
- * (design § D2) — explicit props always win.
8
+ * applicationId, backup tier). Single source for both `App.addBuildkite`
9
+ * entry paths (design § D2) — explicit props always win.
10
10
  */
11
11
  export function resolveBuildkiteConstructProps(app, props) {
12
12
  const configEnvironment = getConfig().environment;
@@ -24,7 +24,10 @@ export function resolveBuildkiteConstructProps(app, props) {
24
24
  if (constructProps.applicationId === undefined) {
25
25
  constructProps.applicationId = app.getName();
26
26
  }
27
- return constructProps;
27
+ const appBackupTier = app.getBackupTier();
28
+ return appBackupTier === undefined
29
+ ? constructProps
30
+ : { ...constructProps, appBackupTier };
28
31
  }
29
32
  /**
30
33
  * Factory for the self-hosted Buildkite agent fleet — the codemod add path
@@ -201,7 +201,12 @@ export interface PayloadComputeConfig {
201
201
  * S3 bucket configuration for pattern sub-buckets.
202
202
  */
203
203
  export interface PatternStorageBucketConfig {
204
- /** Enable versioning. Default: false */
204
+ /**
205
+ * Bucket versioning. Absent, the pattern decides: the media bucket follows
206
+ * the app backup tier (on at `resilient`/`enterprise`), while assets (build
207
+ * output, pruned per deploy) and cache (ISR, regenerated at runtime) stay
208
+ * off at every tier. An explicit value always wins.
209
+ */
205
210
  versioned?: boolean;
206
211
  }
207
212
  /**
@@ -210,9 +215,7 @@ export interface PatternStorageBucketConfig {
210
215
  *
211
216
  * @example
212
217
  * storage: {
213
- * assets: { versioned: true },
214
- * cache: { versioned: false },
215
- * media: { versioned: true }
218
+ * media: { versioned: true } // force on below the resilient tier
216
219
  * }
217
220
  */
218
221
  export interface PayloadStorageConfig {
@@ -219,6 +219,7 @@ export class Payload extends Construct {
219
219
  createAssetsBucket() {
220
220
  const assetsConfig = this.props.storage?.assets ?? {};
221
221
  const assetsProps = {
222
+ // Build output, pruned on every deploy — versioning would only bill for churn.
222
223
  versioned: assetsConfig.versioned ?? false,
223
224
  deployment: {
224
225
  source: `${PAYLOAD_DEFAULTS.SOURCE}/${PAYLOAD_DEFAULTS.OPENNEXT.ASSETS}`,
@@ -234,6 +235,7 @@ export class Payload extends Construct {
234
235
  createCacheBucket() {
235
236
  const cacheConfig = this.props.storage?.cache ?? {};
236
237
  const cacheProps = {
238
+ // ISR cache, regenerated at runtime — versioning would only bill for churn.
237
239
  versioned: cacheConfig.versioned ?? false,
238
240
  deployment: {
239
241
  source: `${PAYLOAD_DEFAULTS.SOURCE}/${PAYLOAD_DEFAULTS.OPENNEXT.CACHE}`,
@@ -244,8 +246,11 @@ export class Payload extends Construct {
244
246
  }
245
247
  createMediaBucket() {
246
248
  const mediaConfig = this.props.storage?.media ?? {};
249
+ // Customer uploads: versioning follows the app backup tier unless set here.
247
250
  const mediaProps = {
248
- versioned: mediaConfig.versioned ?? false
251
+ ...(mediaConfig.versioned !== undefined && {
252
+ versioned: mediaConfig.versioned
253
+ })
249
254
  };
250
255
  return this.app.addStorage(StorageFactory.build(`${this.props.name}-media`, mediaProps));
251
256
  }
@@ -33,6 +33,11 @@ export interface S3Props {
33
33
  readonly bucketName?: string;
34
34
  readonly publicReadAccess?: boolean;
35
35
  readonly websiteHosting?: WebsiteHostingConfig;
36
+ /**
37
+ * Bucket versioning. Defaults from the effective backup tier — the
38
+ * per-bucket `backupVaultTier`, else the app's `backup: { tier }` — on at
39
+ * `resilient`/`enterprise`, off otherwise. An explicit value always wins.
40
+ */
36
41
  readonly versioned?: boolean;
37
42
  readonly encryption?: "AES256" | "KMS";
38
43
  readonly kmsKeyArn?: string;
@@ -42,6 +47,10 @@ export interface S3Props {
42
47
  * false to opt out. Ignored for non-KMS encryption.
43
48
  */
44
49
  readonly bucketKeyEnabled?: boolean;
50
+ /**
51
+ * Per-bucket backup tier override. Beats the app-level tier for both the
52
+ * `fjall:disasterRecovery:tier` tag and the versioning default.
53
+ */
45
54
  readonly backupVaultTier?: BackupTier;
46
55
  readonly cors?: CorsRule[];
47
56
  readonly deployment?: S3DeploymentConfig;
@@ -64,6 +73,12 @@ export interface S3Props {
64
73
  * construction.
65
74
  */
66
75
  readonly appName?: string;
76
+ /**
77
+ * The owning app's backup tier (`App.getApp({ backup: { tier } })`), which
78
+ * the versioning default follows. `StorageFactory` fills it from the App as
79
+ * it does `appName`; pass it only for direct `new Storage(...)` construction.
80
+ */
81
+ readonly appBackupTier?: BackupTier;
67
82
  }
68
83
  export interface StorageBuildProps extends S3Props {
69
84
  readonly stackPlacement?: "storage" | "cdn" | "compute";
@@ -84,6 +84,7 @@ export class Storage extends Construct {
84
84
  bucketKeyEnabled: props.bucketKeyEnabled
85
85
  }),
86
86
  backupVaultTier: props.backupVaultTier,
87
+ appBackupTier: props.appBackupTier,
87
88
  publicReadAccess: props.publicReadAccess,
88
89
  websiteHosting: props.websiteHosting,
89
90
  resourcePolicyStatements: props.resourcePolicyStatements,
@@ -218,12 +219,14 @@ export class StorageFactory {
218
219
  const { stackPlacement, ...s3Props } = props;
219
220
  const fn = (app, scope) => {
220
221
  validateStorageProps(s3Props);
221
- // The App is the only place the app name is known here; website-hosting
222
- // buckets need it for their (app, bucket)-keyed exports — the same fill
223
- // CdnFactory performs for Cdn.
222
+ // The App is the only place the app name and backup tier are known
223
+ // here: website-hosting buckets need the name for their (app,
224
+ // bucket)-keyed exports (the same fill CdnFactory performs for Cdn), and
225
+ // the bucket's versioning default follows the tier.
224
226
  return new Storage(scope, id, {
225
227
  ...s3Props,
226
- appName: s3Props.appName ?? app.getName()
228
+ appName: s3Props.appName ?? app.getName(),
229
+ appBackupTier: s3Props.appBackupTier ?? app.getBackupTier()
227
230
  });
228
231
  };
229
232
  if (stackPlacement) {
@@ -46,7 +46,19 @@ export interface ResourcePolicyStatement {
46
46
  * `bucketKeyEnabled: true` (pass `false` to opt out).
47
47
  */
48
48
  export interface S3BucketProps extends BucketProps {
49
+ /**
50
+ * Per-bucket backup tier override. Beats `appBackupTier` for both the
51
+ * `fjall:disasterRecovery:tier` tag and the versioning default.
52
+ */
49
53
  backupVaultTier?: BackupTier;
54
+ /**
55
+ * The owning app's backup tier (`App.getApp({ backup: { tier } })`). The
56
+ * versioning default follows the effective tier — this, unless
57
+ * `backupVaultTier` overrides it — on at `resilient`/`enterprise`. The
58
+ * `StorageFactory` and `BuildkiteFactory` paths fill it from the App; only
59
+ * direct `new S3Bucket` construction needs to pass it.
60
+ */
61
+ appBackupTier?: BackupTier;
50
62
  publicReadAccess?: boolean;
51
63
  websiteHosting?: WebsiteHostingConfig;
52
64
  /**
@@ -80,5 +92,7 @@ export interface S3BucketProps extends BucketProps {
80
92
  }
81
93
  export declare class S3Bucket extends Bucket {
82
94
  readonly backupVaultTier?: BackupTier;
95
+ /** The tier the bucket runs under: `backupVaultTier`, else `appBackupTier`. */
96
+ readonly effectiveBackupTier?: BackupTier;
83
97
  constructor(scope: Construct, id: string, props?: S3BucketProps);
84
98
  }
@@ -3,6 +3,7 @@ import { Annotations, CfnOutput, Duration, RemovalPolicy, Tags } from "aws-cdk-l
3
3
  import { BlockPublicAccess, Bucket, BucketEncryption } from "aws-cdk-lib/aws-s3";
4
4
  import { ArnPrincipal, Effect, PolicyStatement, StarPrincipal } from "aws-cdk-lib/aws-iam";
5
5
  import { RegionInfo } from "aws-cdk-lib/region-info";
6
+ import { resolveEffectiveBackupTier, shouldAutoVersion } from "../../../utils/backupTierMapping.js";
6
7
  import { toPascalCase } from "../../../utils/capitaliseString.js";
7
8
  import { bucketWebsiteEndpointExportName, bucketWebsiteHostedZoneIdExportName } from "@fjall/util";
8
9
  import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
@@ -15,9 +16,6 @@ export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
15
16
  * every site so the retention window cannot drift.
16
17
  */
17
18
  export const NONCURRENT_VERSION_EXPIRY_DAYS = 30;
18
- function shouldAutoVersion(tier) {
19
- return tier === "resilient" || tier === "enterprise";
20
- }
21
19
  function toResourcePolicyPrincipal(identifier) {
22
20
  return identifier === "*"
23
21
  ? new StarPrincipal()
@@ -25,10 +23,13 @@ function toResourcePolicyPrincipal(identifier) {
25
23
  }
26
24
  export class S3Bucket extends Bucket {
27
25
  backupVaultTier;
26
+ /** The tier the bucket runs under: `backupVaultTier`, else `appBackupTier`. */
27
+ effectiveBackupTier;
28
28
  constructor(scope, id, props = {}) {
29
- const { websiteHosting, backupVaultTier, resourcePolicyStatements, aliasTargetName, appName, ...cdkProps } = props;
29
+ const { websiteHosting, backupVaultTier, appBackupTier, resourcePolicyStatements, aliasTargetName, appName, ...cdkProps } = props;
30
30
  const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
31
- const versioned = props.versioned ?? shouldAutoVersion(backupVaultTier);
31
+ const effectiveBackupTier = resolveEffectiveBackupTier(backupVaultTier, appBackupTier);
32
+ const versioned = props.versioned ?? shouldAutoVersion(effectiveBackupTier);
32
33
  const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
33
34
  // Mirrors the CDK Bucket's own resolution: an encryptionKey with no
34
35
  // explicit encryption infers KMS. DSSE is excluded deliberately — S3
@@ -86,6 +87,7 @@ export class S3Bucket extends Bucket {
86
87
  ]
87
88
  });
88
89
  this.backupVaultTier = backupVaultTier;
90
+ this.effectiveBackupTier = effectiveBackupTier;
89
91
  for (const statement of resourcePolicyStatements ?? []) {
90
92
  this.addToResourcePolicy(new PolicyStatement({
91
93
  ...(statement.sid !== undefined && { sid: statement.sid }),
@@ -12,9 +12,12 @@ interface CustomResourceProps {
12
12
  /** Fixed name for the handler's execution role (account-global — see LambdaFunctionProps). */
13
13
  roleName?: string;
14
14
  inlinePolicy: PolicyStatement[];
15
- properties?: {
16
- [key: string]: string;
17
- };
15
+ /**
16
+ * Passed to CloudFormation as the custom resource's properties verbatim —
17
+ * nested objects and lists survive intact (CFN forwards the JSON to the
18
+ * handler unchanged; only scalar leaves are coerced to strings).
19
+ */
20
+ properties?: Record<string, unknown>;
18
21
  /**
19
22
  * CloudFormation resource type for the custom resource (must begin with
20
23
  * `Custom::`). Defaults to `AWS::CloudFormation::CustomResource`; set a
@@ -27,6 +30,12 @@ interface CustomResourceProps {
27
30
  assetOptions?: AssetOptions;
28
31
  handler?: string;
29
32
  timeout?: Duration;
33
+ /**
34
+ * CloudFormation's own wait for the handler's response. Unset, a lost
35
+ * response stalls the stack for an hour; set it below the stack's patience
36
+ * for handlers whose failure must surface fast.
37
+ */
38
+ serviceTimeout?: Duration;
30
39
  }
31
40
  export declare class CustomResource extends Construct {
32
41
  readonly response: string;
@@ -65,6 +65,9 @@ export class CustomResource extends Construct {
65
65
  properties: props.properties,
66
66
  ...(props.resourceType !== undefined && {
67
67
  resourceType: props.resourceType
68
+ }),
69
+ ...(props.serviceTimeout !== undefined && {
70
+ serviceTimeout: props.serviceTimeout
68
71
  })
69
72
  });
70
73
  this.response = this.resource.getAtt("Response").toString();
@@ -5,7 +5,16 @@ export type BackupTier = "standard" | "resilient" | "enterprise";
5
5
  /**
6
6
  * Shared mapping from application backup tier names to AWS Backup plan tag values.
7
7
  *
8
- * Used by both App.applyBackupTag() (app-level tag) and StandardTagsAspect
9
- * (per-resource override tag) to ensure consistent tier → tag translation.
8
+ * Used by both App.applyBackupTier() (app-level tag) and StandardTagsAspect
9
+ * (per-resource tag) to ensure consistent tier → tag translation.
10
10
  */
11
11
  export declare const BACKUP_TIER_TAG_MAP: Readonly<Record<BackupTier, string>>;
12
+ /**
13
+ * The tier a resource actually runs under: its own override, else the
14
+ * app-level tier `App.getApp({ backup: { tier } })` declared. The single
15
+ * resolution every tier-derived default reads (S3 versioning, the DR tag),
16
+ * so the derivations cannot drift.
17
+ */
18
+ export declare function resolveEffectiveBackupTier(resourceTier: BackupTier | undefined, appTier: BackupTier | undefined): BackupTier | undefined;
19
+ /** Tiers whose S3 buckets version by default. */
20
+ export declare function shouldAutoVersion(tier: BackupTier | undefined): boolean;
@@ -3,11 +3,24 @@ export const BACKUP_TIER_TAG_KEY = "fjall:disasterRecovery:tier";
3
3
  /**
4
4
  * Shared mapping from application backup tier names to AWS Backup plan tag values.
5
5
  *
6
- * Used by both App.applyBackupTag() (app-level tag) and StandardTagsAspect
7
- * (per-resource override tag) to ensure consistent tier → tag translation.
6
+ * Used by both App.applyBackupTier() (app-level tag) and StandardTagsAspect
7
+ * (per-resource tag) to ensure consistent tier → tag translation.
8
8
  */
9
9
  export const BACKUP_TIER_TAG_MAP = Object.freeze({
10
10
  standard: "default",
11
11
  resilient: "resilient",
12
12
  enterprise: "enterprise"
13
13
  });
14
+ /**
15
+ * The tier a resource actually runs under: its own override, else the
16
+ * app-level tier `App.getApp({ backup: { tier } })` declared. The single
17
+ * resolution every tier-derived default reads (S3 versioning, the DR tag),
18
+ * so the derivations cannot drift.
19
+ */
20
+ export function resolveEffectiveBackupTier(resourceTier, appTier) {
21
+ return resourceTier ?? appTier;
22
+ }
23
+ /** Tiers whose S3 buckets version by default. */
24
+ export function shouldAutoVersion(tier) {
25
+ return tier === "resilient" || tier === "enterprise";
26
+ }
@@ -22,10 +22,13 @@ export declare class StandardTagsAspect implements IAspect {
22
22
  */
23
23
  private addIpamPoolTag;
24
24
  /**
25
- * Add disaster recovery tier tag to S3 buckets with backupVaultTier property.
26
- * This serves as a per-resource override when `backupVaultTier` is set on a
27
- * specific S3 bucket. CDK child tags override parent tags, so this takes
28
- * precedence over the app-level backup tag set via `App.getApp({ backup })`.
25
+ * Add the disaster recovery tier tag to S3 buckets from the bucket's
26
+ * effective tier the per-bucket `backupVaultTier` override, else the
27
+ * app-level tier the factory threaded in. CDK child tags override parent
28
+ * tags, so an override wins over the app-level tag set via
29
+ * `App.getApp({ backup })`; an inherited app tier writes the value that tag
30
+ * already carries. The same resolution decides the bucket's versioning
31
+ * default (`S3Bucket`).
29
32
  *
30
33
  * Tag format: fjall:disasterRecovery:tier = "{tier}"
31
34
  *
@@ -54,10 +54,13 @@ export class StandardTagsAspect {
54
54
  }
55
55
  }
56
56
  /**
57
- * Add disaster recovery tier tag to S3 buckets with backupVaultTier property.
58
- * This serves as a per-resource override when `backupVaultTier` is set on a
59
- * specific S3 bucket. CDK child tags override parent tags, so this takes
60
- * precedence over the app-level backup tag set via `App.getApp({ backup })`.
57
+ * Add the disaster recovery tier tag to S3 buckets from the bucket's
58
+ * effective tier the per-bucket `backupVaultTier` override, else the
59
+ * app-level tier the factory threaded in. CDK child tags override parent
60
+ * tags, so an override wins over the app-level tag set via
61
+ * `App.getApp({ backup })`; an inherited app tier writes the value that tag
62
+ * already carries. The same resolution decides the bucket's versioning
63
+ * default (`S3Bucket`).
61
64
  *
62
65
  * Tag format: fjall:disasterRecovery:tier = "{tier}"
63
66
  *
@@ -70,8 +73,8 @@ export class StandardTagsAspect {
70
73
  let construct = resource;
71
74
  while (construct) {
72
75
  const backupConstruct = construct;
73
- if (backupConstruct.backupVaultTier) {
74
- const tier = backupConstruct.backupVaultTier;
76
+ if (backupConstruct.effectiveBackupTier) {
77
+ const tier = backupConstruct.effectiveBackupTier;
75
78
  const tagValue = BACKUP_TIER_TAG_MAP[tier];
76
79
  Tags.of(resource).add(BACKUP_TIER_TAG_KEY, tagValue);
77
80
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "29.0.0",
3
+ "version": "31.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -78,8 +78,8 @@
78
78
  },
79
79
  "dependencies": {
80
80
  "@aws-sdk/client-organizations": "^3.1098.0",
81
- "@fjall/generator": "^29.0.0",
82
- "@fjall/util": "^29.0.0",
81
+ "@fjall/generator": "^31.0.0",
82
+ "@fjall/util": "^31.0.0",
83
83
  "constructs": "^10.7.2",
84
84
  "zod": "^4.4.3"
85
85
  },