@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.
@@ -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,5 +1,5 @@
1
1
  export * from "./identityCenter.js";
2
- export * from "./identityCenterMembership.js";
2
+ export * from "./identityCentreConfig.js";
3
3
  export * from "./ipam.js";
4
4
  export * from "./ecrDefaultImage.js";
5
5
  export * from "./oidcConnector.js";
@@ -1,5 +1,5 @@
1
1
  export * from "./identityCenter.js";
2
- export * from "./identityCenterMembership.js";
2
+ export * from "./identityCentreConfig.js";
3
3
  export * from "./ipam.js";
4
4
  export * from "./ecrDefaultImage.js";
5
5
  export * from "./oidcConnector.js";
@@ -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
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "commonjs",
3
+ "private": true
4
+ }
@@ -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", { fjallOrgId });
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
- * Tagged image suffix when `target` is set: `<service>-<target>-latest`.
634
- * Mutually exclusive with `image` (pre-built URI).
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
  /**