Home Resume
Homeβ€Ί Blogβ€Ί Week 6 - Building an Account Vending Machine with …
AWS Weekly Lab AWS Terraform

Building an Account Vending Machine with AWS Organizations, SCPs & Step Functions (No Control Tower)

A ServiceNow ticket creates a real AWS account, drops it into the right Organizational Unit, and inherits its guardrails automatically β€” no Control Tower required

AWS Platform Engineering Lab Β· Week 06

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.

The Real-World Pain Point

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:

1

Self-Service Request

A ServiceNow ticket specifies the account name, owner email, and target OU. No AWS console access needed to request one.

2

Automatic Account Creation

Step Functions calls organizations:CreateAccount, polls until AWS finishes provisioning, then moves the new account into its OU.

3

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.

4

Ticket Auto-Closes

The requester gets the new account ID back in their ServiceNow ticket, automatically, with no engineer in the loop.

Who Benefits

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

🏒 AWS Organizations (OUs, accounts)
πŸ”’ Service Control Policies (SCPs)
πŸ”„ Step Functions (state machines)
⚑ Lambda (Python, boto3)
🌐 API Gateway (REST + HMAC)
πŸ” IAM (least privilege, management account)

Required Engineering Skills

πŸ—οΈ Terraform (modules, HCP Terraform)
🐍 Python (boto3)
πŸ”‘ HMAC-SHA256 (webhook security)
πŸ”— Git/VCS-driven CI workflows

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

Account Vending Machine Architecture ServiceNow webhook triggers Step Functions to create an AWS account, poll its status, move it into an OU, and notify ServiceNow Ingestion Layer ServiceNow account request ticket API Gateway POST /webhook webhook_receiver Ξ» Validates HMAC Β· starts execution Orchestration Layer Step Functions: CreateAccount β†’ Poll β†’ MoveAccount β†’ Notify Account Provisioning Layer account_creator Ξ» CreateAccount + poll status account_mover Ξ» MoveAccount into target OU status_notifier Ξ» Closes ServiceNow ticket AWS Organizations Workloads-OU Sandbox OU SCP: deny large instances + region Production OU no SCP attached (yet) closes ticket Figure 1: Simulated Account Vending Machine β€” Organizations + SCPs + Step Functions, no Control Tower

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:

HCL β€” Nesting Under an Existing OU
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

Prerequisite β€” Enable the SCP Policy Type First

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 CLI β€” One-Time Root Setup
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:

HCL β€” Sandbox Guardrail SCP
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.

HCP Terraform apply run for week-06-dev showing resources created
HCP Terraform β€” week-06-dev apply run, confirmed via the HCP UI run page (VCS-connected workspace, no local CLI apply)

The OUs now exist, nested under the existing structure rather than sitting at root:

Sandbox OU nested under Workloads-OU in AWS Organizations console
The Sandbox OU, reached via Root β†’ Workloads-OU β†’ Sandbox β€” nested inside 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:

sandbox-guardrail-scp attached directly to the Sandbox OU
Sandbox OU β€” Policies tab showing sandbox-guardrail-scp "Attached directly", alongside the inherited FullAWSAccess defaults

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:

1
CreateAccount
account_creator Lambda submits the CreateAccount request
2
WaitForAccount
Wait 30s, then poll DescribeCreateAccountStatus
3
IsAccountReady
Choice state: loops back until SUCCEEDED or FAILED
4
MoveAccount
account_mover Lambda moves the new account into its target OU and tags it
5
VendingSucceeded / VendingFailed
status_notifier Lambda closes the ServiceNow ticket either way
API Gateway /webhook POST method execution diagram showing Lambda integration
API Gateway /webhook POST method β€” Client β†’ Method request β†’ Integration request β†’ webhook_receiver Lambda

No live ServiceNow instance needed to test this β€” a small script crafts the same signed webhook payload a real ServiceNow Business Rule would send:

Terminal output showing a simulated ServiceNow webhook call returning 200 OK with an execution ARN
Simulated ServiceNow webhook via scripts/test_webhook.sh β€” HMAC-signed POST returns 200 OK with the Step Functions execution ARN, confirming the pipeline started

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.

Challenge 1: Existing Organization Wasn't a Blank Slate

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.

Challenge 2: Unreadable module directory on a CLI-Driven HCP Workspace

What 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.

Challenge 3: PolicyTypeNotEnabledException Attaching the SCP

What 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.

Challenge 4: Step Functions Failure Path Crashed Before Notifying ServiceNow

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.

Step Functions execution graph showing States.Runtime error in VendingFailed step
The bug, caught live: CreateAccount's Catch routes to VendingFailed, which then errors with States.Runtime before status_notifier ever runs
Challenge 5: Hit the AWS Organizations Account Limit Mid-Test

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.

CloudWatch Logs showing the ConstraintViolationException traceback from account_creator Lambda
CloudWatch Logs for account_creator β€” the real ConstraintViolationException traceback, account quota exceeded

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.

What's Missing for Production
  • The shared hcp-terraform-role currently has AdministratorAccess β€” broader than this pipeline actually needs; a production setup should scope it down
  • No SCP yet on the Production OU β€” only Sandbox has 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.

ResourceUsageCost
AWS Organizations, OUs, SCPsn/aFree
Step Functions~10 state transitions per vend<$0.001
Lambda (4 functions)~6 invocations per vend<$0.001
API Gateway1 request per vend<$0.001
Vended AWS accountsn/a directlyNo charge to create, but each is a real, persistent account β€” not destroyed by terraform destroy
Pipeline infrastructure destroyed: $0. Vended accounts are a separate, manual cleanup β€” they were never written into Terraform state, since they're created dynamically by Lambda at runtime, not declared statically.

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.

ResourceDestroyed by Terraform?Manual step needed?
Sandbox / Production OUsYes β€” if emptyMove any vended accounts out first; AWS refuses to delete a non-empty OU
Sandbox guardrail SCPYesNone
Lambda functions (4)YesNone
Step Functions state machineYesNone
API GatewayYesNone
IAM roles + policiesYesNone
Vended AWS accountsNo β€” never in stateClose manually via Organizations console; closed accounts suspend for ~90 days, not deleted instantly
A vended account is forever-ish
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.
HCP Terraform destroy run for week-06-dev, triggered via UI, 39 resources destroyed
HCP Terraform β€” week-06-dev destroy run, triggered via UI and confirmed (39 resources destroyed, 0 added/changed)

References

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.

AWS Organizations SCP Updates (2025–2026)

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/ArnLike patterns. This unlocks finer-grained guardrails without workarounds.
Week 06 complete. A simulated Account Vending Machine: Organizations + SCPs + a ServiceNow-triggered Step Functions pipeline, deployed without ever enabling Control Tower's permanent Landing Zone. Next up: Week 7’s IAM Identity Center SSO.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent