@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BeeSolve
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # @beesolve/aws-accounts
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@beesolve/aws-accounts)](https://www.npmjs.com/package/@beesolve/aws-accounts)
4
+ [![license](https://img.shields.io/npm/l/@beesolve/aws-accounts)](./LICENSE)
5
+
6
+ Config-driven management for AWS Organizations and IAM Identity Center. Define your org structure, accounts, permission sets, and access assignments in a single TypeScript file — then `plan` and `apply` changes like Terraform.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ npm install @beesolve/aws-accounts
12
+ ```
13
+
14
+ Requires Node.js 24+ and valid AWS credentials (via environment, profile, or SSO).
15
+
16
+ ## Quick Start
17
+
18
+ ```bash
19
+ # 1. Create a project directory
20
+ mkdir my-org && cd my-org
21
+ npm init -y
22
+ npm install @beesolve/aws-accounts
23
+
24
+ # 2. Deploy remote infrastructure (S3 bucket, IAM role, Lambda)
25
+ npx aws-accounts bootstrap --region us-east-1
26
+
27
+ # 3. Scan your AWS org and generate aws.config.ts
28
+ npx aws-accounts init
29
+
30
+ # 4. Edit aws.config.ts to model your desired state
31
+
32
+ # 5. Preview and apply changes
33
+ npx aws-accounts plan
34
+ npx aws-accounts apply
35
+ ```
36
+
37
+ After `init`, `aws.config.ts` is your source of truth. Edit it to add accounts, move OUs, manage permission sets, and control access — then sync with `plan` / `apply`.
38
+
39
+ ## Commands
40
+
41
+ | Command | Description |
42
+ |---------|-------------|
43
+ | `bootstrap` | One-time setup: deploys S3 bucket, IAM role, and Lambda to your AWS account |
44
+ | `init` | Scans live AWS state and generates `aws.config.ts` + `aws.config.types.ts` |
45
+ | `regenerate` | Refreshes `aws.config.types.ts` (picklists, autocomplete) from current config |
46
+ | `plan` | Computes diff between desired config and actual AWS state |
47
+ | `apply` | Executes planned operations via Lambda |
48
+ | `upgrade` | Updates the deployed Lambda function code |
49
+ | `scan` | Refreshes remote state in S3 (advanced/recovery use) |
50
+ | `graveyard` | Lists accounts parked in the Graveyard OU |
51
+
52
+ ## Workflow
53
+
54
+ The tool has four phases:
55
+
56
+ 1. **Bootstrap** (one-time) — `bootstrap` deploys the remote infrastructure. Run once per AWS organization.
57
+ 2. **Init** (one-time) — `init` scans your org and generates the config files. After this, `aws.config.ts` is your editable source of truth.
58
+ 3. **Edit** (steady state) — modify `aws.config.ts` to model your desired org structure. Run `regenerate` to refresh IDE autocomplete after edits.
59
+ 4. **Sync** — `plan` shows what will change; `apply` executes it.
60
+
61
+ ## Configuration
62
+
63
+ After `init`, your project contains:
64
+
65
+ - **`aws.config.ts`** — your desired state: OUs, accounts, users, groups, permission sets, assignments
66
+ - **`aws.config.types.ts`** — generated types and helpers for IDE autocomplete
67
+
68
+ ### IAM Policy Helpers
69
+
70
+ `aws.config.types.ts` exports `iam` helpers with service-scoped action autocomplete:
71
+
72
+ ```ts
73
+ import { awsConfigSchema, iam, type AwsConfig } from "./aws.config.types.js";
74
+
75
+ // Full autocomplete for IAM actions
76
+ Action: [iam.s3("GetObject"), iam.identitystore("CreateGroupMembership")]
77
+ ```
78
+
79
+ When `init` generates your config, recognized IAM actions in inline policies are emitted as helper calls rather than raw strings.
80
+
81
+ ## Supported Mutations
82
+
83
+ ### AWS Organizations
84
+
85
+ - Create, rename, and delete OUs (delete requires `--allow-destructive`)
86
+ - Move accounts between OUs
87
+ - Create and rename member accounts
88
+ - Reconcile account resource tags
89
+ - Park removed accounts in a `Graveyard` OU (`--allow-destructive`)
90
+
91
+ ### IAM Identity Center
92
+
93
+ - Create and delete users and groups
94
+ - Update user display name and email
95
+ - Update group descriptions
96
+ - Manage group memberships
97
+ - Create, update, and delete permission sets
98
+ - Manage inline policies, AWS managed policies, and customer-managed policy references
99
+ - Grant and revoke account assignments
100
+ - Reprovision changed permission sets
101
+
102
+ ## Plan/Apply Safety
103
+
104
+ - `plan` fetches current remote state from S3 before computing the diff.
105
+ - `apply` recomputes the plan before executing — no stale operations.
106
+ - Destructive operations (OU deletion, entity removal) require `--allow-destructive`.
107
+ - `--ignore-unsupported` proceeds only for non-destructive unsupported diffs.
108
+ - Destructive unsupported diffs always block `apply` (no override).
109
+ - Human-readable previews mark destructive operations explicitly.
110
+
111
+ ### Example: destructive apply
112
+
113
+ ```bash
114
+ npx aws-accounts plan
115
+ npx aws-accounts apply --allow-destructive
116
+ ```
117
+
118
+ ```text
119
+ Plan: 3 operation(s), 0 unsupported diff(s)
120
+ Destructive operations detected: 1. Apply requires --allow-destructive.
121
+ remove user "alice" from IdC group "Admins"
122
+ revoke IdC assignment "AdminAccess" from group "Admins" on "AppAccount"
123
+ [destructive] delete IdC group "Admins"
124
+ ```
125
+
126
+ ### Recovery after failed apply
127
+
128
+ If `apply` fails mid-run, the Lambda persists partial state to S3. Recovery:
129
+
130
+ ```bash
131
+ npx aws-accounts scan # refresh state from live AWS
132
+ npx aws-accounts plan # review remaining diff
133
+ npx aws-accounts apply # re-apply (add --allow-destructive if needed)
134
+ ```
135
+
136
+ ## CLI Options
137
+
138
+ ```
139
+ npx aws-accounts <command> [options]
140
+
141
+ Options:
142
+ --profile <name> AWS profile (fallback: AWS_PROFILE)
143
+ --region <region> AWS region (fallback: AWS_REGION, AWS_DEFAULT_REGION)
144
+ --yes Skip interactive confirmations
145
+ --json Output plan as JSON (plan command)
146
+ --allow-destructive Allow destructive operations (apply command)
147
+ --ignore-unsupported Proceed with non-destructive unsupported diffs (apply command)
148
+ --refresh Force state refresh before planning (plan command)
149
+ --help Show help
150
+ ```
151
+
152
+ ## IAM Permissions
153
+
154
+ The CLI delegates all AWS operations to a deployed Lambda. Day-to-day usage requires only Lambda invoke permission:
155
+
156
+ ```json
157
+ {
158
+ "Version": "2012-10-17",
159
+ "Statement": [{
160
+ "Effect": "Allow",
161
+ "Action": "lambda:InvokeFunction",
162
+ "Resource": "arn:aws:lambda:*:*:function:beesolve-aws-accounts"
163
+ }]
164
+ }
165
+ ```
166
+
167
+ `bootstrap` and `upgrade` require broader permissions for deploying infrastructure (S3, IAM, Lambda, SSO). See the full policy in the [docs](./docs/adr/002-architecture-and-technology-choices.md).
168
+
169
+ Commands that need no AWS permissions: `regenerate` (local codegen only), `graveyard` (reads local cache only).
170
+
171
+ ## FAQ
172
+
173
+ ### I moved an account manually in the AWS Console. How do I fix my config?
174
+
175
+ Run `npx aws-accounts init` (or `npx aws-accounts init --yes` for non-interactive). This rewrites `aws.config.ts` from live AWS state.
176
+
177
+ `scan` alone won't help — it refreshes remote state in S3 but doesn't touch your config file.
178
+
179
+ ### What happens if I run `scan` + `regenerate`?
180
+
181
+ `regenerate` refreshes only `aws.config.types.ts` from the current `aws.config.ts`. It doesn't rewrite config, so stale config stays stale. Use `init` to reset config to live state.
182
+
183
+ ### Multiple Identity Center instances?
184
+
185
+ If multiple instances exist, the CLI will fail and require `--instance-arn`.
186
+
187
+ ## License
188
+
189
+ [MIT](./LICENSE)
@@ -0,0 +1,135 @@
1
+ import {
2
+ CreateAccountCommand,
3
+ DescribeCreateAccountStatusCommand,
4
+ ListAccountsCommand,
5
+ MoveAccountCommand
6
+ } from "@aws-sdk/client-organizations";
7
+ import { delay } from "./helpers.js";
8
+ async function createAccountAndMoveToOu(props) {
9
+ props.logger.log(
10
+ `Creating account "${props.accountName}" (${props.accountEmail})...`
11
+ );
12
+ const createResponse = await props.organizationsClient.send(
13
+ new CreateAccountCommand({
14
+ AccountName: props.accountName,
15
+ Email: props.accountEmail
16
+ })
17
+ );
18
+ const createRequestId = createResponse.CreateAccountStatus?.Id;
19
+ if (createRequestId == null) {
20
+ throw new Error("CreateAccount did not return a request id.");
21
+ }
22
+ const accountId = await pollCreateAccountStatusUntilTerminal({
23
+ organizationsClient: props.organizationsClient,
24
+ logger: props.logger,
25
+ createRequestId,
26
+ timeoutInMs: props.timeoutInMs,
27
+ pollIntervalInMs: props.pollIntervalInMs
28
+ });
29
+ props.logger.log(
30
+ `Moving account "${props.accountName}" (${accountId}) to destination OU (${props.destinationParentId})...`
31
+ );
32
+ await props.organizationsClient.send(
33
+ new MoveAccountCommand({
34
+ AccountId: accountId,
35
+ SourceParentId: props.sourceParentId,
36
+ DestinationParentId: props.destinationParentId
37
+ })
38
+ );
39
+ const account = await resolveCreatedAccountRecord({
40
+ organizationsClient: props.organizationsClient,
41
+ accountId,
42
+ destinationParentId: props.destinationParentId
43
+ });
44
+ return {
45
+ accountId,
46
+ account
47
+ };
48
+ }
49
+ async function pollCreateAccountStatusUntilTerminal(props) {
50
+ const startedAt = Date.now();
51
+ let lastStatus;
52
+ while (Date.now() - startedAt < props.timeoutInMs) {
53
+ const response = await props.organizationsClient.send(
54
+ new DescribeCreateAccountStatusCommand({
55
+ CreateAccountRequestId: props.createRequestId
56
+ })
57
+ );
58
+ const status = response.CreateAccountStatus;
59
+ const state = status?.State ?? "UNKNOWN";
60
+ if (state !== lastStatus) {
61
+ props.logger.log(`CreateAccount status: ${state}`);
62
+ lastStatus = state;
63
+ }
64
+ if (state === "SUCCEEDED") {
65
+ if (status?.AccountId == null) {
66
+ throw new Error(
67
+ "CreateAccount succeeded but response did not include AccountId."
68
+ );
69
+ }
70
+ return status.AccountId;
71
+ }
72
+ if (state === "FAILED") {
73
+ throw new Error(
74
+ `CreateAccount failed: ${status?.FailureReason ?? "unknown reason"}.`
75
+ );
76
+ }
77
+ await delay(props.pollIntervalInMs);
78
+ }
79
+ throw new Error(
80
+ `CreateAccount timed out after ${props.timeoutInMs}ms. Check AWS Organizations and retry.`
81
+ );
82
+ }
83
+ async function resolveCreatedAccountRecord(props) {
84
+ const account = await findAccountById({
85
+ organizationsClient: props.organizationsClient,
86
+ accountId: props.accountId
87
+ });
88
+ if (account == null) {
89
+ throw new Error(
90
+ `Created account "${props.accountId}" could not be resolved from AWS Organizations list.`
91
+ );
92
+ }
93
+ return {
94
+ id: account.id,
95
+ arn: account.arn,
96
+ name: account.name,
97
+ email: account.email,
98
+ status: account.status,
99
+ parentId: props.destinationParentId
100
+ };
101
+ }
102
+ async function findAccountById(props) {
103
+ let nextToken;
104
+ do {
105
+ const response = await props.organizationsClient.send(
106
+ new ListAccountsCommand({ NextToken: nextToken })
107
+ );
108
+ const matched = (response.Accounts ?? []).find(
109
+ (account) => isCompleteAccountWithStatus(account, props.accountId)
110
+ );
111
+ if (matched?.Id != null && matched.Arn != null && matched.Name != null && matched.Email != null && matched.Status != null) {
112
+ return {
113
+ id: matched.Id,
114
+ arn: matched.Arn,
115
+ name: matched.Name,
116
+ email: matched.Email,
117
+ status: matched.Status
118
+ };
119
+ }
120
+ nextToken = response.NextToken;
121
+ } while (nextToken != null);
122
+ return void 0;
123
+ }
124
+ function isCompleteAccountWithStatus(account, expectedAccountId) {
125
+ if (account.Id == null || account.Arn == null || account.Name == null || account.Email == null || account.Status == null) {
126
+ return false;
127
+ }
128
+ if (expectedAccountId == null) {
129
+ return true;
130
+ }
131
+ return account.Id === expectedAccountId;
132
+ }
133
+ export {
134
+ createAccountAndMoveToOu
135
+ };