Skip to main content

Config-driven AWS account management with TypeScript

· 7 min read
Ivan Barlog
AWS Solutions Architect @ BeeSolve

Managing the AWS Organization shouldn't require you to fight CloudFormation or click through Control Tower. It should be code. That's the reason I've built a tool which lets you do exactly that. The tool is called @beesolve/aws-accounts and it is publicly available. If you manage multiple accounts and you are not satisfied with existing solutions this article might be for you.

The problem

To my knowledge there's no lightweight, developer-friendly way to manage AWS Organizations and IAM Identity Center as code. The existing options are either too clunky, too complex or give you much more than you've asked for.

  • CloudFormation/CDK - doesn't support organization management out of the box
  • pepperize/cdk-organizations - good take but was too much for me + waiting for CloudFormation for simple change was too painful
  • Control Tower - too complex to set up, couldn't even get it fully working
  • manual scripts - worked but didn't scale, no declarative state

None of these felt right.

The solution - @beesolve/aws-accounts

If you are looking for an easy-to-use, TypeScript-first, infrastructure-as-code tool for developers you are at the correct address. After installing @beesolve/aws-accounts from npm you will get instant access to the following features:

  • config as a code (single TypeScript file)
  • plan/apply workflow with remote state in S3 inspired by Terraform
  • full type-safety and autocomplete (generated types)
  • works with existing organizations - init scans your infrastructure and generates the config
  • easy to try, easy to ditch - no infrastructure beyond Lambda + S3 bucket

The quick and straightforward setup can be found at README.md.

Here is an example of a config file generated by the tool. The sample contains specification of management account in root of the organization, two OUs with another account in each, a permission set which allows admin access to all accounts and a sample SCP preventing account leaving the organization.

import { iam, type AwsConfig } from "./aws.config.types.js";

const awsConfig = {
organizationalUnits: [
{
name: "root",
parentName: null,
accounts: [
{
name: "ManagementAccount",
email: "management@example.com",
},
],
},
{
name: "dev",
parentName: "root",
accounts: [
{
name: "ProjectADev",
email: "project-a-dev@example.com",
},
],
},
{
name: "prod",
parentName: "root",
accounts: [
{
name: "ProjectAProd",
email: "project-a-prod@example.com",
},
],
},
],
users: [
{
userName: "admin",
displayName: "admin",
email: "admin@example.com",
},
],
groups: [
{
displayName: "admin",
members: ["admin"],
},
],
permissionSets: [
{
name: "AdministratorAccess",
sessionDuration: "PT12H",
awsManagedPolicies: ["arn:aws:iam::aws:policy/AdministratorAccess"],
},
],
assignments: [
{
permissionSet: "AdministratorAccess",
group: "admin",
accounts: [
"ManagementAccount",
"ProjectADev",
"ProjectAProd"
],
},
],
policies: {
serviceControlPolicies: [
{
name: "PreventLeaveOrganisation",
description: "Prevents member accounts to leave the organisation",
content: {
Statement: [
{
Action: [iam.organizations("LeaveOrganization")],
Effect: "Deny",
Resource: "*",
},
],
Version: "2012-10-17",
},
},
],
},
} satisfies AwsConfig;

export default awsConfig;

Notice there are no account IDs in the config. Everything references names which are autocompleted. The tool resolves IDs from remote state.

Your only focus as a developer is the one thing you love - code. Simple, autocompleted code.

You will always know what is going to be applied - before each apply you need to run plan which gives you a breakdown of what has changed in the config file. It looks like this for creating new account:

$ npx aws-accounts plan
Using cached state (fetched 2 minute(s) ago). Use --refresh to force a fresh fetch.
Plan: 1 operation(s), 0 unsupported diff(s)
create account "Test" (test@example.com) in root

Or you can create new account inside new OU:

$ npx aws-accounts plan
Using cached state (fetched 4 minute(s) ago). Use --refresh to force a fresh fetch.
Plan: 2 operation(s), 0 unsupported diff(s)
create OU "test" under root
create account "Test" (test@example.com) in test

Whatever you need — creating accounts, moving OUs, managing permissions — if the tool supports it, you'll see it in the plan.

Destructive actions are clearly marked. Not everything is supported yet - unsupported changes are flagged and won't be applied:

$ npx aws-accounts plan
Plan: 25 operation(s), 1 unsupported diff(s)
Destructive operations detected: 7. Apply requires --allow-destructive.
[destructive] move removed account "Aggregator account" (0123456789) from Security -> Graveyard
[destructive] move removed account "Backup administrator" (1234567890) from Security -> Graveyard
...
revoke IdC assignment "AdministratorAccess" from group "admin" on "Aggregator account"
revoke IdC assignment "AWSAdministratorAccess" from group "AWSAuditAccountAdmins" on "Aggregator account"
...
Unsupported diffs:
- removed OU "Security" [destructive]
These changes require manual action in the AWS Console and will not be applied automatically.
How to ditch the tool

All resources related to the tool are tagged with ManagedBy:beesolve-aws-accounts. When you decide to stop using the tool you just need to list all the resources by tag and delete them.

The tool only ever uses AWS APIs through AWS clients which means there is no CloudFormation to manage/delete.

The infrastructure managed by the tool is always yours. Deleting the tool won't affect it in any way.

Bonus: @beesolve/iam-policy-ts

While building this project I've also created a standalone package @beesolve/iam-policy-ts which helps you to build IAM Policies in type-safe fashion. The tool is built around AWS Policy Generator and daily releases ensure that you have always the latest policy rules available in your code.

The tool also contains a codegen utility that converts JSON policies to TypeScript - this is what @beesolve/aws-accounts uses internally to generate your config.

The package itself is not tied to @beesolve/aws-accounts and can be used by anyone who wants to create type-safe IAM policies.

The simplest example of policy helpers would be:

import { s3, lambda } from "@beesolve/iam-policy-ts";

s3("GetObject"); // "s3:GetObject" — fully typed, autocompleted
lambda("InvokeFunction"); // "lambda:InvokeFunction"
show more complex example 👨‍💻

Here is another example of tool use for specifying the permissions needed for running the @beesolve/aws-accounts:

{
name: "AccountsAdmin",
description: "",
sessionDuration: "PT12H",
inlinePolicy: {
Statement: [
{
Action: [
iam.sts("GetCallerIdentity"),
iam.s3("CreateBucket"),
iam.s3("PutBucketTagging"),
iam.iam("GetRole"),
iam.iam("CreateRole"),
iam.iam("TagRole"),
iam.iam("PutRolePolicy"),
iam.iam("PassRole"),
iam.lambda("GetFunction"),
iam.lambda("CreateFunction"),
iam.lambda("UpdateFunctionCode"),
iam.lambda("UpdateFunctionConfiguration"),
iam.lambda("TagResource"),
iam.lambda("PutFunctionConcurrency"),
iam.sso("ListPermissionSets"),
iam.sso("DescribePermissionSet"),
iam.sso("CreatePermissionSet"),
iam.sso("UpdatePermissionSet"),
iam.sso("PutInlinePolicyToPermissionSet"),
iam.sso("TagResource"),
],
Effect: "Allow",
Resource: "*",
Sid: "RemoteBootstrapCommand",
},
{
Action: [iam.lambda("InvokeFunction")],
Effect: "Allow",
Resource: "arn:aws:lambda:*:*:function:beesolve-aws-accounts",
Sid: "RemoteScanPlanApplyCommands",
},
{
Action: [iam.lambda("UpdateFunctionCode")],
Effect: "Allow",
Resource: "arn:aws:lambda:*:*:function:beesolve-aws-accounts",
Sid: "RemoteUpgradeCommand",
},
],
Version: "2012-10-17",
},
awsManagedPolicies: [],
customerManagedPolicies: [],
},

Conclusion

If you are a developer managing a small to medium AWS Organization and you want a simple, code-driven workflow - give it a try and let me know about your experience. You can provide your feedback through Github issues or you can reach out on LinkedIn.

This tool won't replace Control Tower for enterprises with complex compliance needs but for humble developers this might just be enough.

Built with LLMs

This project was built with the help of LLM coding tools (Claude, Cursor, Kiro). I would never have time to build this by myself but it was fun to see how my thoughts have been materialized in front of my eyes. It took me a while in order to understand how these tools work and how to use them efficiently so maybe I will be writing separate article on this.