@beesolve/aws-accounts 1.1.0 → 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.
package/dist/cli.js CHANGED
@@ -11,7 +11,10 @@ import {
11
11
  resolveAwsRegion
12
12
  } from "./awsClientConfig.js";
13
13
  import { consoleLogger } from "./logger.js";
14
- import { runGraveyardCommand } from "./commands/graveyard.js";
14
+ import {
15
+ runGraveyardCloseCommand,
16
+ runGraveyardCommand
17
+ } from "./commands/graveyard.js";
15
18
  import { runProfileCommand } from "./commands/profile.js";
16
19
  import { runRegenerateCommand } from "./commands/regenerate.js";
17
20
  import { runValidateCommand } from "./commands/validate.js";
@@ -28,6 +31,7 @@ import {
28
31
  exitCodeForCliErrorKind,
29
32
  toUsageError
30
33
  } from "./error.js";
34
+ import { readAwsContextFromFile, readPackageVersion } from "./awsConfig.js";
31
35
  const commands = [
32
36
  "bootstrap",
33
37
  "scan",
@@ -55,6 +59,7 @@ async function main() {
55
59
  "ignore-unsupported": { type: "boolean", default: false },
56
60
  "allow-destructive": { type: "boolean", default: false },
57
61
  refresh: { type: "boolean", default: false },
62
+ update: { type: "boolean", default: false },
58
63
  "sso-start-url": { type: "string" },
59
64
  "sso-session": { type: "string", default: "sso" },
60
65
  help: { type: "boolean", default: false }
@@ -101,6 +106,21 @@ async function main() {
101
106
  return;
102
107
  }
103
108
  if (command === "graveyard") {
109
+ const subcommand = args.positionals[1];
110
+ if (subcommand === "close") {
111
+ await runGraveyardCloseCommand({
112
+ logger,
113
+ cachePath: ".remote-state-cache.json",
114
+ contextPath
115
+ });
116
+ return;
117
+ }
118
+ if (subcommand != null) {
119
+ printHelp(logger);
120
+ throw toUsageError(
121
+ `Unknown graveyard subcommand: "${subcommand}". Valid subcommands: close`
122
+ );
123
+ }
104
124
  await runGraveyardCommand({
105
125
  logger,
106
126
  cachePath: ".remote-state-cache.json",
@@ -112,7 +132,9 @@ async function main() {
112
132
  const ssoStartUrl = args.values["sso-start-url"] ?? process.env.AWS_SSO_START_URL;
113
133
  if (ssoStartUrl == null) {
114
134
  printHelp(logger);
115
- throw toUsageError("--sso-start-url is required for the profile command (or set AWS_SSO_START_URL).");
135
+ throw toUsageError(
136
+ "--sso-start-url is required for the profile command (or set AWS_SSO_START_URL)."
137
+ );
116
138
  }
117
139
  await runProfileCommand({
118
140
  logger,
@@ -136,7 +158,8 @@ async function main() {
136
158
  yes: args.values.yes ?? false,
137
159
  refresh: args.values.refresh ?? false,
138
160
  allowDestructive: args.values["allow-destructive"] ?? false,
139
- ignoreUnsupported: args.values["ignore-unsupported"] ?? false
161
+ ignoreUnsupported: args.values["ignore-unsupported"] ?? false,
162
+ update: args.values.update ?? false
140
163
  },
141
164
  logger,
142
165
  overwriteConfirmation,
@@ -147,25 +170,23 @@ async function main() {
147
170
  ssoAdminClient: new SSOAdminClient(clientConfig)
148
171
  };
149
172
  if (command === "bootstrap") {
150
- return runRemoteBootstrap(remoteInput);
151
- }
152
- if (command === "scan") {
153
- return runRemoteScan(remoteInput);
154
- }
155
- if (command === "init") {
156
- return runRemoteInit(remoteInput);
157
- }
158
- if (command === "plan") {
159
- return runRemotePlan(remoteInput);
160
- }
161
- if (command === "apply") {
162
- return runRemoteApply(remoteInput);
163
- }
164
- if (command === "upgrade") {
165
- return runRemoteUpgrade(remoteInput);
173
+ await runRemoteBootstrap(remoteInput);
174
+ } else if (command === "scan") {
175
+ await runRemoteScan(remoteInput);
176
+ } else if (command === "init") {
177
+ await runRemoteInit(remoteInput);
178
+ } else if (command === "plan") {
179
+ await runRemotePlan(remoteInput);
180
+ } else if (command === "apply") {
181
+ await runRemoteApply(remoteInput);
182
+ } else if (command === "upgrade") {
183
+ await runRemoteUpgrade(remoteInput);
184
+ } else {
185
+ printHelp(logger);
186
+ process.exitCode = 1;
187
+ return;
166
188
  }
167
- printHelp(logger);
168
- process.exitCode = 1;
189
+ await printVersionBannerIfNeeded(logger);
169
190
  }
170
191
  function printHelp(logger) {
171
192
  logger.log("@beesolve/aws-accounts");
@@ -174,15 +195,17 @@ function printHelp(logger) {
174
195
  logger.log(
175
196
  " npm run cli -- bootstrap [--profile <name>] [--region <region>] [--yes]"
176
197
  );
198
+ logger.log(" npm run cli -- scan [--profile <name>] [--region <region>]");
177
199
  logger.log(
178
- " npm run cli -- scan [--profile <name>] [--region <region>]"
200
+ " npm run cli -- init [--profile <name>] [--region <region>] [--yes]"
179
201
  );
180
202
  logger.log(
181
- " npm run cli -- init [--profile <name>] [--region <region>] [--yes]"
203
+ " npm run cli -- init --update [--profile <name>] [--region <region>] [--yes]"
182
204
  );
183
205
  logger.log(" npm run cli -- regenerate [--yes]");
184
206
  logger.log(" npm run cli -- validate");
185
207
  logger.log(" npm run cli -- graveyard");
208
+ logger.log(" npm run cli -- graveyard close");
186
209
  logger.log(
187
210
  " npm run cli -- profile --sso-start-url <url> [--sso-session <name>] (env: AWS_SSO_START_URL)"
188
211
  );
@@ -192,9 +215,7 @@ function printHelp(logger) {
192
215
  logger.log(
193
216
  " npm run cli -- apply [--profile <name>] [--region <region>] [--yes] [--allow-destructive] [--ignore-unsupported]"
194
217
  );
195
- logger.log(
196
- " npm run cli -- upgrade [--profile <name>] [--region <region>]"
197
- );
218
+ logger.log(" npm run cli -- upgrade [--profile <name>] [--region <region>]");
198
219
  logger.log("");
199
220
  logger.log("Environment fallback:");
200
221
  logger.log(" AWS_PROFILE, AWS_REGION, AWS_DEFAULT_REGION");
@@ -227,6 +248,22 @@ function buildOverwriteConfirmation(props) {
227
248
  }
228
249
  };
229
250
  }
251
+ async function printVersionBannerIfNeeded(logger) {
252
+ try {
253
+ const [context, currentVersion] = await Promise.all([
254
+ readAwsContextFromFile(contextPath),
255
+ readPackageVersion()
256
+ ]);
257
+ const remoteVersion = context.deployment?.cliVersion;
258
+ if (remoteVersion != null && remoteVersion !== currentVersion) {
259
+ logger.log("");
260
+ logger.log(
261
+ `New version installed (local: ${currentVersion}, remote: ${remoteVersion}). Run upgrade then init --update to sync.`
262
+ );
263
+ }
264
+ } catch {
265
+ }
266
+ }
230
267
  main().catch((error) => {
231
268
  const classified = classifyCliError(error);
232
269
  consoleLogger.error(`CLI ${classified.kind} error: ${classified.message}`);
@@ -1,5 +1,31 @@
1
1
  import { readAwsContextFromFile } from "../awsConfig.js";
2
2
  import { readStateCache } from "../remoteStateCache.js";
3
+ async function runGraveyardCloseCommand(props) {
4
+ const [cache, context] = await Promise.all([
5
+ readStateCache(props.cachePath),
6
+ readAwsContextFromFile(props.contextPath)
7
+ ]);
8
+ if (cache == null) {
9
+ throw new Error(
10
+ `No remote state cache found at "${props.cachePath}". Run a scan or apply command first to populate the cache.`
11
+ );
12
+ }
13
+ const graveyardOuId = context.organization.graveyardOuId;
14
+ const eligible = cache.state.organization.accounts.filter((a) => a.parentId === graveyardOuId && a.status === "ACTIVE").sort((a, b) => a.name.localeCompare(b.name));
15
+ if (eligible.length === 0) {
16
+ props.logger.log("No accounts eligible for closure in Graveyard.");
17
+ return;
18
+ }
19
+ props.logger.log(`${eligible.length} account(s) eligible for closure:
20
+ `);
21
+ for (const account of eligible) {
22
+ props.logger.log(`# ${account.name} (${account.id})`);
23
+ props.logger.log(
24
+ `aws organizations close-account --account-id ${account.id}`
25
+ );
26
+ props.logger.log("");
27
+ }
28
+ }
3
29
  async function runGraveyardCommand(props) {
4
30
  const [cache, context] = await Promise.all([
5
31
  readStateCache(props.cachePath),
@@ -42,5 +68,6 @@ async function runGraveyardCommand(props) {
42
68
  };
43
69
  }
44
70
  export {
71
+ runGraveyardCloseCommand,
45
72
  runGraveyardCommand
46
73
  };
@@ -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,6 +52,7 @@ import {
51
52
  import { applyReservedOuDeletionGuard } from "../reservedOuDeletion.js";
52
53
  import { validateState } from "../state.js";
53
54
  import { assertUnreachable, delay } from "../helpers.js";
55
+ import { toPreconditionError } from "../error.js";
54
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"]),
@@ -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,22 +177,7 @@ 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: 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: [
@@ -220,7 +210,12 @@ async function ensureIamRole(props) {
220
210
  },
221
211
  {
222
212
  Effect: "Allow",
223
- Action: [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") {
@@ -765,6 +839,33 @@ function formatOperationLine(operation) {
765
839
  const duration = operation.sessionDuration ?? "default";
766
840
  return ` update IdC permission set session duration "${operation.permissionSetName}" -> ${duration}`;
767
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
+ }
768
869
  assertUnreachable(operation, "Unsupported operation kind in formatOperationLine.");
769
870
  }
770
871
  function formatPrincipalLabel(principalType, principalName) {
@@ -1,5 +1,6 @@
1
1
  import { loadAwsConfigModelFromTsFile } from "../awsConfig.js";
2
2
  const INLINE_POLICY_MAX_CHARS = 10240;
3
+ const ORG_POLICY_CONTENT_MAX_BYTES = 5120;
3
4
  async function runValidateCommand(input) {
4
5
  const configPath = input.configPath ?? "aws.config.ts";
5
6
  const typesPath = input.typesPath ?? "aws.config.types.ts";
@@ -14,6 +15,8 @@ async function runValidateCommand(input) {
14
15
  checkCircularOuReferences(config, errors);
15
16
  checkAssignmentPrincipals(config, errors);
16
17
  checkInlinePolicySizes(config, errors);
18
+ checkOrgPolicySizes(config, errors);
19
+ checkOrgPolicyTargets(config, errors);
17
20
  if (errors.length > 0) {
18
21
  for (const error of errors) {
19
22
  input.logger.log(`Error: ${error}`);
@@ -62,6 +65,48 @@ function checkAssignmentPrincipals(config, errors) {
62
65
  }
63
66
  }
64
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
+ }
65
110
  function checkInlinePolicySizes(config, errors) {
66
111
  for (const ps of config.permissionSets) {
67
112
  if (ps.inlinePolicy == null) {