π In This Post
Why β The Problem This Solves
Every growing AWS footprint eventually hits the same wall: someone needs a new AWS account, and giving them one safely is more work than it sounds. A new team needs a sandbox. A new environment needs isolation. A security review wants every workload in its own account, not crammed into a shared one.
Done manually, every new account means a human clicking through account creation, then remembering to attach the right guardrails, then placing it in the right part of the org β and hoping nothing gets forgotten under deadline pressure. That's exactly the kind of repetitive, error-prone provisioning this lab series has been automating since Week 1, just applied one level up: instead of vending EC2 instances or databases, this week vends entire AWS accounts.
AWS Control Tower solves this with a fully managed Landing Zone β but enabling it is a heavy, largely irreversible step: it provisions dedicated Log Archive and Audit accounts, org-wide CloudTrail/Config, and there's no terraform destroy equivalent to undo it later. That's the right tradeoff for a real organization committing to AWS long-term. It's the wrong tradeoff for a personal lab account meant to be built and torn down weekly.
The Business Value
This project builds the same underlying pattern Control Tower's Account Factory automates β minus the permanent Landing Zone:
Self-Service Request
A ServiceNow ticket specifies the account name, owner email, and target OU. No AWS console access needed to request one.
Automatic Account Creation
Step Functions calls organizations:CreateAccount, polls until AWS finishes provisioning, then moves the new account into its OU.
Guardrails Apply Instantly
The Service Control Policy attached to that OU applies to the new account the moment it lands there β no per-account setup, no follow-up ticket.
Ticket Auto-Closes
The requester gets the new account ID back in their ServiceNow ticket, automatically, with no engineer in the loop.
Platform teams stop being a bottleneck for account requests. Security teams get guaranteed guardrails on day one of every new account's life, not whenever someone remembers to attach them. Engineers get a self-service pattern they can hand off entirely.
What You Need to Know β Skills & Tools
This week trades the data-pipeline complexity of Week 4 for organizational and IAM complexity. Before attempting it, you should be comfortable with:
Required AWS Knowledge
Required Engineering Skills
Concepts to Understand Before Starting
Organizational Units (OUs)
Folders in the Organizations tree used to group accounts so one policy can govern many accounts at once, instead of per-account.
Service Control Policies
Org-level policies that set a permission ceiling β even an account's root user can't bypass an SCP attached above it.
Account Vending Machine
An industry term (not an AWS product) for any system that creates pre-baselined AWS accounts on demand. Control Tower's Account Factory is one implementation of this pattern β not the only one.
Step Functions Polling Loop
Account creation is asynchronous. The same Wait β Check β Choice pattern from Week 4's Glue crawler polling reappears here for DescribeCreateAccountStatus.
Architecture β How It Fits Together
Why Nest Under an Existing OU Instead of the Root?
This Organization already had real, active accounts and Control-Tower-style OUs (Core-OU, Archived-Accounts, Workloads-OU) sitting at the root β leftovers from a Landing Zone that had been enabled and later decommissioned. Rather than scatter new Sandbox/Production OUs alongside that existing structure, this project nests them inside the existing Workloads-OU, as siblings of the existing Dev OU. Always read the current state of a shared resource like an Organization before assuming it's a blank slate.
Why a Separate Lambda per Pipeline Step?
Following the same action-dispatch pattern as Week 4's glue_trigger, account_creator handles both create_account and get_account_status via an action key, since they share the same boto3 client and IAM role. account_mover and status_notifier are separate because they need genuinely different IAM permissions β least privilege is easier to enforce when a Lambda's role only ever needs to do one category of thing.
How We Built It β Step by Step
Step 1: Terraform Modules
Six Terraform modules: organizations, scp, iam, lambda, step-functions, and api-gateway. The dev environment wires them together, with the Organizations OU lookup done via a real data source rather than assumed:
data "aws_organizations_organizational_units" "root_children" {
parent_id = data.aws_organizations_organization.current.roots[0].id
}
locals {
workloads_ou_id = [
for ou in data.aws_organizations_organizational_units.root_children.children :
ou.id if ou.name == var.parent_ou_name
][0]
}
module "organizations" {
source = "../../modules/organizations"
parent_id = local.workloads_ou_id
ou_names = var.ou_names # ["Sandbox", "Production"]
}
Step 2: Service Control Policy
Service Control Policies are a policy type that has to be switched on for the Organization's root before any SCP can attach β a one-time, manual, root-level setting with no Terraform resource for it in the AWS provider. Run this once before applying anything below:
aws organizations enable-policy-type \ --root-id <root-id> \ --policy-type SERVICE_CONTROL_POLICY # verify it took effect aws organizations list-roots
The Sandbox OU gets a guardrail SCP denying large/expensive EC2 instance types and restricting activity to allowed regions β attached once, inherited by every account that ever lands in that OU:
resource "aws_organizations_policy" "sandbox_guardrail" {
name = "sandbox-guardrail-scp"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyLargeInstanceTypes"
Effect = "Deny"
Action = ["ec2:RunInstances"]
Resource = "arn:aws:ec2:*:*:instance/*"
Condition = {
StringLike = { "ec2:InstanceType" = var.denied_instance_types }
}
}]
})
}
resource "aws_organizations_policy_attachment" "sandbox_guardrail" {
policy_id = aws_organizations_policy.sandbox_guardrail.id
target_id = var.sandbox_ou_id
}
Step 3: Deploying via HCP Terraform (VCS-Driven)
State lives in HCP Terraform, same as Week 5 β but this week's workspace required getting the execution model right. A CLI-driven (non-VCS) HCP workspace only uploads the directory you run terraform from, which breaks relative module sources like ../../modules/organizations that reach outside that directory. Connecting the workspace to the GitHub repo (matching Week 5's pattern) fixes it: HCP checks out the full repo, so every module path resolves, and applies are confirmed in the HCP UI run page rather than via local CLI.
The OUs now exist, nested under the existing structure rather than sitting at root:
With the apply complete, the SCP is live and inherited by anything that lands in the Sandbox OU:
Step 4: The Vending Pipeline (Lambda + Step Functions)
Account creation is asynchronous β organizations:CreateAccount returns a request ID immediately, and the real account takes 1β3 minutes to provision. Step Functions polls it with the same Wait β Check β Choice pattern from Week 4:
account_creator Lambda submits the CreateAccount request
Wait 30s, then poll DescribeCreateAccountStatus
Choice state: loops back until SUCCEEDED or FAILED
account_mover Lambda moves the new account into its target OU and tags it
status_notifier Lambda closes the ServiceNow ticket either way
No live ServiceNow instance needed to test this β a small script crafts the same signed webhook payload a real ServiceNow Business Rule would send:
Challenges β What Actually Went Wrong
This week had more genuine surprises than any prior week β mostly because it touches shared org-level state instead of a clean, empty account.
What happened: Before deploying anything, a quick check of the live Organization revealed it already had real active accounts and Control-Tower-style OUs (Core-OU, Archived-Accounts, Workloads-OU) and three accounts sitting in SUSPENDED status β clear residue from a previously enabled and later decommissioned Control Tower Landing Zone.
Fix: Nested the new Sandbox/Production OUs inside the existing Workloads-OU instead of the root, using a real aws_organizations_organizational_units data source lookup rather than assuming a fresh org.
Unreadable module directory on a CLI-Driven HCP WorkspaceWhat happened: A CLI-driven (non-VCS) HCP workspace failed with lstat ../../modules: no such file or directory. HCP's CLI-driven workflow only uploads the directory you run Terraform from β not parent directories β so any module source reaching outside that directory can never resolve.
Fix: Connected the workspace to the GitHub repo (same pattern as Week 5's week-05-dev), with working-directory set to the dev environment path. HCP checks out the whole repo, so every relative module path resolves correctly.
PolicyTypeNotEnabledException Attaching the SCPWhat happened: The SCP attachment failed even though Service Control Policies were enabled at the organization level overall. aws organizations list-roots showed only S3_POLICY enabled on the root β SERVICE_CONTROL_POLICY had never actually been enabled there.
Fix: Ran aws organizations enable-policy-type --root-id <root> --policy-type SERVICE_CONTROL_POLICY once, directly β a one-time root-level setting with no equivalent Terraform resource in the AWS provider, so it's a deliberate manual prerequisite rather than something Terraform manages.
What happened: When account creation genuinely failed, the execution status showed FAILED with zero invocations of status_notifier β meaning ServiceNow never got told. The VendingFailed state referenced $.status_result.failure_reason directly, but that path only ever gets set by the polling branch β when CreateAccount itself fails, only $.error exists, so the JSONPath never resolves and Step Functions fails the task before invoking the Lambda at all.
Fix: Added a NormalizeFailure Pass state that both CreateAccount's and MoveAccount's Catch blocks route through first, mapping $.error.Cause into $.status_result.failure_reason before reaching VendingFailed β so the same field always exists regardless of which branch failed.
What happened: The first real end-to-end test failed with ConstraintViolationException: You have exceeded the allowed number of AWS accounts. The Organization already had 10 accounts β 7 active, 3 already SUSPENDED from the earlier Control Tower decommission β right at AWS's default account-count limit, which counts suspended (not-yet-fully-deleted) accounts too.
Fix: Closing a few legacy active accounts didn't help immediately β closed accounts stay SUSPENDED and still count against the same limit until their ~90-day deletion window completes. A real quota increase via AWS Support is the only way to unblock new account creation today. This is itself a useful, real lesson: production account-vending systems need their quota requested ahead of the demand curve, not discovered at the moment they're needed.
Security β Controls at Every Layer
π SCPs as a Hard Ceiling
The Sandbox guardrail SCP can't be bypassed by IAM inside the vended account β not even by that account's root user. It's enforced at the Organizations level, above any policy the account itself could ever grant.
π Per-Lambda Least Privilege
account_creator can only call CreateAccount/DescribeCreateAccountStatus. account_mover can only call MoveAccount/TagResource. Neither can do what the other does.
π Webhook Authentication
Same HMAC-SHA256 validation pattern as every prior week β hmac.compare_digest(), not ==, to avoid timing attacks. Unsigned requests are rejected before Step Functions ever starts.
πΎ Secrets in SSM, Not Code
ServiceNow credentials and the webhook secret live in SSM Parameter Store as SecureString, read at runtime β nothing in environment variables or source.
- The shared
hcp-terraform-rolecurrently hasAdministratorAccessβ broader than this pipeline actually needs; a production setup should scope it down - No SCP yet on the
ProductionOU β onlySandboxhas a guardrail attached - No automated account-quota monitoring/alerting before hitting the org account limit
- No automated cleanup path for accounts that fail mid-vend and never get tagged
Cost
The vending pipeline itself is essentially free to run β the real cost consideration is what happens after an account is vended.
| Resource | Usage | Cost |
|---|---|---|
| AWS Organizations, OUs, SCPs | n/a | Free |
| Step Functions | ~10 state transitions per vend | <$0.001 |
| Lambda (4 functions) | ~6 invocations per vend | <$0.001 |
| API Gateway | 1 request per vend | <$0.001 |
| Vended AWS accounts | n/a directly | No charge to create, but each is a real, persistent account β not destroyed by terraform destroy |
Cleanup β Tearing It Down
Because this workspace is VCS-connected, destroy has to be confirmed in the HCP UI, not via local CLI β the same restriction Week 5 ran into.
| Resource | Destroyed by Terraform? | Manual step needed? |
|---|---|---|
| Sandbox / Production OUs | Yes β if empty | Move any vended accounts out first; AWS refuses to delete a non-empty OU |
| Sandbox guardrail SCP | Yes | None |
| Lambda functions (4) | Yes | None |
| Step Functions state machine | Yes | None |
| API Gateway | Yes | None |
| IAM roles + policies | Yes | None |
| Vended AWS accounts | No β never in state | Close manually via Organizations console; closed accounts suspend for ~90 days, not deleted instantly |
Triggering the webhook in a real environment creates a real AWS account with no fast undo. Test deliberately, with a throwaway alias email, and only when you actually intend to exercise the full pipeline.
References
- AWS Organizations β Service Control Policies β how SCPs set a permission ceiling, not a grant.
- Enabling and disabling policy types on a root β the one-time root-level step this build hit (Challenge 3).
- aws_organizations_organizational_unit β Terraform resource for OUs.
- aws_organizations_policy / aws_organizations_policy_attachment β SCP creation and attachment.
- HCP Terraform CLI-driven run workflow β documents the working-directory upload limitation behind Challenge 2.
- Full source code β week-06-account-vending-machine
Takeaways β What I'd Do Differently
π Always Read Live State First
Assuming a "personal lab account" is a clean slate almost caused new OUs to be scattered alongside an existing, populated Organization. A few read-only aws organizations calls before writing any Terraform saved real rework.
βοΈ HCP Execution Mode Matters as Much as the Code
CLI-driven and VCS-driven HCP workspaces have genuinely different capabilities β module path resolution, what CLI commands are even allowed. Pick the mode to match how you'll actually operate the workspace, not just to get the first plan to run.
π Step Functions Catch Blocks Need Their Own Testing
The happy path looked complete in review, but the failure path silently dropped the ServiceNow notification until an actual failure exercised it. Failure branches deserve at least one deliberate test, not just a glance at the ASL.
π Quotas Are Infrastructure Too
An account-vending system is only as good as the account quota behind it. In a real rollout, request the quota increase before building the pipeline that depends on it β not after the first real request hits the wall.
Two significant changes since this post was published:
- SCP quotas doubled (May 2026) β max SCPs per node (root/OU/account) increased from 5 to 10; max SCP document size increased from 5,120 to 10,240 characters. The guardrail SCP in this build is well under the old limit, but larger orgs can now consolidate policies they previously had to split.
- Full IAM policy language for SCPs (September 2025) β SCPs previously supported a subset of IAM policy syntax. They now support the full IAM policy language, including Condition keys and more complex
StringLike/ArnLikepatterns. This unlocks finer-grained guardrails without workarounds.
Comments