@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 +21 -0
- package/README.md +189 -0
- package/dist/accountCreation.js +135 -0
- package/dist/applyLogic.js +1203 -0
- package/dist/awsClientConfig.js +26 -0
- package/dist/awsConfig.js +1365 -0
- package/dist/cli.js +201 -0
- package/dist/commands/graveyard.js +46 -0
- package/dist/commands/regenerate.js +17 -0
- package/dist/commands/remote.js +925 -0
- package/dist/diff.js +1012 -0
- package/dist/error.js +66 -0
- package/dist/helpers.js +21 -0
- package/dist/lambda/handler.js +375 -0
- package/dist/lambdaClient.js +220 -0
- package/dist/logger.js +26 -0
- package/dist/operations.js +218 -0
- package/dist/remoteStateCache.js +38 -0
- package/dist/reservedOuDeletion.js +46 -0
- package/dist/scanLogic.js +456 -0
- package/dist/state.js +618 -0
- package/dist/tags.js +14 -0
- package/dist-lambda/handler.mjs +3558 -0
- package/dist-lambda/lambda.zip +0 -0
- package/package.json +59 -0
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
|
+
[](https://www.npmjs.com/package/@beesolve/aws-accounts)
|
|
4
|
+
[](./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
|
+
};
|