@beesolve/aws-accounts 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,925 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { resolve } from "node:path";
4
+ import * as v from "valibot";
5
+ import {
6
+ CreateBucketCommand,
7
+ PutBucketTaggingCommand
8
+ } from "@aws-sdk/client-s3";
9
+ import {
10
+ CreateRoleCommand,
11
+ GetRoleCommand,
12
+ PutRolePolicyCommand,
13
+ TagRoleCommand
14
+ } from "@aws-sdk/client-iam";
15
+ import {
16
+ CreateFunctionCommand,
17
+ GetFunctionCommand,
18
+ LambdaClient,
19
+ PutFunctionConcurrencyCommand,
20
+ ResourceNotFoundException,
21
+ TagResourceCommand,
22
+ UpdateFunctionCodeCommand,
23
+ UpdateFunctionConfigurationCommand
24
+ } from "@aws-sdk/client-lambda";
25
+ import { GetCallerIdentityCommand } from "@aws-sdk/client-sts";
26
+ import {
27
+ CreatePermissionSetCommand,
28
+ DescribePermissionSetCommand,
29
+ ListPermissionSetsCommand,
30
+ PutInlinePolicyToPermissionSetCommand,
31
+ TagResourceCommand as SsoTagResourceCommand,
32
+ UpdatePermissionSetCommand
33
+ } from "@aws-sdk/client-sso-admin";
34
+ import {
35
+ loadAwsConfigModelFromTsFile,
36
+ mapAwsConfigToState,
37
+ readAwsContextFromFile,
38
+ regenerateTypesFromState,
39
+ writeAwsConfigFromState
40
+ } from "../awsConfig.js";
41
+ import { buildAwsClientConfig } from "../awsClientConfig.js";
42
+ import { getStandardTags } from "../tags.js";
43
+ import { diffStates } from "../diff.js";
44
+ import { invokeLambda } from "../lambdaClient.js";
45
+ import {
46
+ isCacheFresh,
47
+ readStateCache,
48
+ writeStateCache
49
+ } from "../remoteStateCache.js";
50
+ import { applyReservedOuDeletionGuard } from "../reservedOuDeletion.js";
51
+ import { validateState } from "../state.js";
52
+ import { assertUnreachable, delay } from "../helpers.js";
53
+ import { iam } from "@beesolve/iam-policy-ts";
54
+ const remoteCommandSchema = v.object({
55
+ subcommand: v.picklist(["bootstrap", "scan", "init", "plan", "apply", "upgrade"]),
56
+ profile: v.optional(v.string()),
57
+ region: v.optional(v.string()),
58
+ flags: v.object({
59
+ yes: v.boolean(),
60
+ refresh: v.boolean(),
61
+ allowDestructive: v.boolean(),
62
+ ignoreUnsupported: v.boolean()
63
+ })
64
+ });
65
+ const contextFilePath = "aws.context.json";
66
+ const configFilePath = "aws.config.ts";
67
+ const typesFilePath = "aws.config.types.ts";
68
+ const cachePath = ".remote-state-cache.json";
69
+ const lambdaZipPath = "dist-lambda/lambda.zip";
70
+ const lambdaRoleName = "beesolve-aws-accounts-lambda-role";
71
+ const lambdaFunctionName = "beesolve-aws-accounts";
72
+ async function runRemoteBootstrap(input) {
73
+ const lambdaZip = await readLambdaZip();
74
+ const callerIdentity = await input.stsClient.send(new GetCallerIdentityCommand({}));
75
+ const accountId = callerIdentity.Account;
76
+ if (accountId == null) {
77
+ throw new Error("Could not determine AWS account ID from STS.");
78
+ }
79
+ const resolvedRegion = input.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1";
80
+ const bucketName = `beesolve-aws-accounts-state-${accountId}-${resolvedRegion}`;
81
+ input.logger.log(`Account: ${accountId}`);
82
+ input.logger.log(`Region: ${resolvedRegion}`);
83
+ input.logger.log(`Bucket: ${bucketName}`);
84
+ try {
85
+ await input.s3Client.send(new CreateBucketCommand({
86
+ Bucket: bucketName,
87
+ CreateBucketConfiguration: resolvedRegion !== "us-east-1" ? {
88
+ LocationConstraint: resolvedRegion
89
+ } : void 0
90
+ }));
91
+ input.logger.log(`Created S3 bucket: ${bucketName}`);
92
+ } catch (error) {
93
+ const s3Error = error;
94
+ if (s3Error.name === "BucketAlreadyOwnedByYou" || s3Error.name === "BucketAlreadyExists") {
95
+ input.logger.log(`S3 bucket already exists: ${bucketName}`);
96
+ } else {
97
+ throw error;
98
+ }
99
+ }
100
+ await input.s3Client.send(new PutBucketTaggingCommand({
101
+ Bucket: bucketName,
102
+ Tagging: {
103
+ TagSet: getStandardTags("state-storage")
104
+ }
105
+ }));
106
+ const { roleArn } = await ensureIamRole({
107
+ iamClient: input.iamClient,
108
+ bucketName,
109
+ logger: input.logger
110
+ });
111
+ const lambdaArn = await ensureLambdaFunction({
112
+ lambdaClient: input.lambdaClient,
113
+ roleArn,
114
+ lambdaZip,
115
+ bucketName,
116
+ resolvedRegion,
117
+ logger: input.logger
118
+ });
119
+ const context = await readAwsContextFromFile(contextFilePath);
120
+ const deployment = {
121
+ profile: input.profile ?? "",
122
+ region: resolvedRegion,
123
+ lambdaArn,
124
+ stateBucketName: bucketName,
125
+ stateCacheTtlSeconds: 300
126
+ };
127
+ const updatedContext = {
128
+ ...context,
129
+ deployment
130
+ };
131
+ const ordered = {
132
+ version: updatedContext.version,
133
+ generatedAt: updatedContext.generatedAt,
134
+ organization: updatedContext.organization,
135
+ identityCenter: updatedContext.identityCenter,
136
+ deployment: updatedContext.deployment
137
+ };
138
+ await writeFile(contextFilePath, `${JSON.stringify(ordered, null, 2)}
139
+ `, "utf8");
140
+ const instanceArn = updatedContext.identityCenter?.instanceArn;
141
+ if (instanceArn != null && instanceArn !== "") {
142
+ await ensureOrganizationManagementPermissionSet({
143
+ ssoAdminClient: input.ssoAdminClient,
144
+ instanceArn,
145
+ tags: getStandardTags("organization-management"),
146
+ logger: input.logger
147
+ }).catch((error) => {
148
+ input.logger.log(`Error creating OrganizationManagement permission set: ${error instanceof Error ? error.message : String(error)}`);
149
+ });
150
+ await ensureOrganizationRemoteManagementPermissionSet({
151
+ ssoAdminClient: input.ssoAdminClient,
152
+ instanceArn,
153
+ lambdaArn,
154
+ tags: getStandardTags("remote-invocation"),
155
+ logger: input.logger
156
+ }).catch((error) => {
157
+ input.logger.log(`Error creating OrganizationRemoteManagement permission set: ${error instanceof Error ? error.message : String(error)}`);
158
+ });
159
+ }
160
+ if (instanceArn == null || instanceArn === "") {
161
+ input.logger.log("IAM Identity Center not configured, skipping permission set creation.");
162
+ }
163
+ input.logger.log("");
164
+ input.logger.log("Bootstrap complete.");
165
+ input.logger.log(` Lambda ARN: ${lambdaArn}`);
166
+ input.logger.log(` State bucket: ${bucketName}`);
167
+ }
168
+ async function ensureIamRole(props) {
169
+ const trustPolicy = JSON.stringify({
170
+ Version: "2012-10-17",
171
+ Statement: [
172
+ {
173
+ Effect: "Allow",
174
+ Principal: { Service: "lambda.amazonaws.com" },
175
+ Action: iam.sts("AssumeRole")
176
+ }
177
+ ]
178
+ });
179
+ const { roleArn } = await getOrCreateIamRole({
180
+ iamClient: props.iamClient,
181
+ trustPolicy,
182
+ logger: props.logger
183
+ });
184
+ const inlinePolicy = JSON.stringify({
185
+ Version: "2012-10-17",
186
+ Statement: [
187
+ {
188
+ Effect: "Allow",
189
+ Action: iam.organizations("*"),
190
+ Resource: "*"
191
+ },
192
+ {
193
+ Effect: "Allow",
194
+ Action: [iam.sso("*"), iam.identitystore("*")],
195
+ Resource: "*"
196
+ },
197
+ {
198
+ Effect: "Allow",
199
+ Action: [iam.s3("GetObject"), iam.s3("PutObject"), iam.s3("ListBucket")],
200
+ Resource: [
201
+ `arn:aws:s3:::${props.bucketName}`,
202
+ `arn:aws:s3:::${props.bucketName}/*`
203
+ ]
204
+ },
205
+ {
206
+ Effect: "Allow",
207
+ Action: [
208
+ iam.logs("CreateLogGroup"),
209
+ iam.logs("CreateLogStream"),
210
+ iam.logs("PutLogEvents")
211
+ ],
212
+ Resource: "arn:aws:logs:*:*:*"
213
+ },
214
+ {
215
+ Effect: "Allow",
216
+ Action: [iam.account("PutAccountName")],
217
+ Resource: "*"
218
+ }
219
+ ]
220
+ });
221
+ await props.iamClient.send(
222
+ new PutRolePolicyCommand({
223
+ RoleName: lambdaRoleName,
224
+ PolicyName: "beesolve-aws-accounts-execution-policy",
225
+ PolicyDocument: inlinePolicy
226
+ })
227
+ );
228
+ return { roleArn };
229
+ }
230
+ async function getOrCreateIamRole(props) {
231
+ try {
232
+ const getRole = await props.iamClient.send(
233
+ new GetRoleCommand({ RoleName: lambdaRoleName })
234
+ );
235
+ const roleArn2 = getRole.Role?.Arn ?? "";
236
+ if (roleArn2 === "") {
237
+ throw new Error("IAM role exists but ARN is empty.");
238
+ }
239
+ props.logger.log(`IAM role already exists: ${lambdaRoleName}`);
240
+ await props.iamClient.send(
241
+ new TagRoleCommand({
242
+ RoleName: lambdaRoleName,
243
+ Tags: getStandardTags("execution-role")
244
+ })
245
+ );
246
+ return { roleArn: roleArn2 };
247
+ } catch (error) {
248
+ if (error.name !== "NoSuchEntityException") {
249
+ throw error;
250
+ }
251
+ }
252
+ const createRole = await props.iamClient.send(
253
+ new CreateRoleCommand({
254
+ RoleName: lambdaRoleName,
255
+ AssumeRolePolicyDocument: props.trustPolicy,
256
+ Description: "Execution role for beesolve-aws-accounts Lambda",
257
+ Tags: getStandardTags("execution-role")
258
+ })
259
+ );
260
+ const roleArn = createRole.Role?.Arn ?? "";
261
+ if (roleArn === "") {
262
+ throw new Error("Failed to create IAM role: ARN is empty.");
263
+ }
264
+ props.logger.log(`Created IAM role: ${lambdaRoleName}`);
265
+ return { roleArn };
266
+ }
267
+ async function ensureLambdaFunction(props) {
268
+ try {
269
+ const getFunction = await props.lambdaClient.send(
270
+ new GetFunctionCommand({ FunctionName: lambdaFunctionName })
271
+ );
272
+ const existingArn = getFunction.Configuration?.FunctionArn ?? "";
273
+ if (existingArn === "") {
274
+ throw new Error("Lambda function exists but ARN is empty.");
275
+ }
276
+ await props.lambdaClient.send(
277
+ new UpdateFunctionCodeCommand({
278
+ FunctionName: lambdaFunctionName,
279
+ ZipFile: props.lambdaZip
280
+ })
281
+ );
282
+ await props.lambdaClient.send(
283
+ new UpdateFunctionConfigurationCommand({
284
+ FunctionName: lambdaFunctionName,
285
+ Environment: {
286
+ Variables: {
287
+ STATE_BUCKET_NAME: props.bucketName
288
+ }
289
+ }
290
+ })
291
+ );
292
+ await props.lambdaClient.send(
293
+ new TagResourceCommand({
294
+ Resource: existingArn,
295
+ Tags: Object.fromEntries(getStandardTags("remote-execution").map((t) => [t.Key, t.Value]))
296
+ })
297
+ );
298
+ props.logger.log(`Updated Lambda function code: ${lambdaFunctionName}`);
299
+ return existingArn;
300
+ } catch (error) {
301
+ if (error instanceof ResourceNotFoundException) {
302
+ const lambdaArn = await createLambdaFunctionWithRetry({
303
+ lambdaClient: props.lambdaClient,
304
+ roleArn: props.roleArn,
305
+ lambdaZip: props.lambdaZip,
306
+ bucketName: props.bucketName,
307
+ logger: props.logger
308
+ });
309
+ await props.lambdaClient.send(
310
+ new PutFunctionConcurrencyCommand({
311
+ FunctionName: lambdaFunctionName,
312
+ ReservedConcurrentExecutions: 1
313
+ })
314
+ );
315
+ props.logger.log(`Created Lambda function: ${lambdaFunctionName}`);
316
+ props.logger.log(`Set reserved concurrency to 1`);
317
+ return lambdaArn;
318
+ }
319
+ throw error;
320
+ }
321
+ }
322
+ const IAM_PROPAGATION_MAX_ATTEMPTS = 10;
323
+ const IAM_PROPAGATION_RETRY_INTERVAL_MS = 2e3;
324
+ async function createLambdaFunctionWithRetry(props) {
325
+ for (let attempt = 1; attempt <= IAM_PROPAGATION_MAX_ATTEMPTS; attempt++) {
326
+ try {
327
+ const createResult = await props.lambdaClient.send(
328
+ new CreateFunctionCommand({
329
+ FunctionName: lambdaFunctionName,
330
+ Runtime: "nodejs24.x",
331
+ Handler: "handler.handler",
332
+ Role: props.roleArn,
333
+ Code: { ZipFile: props.lambdaZip },
334
+ Timeout: 900,
335
+ MemorySize: 512,
336
+ PackageType: "Zip",
337
+ Architectures: ["arm64"],
338
+ Environment: {
339
+ Variables: {
340
+ STATE_BUCKET_NAME: props.bucketName
341
+ }
342
+ },
343
+ Tags: Object.fromEntries(getStandardTags("remote-execution").map((t) => [t.Key, t.Value]))
344
+ })
345
+ );
346
+ const lambdaArn = createResult.FunctionArn ?? "";
347
+ if (lambdaArn === "") {
348
+ throw new Error("Failed to create Lambda function: ARN is empty.");
349
+ }
350
+ return lambdaArn;
351
+ } catch (error) {
352
+ const isRoleNotReady = error.name === "InvalidParameterValueException";
353
+ if (!isRoleNotReady || attempt === IAM_PROPAGATION_MAX_ATTEMPTS) {
354
+ throw error;
355
+ }
356
+ props.logger.log(`Waiting for IAM role to propagate (attempt ${attempt}/${IAM_PROPAGATION_MAX_ATTEMPTS})...`);
357
+ await delay(IAM_PROPAGATION_RETRY_INTERVAL_MS);
358
+ }
359
+ }
360
+ throw new Error("Unreachable: retry loop exhausted without throwing.");
361
+ }
362
+ async function runRemoteScan(input) {
363
+ const deployment = await readDeploymentFromContext();
364
+ const clientConfig = buildAwsClientConfig({
365
+ profile: input.profile ?? (deployment.profile || void 0),
366
+ region: input.region ?? (deployment.region || void 0)
367
+ });
368
+ const lambdaClient = new LambdaClient(clientConfig);
369
+ input.logger.log("Invoking remote scan...");
370
+ const result = await invokeLambda({
371
+ lambdaClient,
372
+ lambdaArn: deployment.lambdaArn,
373
+ payload: { action: "scan" }
374
+ });
375
+ if (!result.ok) {
376
+ throw new Error(formatLambdaError(result.error));
377
+ }
378
+ const response = result.response;
379
+ if (!("action" in response) || response.action !== "scan") {
380
+ throw new Error("Unexpected response from Lambda scan action.");
381
+ }
382
+ input.logger.log("Scan complete.");
383
+ input.logger.log(` Organizational Units: ${response.summary.organizationalUnits}`);
384
+ input.logger.log(` Accounts: ${response.summary.accounts}`);
385
+ input.logger.log(` Users: ${response.summary.users}`);
386
+ input.logger.log(` Groups: ${response.summary.groups}`);
387
+ input.logger.log(` Permission Sets: ${response.summary.permissionSets}`);
388
+ input.logger.log(` Account Assignments: ${response.summary.accountAssignments}`);
389
+ await writeStateCache(cachePath, response.state);
390
+ input.logger.log("State cache updated.");
391
+ }
392
+ const statePath = "state.json";
393
+ async function runRemoteInit(input) {
394
+ const deployment = await readDeploymentFromContext();
395
+ input.logger.log("Invoking remote scan...");
396
+ const result = await invokeLambda({
397
+ lambdaClient: input.lambdaClient,
398
+ lambdaArn: deployment.lambdaArn,
399
+ payload: { action: "scan" }
400
+ });
401
+ if (!result.ok) {
402
+ throw new Error(formatLambdaError(result.error));
403
+ }
404
+ const response = result.response;
405
+ if (!("action" in response) || response.action !== "scan") {
406
+ throw new Error("Unexpected response from Lambda scan action.");
407
+ }
408
+ input.logger.log("Scan complete.");
409
+ input.logger.log(` Organizational Units: ${response.summary.organizationalUnits}`);
410
+ input.logger.log(` Accounts: ${response.summary.accounts}`);
411
+ input.logger.log(` Users: ${response.summary.users}`);
412
+ input.logger.log(` Groups: ${response.summary.groups}`);
413
+ input.logger.log(` Permission Sets: ${response.summary.permissionSets}`);
414
+ input.logger.log(` Account Assignments: ${response.summary.accountAssignments}`);
415
+ await Promise.all([
416
+ writeFile(statePath, `${JSON.stringify(response.state, null, 2)}
417
+ `, "utf8"),
418
+ writeStateCache(cachePath, response.state)
419
+ ]);
420
+ input.logger.log("State written to state.json and cache updated.");
421
+ const configWriteResult = await writeAwsConfigFromState({
422
+ statePath,
423
+ contextPath: contextFilePath,
424
+ configPath: configFilePath,
425
+ typesPath: typesFilePath,
426
+ logger: input.logger,
427
+ overwriteConfirmation: input.overwriteConfirmation
428
+ });
429
+ const writtenFiles = configWriteResult.files.filter((f) => f.status === "written");
430
+ if (writtenFiles.length > 0) {
431
+ input.logger.log("");
432
+ input.logger.log("Init complete.");
433
+ for (const file of writtenFiles) {
434
+ input.logger.log(` ${file.path}: ${file.status}`);
435
+ }
436
+ }
437
+ }
438
+ async function runRemotePlan(input) {
439
+ const deployment = await readDeploymentFromContext();
440
+ const currentState = await fetchCurrentState({
441
+ input,
442
+ deployment
443
+ });
444
+ const [context, config] = await Promise.all([
445
+ readAwsContextFromFile(contextFilePath),
446
+ loadAwsConfigModelFromTsFile({
447
+ configPath: configFilePath,
448
+ typesPath: typesFilePath
449
+ })
450
+ ]);
451
+ const desiredState = mapAwsConfigToState({
452
+ config,
453
+ currentState,
454
+ context
455
+ });
456
+ const plan = applyReservedOuDeletionGuard({
457
+ plan: diffStates({
458
+ current: currentState,
459
+ next: desiredState
460
+ }),
461
+ context
462
+ });
463
+ displayPlan({ plan, logger: input.logger });
464
+ }
465
+ async function runRemoteApply(input) {
466
+ const deployment = await readDeploymentFromContext();
467
+ const currentState = await fetchCurrentState({
468
+ input,
469
+ deployment
470
+ });
471
+ const [context, config] = await Promise.all([
472
+ readAwsContextFromFile(contextFilePath),
473
+ loadAwsConfigModelFromTsFile({
474
+ configPath: configFilePath,
475
+ typesPath: typesFilePath
476
+ })
477
+ ]);
478
+ const desiredState = mapAwsConfigToState({
479
+ config,
480
+ currentState,
481
+ context
482
+ });
483
+ const plan = applyReservedOuDeletionGuard({
484
+ plan: diffStates({
485
+ current: currentState,
486
+ next: desiredState
487
+ }),
488
+ context
489
+ });
490
+ if (plan.operations.length === 0) {
491
+ input.logger.log("No changes.");
492
+ return;
493
+ }
494
+ displayPlan({ plan, logger: input.logger });
495
+ if (!input.flags.yes) {
496
+ if (process.stdin.isTTY !== true) {
497
+ throw new Error(
498
+ "Refusing to apply changes in non-interactive mode without --yes."
499
+ );
500
+ }
501
+ const readlineInterface = createInterface({
502
+ input: process.stdin,
503
+ output: process.stdout
504
+ });
505
+ try {
506
+ const answer = await readlineInterface.question(
507
+ "Proceed with applying these changes? [y/N] "
508
+ );
509
+ const normalized = answer.trim().toLowerCase();
510
+ if (normalized !== "y" && normalized !== "yes") {
511
+ input.logger.log("Apply cancelled.");
512
+ return;
513
+ }
514
+ } finally {
515
+ readlineInterface.close();
516
+ }
517
+ }
518
+ const clientConfig = buildAwsClientConfig({
519
+ profile: input.profile ?? (deployment.profile || void 0),
520
+ region: input.region ?? (deployment.region || void 0)
521
+ });
522
+ const lambdaClient = new LambdaClient(clientConfig);
523
+ input.logger.log("Applying changes remotely...");
524
+ const result = await invokeLambda({
525
+ lambdaClient,
526
+ lambdaArn: deployment.lambdaArn,
527
+ payload: {
528
+ action: "apply",
529
+ operations: plan.operations,
530
+ allowDestructive: input.flags.allowDestructive
531
+ }
532
+ });
533
+ if (!result.ok) {
534
+ const error = result.error;
535
+ if (error.kind === "concurrencyConflict") {
536
+ input.logger.log("Another apply is in progress. Retry later.");
537
+ return;
538
+ }
539
+ if (error.kind === "operationFailed") {
540
+ input.logger.log(
541
+ `Apply failed at operation ${error.failedOperation + 1} of ${error.totalOperations}: ${error.error}`
542
+ );
543
+ await writeStateCache(cachePath, error.partialState);
544
+ input.logger.log("State cache updated with partial state.");
545
+ return;
546
+ }
547
+ throw new Error(formatLambdaError(error));
548
+ }
549
+ const response = result.response;
550
+ if (!("action" in response) || response.action !== "apply") {
551
+ throw new Error("Unexpected response from Lambda apply action.");
552
+ }
553
+ input.logger.log(`Applied ${response.operationsCompleted} operation(s).`);
554
+ await writeStateCache(cachePath, response.state);
555
+ input.logger.log("State cache updated.");
556
+ await regenerateTypesFromState({
557
+ state: response.state,
558
+ contextPath: contextFilePath,
559
+ configPath: configFilePath,
560
+ typesPath: typesFilePath,
561
+ logger: input.logger
562
+ });
563
+ }
564
+ async function runRemoteUpgrade(input) {
565
+ const deployment = await readDeploymentFromContext();
566
+ const lambdaZip = await readLambdaZip();
567
+ input.logger.log(`Updating Lambda function code: ${deployment.lambdaArn}`);
568
+ const updateResult = await input.lambdaClient.send(
569
+ new UpdateFunctionCodeCommand({
570
+ FunctionName: deployment.lambdaArn,
571
+ ZipFile: lambdaZip
572
+ })
573
+ );
574
+ const lastModified = updateResult.LastModified ?? "unknown";
575
+ input.logger.log(`Upgrade complete. Last modified: ${lastModified}`);
576
+ }
577
+ async function readDeploymentFromContext() {
578
+ const context = await readAwsContextFromFile(contextFilePath);
579
+ if (context.deployment == null) {
580
+ throw new Error(
581
+ "No deployment found in aws.context.json. Run `aws-accounts bootstrap` first."
582
+ );
583
+ }
584
+ return context.deployment;
585
+ }
586
+ async function fetchCurrentState(props) {
587
+ if (!props.input.flags.refresh) {
588
+ const cache = await readStateCache(cachePath);
589
+ if (cache != null && isCacheFresh(cache, props.deployment.stateCacheTtlSeconds)) {
590
+ props.input.logger.log("Using cached state.");
591
+ return cache.state;
592
+ }
593
+ }
594
+ props.input.logger.log("Fetching remote state...");
595
+ const clientConfig = buildAwsClientConfig({
596
+ profile: props.input.profile ?? (props.deployment.profile || void 0),
597
+ region: props.input.region ?? (props.deployment.region || void 0)
598
+ });
599
+ const lambdaClient = new LambdaClient(clientConfig);
600
+ const result = await invokeLambda({
601
+ lambdaClient,
602
+ lambdaArn: props.deployment.lambdaArn,
603
+ payload: { action: "getStateUrl" }
604
+ });
605
+ if (!result.ok) {
606
+ throw new Error(formatLambdaError(result.error));
607
+ }
608
+ const response = result.response;
609
+ if (!("action" in response) || response.action !== "getStateUrl") {
610
+ throw new Error("Unexpected response from Lambda getStateUrl action.");
611
+ }
612
+ const stateResponse = await fetch(response.url);
613
+ if (!stateResponse.ok) {
614
+ throw new Error(
615
+ `Failed to fetch state from pre-signed URL: ${stateResponse.status} ${stateResponse.statusText}`
616
+ );
617
+ }
618
+ const stateJson = await stateResponse.json();
619
+ const state = validateState(stateJson);
620
+ await writeStateCache(cachePath, state);
621
+ props.input.logger.log("State cache updated.");
622
+ return state;
623
+ }
624
+ function displayPlan(props) {
625
+ props.logger.log(
626
+ `Plan: ${props.plan.operations.length} operation(s), ${props.plan.unsupported.length} unsupported diff(s)`
627
+ );
628
+ const destructiveOperations = props.plan.operations.filter(
629
+ (op) => isDestructiveOperation(op)
630
+ );
631
+ if (destructiveOperations.length > 0) {
632
+ props.logger.log(
633
+ `Destructive operations detected: ${destructiveOperations.length}. Apply requires --allow-destructive.`
634
+ );
635
+ }
636
+ for (const operation of props.plan.operations) {
637
+ props.logger.log(formatOperationLine(operation));
638
+ }
639
+ if (props.plan.unsupported.length > 0) {
640
+ props.logger.log("Unsupported diffs:");
641
+ for (const diff of props.plan.unsupported) {
642
+ props.logger.log(` - ${diff.description} [${diff.category}]`);
643
+ }
644
+ }
645
+ }
646
+ function isDestructiveOperation(operation) {
647
+ return operation.kind === "deleteOu" || operation.kind === "removeAccount" || operation.kind === "deleteIdcUser" || operation.kind === "deleteIdcGroup" || operation.kind === "deleteIdcPermissionSet";
648
+ }
649
+ function formatOperationLine(operation) {
650
+ if (operation.kind === "moveAccount") {
651
+ return ` move account "${operation.accountName}" (${operation.accountId}) from ${operation.fromOuName} -> ${operation.toOuName}`;
652
+ }
653
+ if (operation.kind === "createOu") {
654
+ return ` create OU "${operation.ouName}" under ${operation.parentOuName}`;
655
+ }
656
+ if (operation.kind === "renameOu") {
657
+ return ` rename OU "${operation.fromOuName}" -> "${operation.toOuName}"`;
658
+ }
659
+ if (operation.kind === "deleteOu") {
660
+ return ` [destructive] delete OU "${operation.ouName}" from ${operation.parentOuName}`;
661
+ }
662
+ if (operation.kind === "createAccount") {
663
+ return ` create account "${operation.accountName}" (${operation.accountEmail}) in ${operation.targetOuName}`;
664
+ }
665
+ if (operation.kind === "updateAccountTags") {
666
+ return ` update account tags "${operation.accountName}" (${operation.accountId})`;
667
+ }
668
+ if (operation.kind === "updateAccountName") {
669
+ return ` rename account (${operation.accountId}): "${operation.fromAccountName}" -> "${operation.toAccountName}"`;
670
+ }
671
+ if (operation.kind === "removeAccount") {
672
+ return ` [destructive] move removed account "${operation.accountName}" (${operation.accountId}) from ${operation.fromOuName} -> ${operation.toOuName}`;
673
+ }
674
+ if (operation.kind === "createIdcUser") {
675
+ return ` create IdC user "${operation.userName}"`;
676
+ }
677
+ if (operation.kind === "updateIdcUser") {
678
+ return ` update IdC user "${operation.userName}"`;
679
+ }
680
+ if (operation.kind === "deleteIdcUser") {
681
+ return ` [destructive] delete IdC user "${operation.userName}"`;
682
+ }
683
+ if (operation.kind === "createIdcGroup") {
684
+ return ` create IdC group "${operation.groupDisplayName}"`;
685
+ }
686
+ if (operation.kind === "updateIdcGroupDescription") {
687
+ return ` update IdC group description for "${operation.groupDisplayName}"`;
688
+ }
689
+ if (operation.kind === "deleteIdcGroup") {
690
+ return ` [destructive] delete IdC group "${operation.groupDisplayName}"`;
691
+ }
692
+ if (operation.kind === "addIdcGroupMembership") {
693
+ return ` add user "${operation.userName}" to IdC group "${operation.groupDisplayName}"`;
694
+ }
695
+ if (operation.kind === "createIdcPermissionSet") {
696
+ return ` create IdC permission set "${operation.permissionSetName}"`;
697
+ }
698
+ if (operation.kind === "updateIdcPermissionSetDescription") {
699
+ return ` update IdC permission set description for "${operation.permissionSetName}"`;
700
+ }
701
+ if (operation.kind === "deleteIdcPermissionSet") {
702
+ return ` [destructive] delete IdC permission set "${operation.permissionSetName}"`;
703
+ }
704
+ if (operation.kind === "putIdcPermissionSetInlinePolicy") {
705
+ return ` put inline policy on IdC permission set "${operation.permissionSetName}"`;
706
+ }
707
+ if (operation.kind === "deleteIdcPermissionSetInlinePolicy") {
708
+ return ` delete inline policy from IdC permission set "${operation.permissionSetName}"`;
709
+ }
710
+ if (operation.kind === "attachIdcManagedPolicyToPermissionSet") {
711
+ return ` attach managed policy "${operation.managedPolicyArn}" to IdC permission set "${operation.permissionSetName}"`;
712
+ }
713
+ if (operation.kind === "detachIdcManagedPolicyFromPermissionSet") {
714
+ return ` detach managed policy "${operation.managedPolicyArn}" from IdC permission set "${operation.permissionSetName}"`;
715
+ }
716
+ if (operation.kind === "attachIdcCustomerManagedPolicyReferenceToPermissionSet") {
717
+ return ` attach customer-managed policy "${operation.customerManagedPolicyPath}${operation.customerManagedPolicyName}" to IdC permission set "${operation.permissionSetName}"`;
718
+ }
719
+ if (operation.kind === "detachIdcCustomerManagedPolicyReferenceFromPermissionSet") {
720
+ return ` detach customer-managed policy "${operation.customerManagedPolicyPath}${operation.customerManagedPolicyName}" from IdC permission set "${operation.permissionSetName}"`;
721
+ }
722
+ if (operation.kind === "provisionIdcPermissionSet") {
723
+ return ` provision IdC permission set "${operation.permissionSetName}" to all provisioned accounts`;
724
+ }
725
+ if (operation.kind === "removeIdcGroupMembership") {
726
+ return ` remove user "${operation.userName}" from IdC group "${operation.groupDisplayName}"`;
727
+ }
728
+ if (operation.kind === "grantIdcAccountAssignment") {
729
+ return ` grant IdC assignment "${operation.permissionSetName}" to ${formatPrincipalLabel(operation.principalType, operation.principalName)} on "${operation.accountName}"`;
730
+ }
731
+ if (operation.kind === "revokeIdcAccountAssignment") {
732
+ return ` revoke IdC assignment "${operation.permissionSetName}" from ${formatPrincipalLabel(operation.principalType, operation.principalName)} on "${operation.accountName}"`;
733
+ }
734
+ assertUnreachable(operation, "Unsupported operation kind in formatOperationLine.");
735
+ }
736
+ function formatPrincipalLabel(principalType, principalName) {
737
+ if (principalType === "GROUP") {
738
+ return `group "${principalName}"`;
739
+ }
740
+ return `user "${principalName}"`;
741
+ }
742
+ function formatLambdaError(error) {
743
+ if (error.kind === "validation") {
744
+ return `Lambda validation error: ${error.details}`;
745
+ }
746
+ if (error.kind === "concurrencyConflict") {
747
+ return `Lambda concurrency conflict: ${error.message}`;
748
+ }
749
+ if (error.kind === "operationFailed") {
750
+ return `Lambda operation failed: ${error.error}`;
751
+ }
752
+ if (error.kind === "invocationError") {
753
+ return `Lambda invocation error: ${error.message}`;
754
+ }
755
+ return `Lambda error: ${JSON.stringify(error)}`;
756
+ }
757
+ async function findPermissionSetByName(props) {
758
+ let nextToken;
759
+ do {
760
+ const listResponse = await props.ssoAdminClient.send(
761
+ new ListPermissionSetsCommand({
762
+ InstanceArn: props.instanceArn,
763
+ NextToken: nextToken
764
+ })
765
+ );
766
+ const permissionSetArns = listResponse.PermissionSets ?? [];
767
+ for (const arn of permissionSetArns) {
768
+ const describeResponse = await props.ssoAdminClient.send(
769
+ new DescribePermissionSetCommand({
770
+ InstanceArn: props.instanceArn,
771
+ PermissionSetArn: arn
772
+ })
773
+ );
774
+ if (describeResponse.PermissionSet?.Name === props.name) {
775
+ return arn;
776
+ }
777
+ }
778
+ nextToken = listResponse.NextToken;
779
+ } while (nextToken != null);
780
+ return void 0;
781
+ }
782
+ async function ensureOrganizationManagementPermissionSet(props) {
783
+ const permissionSetName = "OrganizationManagement";
784
+ const description = "Full organization management access for AWS Organizations, IAM Identity Center, and IAM";
785
+ const sessionDuration = "PT4H";
786
+ const inlinePolicy = JSON.stringify({
787
+ Version: "2012-10-17",
788
+ Statement: [{
789
+ Effect: "Allow",
790
+ Action: [iam.organizations("*"), iam.sso("*"), iam.identitystore("*"), iam.account("*"), iam.iam("*")],
791
+ Resource: "*"
792
+ }]
793
+ });
794
+ const existingArn = await findPermissionSetByName({
795
+ ssoAdminClient: props.ssoAdminClient,
796
+ instanceArn: props.instanceArn,
797
+ name: permissionSetName
798
+ });
799
+ const permissionSetArn = existingArn != null ? await updateExistingPermissionSet({
800
+ ssoAdminClient: props.ssoAdminClient,
801
+ instanceArn: props.instanceArn,
802
+ permissionSetArn: existingArn,
803
+ permissionSetName,
804
+ description,
805
+ sessionDuration,
806
+ logger: props.logger
807
+ }) : await createNewPermissionSet({
808
+ ssoAdminClient: props.ssoAdminClient,
809
+ instanceArn: props.instanceArn,
810
+ permissionSetName,
811
+ description,
812
+ sessionDuration,
813
+ tags: props.tags,
814
+ logger: props.logger
815
+ });
816
+ await props.ssoAdminClient.send(
817
+ new PutInlinePolicyToPermissionSetCommand({
818
+ InstanceArn: props.instanceArn,
819
+ PermissionSetArn: permissionSetArn,
820
+ InlinePolicy: inlinePolicy
821
+ })
822
+ );
823
+ await props.ssoAdminClient.send(
824
+ new SsoTagResourceCommand({
825
+ InstanceArn: props.instanceArn,
826
+ ResourceArn: permissionSetArn,
827
+ Tags: props.tags.map((t) => ({ Key: t.Key, Value: t.Value }))
828
+ })
829
+ );
830
+ return { permissionSetArn };
831
+ }
832
+ async function ensureOrganizationRemoteManagementPermissionSet(props) {
833
+ const permissionSetName = "OrganizationRemoteManagement";
834
+ const description = "Minimal access to invoke the beesolve-aws-accounts remote management Lambda";
835
+ const sessionDuration = "PT1H";
836
+ const inlinePolicy = JSON.stringify({
837
+ Version: "2012-10-17",
838
+ Statement: [{
839
+ Effect: "Allow",
840
+ Action: [iam.lambda("InvokeFunction")],
841
+ Resource: props.lambdaArn
842
+ }]
843
+ });
844
+ const existingArn = await findPermissionSetByName({
845
+ ssoAdminClient: props.ssoAdminClient,
846
+ instanceArn: props.instanceArn,
847
+ name: permissionSetName
848
+ });
849
+ const permissionSetArn = existingArn != null ? await updateExistingPermissionSet({
850
+ ssoAdminClient: props.ssoAdminClient,
851
+ instanceArn: props.instanceArn,
852
+ permissionSetArn: existingArn,
853
+ permissionSetName,
854
+ description,
855
+ sessionDuration,
856
+ logger: props.logger
857
+ }) : await createNewPermissionSet({
858
+ ssoAdminClient: props.ssoAdminClient,
859
+ instanceArn: props.instanceArn,
860
+ permissionSetName,
861
+ description,
862
+ sessionDuration,
863
+ tags: props.tags,
864
+ logger: props.logger
865
+ });
866
+ await props.ssoAdminClient.send(
867
+ new PutInlinePolicyToPermissionSetCommand({
868
+ InstanceArn: props.instanceArn,
869
+ PermissionSetArn: permissionSetArn,
870
+ InlinePolicy: inlinePolicy
871
+ })
872
+ );
873
+ await props.ssoAdminClient.send(
874
+ new SsoTagResourceCommand({
875
+ InstanceArn: props.instanceArn,
876
+ ResourceArn: permissionSetArn,
877
+ Tags: props.tags.map((t) => ({ Key: t.Key, Value: t.Value }))
878
+ })
879
+ );
880
+ return { permissionSetArn };
881
+ }
882
+ async function updateExistingPermissionSet(props) {
883
+ await props.ssoAdminClient.send(
884
+ new UpdatePermissionSetCommand({
885
+ InstanceArn: props.instanceArn,
886
+ PermissionSetArn: props.permissionSetArn,
887
+ Description: props.description,
888
+ SessionDuration: props.sessionDuration
889
+ })
890
+ );
891
+ props.logger.log(`Updated permission set: ${props.permissionSetName}`);
892
+ return props.permissionSetArn;
893
+ }
894
+ async function createNewPermissionSet(props) {
895
+ const createResponse = await props.ssoAdminClient.send(
896
+ new CreatePermissionSetCommand({
897
+ InstanceArn: props.instanceArn,
898
+ Name: props.permissionSetName,
899
+ Description: props.description,
900
+ SessionDuration: props.sessionDuration,
901
+ Tags: props.tags.map((t) => ({ Key: t.Key, Value: t.Value }))
902
+ })
903
+ );
904
+ const permissionSetArn = createResponse.PermissionSet?.PermissionSetArn ?? "";
905
+ if (permissionSetArn === "") {
906
+ throw new Error(`Failed to create permission set "${props.permissionSetName}": ARN is empty.`);
907
+ }
908
+ props.logger.log(`Created permission set: ${props.permissionSetName}`);
909
+ return permissionSetArn;
910
+ }
911
+ async function readLambdaZip() {
912
+ try {
913
+ return await readFile(resolve(lambdaZipPath));
914
+ } catch {
915
+ throw new Error("dist-lambda/lambda.zip not found. Run `npm run build:lambda` first.");
916
+ }
917
+ }
918
+ export {
919
+ runRemoteApply,
920
+ runRemoteBootstrap,
921
+ runRemoteInit,
922
+ runRemotePlan,
923
+ runRemoteScan,
924
+ runRemoteUpgrade
925
+ };