@fjall/components-infrastructure 2.23.0 → 2.24.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/identityCenter.d.ts +20 -8
- package/dist/lib/config/aws/identityCenter.js +194 -85
- package/dist/lib/config/aws/identityCentreConfig.d.ts +147 -0
- package/dist/lib/config/aws/identityCentreConfig.js +338 -0
- package/dist/lib/config/aws/index.d.ts +1 -1
- package/dist/lib/config/aws/index.js +1 -1
- package/dist/lib/config/aws/oidcConnector.d.ts +9 -0
- package/dist/lib/config/aws/oidcConnector.js +17 -1
- package/dist/lib/lambda-assets/identity-store-user/asset/index.js +198 -0
- package/dist/lib/lambda-assets/identity-store-user/asset/package.json +4 -0
- package/dist/lib/patterns/aws/account.d.ts +11 -0
- package/dist/lib/patterns/aws/account.js +6 -1
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +3 -2
- package/dist/lib/patterns/aws/organisation.d.ts +9 -5
- package/dist/lib/patterns/aws/organisation.js +24 -27
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +1 -1
- package/dist/lib/resources/aws/iam/identityCenter/index.d.ts +1 -0
- package/dist/lib/resources/aws/iam/identityCenter/index.js +1 -0
- package/dist/lib/resources/aws/iam/identityCenter/permissionSet.d.ts +4 -0
- package/dist/lib/resources/aws/iam/identityCenter/permissionSet.js +12 -0
- package/dist/lib/resources/aws/iam/identityCenter/user.d.ts +35 -0
- package/dist/lib/resources/aws/iam/identityCenter/user.js +85 -0
- package/package.json +6 -5
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { IDENTITY_CENTRE_SOURCES, VALIDATION_PATTERNS } from "@fjall/generator";
|
|
2
|
+
import { stripAndCamelCase } from "../../utils/stripAndCamelCase.js";
|
|
3
|
+
/**
|
|
4
|
+
* Customer-facing `identityCentre` configuration for the Organisation
|
|
5
|
+
* construct. The discriminated union on `source` mirrors AWS ground truth:
|
|
6
|
+
* the identity source of an Identity Center instance is neither readable nor
|
|
7
|
+
* settable via any supported API, so the mode is user-declared and
|
|
8
|
+
* authoritative. Design: aiDocs/designs/2026-07-04-org-user-management-design.md
|
|
9
|
+
*
|
|
10
|
+
* The source vocabulary is canonical in @fjall/generator
|
|
11
|
+
* (organisationSchemas.ts) — re-exported here so existing consumers keep
|
|
12
|
+
* their import path.
|
|
13
|
+
*/
|
|
14
|
+
export { IDENTITY_CENTRE_SOURCES } from "@fjall/generator";
|
|
15
|
+
export const DEFAULT_PERMISSION_SETS = {
|
|
16
|
+
AdministratorAccess: {
|
|
17
|
+
managedPolicyArn: "arn:aws:iam::aws:policy/AdministratorAccess",
|
|
18
|
+
description: "Permission set for associated AdministratorAccess policy"
|
|
19
|
+
},
|
|
20
|
+
ReadOnlyAccess: {
|
|
21
|
+
managedPolicyArn: "arn:aws:iam::aws:policy/ReadOnlyAccess",
|
|
22
|
+
description: "Permission set for associated ReadOnlyAccess policy"
|
|
23
|
+
},
|
|
24
|
+
Billing: {
|
|
25
|
+
managedPolicyArn: "arn:aws:iam::aws:policy/AWSBillingReadOnlyAccess",
|
|
26
|
+
description: "Permission set for associated Billing policy"
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Defaults (unless suppressed) with same-named config entries merged over
|
|
31
|
+
* them, plus the remaining custom sets. Consumed by both the validator and
|
|
32
|
+
* the IdentityCenter construct — the two MUST agree on this derivation.
|
|
33
|
+
*/
|
|
34
|
+
export function effectivePermissionSets(config) {
|
|
35
|
+
const result = {};
|
|
36
|
+
if (config?.defaultPermissionSets !== false) {
|
|
37
|
+
for (const [name, def] of Object.entries(DEFAULT_PERMISSION_SETS)) {
|
|
38
|
+
result[name] = {
|
|
39
|
+
managedPolicies: [def.managedPolicyArn],
|
|
40
|
+
description: def.description,
|
|
41
|
+
isDefault: true
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const [name, spec] of Object.entries(config?.permissionSets ?? {})) {
|
|
46
|
+
const base = result[name];
|
|
47
|
+
result[name] =
|
|
48
|
+
base !== undefined
|
|
49
|
+
? { ...base, ...spec, isDefault: true }
|
|
50
|
+
: { ...spec, isDefault: false };
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Canonical names of the accounts a permission set is assigned to, in
|
|
56
|
+
* declaration order: explicit `accounts` entries resolve case-insensitively
|
|
57
|
+
* to the workload ACCOUNTS names (unknown entries drop out — Rule 4 reports
|
|
58
|
+
* them), otherwise every workload account; the management opt-in appends the
|
|
59
|
+
* "management" pseudo-account. Duplicate entries are preserved so the
|
|
60
|
+
* validator's duplicate-declaration detection still fires. Consumed by both
|
|
61
|
+
* the validator (Rule 8 collision detection) and the IdentityCenter
|
|
62
|
+
* construct — the two MUST agree on this derivation.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveTargetAccountNames(spec, workloadAccountNames) {
|
|
65
|
+
let targets;
|
|
66
|
+
if (Array.isArray(spec.accounts)) {
|
|
67
|
+
const canonicalByLower = new Map(workloadAccountNames.map((name) => [name.toLowerCase(), name]));
|
|
68
|
+
targets = spec.accounts
|
|
69
|
+
.map((accountName) => canonicalByLower.get(accountName.toLowerCase()))
|
|
70
|
+
.filter((resolved) => resolved !== undefined);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
targets = [...workloadAccountNames];
|
|
74
|
+
}
|
|
75
|
+
if (spec.includeManagementAccount === true) {
|
|
76
|
+
targets.push("management");
|
|
77
|
+
}
|
|
78
|
+
return targets;
|
|
79
|
+
}
|
|
80
|
+
/** Groups bound to a set: explicit list, else the implicit same-named group. */
|
|
81
|
+
export function boundGroupsForSet(name, spec) {
|
|
82
|
+
return spec.groups !== undefined && spec.groups.length > 0
|
|
83
|
+
? spec.groups
|
|
84
|
+
: [name];
|
|
85
|
+
}
|
|
86
|
+
export function implicitGroupNames(sets) {
|
|
87
|
+
return Object.entries(sets)
|
|
88
|
+
.filter(([, spec]) => spec.groups === undefined || spec.groups.length === 0)
|
|
89
|
+
.map(([name]) => name);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Assignment construct-id derivation shared by the validator (collision
|
|
93
|
+
* detection) and the IdentityCenter construct — the two MUST agree. The
|
|
94
|
+
* implicit same-named group omits the group segment to keep the legacy id
|
|
95
|
+
* shape, so existing deployments do not replace their assignments.
|
|
96
|
+
*/
|
|
97
|
+
export function assignmentConstructId(accountName, setName, groupName) {
|
|
98
|
+
return groupName === setName
|
|
99
|
+
? `${stripAndCamelCase(accountName)}${setName}Assignment`
|
|
100
|
+
: `${stripAndCamelCase(accountName)}${setName}${groupName}Assignment`;
|
|
101
|
+
}
|
|
102
|
+
/** Every group the config will create: implicit per-set groups + custom groups. */
|
|
103
|
+
export function allGroupNames(config) {
|
|
104
|
+
const names = new Set(implicitGroupNames(effectivePermissionSets(config)));
|
|
105
|
+
for (const name of Object.keys(config?.groups ?? {})) {
|
|
106
|
+
names.add(name);
|
|
107
|
+
}
|
|
108
|
+
return [...names];
|
|
109
|
+
}
|
|
110
|
+
const SESSION_DURATION_PATTERN = /^PT(?:(\d+)H)?(?:(\d+)M)?$/;
|
|
111
|
+
// AWS Identity Center permission-set session bounds (1–12 hours).
|
|
112
|
+
const SESSION_DURATION_MIN_MINUTES = 60;
|
|
113
|
+
const SESSION_DURATION_MAX_MINUTES = 720;
|
|
114
|
+
// Intersection of the CFN PermissionSet Name pattern ([\w+=,.@-]{1,32}) and
|
|
115
|
+
// the CfnOutput export-name charset — underscore is excluded because group
|
|
116
|
+
// export names (`${name}GroupId`) forbid it.
|
|
117
|
+
const RESOURCE_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]*$/;
|
|
118
|
+
const PERMISSION_SET_NAME_MAX_LENGTH = 32;
|
|
119
|
+
const GROUP_NAME_MAX_LENGTH = 64;
|
|
120
|
+
const NAME_SHAPE_REQUIREMENT = "must start with a letter and contain only letters, digits and hyphens";
|
|
121
|
+
function isRecord(value) {
|
|
122
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
123
|
+
}
|
|
124
|
+
function isBlank(value) {
|
|
125
|
+
return value === undefined || value.trim() === "";
|
|
126
|
+
}
|
|
127
|
+
function groupsHoldingUser(config, email) {
|
|
128
|
+
const lower = email.toLowerCase();
|
|
129
|
+
return Object.entries(config.memberships ?? {})
|
|
130
|
+
.filter(([, emails]) => emails.some((e) => e.toLowerCase() === lower))
|
|
131
|
+
.map(([groupName]) => groupName);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Synth-time validation — every rule fires before any CloudFormation call so
|
|
135
|
+
* a typo never surfaces as a mid-deploy custom-resource failure. Collects
|
|
136
|
+
* every violation and throws once; warnings/infos are returned for the
|
|
137
|
+
* construct to surface via CDK Annotations.
|
|
138
|
+
*/
|
|
139
|
+
export function validateIdentityCentreConfig(config, context) {
|
|
140
|
+
const errors = [];
|
|
141
|
+
const warnings = [];
|
|
142
|
+
const infos = [];
|
|
143
|
+
// Rule 7 — the union enforced for plain-JS callers.
|
|
144
|
+
const sources = IDENTITY_CENTRE_SOURCES;
|
|
145
|
+
if (!sources.includes(config.source)) {
|
|
146
|
+
errors.push(`identityCentre.source must be one of ${IDENTITY_CENTRE_SOURCES.join(" | ")}, got "${String(config.source)}"`);
|
|
147
|
+
}
|
|
148
|
+
if ("users" in config &&
|
|
149
|
+
config.users !== undefined &&
|
|
150
|
+
!isRecord(config.users)) {
|
|
151
|
+
errors.push("identityCentre.users must be an object mapping email addresses to user specs");
|
|
152
|
+
}
|
|
153
|
+
const users = "users" in config && isRecord(config.users) ? config.users : {};
|
|
154
|
+
if (config.source === "external" && Object.keys(users).length > 0) {
|
|
155
|
+
errors.push('identityCentre.users cannot be declared when source is "external" — the IdP (Google/Okta/Entra) owns user objects there. Manage group memberships only, or switch to "external-manual" for the SAML-without-SCIM pre-provisioning pattern.');
|
|
156
|
+
}
|
|
157
|
+
if (config.source === "fjall-managed" && !("users" in config)) {
|
|
158
|
+
errors.push('identityCentre.users is required when source is "fjall-managed" (an empty object is valid).');
|
|
159
|
+
}
|
|
160
|
+
// Rule 5 — user entries: email keys and name fields. Identity-store emails
|
|
161
|
+
// are case-insensitive, so case-variant keys are duplicates.
|
|
162
|
+
const userKeyByLower = new Map();
|
|
163
|
+
for (const email of Object.keys(users)) {
|
|
164
|
+
const existing = userKeyByLower.get(email.toLowerCase());
|
|
165
|
+
if (existing !== undefined) {
|
|
166
|
+
errors.push(`users declares "${existing}" and "${email}", which differ only by letter case — identity-store emails are case-insensitive; keep one`);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
userKeyByLower.set(email.toLowerCase(), email);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const [email, spec] of Object.entries(users)) {
|
|
173
|
+
if (!VALIDATION_PATTERNS.EMAIL.test(email)) {
|
|
174
|
+
errors.push(`users key "${email}" is not a valid email address`);
|
|
175
|
+
}
|
|
176
|
+
if (isBlank(spec.givenName) || isBlank(spec.familyName)) {
|
|
177
|
+
errors.push(`users["${email}"] must declare non-empty givenName and familyName`);
|
|
178
|
+
}
|
|
179
|
+
if (spec.displayName !== undefined && spec.displayName.trim() === "") {
|
|
180
|
+
errors.push(`users["${email}"].displayName must not be blank — omit it to default to "givenName familyName"`);
|
|
181
|
+
}
|
|
182
|
+
if (spec.expires !== undefined) {
|
|
183
|
+
const parsed = Date.parse(spec.expires);
|
|
184
|
+
if (Number.isNaN(parsed)) {
|
|
185
|
+
errors.push(`users["${email}"].expires "${spec.expires}" is not a parseable ISO date (expected e.g. "2026-09-30")`);
|
|
186
|
+
}
|
|
187
|
+
else if (parsed < (context.now ?? new Date()).getTime()) {
|
|
188
|
+
// Rule 6 — error, not warning: the exposure is live until detached.
|
|
189
|
+
const holding = groupsHoldingUser(config, email);
|
|
190
|
+
if (holding.length > 0) {
|
|
191
|
+
errors.push(`users["${email}"] expired on ${spec.expires} but still holds memberships in: ${holding.join(", ")}. Remove the memberships (fjall user remove ${email}) or extend expires.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const sets = effectivePermissionSets(config);
|
|
197
|
+
const groupUniverse = new Set(allGroupNames(config));
|
|
198
|
+
const workloadNames = new Set(context.workloadAccountNames.map((name) => name.toLowerCase()));
|
|
199
|
+
for (const [name, spec] of Object.entries(sets)) {
|
|
200
|
+
if (!RESOURCE_NAME_PATTERN.test(name) ||
|
|
201
|
+
name.length > PERMISSION_SET_NAME_MAX_LENGTH) {
|
|
202
|
+
errors.push(`permissionSets key "${name}" ${NAME_SHAPE_REQUIREMENT}, at most ${PERMISSION_SET_NAME_MAX_LENGTH} characters (CloudFormation permission-set name and export-name constraints)`);
|
|
203
|
+
}
|
|
204
|
+
if ((spec.managedPolicies === undefined ||
|
|
205
|
+
spec.managedPolicies.length === 0) &&
|
|
206
|
+
spec.inlinePolicy === undefined &&
|
|
207
|
+
(spec.customerManagedPolicies === undefined ||
|
|
208
|
+
spec.customerManagedPolicies.length === 0)) {
|
|
209
|
+
errors.push(`permissionSets["${name}"] declares no managedPolicies, inlinePolicy, or customerManagedPolicies — it would grant nothing`);
|
|
210
|
+
}
|
|
211
|
+
// Rule 3 — explicit group bindings must resolve to a group that will exist.
|
|
212
|
+
for (const groupName of spec.groups ?? []) {
|
|
213
|
+
if (!groupUniverse.has(groupName)) {
|
|
214
|
+
errors.push(`permissionSets["${name}"].groups references "${groupName}", which is neither a declared group nor another set's implicit group. Declare it under identityCentre.groups.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Rule 4 — account scoping against the workload ACCOUNTS names.
|
|
218
|
+
if (Array.isArray(spec.accounts)) {
|
|
219
|
+
for (const accountName of spec.accounts) {
|
|
220
|
+
if (!workloadNames.has(accountName.toLowerCase())) {
|
|
221
|
+
errors.push(`permissionSets["${name}"].accounts references unknown account "${accountName}". Workload accounts: ${context.workloadAccountNames.join(", ")}. The management account is reachable only via includeManagementAccount: true.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (spec.accounts.length === 0 &&
|
|
225
|
+
spec.includeManagementAccount !== true) {
|
|
226
|
+
warnings.push({
|
|
227
|
+
code: "unassigned-permission-set",
|
|
228
|
+
message: `permissionSets["${name}"] has accounts: [] and no management opt-in — it is assigned nowhere`
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (spec.sessionDuration !== undefined) {
|
|
233
|
+
const match = SESSION_DURATION_PATTERN.exec(spec.sessionDuration);
|
|
234
|
+
if (match === null ||
|
|
235
|
+
(match[1] === undefined && match[2] === undefined)) {
|
|
236
|
+
errors.push(`permissionSets["${name}"].sessionDuration "${spec.sessionDuration}" is not an ISO-8601 duration like "PT12H"`);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
const totalMinutes = Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0);
|
|
240
|
+
if (totalMinutes < SESSION_DURATION_MIN_MINUTES ||
|
|
241
|
+
totalMinutes > SESSION_DURATION_MAX_MINUTES) {
|
|
242
|
+
errors.push(`permissionSets["${name}"].sessionDuration "${spec.sessionDuration}" is outside the AWS Identity Center range PT1H to PT12H`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Rule 8 — construct-id collisions from name concatenation (a set
|
|
248
|
+
// "DataEngOps" and a set "DataEng" bound to group "Ops" produce the same
|
|
249
|
+
// assignment id on one account). Shares the construct's target derivation.
|
|
250
|
+
const assignmentIdOwners = new Map();
|
|
251
|
+
for (const [name, spec] of Object.entries(sets)) {
|
|
252
|
+
const targets = resolveTargetAccountNames(spec, context.workloadAccountNames);
|
|
253
|
+
for (const accountName of targets) {
|
|
254
|
+
for (const groupName of boundGroupsForSet(name, spec)) {
|
|
255
|
+
const id = assignmentConstructId(accountName, name, groupName);
|
|
256
|
+
const claim = `permissionSets["${name}"] on account "${accountName}" via group "${groupName}"`;
|
|
257
|
+
const owner = assignmentIdOwners.get(id);
|
|
258
|
+
if (owner === undefined) {
|
|
259
|
+
assignmentIdOwners.set(id, claim);
|
|
260
|
+
}
|
|
261
|
+
else if (owner === claim) {
|
|
262
|
+
errors.push(`${claim} is declared more than once — remove the duplicate accounts or groups entry`);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
errors.push(`assignment construct ids collide on "${id}": ${owner} vs ${claim}. Rename one of the clashing sets or groups.`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const [groupName, emails] of Object.entries(config.memberships ?? {})) {
|
|
271
|
+
// Rule 1 — membership group names must resolve.
|
|
272
|
+
if (!groupUniverse.has(groupName)) {
|
|
273
|
+
errors.push(`memberships references undeclared group "${groupName}". Groups that will exist: ${[...groupUniverse].join(", ")}`);
|
|
274
|
+
}
|
|
275
|
+
const seenByLower = new Map();
|
|
276
|
+
for (const email of emails) {
|
|
277
|
+
if (!VALIDATION_PATTERNS.EMAIL.test(email)) {
|
|
278
|
+
errors.push(`memberships["${groupName}"] entry "${email}" is not a valid email address`);
|
|
279
|
+
}
|
|
280
|
+
const first = seenByLower.get(email.toLowerCase());
|
|
281
|
+
if (first !== undefined) {
|
|
282
|
+
errors.push(first === email
|
|
283
|
+
? `memberships["${groupName}"] lists "${email}" twice`
|
|
284
|
+
: `memberships["${groupName}"] lists "${first}" and "${email}", which differ only by letter case — identity-store emails are case-insensitive`);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
seenByLower.set(email.toLowerCase(), email);
|
|
288
|
+
}
|
|
289
|
+
// Rule 2 — fjall-managed memberships must reference declared users.
|
|
290
|
+
if (config.source === "fjall-managed" && users[email] === undefined) {
|
|
291
|
+
const caseVariant = userKeyByLower.get(email.toLowerCase());
|
|
292
|
+
errors.push(caseVariant !== undefined
|
|
293
|
+
? `memberships["${groupName}"] references "${email}", which differs only by letter case from declared user "${caseVariant}" — use identical casing`
|
|
294
|
+
: `memberships["${groupName}"] references "${email}", which is not declared under users — in fjall-managed mode every member must be a users entry`);
|
|
295
|
+
}
|
|
296
|
+
if (config.source === "external-manual" && users[email] === undefined) {
|
|
297
|
+
const caseVariant = userKeyByLower.get(email.toLowerCase());
|
|
298
|
+
if (caseVariant !== undefined) {
|
|
299
|
+
errors.push(`memberships["${groupName}"] references "${email}", which differs only by letter case from pre-provisioned user "${caseVariant}" — use identical casing`);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
infos.push({
|
|
303
|
+
code: "external-lookup-fallback",
|
|
304
|
+
message: `memberships["${groupName}"] member "${email}" is not pre-provisioned under users — it will resolve by identity-store lookup at deploy time`
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const boundGroups = new Set();
|
|
311
|
+
for (const [name, spec] of Object.entries(sets)) {
|
|
312
|
+
for (const groupName of boundGroupsForSet(name, spec)) {
|
|
313
|
+
boundGroups.add(groupName);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const groupName of Object.keys(config.groups ?? {})) {
|
|
317
|
+
if (!RESOURCE_NAME_PATTERN.test(groupName) ||
|
|
318
|
+
groupName.length > GROUP_NAME_MAX_LENGTH) {
|
|
319
|
+
errors.push(`groups key "${groupName}" ${NAME_SHAPE_REQUIREMENT}, at most ${GROUP_NAME_MAX_LENGTH} characters (group export-name constraints)`);
|
|
320
|
+
}
|
|
321
|
+
const hasMembers = (config.memberships?.[groupName]?.length ?? 0) > 0;
|
|
322
|
+
if (!boundGroups.has(groupName) && !hasMembers) {
|
|
323
|
+
warnings.push({
|
|
324
|
+
code: "decorative-group",
|
|
325
|
+
message: `groups["${groupName}"] is bound to no permission set and has no members — it grants nothing. Bind it via permissionSets[].groups or add memberships.`
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (config.resolutionVersion !== undefined &&
|
|
330
|
+
(!Number.isInteger(config.resolutionVersion) ||
|
|
331
|
+
config.resolutionVersion < 1)) {
|
|
332
|
+
errors.push(`identityCentre.resolutionVersion must be a positive integer, got ${String(config.resolutionVersion)}`);
|
|
333
|
+
}
|
|
334
|
+
if (errors.length > 0) {
|
|
335
|
+
throw new Error(`identityCentre configuration invalid:\n- ${errors.join("\n- ")}`);
|
|
336
|
+
}
|
|
337
|
+
return { warnings, infos };
|
|
338
|
+
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import { type GovernancePreset } from "@fjall/generator";
|
|
1
2
|
import { Construct } from "constructs";
|
|
2
3
|
export interface OidcConnectorProps {
|
|
3
4
|
fjallOrgId: string;
|
|
5
|
+
/**
|
|
6
|
+
* Governance tier selecting the deploy-role permissions-boundary CONTENT.
|
|
7
|
+
* When set, a `FjallDeployBoundary${fjallOrgId}` customer-managed policy is
|
|
8
|
+
* created and attached to the deploy role as its permissions boundary — the
|
|
9
|
+
* hard cap that survives CDK's assume-role hand-off (unlike a session policy).
|
|
10
|
+
* Absent ⇒ no boundary (today's behaviour — backward-compatible).
|
|
11
|
+
*/
|
|
12
|
+
securityTier?: GovernancePreset;
|
|
4
13
|
}
|
|
5
14
|
export declare class OidcConnector extends Construct {
|
|
6
15
|
readonly deployRoleArn: string;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { SECURITY_TIER_TO_BOUNDARY, deployBoundaryName } from "@fjall/generator";
|
|
1
2
|
import { CfnOutput, Duration } from "aws-cdk-lib";
|
|
2
3
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
4
|
import { Runtime } from "aws-cdk-lib/aws-lambda";
|
|
4
5
|
import { Construct } from "constructs";
|
|
6
|
+
import { ManagedPolicy } from "../../resources/aws/iam/managedPolicy.js";
|
|
5
7
|
import { Role } from "../../resources/aws/iam/role.js";
|
|
6
8
|
import { CustomResource } from "../../resources/aws/utilities/customResource.js";
|
|
7
9
|
/**
|
|
@@ -79,6 +81,17 @@ export class OidcConnector extends Construct {
|
|
|
79
81
|
const providerArn = providerResource.resource
|
|
80
82
|
.getAtt("ProviderArn")
|
|
81
83
|
.toString();
|
|
84
|
+
// Permissions boundary (hard cap). Created here — in the account-connection
|
|
85
|
+
// stack that runs BEFORE any bootstrap/deploy — because
|
|
86
|
+
// `cdk bootstrap --custom-permissions-boundary <name>` LOOKS THE POLICY UP
|
|
87
|
+
// by name; it never creates it. So the policy must exist by name first.
|
|
88
|
+
const deployBoundary = props.securityTier !== undefined
|
|
89
|
+
? new ManagedPolicy(this, "DeployBoundary", {
|
|
90
|
+
managedPolicyName: deployBoundaryName(props.fjallOrgId),
|
|
91
|
+
description: "Fjall deploy-role permissions boundary — hard cap on the OIDC deploy role and the CDK cfn-exec-role",
|
|
92
|
+
statements: SECURITY_TIER_TO_BOUNDARY[props.securityTier].map((statement) => iam.PolicyStatement.fromJson(statement))
|
|
93
|
+
})
|
|
94
|
+
: undefined;
|
|
82
95
|
const deployRole = new Role(this, "DeployRole", {
|
|
83
96
|
roleName: `FjallDeploy${props.fjallOrgId}`,
|
|
84
97
|
path: "/fjall/",
|
|
@@ -91,7 +104,10 @@ export class OidcConnector extends Construct {
|
|
|
91
104
|
}, "sts:AssumeRoleWithWebIdentity"),
|
|
92
105
|
managedPolicies: [
|
|
93
106
|
iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")
|
|
94
|
-
]
|
|
107
|
+
],
|
|
108
|
+
...(deployBoundary !== undefined && {
|
|
109
|
+
permissionsBoundary: deployBoundary
|
|
110
|
+
})
|
|
95
111
|
});
|
|
96
112
|
this.deployRoleArn = deployRole.roleArn;
|
|
97
113
|
new CfnOutput(this, "OidcDeployRoleArn", {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom::FjallIdentityStoreUser handler (CDK Provider framework onEvent).
|
|
3
|
+
*
|
|
4
|
+
* Authored directly under asset/ — unlike cert-generator there is no build
|
|
5
|
+
* step because the only dependency, @aws-sdk/client-identitystore, ships in
|
|
6
|
+
* the Lambda Node 22 runtime. asset/package.json pins CJS resolution.
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle:
|
|
9
|
+
* - Create: CreateUser(userName = email). On ConflictException the user
|
|
10
|
+
* already exists (console, SCIM, legacy CLI) → GetUserId by emails.value
|
|
11
|
+
* and ADOPT it: converge declared attributes, mark the physical id
|
|
12
|
+
* `adopted#<UserId>`. A ResourceNotFoundException immediately after the
|
|
13
|
+
* conflict means the exception was thrown for a concurrent create that
|
|
14
|
+
* has since vanished — retry the create exactly once.
|
|
15
|
+
* - Update: email (= userName) change returns a NEW physical id, which
|
|
16
|
+
* CloudFormation treats as replacement (Delete arrives for the old id) —
|
|
17
|
+
* unless the new email resolves to the SAME user, in which case the prior
|
|
18
|
+
* physical id is preserved so the cleanup Delete never fires.
|
|
19
|
+
* Otherwise attributes converge in place via UpdateUser.
|
|
20
|
+
* - Delete: skipped for adopted users — Fjall never destroys a user it did
|
|
21
|
+
* not create. ResourceNotFoundException is tolerated (already gone).
|
|
22
|
+
* Rollback after a create can strand a just-created user as adopted on the
|
|
23
|
+
* re-adopt path; deliberate leak-safe bias — never delete when uncertain.
|
|
24
|
+
*
|
|
25
|
+
* The adopted flag lives in the PhysicalResourceId (`adopted#`/`created#`
|
|
26
|
+
* prefix) because Delete events carry the physical id but not prior Data.
|
|
27
|
+
* Data.UserId always carries the bare UserId for membership wiring.
|
|
28
|
+
*
|
|
29
|
+
* Logging discipline: request type + logical id only — no attribute values.
|
|
30
|
+
*/
|
|
31
|
+
"use strict";
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
IdentitystoreClient,
|
|
35
|
+
CreateUserCommand,
|
|
36
|
+
UpdateUserCommand,
|
|
37
|
+
DeleteUserCommand,
|
|
38
|
+
GetUserIdCommand
|
|
39
|
+
} = require("@aws-sdk/client-identitystore");
|
|
40
|
+
|
|
41
|
+
const ADOPTED_PREFIX = "adopted#";
|
|
42
|
+
const CREATED_PREFIX = "created#";
|
|
43
|
+
|
|
44
|
+
const client = new IdentitystoreClient({});
|
|
45
|
+
|
|
46
|
+
function parsePhysicalId(physicalId) {
|
|
47
|
+
if (physicalId.startsWith(ADOPTED_PREFIX)) {
|
|
48
|
+
return { userId: physicalId.slice(ADOPTED_PREFIX.length), adopted: true };
|
|
49
|
+
}
|
|
50
|
+
if (physicalId.startsWith(CREATED_PREFIX)) {
|
|
51
|
+
return { userId: physicalId.slice(CREATED_PREFIX.length), adopted: false };
|
|
52
|
+
}
|
|
53
|
+
// Unknown shape: fail safe — treat as adopted so Delete never fires.
|
|
54
|
+
return { userId: physicalId, adopted: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function getUserIdByEmail(identityStoreId, email) {
|
|
58
|
+
const response = await client.send(
|
|
59
|
+
new GetUserIdCommand({
|
|
60
|
+
IdentityStoreId: identityStoreId,
|
|
61
|
+
AlternateIdentifier: {
|
|
62
|
+
UniqueAttribute: {
|
|
63
|
+
AttributePath: "emails.value",
|
|
64
|
+
AttributeValue: email
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
return response.UserId;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function convergeAttributes(props, userId) {
|
|
73
|
+
await client.send(
|
|
74
|
+
new UpdateUserCommand({
|
|
75
|
+
IdentityStoreId: props.IdentityStoreId,
|
|
76
|
+
UserId: userId,
|
|
77
|
+
Operations: [
|
|
78
|
+
{ AttributePath: "displayName", AttributeValue: props.DisplayName },
|
|
79
|
+
{ AttributePath: "name.givenName", AttributeValue: props.GivenName },
|
|
80
|
+
{ AttributePath: "name.familyName", AttributeValue: props.FamilyName }
|
|
81
|
+
]
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function result(prefix, userId) {
|
|
87
|
+
return {
|
|
88
|
+
PhysicalResourceId: prefix + userId,
|
|
89
|
+
Data: {
|
|
90
|
+
UserId: userId,
|
|
91
|
+
Adopted: prefix === ADOPTED_PREFIX ? "true" : "false"
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function createOrAdopt(props, attempt) {
|
|
97
|
+
try {
|
|
98
|
+
const created = await client.send(
|
|
99
|
+
new CreateUserCommand({
|
|
100
|
+
IdentityStoreId: props.IdentityStoreId,
|
|
101
|
+
UserName: props.Email,
|
|
102
|
+
DisplayName: props.DisplayName,
|
|
103
|
+
Name: { GivenName: props.GivenName, FamilyName: props.FamilyName },
|
|
104
|
+
Emails: [{ Value: props.Email, Primary: true, Type: "work" }]
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
return result(CREATED_PREFIX, created.UserId);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err.name !== "ConflictException") {
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
let userId;
|
|
113
|
+
try {
|
|
114
|
+
userId = await getUserIdByEmail(props.IdentityStoreId, props.Email);
|
|
115
|
+
} catch (lookupErr) {
|
|
116
|
+
if (lookupErr.name === "ResourceNotFoundException") {
|
|
117
|
+
if (attempt === 0) {
|
|
118
|
+
return createOrAdopt(props, 1);
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Cannot adopt existing user ${props.Email}: CreateUser reports a conflict but no user resolves by emails.value. A user whose userName is ${props.Email} with a different email attribute likely exists — align that user's email in the identity store (or remove the user), then redeploy.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
throw lookupErr;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await convergeAttributes(props, userId);
|
|
128
|
+
} catch (updateErr) {
|
|
129
|
+
if (updateErr.name === "ConflictException") {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Cannot adopt existing user ${props.Email}: converging its attributes hit a uniqueness conflict (${updateErr.message}). Resolve the clashing user in the identity store, then redeploy.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
throw updateErr;
|
|
135
|
+
}
|
|
136
|
+
return result(ADOPTED_PREFIX, userId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function onUpdate(event) {
|
|
141
|
+
const props = event.ResourceProperties;
|
|
142
|
+
const oldProps = event.OldResourceProperties || {};
|
|
143
|
+
const prior = parsePhysicalId(event.PhysicalResourceId);
|
|
144
|
+
if (props.Email !== oldProps.Email) {
|
|
145
|
+
const next = await createOrAdopt(props, 0);
|
|
146
|
+
const nextParsed = parsePhysicalId(next.PhysicalResourceId);
|
|
147
|
+
if (nextParsed.userId === prior.userId) {
|
|
148
|
+
// Same underlying user (email changed out-of-band): a new physical id
|
|
149
|
+
// would make CloudFormation cleanup-Delete the old id — destroying the
|
|
150
|
+
// live user. Keep the prior id and flag.
|
|
151
|
+
return result(
|
|
152
|
+
prior.adopted ? ADOPTED_PREFIX : CREATED_PREFIX,
|
|
153
|
+
prior.userId
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
await convergeAttributes(props, prior.userId);
|
|
159
|
+
return result(prior.adopted ? ADOPTED_PREFIX : CREATED_PREFIX, prior.userId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function onDelete(event) {
|
|
163
|
+
const { userId, adopted } = parsePhysicalId(event.PhysicalResourceId);
|
|
164
|
+
if (adopted) {
|
|
165
|
+
console.log(`Skipping delete of adopted user ${userId}`);
|
|
166
|
+
return { PhysicalResourceId: event.PhysicalResourceId };
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
await client.send(
|
|
170
|
+
new DeleteUserCommand({
|
|
171
|
+
IdentityStoreId: event.ResourceProperties.IdentityStoreId,
|
|
172
|
+
UserId: userId
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (err.name !== "ResourceNotFoundException") {
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
console.log(`User ${userId} already deleted`);
|
|
180
|
+
}
|
|
181
|
+
return { PhysicalResourceId: event.PhysicalResourceId };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
exports.handler = async (event) => {
|
|
185
|
+
console.log(
|
|
186
|
+
`FjallIdentityStoreUser ${event.RequestType} ${event.LogicalResourceId}`
|
|
187
|
+
);
|
|
188
|
+
switch (event.RequestType) {
|
|
189
|
+
case "Create":
|
|
190
|
+
return createOrAdopt(event.ResourceProperties, 0);
|
|
191
|
+
case "Update":
|
|
192
|
+
return onUpdate(event);
|
|
193
|
+
case "Delete":
|
|
194
|
+
return onDelete(event);
|
|
195
|
+
default:
|
|
196
|
+
throw new Error(`Unsupported request type: ${event.RequestType}`);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
@@ -11,9 +11,20 @@ import type { SecurityHubHubProps } from "../../config/aws/securityHubHub.js";
|
|
|
11
11
|
import type { ConfigRecorderProps } from "../../config/aws/configRecorder.js";
|
|
12
12
|
import type { ConfigRulePresetProps } from "../../config/aws/configRulePreset.js";
|
|
13
13
|
import type { OrganisationType } from "./interfaces/organisation.js";
|
|
14
|
+
import { type GovernancePreset } from "@fjall/generator";
|
|
14
15
|
export interface AccountProps extends StackProps {
|
|
15
16
|
accountId?: string;
|
|
16
17
|
region?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Governance tier selecting the deploy-role permissions-boundary CONTENT
|
|
20
|
+
* (Phase E, seam 1). Forwarded to the account's `OidcConnector`, which
|
|
21
|
+
* creates `FjallDeployBoundary${fjallOrgId}` from
|
|
22
|
+
* `SECURITY_TIER_TO_BOUNDARY[securityTier]`. Undefined ⇒ no boundary
|
|
23
|
+
* (backward-compatible). Sourced from the generated `account/infrastructure.ts`
|
|
24
|
+
* (the `--security` dial baked in at generation time — the tier is not
|
|
25
|
+
* persisted anywhere the deploy flow could read it).
|
|
26
|
+
*/
|
|
27
|
+
securityTier?: GovernancePreset;
|
|
17
28
|
}
|
|
18
29
|
export declare class Account extends Stack {
|
|
19
30
|
readonly organisationType: OrganisationType;
|
|
@@ -65,7 +65,12 @@ export class Account extends Stack {
|
|
|
65
65
|
fjallOrgId &&
|
|
66
66
|
!oidcAlreadyConfigured &&
|
|
67
67
|
!accountGlobalsConfigured) {
|
|
68
|
-
new OidcConnector(this, "OidcConnector", {
|
|
68
|
+
new OidcConnector(this, "OidcConnector", {
|
|
69
|
+
fjallOrgId,
|
|
70
|
+
...(props.securityTier !== undefined && {
|
|
71
|
+
securityTier: props.securityTier
|
|
72
|
+
})
|
|
73
|
+
});
|
|
69
74
|
}
|
|
70
75
|
if (!accountGlobalsConfigured) {
|
|
71
76
|
new AccountMonitoringRole(this, "MonitoringRole", fjallOrgId ? { fjallOrgId } : undefined);
|
|
@@ -630,8 +630,9 @@ export interface EcsServiceConfig {
|
|
|
630
630
|
* (absolute or relative), an optional build `context` for monorepo
|
|
631
631
|
* layouts, and an optional multi-stage `target`.
|
|
632
632
|
*
|
|
633
|
-
*
|
|
634
|
-
* Mutually exclusive with `image`
|
|
633
|
+
* Content-hash image tag when `target` is set:
|
|
634
|
+
* `<service>-<target>-sha-<12 hex>`. Mutually exclusive with `image`
|
|
635
|
+
* (pre-built URI).
|
|
635
636
|
*/
|
|
636
637
|
docker?: DockerBuild;
|
|
637
638
|
/**
|