@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.
- 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/resources/aws/utilities/customResource.d.ts +12 -3
- package/dist/lib/resources/aws/utilities/customResource.js +3 -0
- package/package.json +3 -3
|
@@ -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
|
})
|
|
@@ -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();
|
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
|
},
|