@beesolve/aws-accounts 1.0.7 → 1.2.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.
@@ -36,6 +36,7 @@ import {
36
36
  loadAwsConfigModelFromTsFile,
37
37
  mapAwsConfigToState,
38
38
  readAwsContextFromFile,
39
+ readPackageVersion,
39
40
  regenerateTypesFromState,
40
41
  writeAwsConfigFromState
41
42
  } from "../awsConfig.js";
@@ -51,7 +52,8 @@ import {
51
52
  import { applyReservedOuDeletionGuard } from "../reservedOuDeletion.js";
52
53
  import { validateState } from "../state.js";
53
54
  import { assertUnreachable, delay } from "../helpers.js";
54
- import { iam } from "@beesolve/iam-policy-ts";
55
+ import { toPreconditionError } from "../error.js";
56
+ import { sts, organizations, sso, identitystore, s3, logs, account, iam, lambda } from "@beesolve/iam-policy-ts";
55
57
  const remoteCommandSchema = v.object({
56
58
  subcommand: v.picklist(["bootstrap", "scan", "init", "plan", "apply", "upgrade"]),
57
59
  profile: v.optional(v.string()),
@@ -60,7 +62,8 @@ const remoteCommandSchema = v.object({
60
62
  yes: v.boolean(),
61
63
  refresh: v.boolean(),
62
64
  allowDestructive: v.boolean(),
63
- ignoreUnsupported: v.boolean()
65
+ ignoreUnsupported: v.boolean(),
66
+ update: v.boolean()
64
67
  })
65
68
  });
66
69
  const contextFilePath = "aws.context.json";
@@ -121,12 +124,14 @@ async function runRemoteBootstrap(input) {
121
124
  context = await readAwsContextFromFile(contextFilePath);
122
125
  } catch {
123
126
  }
127
+ const cliVersionForBootstrap = await readPackageVersion();
124
128
  const deployment = {
125
129
  profile: input.profile ?? "",
126
130
  region: resolvedRegion,
127
131
  lambdaArn,
128
132
  stateBucketName: bucketName,
129
- stateCacheTtlSeconds: 300
133
+ stateCacheTtlSeconds: 300,
134
+ cliVersion: cliVersionForBootstrap
130
135
  };
131
136
  const updatedContext = context != null ? { ...context, deployment } : {
132
137
  version: "1",
@@ -172,38 +177,23 @@ async function runRemoteBootstrap(input) {
172
177
  input.logger.log(` Lambda ARN: ${lambdaArn}`);
173
178
  input.logger.log(` State bucket: ${bucketName}`);
174
179
  }
175
- async function ensureIamRole(props) {
176
- const trustPolicy = JSON.stringify({
177
- Version: "2012-10-17",
178
- Statement: [
179
- {
180
- Effect: "Allow",
181
- Principal: { Service: "lambda.amazonaws.com" },
182
- Action: iam.sts("AssumeRole")
183
- }
184
- ]
185
- });
186
- const { roleArn } = await getOrCreateIamRole({
187
- iamClient: props.iamClient,
188
- trustPolicy,
189
- logger: props.logger
190
- });
180
+ async function applyLambdaRolePolicy(props) {
191
181
  const inlinePolicy = JSON.stringify({
192
182
  Version: "2012-10-17",
193
183
  Statement: [
194
184
  {
195
185
  Effect: "Allow",
196
- Action: iam.organizations("*"),
186
+ Action: organizations("*"),
197
187
  Resource: "*"
198
188
  },
199
189
  {
200
190
  Effect: "Allow",
201
- Action: [iam.sso("*"), iam.identitystore("*")],
191
+ Action: [sso("*"), identitystore("*")],
202
192
  Resource: "*"
203
193
  },
204
194
  {
205
195
  Effect: "Allow",
206
- Action: [iam.s3("GetObject"), iam.s3("PutObject"), iam.s3("ListBucket")],
196
+ Action: [s3("GetObject"), s3("PutObject"), s3("ListBucket")],
207
197
  Resource: [
208
198
  `arn:aws:s3:::${props.bucketName}`,
209
199
  `arn:aws:s3:::${props.bucketName}/*`
@@ -212,15 +202,20 @@ async function ensureIamRole(props) {
212
202
  {
213
203
  Effect: "Allow",
214
204
  Action: [
215
- iam.logs("CreateLogGroup"),
216
- iam.logs("CreateLogStream"),
217
- iam.logs("PutLogEvents")
205
+ logs("CreateLogGroup"),
206
+ logs("CreateLogStream"),
207
+ logs("PutLogEvents")
218
208
  ],
219
209
  Resource: "arn:aws:logs:*:*:*"
220
210
  },
221
211
  {
222
212
  Effect: "Allow",
223
- Action: [iam.account("PutAccountName")],
213
+ Action: [
214
+ account("PutAccountName"),
215
+ account("GetAlternateContact"),
216
+ account("PutAlternateContact"),
217
+ account("DeleteAlternateContact")
218
+ ],
224
219
  Resource: "*"
225
220
  }
226
221
  ]
@@ -232,6 +227,27 @@ async function ensureIamRole(props) {
232
227
  PolicyDocument: inlinePolicy
233
228
  })
234
229
  );
230
+ }
231
+ async function ensureIamRole(props) {
232
+ const trustPolicy = JSON.stringify({
233
+ Version: "2012-10-17",
234
+ Statement: [
235
+ {
236
+ Effect: "Allow",
237
+ Principal: { Service: "lambda.amazonaws.com" },
238
+ Action: sts("AssumeRole")
239
+ }
240
+ ]
241
+ });
242
+ const { roleArn } = await getOrCreateIamRole({
243
+ iamClient: props.iamClient,
244
+ trustPolicy,
245
+ logger: props.logger
246
+ });
247
+ await applyLambdaRolePolicy({
248
+ iamClient: props.iamClient,
249
+ bucketName: props.bucketName
250
+ });
235
251
  return { roleArn };
236
252
  }
237
253
  async function getOrCreateIamRole(props) {
@@ -395,11 +411,27 @@ async function runRemoteScan(input) {
395
411
  input.logger.log(` Groups: ${response.summary.groups}`);
396
412
  input.logger.log(` Permission Sets: ${response.summary.permissionSets}`);
397
413
  input.logger.log(` Account Assignments: ${response.summary.accountAssignments}`);
414
+ input.logger.log(` Policies: ${response.summary.policies}`);
415
+ input.logger.log(` Policy Attachments: ${response.summary.policyAttachments}`);
398
416
  await writeStateCache(cachePath, response.state);
399
417
  input.logger.log("State cache updated.");
400
418
  }
401
419
  async function runRemoteInit(input) {
402
- const deployment = await readDeploymentFromContext();
420
+ const isUpdate = input.flags.update;
421
+ let existingConfig;
422
+ if (isUpdate) {
423
+ try {
424
+ existingConfig = await loadAwsConfigModelFromTsFile({
425
+ configPath: configFilePath,
426
+ typesPath: typesFilePath
427
+ });
428
+ } catch {
429
+ }
430
+ }
431
+ const [deployment, cliVersion] = await Promise.all([
432
+ readDeploymentFromContext(),
433
+ readPackageVersion()
434
+ ]);
403
435
  input.logger.log("Invoking remote scan...");
404
436
  const result = await invokeLambda({
405
437
  lambdaClient: input.lambdaClient,
@@ -420,14 +452,17 @@ async function runRemoteInit(input) {
420
452
  input.logger.log(` Groups: ${response.summary.groups}`);
421
453
  input.logger.log(` Permission Sets: ${response.summary.permissionSets}`);
422
454
  input.logger.log(` Account Assignments: ${response.summary.accountAssignments}`);
455
+ input.logger.log(` Policies: ${response.summary.policies}`);
456
+ input.logger.log(` Policy Attachments: ${response.summary.policyAttachments}`);
423
457
  await writeStateCache(cachePath, response.state);
424
458
  input.logger.log("State cache updated.");
425
459
  const context = await readAwsContextFromFile(contextFilePath);
426
460
  const graveyardOu = response.state.organization.organizationalUnits.find(
427
461
  (ou) => ou.name === "Graveyard"
428
462
  );
429
- const updatedContext = {
430
- ...context,
463
+ const ordered = {
464
+ version: context.version,
465
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
431
466
  organization: {
432
467
  managementAccountId: context.organization.managementAccountId,
433
468
  rootId: response.state.organization.rootId,
@@ -436,14 +471,8 @@ async function runRemoteInit(input) {
436
471
  identityCenter: {
437
472
  instanceArn: response.state.identityCenter.instanceArn,
438
473
  identityStoreId: response.state.identityCenter.identityStoreId
439
- }
440
- };
441
- const ordered = {
442
- version: updatedContext.version,
443
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
444
- organization: updatedContext.organization,
445
- identityCenter: updatedContext.identityCenter,
446
- deployment: updatedContext.deployment
474
+ },
475
+ deployment: { ...deployment, cliVersion }
447
476
  };
448
477
  await writeFile(contextFilePath, `${JSON.stringify(ordered, null, 2)}
449
478
  `, "utf8");
@@ -453,12 +482,13 @@ async function runRemoteInit(input) {
453
482
  configPath: configFilePath,
454
483
  typesPath: typesFilePath,
455
484
  logger: input.logger,
456
- overwriteConfirmation: input.overwriteConfirmation
485
+ overwriteConfirmation: input.overwriteConfirmation,
486
+ existingConfig
457
487
  });
458
488
  const writtenFiles = configWriteResult.files.filter((f) => f.status === "written");
459
489
  if (writtenFiles.length > 0) {
460
490
  input.logger.log("");
461
- input.logger.log("Init complete.");
491
+ input.logger.log(isUpdate ? "Init --update complete." : "Init complete.");
462
492
  for (const file of writtenFiles) {
463
493
  input.logger.log(` ${file.path}: ${file.status}`);
464
494
  }
@@ -477,6 +507,7 @@ async function runRemotePlan(input) {
477
507
  typesPath: typesFilePath
478
508
  })
479
509
  ]);
510
+ warnIfRemotePoliciesNotInConfig({ currentState, config, logger: input.logger });
480
511
  const desiredState = mapAwsConfigToState({
481
512
  config,
482
513
  currentState,
@@ -504,6 +535,7 @@ async function runRemoteApply(input) {
504
535
  typesPath: typesFilePath
505
536
  })
506
537
  ]);
538
+ warnIfRemotePoliciesNotInConfig({ currentState, config, logger: input.logger });
507
539
  const desiredState = mapAwsConfigToState({
508
540
  config,
509
541
  currentState,
@@ -591,8 +623,11 @@ async function runRemoteApply(input) {
591
623
  });
592
624
  }
593
625
  async function runRemoteUpgrade(input) {
594
- const deployment = await readDeploymentFromContext();
595
- const lambdaZip = await readLambdaZip();
626
+ const [deployment, cliVersion, lambdaZip] = await Promise.all([
627
+ readDeploymentFromContext(),
628
+ readPackageVersion(),
629
+ readLambdaZip()
630
+ ]);
596
631
  input.logger.log(`Updating Lambda function code: ${deployment.lambdaArn}`);
597
632
  await waitForLambdaReady(input.lambdaClient, deployment.lambdaArn);
598
633
  const updateResult = await input.lambdaClient.send(
@@ -602,12 +637,51 @@ async function runRemoteUpgrade(input) {
602
637
  })
603
638
  );
604
639
  const lastModified = updateResult.LastModified ?? "unknown";
605
- input.logger.log(`Upgrade complete. Last modified: ${lastModified}`);
640
+ input.logger.log(`Lambda updated. Last modified: ${lastModified}`);
641
+ input.logger.log("Updating IAM role policy...");
642
+ await applyLambdaRolePolicy({
643
+ iamClient: input.iamClient,
644
+ bucketName: deployment.stateBucketName
645
+ });
646
+ input.logger.log("IAM role policy updated.");
647
+ const context = await readAwsContextFromFile(contextFilePath);
648
+ const ordered = {
649
+ version: context.version,
650
+ generatedAt: context.generatedAt,
651
+ organization: context.organization,
652
+ identityCenter: context.identityCenter,
653
+ deployment: { ...deployment, cliVersion }
654
+ };
655
+ await writeFile(contextFilePath, `${JSON.stringify(ordered, null, 2)}
656
+ `, "utf8");
657
+ input.logger.log("");
658
+ input.logger.log("Run init --update to sync your config with new remote features before using plan/apply.");
659
+ }
660
+ function warnIfRemotePoliciesNotInConfig(props) {
661
+ const remotePolicies = props.currentState.organization.policies ?? [];
662
+ const hasRemotePolicies = remotePolicies.length > 0;
663
+ const hasLocalPolicies = (props.config.policies?.serviceControlPolicies?.length ?? 0) > 0 || (props.config.policies?.resourceControlPolicies?.length ?? 0) > 0;
664
+ if (hasRemotePolicies && !hasLocalPolicies) {
665
+ props.logger.log("");
666
+ props.logger.log("Warning: remote state contains SCPs/RCPs not present in your config. Proceeding could delete them.");
667
+ props.logger.log("Run init --update to sync first.");
668
+ props.logger.log("");
669
+ }
606
670
  }
607
671
  async function readDeploymentFromContext() {
608
- const context = await readAwsContextFromFile(contextFilePath);
672
+ let context;
673
+ try {
674
+ context = await readAwsContextFromFile(contextFilePath);
675
+ } catch (err) {
676
+ if (err.code === "ENOENT") {
677
+ throw toPreconditionError(
678
+ "aws.context.json not found. Run `aws-accounts bootstrap` first."
679
+ );
680
+ }
681
+ throw err;
682
+ }
609
683
  if (context.deployment == null) {
610
- throw new Error(
684
+ throw toPreconditionError(
611
685
  "No deployment found in aws.context.json. Run `aws-accounts bootstrap` first."
612
686
  );
613
687
  }
@@ -674,7 +748,7 @@ function displayPlan(props) {
674
748
  }
675
749
  }
676
750
  function isDestructiveOperation(operation) {
677
- return operation.kind === "deleteOu" || operation.kind === "removeAccount" || operation.kind === "deleteIdcUser" || operation.kind === "deleteIdcGroup" || operation.kind === "deleteIdcPermissionSet";
751
+ return operation.kind === "deleteOu" || operation.kind === "removeAccount" || operation.kind === "deleteIdcUser" || operation.kind === "deleteIdcGroup" || operation.kind === "deleteIdcPermissionSet" || operation.kind === "detachOrgPolicy" || operation.kind === "deleteOrgPolicy";
678
752
  }
679
753
  function formatOperationLine(operation) {
680
754
  if (operation.kind === "moveAccount") {
@@ -761,6 +835,37 @@ function formatOperationLine(operation) {
761
835
  if (operation.kind === "revokeIdcAccountAssignment") {
762
836
  return ` revoke IdC assignment "${operation.permissionSetName}" from ${formatPrincipalLabel(operation.principalType, operation.principalName)} on "${operation.accountName}"`;
763
837
  }
838
+ if (operation.kind === "updateIdcPermissionSetSessionDuration") {
839
+ const duration = operation.sessionDuration ?? "default";
840
+ return ` update IdC permission set session duration "${operation.permissionSetName}" -> ${duration}`;
841
+ }
842
+ if (operation.kind === "createOrgPolicy") {
843
+ return ` create org policy "${operation.policyName}" (${operation.policyType})`;
844
+ }
845
+ if (operation.kind === "updateOrgPolicyContent") {
846
+ return ` update org policy content "${operation.policyName}"`;
847
+ }
848
+ if (operation.kind === "updateOrgPolicyDescription") {
849
+ return ` update org policy description "${operation.policyName}"`;
850
+ }
851
+ if (operation.kind === "attachOrgPolicy") {
852
+ return ` attach org policy "${operation.policyName}" to "${operation.targetName}"`;
853
+ }
854
+ if (operation.kind === "detachOrgPolicy") {
855
+ return ` [destructive] detach org policy "${operation.policyName}" from "${operation.targetName}"`;
856
+ }
857
+ if (operation.kind === "deleteOrgPolicy") {
858
+ return ` [destructive] delete org policy "${operation.policyName}"`;
859
+ }
860
+ if (operation.kind === "putAlternateContact") {
861
+ return ` set ${operation.contactType} alternate contact for "${operation.accountName}" (${operation.accountId})`;
862
+ }
863
+ if (operation.kind === "deleteAlternateContact") {
864
+ return ` [destructive] delete ${operation.contactType} alternate contact for "${operation.accountName}" (${operation.accountId})`;
865
+ }
866
+ if (operation.kind === "setIdcAccessControlAttributes") {
867
+ return ` set IdC access control attributes (${operation.attributes.length} attribute(s))`;
868
+ }
764
869
  assertUnreachable(operation, "Unsupported operation kind in formatOperationLine.");
765
870
  }
766
871
  function formatPrincipalLabel(principalType, principalName) {
@@ -817,7 +922,7 @@ async function ensureOrganizationManagementPermissionSet(props) {
817
922
  Version: "2012-10-17",
818
923
  Statement: [{
819
924
  Effect: "Allow",
820
- Action: [iam.organizations("*"), iam.sso("*"), iam.identitystore("*"), iam.account("*"), iam.iam("*")],
925
+ Action: [organizations("*"), sso("*"), identitystore("*"), account("*"), iam("*")],
821
926
  Resource: "*"
822
927
  }]
823
928
  });
@@ -867,7 +972,7 @@ async function ensureOrganizationRemoteManagementPermissionSet(props) {
867
972
  Version: "2012-10-17",
868
973
  Statement: [{
869
974
  Effect: "Allow",
870
- Action: [iam.lambda("InvokeFunction")],
975
+ Action: [lambda("InvokeFunction")],
871
976
  Resource: props.lambdaArn
872
977
  }]
873
978
  });
@@ -0,0 +1,125 @@
1
+ import { loadAwsConfigModelFromTsFile } from "../awsConfig.js";
2
+ const INLINE_POLICY_MAX_CHARS = 10240;
3
+ const ORG_POLICY_CONTENT_MAX_BYTES = 5120;
4
+ async function runValidateCommand(input) {
5
+ const configPath = input.configPath ?? "aws.config.ts";
6
+ const typesPath = input.typesPath ?? "aws.config.types.ts";
7
+ let config;
8
+ try {
9
+ config = await loadAwsConfigModelFromTsFile({ configPath, typesPath });
10
+ } catch (error) {
11
+ input.logger.log(`Config error: ${error instanceof Error ? error.message : String(error)}`);
12
+ return false;
13
+ }
14
+ const errors = [];
15
+ checkCircularOuReferences(config, errors);
16
+ checkAssignmentPrincipals(config, errors);
17
+ checkInlinePolicySizes(config, errors);
18
+ checkOrgPolicySizes(config, errors);
19
+ checkOrgPolicyTargets(config, errors);
20
+ if (errors.length > 0) {
21
+ for (const error of errors) {
22
+ input.logger.log(`Error: ${error}`);
23
+ }
24
+ input.logger.log(`
25
+ Validation failed with ${errors.length} error(s).`);
26
+ return false;
27
+ }
28
+ input.logger.log("Config is valid.");
29
+ return true;
30
+ }
31
+ function checkCircularOuReferences(config, errors) {
32
+ const parentByName = new Map(
33
+ config.organizationalUnits.map((ou) => [ou.name, ou.parentName])
34
+ );
35
+ const confirmed = /* @__PURE__ */ new Set();
36
+ for (const ou of config.organizationalUnits) {
37
+ if (ou.name === "root" || confirmed.has(ou.name)) {
38
+ continue;
39
+ }
40
+ const visited = /* @__PURE__ */ new Set();
41
+ let current = ou.name;
42
+ while (current != null) {
43
+ if (visited.has(current)) {
44
+ errors.push(`Circular OU reference detected: "${current}" is its own ancestor.`);
45
+ confirmed.add(current);
46
+ break;
47
+ }
48
+ visited.add(current);
49
+ current = parentByName.get(current) ?? null;
50
+ }
51
+ }
52
+ }
53
+ function checkAssignmentPrincipals(config, errors) {
54
+ for (const assignment of config.assignments) {
55
+ const hasGroup = assignment.group != null;
56
+ const hasUser = assignment.user != null;
57
+ if (hasGroup && hasUser) {
58
+ errors.push(
59
+ `Assignment for permission set "${assignment.permissionSet}" specifies both "group" and "user" \u2014 only one is allowed.`
60
+ );
61
+ } else if (!hasGroup && !hasUser) {
62
+ errors.push(
63
+ `Assignment for permission set "${assignment.permissionSet}" has no principal \u2014 "group" or "user" is required.`
64
+ );
65
+ }
66
+ }
67
+ }
68
+ function checkOrgPolicySizes(config, errors) {
69
+ for (const policy of config.policies?.serviceControlPolicies ?? []) {
70
+ const contentBytes = Buffer.byteLength(JSON.stringify(policy.content), "utf8");
71
+ if (contentBytes > ORG_POLICY_CONTENT_MAX_BYTES) {
72
+ errors.push(
73
+ `Service control policy "${policy.name}" content is ${contentBytes} bytes (limit: ${ORG_POLICY_CONTENT_MAX_BYTES}).`
74
+ );
75
+ }
76
+ }
77
+ for (const policy of config.policies?.resourceControlPolicies ?? []) {
78
+ const contentBytes = Buffer.byteLength(JSON.stringify(policy.content), "utf8");
79
+ if (contentBytes > ORG_POLICY_CONTENT_MAX_BYTES) {
80
+ errors.push(
81
+ `Resource control policy "${policy.name}" content is ${contentBytes} bytes (limit: ${ORG_POLICY_CONTENT_MAX_BYTES}).`
82
+ );
83
+ }
84
+ }
85
+ }
86
+ function checkOrgPolicyTargets(config, errors) {
87
+ const ouNames = new Set(config.organizationalUnits.map((ou) => ou.name));
88
+ const accountNames = new Set(
89
+ config.organizationalUnits.flatMap((ou) => ou.accounts.map((a) => a.name))
90
+ );
91
+ for (const policy of config.policies?.serviceControlPolicies ?? []) {
92
+ for (const target of policy.targets) {
93
+ if (target !== "root" && !ouNames.has(target) && !accountNames.has(target)) {
94
+ errors.push(
95
+ `Service control policy "${policy.name}" references unknown target "${target}".`
96
+ );
97
+ }
98
+ }
99
+ }
100
+ for (const policy of config.policies?.resourceControlPolicies ?? []) {
101
+ for (const target of policy.targets) {
102
+ if (target !== "root" && !ouNames.has(target) && !accountNames.has(target)) {
103
+ errors.push(
104
+ `Resource control policy "${policy.name}" references unknown target "${target}".`
105
+ );
106
+ }
107
+ }
108
+ }
109
+ }
110
+ function checkInlinePolicySizes(config, errors) {
111
+ for (const ps of config.permissionSets) {
112
+ if (ps.inlinePolicy == null) {
113
+ continue;
114
+ }
115
+ const length = JSON.stringify(ps.inlinePolicy).length;
116
+ if (length > INLINE_POLICY_MAX_CHARS) {
117
+ errors.push(
118
+ `Permission set "${ps.name}" inline policy is ${length} characters (limit: ${INLINE_POLICY_MAX_CHARS}).`
119
+ );
120
+ }
121
+ }
122
+ }
123
+ export {
124
+ runValidateCommand
125
+ };