@fjall/components-infrastructure 30.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.
@@ -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
  })
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "30.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": "^30.0.0",
82
- "@fjall/util": "^30.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
  },