@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 +19 -6
- package/dist/lib/app.js +30 -10
- package/dist/lib/config/aws/deployBoundarySingleton.d.ts +124 -0
- package/dist/lib/config/aws/deployBoundarySingleton.js +257 -0
- package/dist/lib/config/aws/index.d.ts +1 -0
- package/dist/lib/config/aws/index.js +1 -0
- package/dist/lib/config/aws/oidcConnector.d.ts +20 -4
- package/dist/lib/config/aws/oidcConnector.js +29 -12
- package/dist/lib/config/aws/scpPreset.js +4 -2
- package/dist/lib/patterns/aws/account.d.ts +22 -4
- package/dist/lib/patterns/aws/account.js +3 -0
- package/dist/lib/patterns/aws/buildkite/buildkite.d.ts +6 -0
- package/dist/lib/patterns/aws/buildkite/buildkite.js +8 -4
- package/dist/lib/patterns/aws/buildkite/factory.d.ts +3 -3
- package/dist/lib/patterns/aws/buildkite/factory.js +7 -4
- package/dist/lib/patterns/aws/interfaces/pattern.d.ts +7 -4
- package/dist/lib/patterns/aws/payload.js +6 -1
- package/dist/lib/patterns/aws/storage.d.ts +15 -0
- package/dist/lib/patterns/aws/storage.js +7 -4
- package/dist/lib/resources/aws/storage/s3.d.ts +14 -0
- package/dist/lib/resources/aws/storage/s3.js +7 -5
- package/dist/lib/resources/aws/utilities/customResource.d.ts +12 -3
- package/dist/lib/resources/aws/utilities/customResource.js +3 -0
- package/dist/lib/utils/backupTierMapping.d.ts +11 -2
- package/dist/lib/utils/backupTierMapping.js +15 -2
- package/dist/lib/utils/standardTagsAspect.d.ts +7 -4
- package/dist/lib/utils/standardTagsAspect.js +9 -6
- package/package.json +3 -3
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
350
|
-
*
|
|
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
|
|
69
|
+
// Apply backup tier if configured
|
|
68
70
|
if (options?.backup && typeof options.backup === "object") {
|
|
69
|
-
this.
|
|
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
|
-
|
|
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
|
|
168
|
+
// Apply backup tier if configured and not already set
|
|
149
169
|
if (options?.backup &&
|
|
150
170
|
typeof options.backup === "object" &&
|
|
151
|
-
|
|
152
|
-
App.instance.
|
|
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
|
-
*
|
|
562
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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,
|
|
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).
|
|
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.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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:
|
|
135
|
-
path:
|
|
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
|
-
|
|
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 ⇒
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
222
|
-
// buckets need
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
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.
|
|
9
|
-
* (per-resource
|
|
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.
|
|
7
|
-
* (per-resource
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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.
|
|
74
|
-
const tier = backupConstruct.
|
|
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": "
|
|
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": "^
|
|
82
|
-
"@fjall/util": "^
|
|
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
|
},
|