DEV Community

Cover image for AWS Support Said You Can't Import Control Tower's Management Account Into AFT. You Can.

AWS Support Said You Can't Import Control Tower's Management Account Into AFT. You Can.

Every Account Factory for Terraform tutorial I have read starts the same way: empty organization, fresh Control Tower landing zone, push an account request, watch a shiny new account appear.

That is not the situation most of us are in.

I inherited nine AWS accounts. All of them were created by hand in the Console, over three years, by people who had long since moved on. Control Tower was already live. The accounts were already enrolled. Nobody was going to let me delete and re-vend production so the tooling could feel good about itself.

So the job was the awkward one: adopt AFT on top of accounts that already exist. Most of it went fine. Three accounts did not. The last of those is the one the internet will confidently tell you is impossible.

It is not. It takes two strings that nobody wrote down.

Here is what actually happens, and the two undocumented traps that cost me the most time.

What "importing" into AFT actually means

Worth getting this straight, because the word "import" is doing a lot of work.

There is no terraform import here. You are not moving resources into state. You are writing an account request file for an account that already exists, and letting AFT recognise it rather than create it.

When you push that file, AFT compares your requested account email against the accounts already in your organization. If it finds a match, it skips provisioning entirely and runs everything downstream: creates the AWSAFTExecution role, applies your tags, writes metadata to DynamoDB, and builds the account a customization pipeline.

That last part is the whole point. Once an account has an AFT pipeline, you can push Terraform at it like any other account, and re-invoke customizations on demand.

This has worked for ordinary enrolled accounts since AFT 1.3.3, back in February 2022. Well trodden ground.

The ordinary accounts went in without a fight

Six of my nine accounts were normal workload accounts in normal OUs. For those, the process is genuinely boring, which is the highest compliment you can pay infrastructure tooling.

Write the request, match the email exactly, match the account name exactly, name the OU, push. A few minutes later the pipeline exists.

Two things that bit me and are worth knowing before they bite you:

  • The email must match AWS Organizations, not Service Catalog. Several of my older accounts had a stale email in the Service Catalog provisioned product and the correct one in Organizations. AFT reads Organizations. If those two have drifted, re-register the account in Control Tower before you go near AFT.
  • Only changes to control_tower_parameters trigger a re-provision. If you edit only your tags or your change management fields, AFT classifies it as a customization request and runs the Step Function without touching Service Catalog. That is correct behaviour and it looks exactly like nothing happening.

Then I got to Log Archive, Audit, and the Control Tower management account.

Then I hit the shared accounts

Control Tower creates three accounts that it considers its own: Log Archive, Audit, and the management account itself. AFT calls these shared accounts and routes them down a completely separate code path.

You can see the list in the source, in shared_account.py. A function called get_shared_ids reads three SSM parameters and returns exactly three account IDs: log archive, audit, and CT management.

That is the first useful thing to know. The CT management account is not an unsupported edge case that happens to work. It is enumerated, by name, in the framework's own definition of a shared account. AFT maintainers shipped this in release 1.4.0 in May 2022. Four years ago.

So I wrote the Log Archive request the way I had written the other six, using the OU format the docs use for nested OUs, and it failed with this:

Unsupported action: Cannot change OU for a Shared CT account or CT management account

I was not trying to change the OU. I had not touched it. That is the trap.

Trap 1: the OU name format flips, and the error lies to you

AFT normally wants nested OUs written as Name (ou-id). For a second level OU you write Sandbox (ou-abcd-1a2b3c4d), because a bare name is ambiguous once you have OUs at different depths.

Shared accounts ignore that convention entirely.

The shared account path calls ou_contains_account, which does a plain string comparison against the OU's Name as returned by AWS Organizations. Not the ID. Not the path. The name, and nothing else.

AFT does have a parser for that format. There is a helper called ou_name_is_nested_format and another called get_name_and_id_from_nested_ou, and the source comment even uses Sandbox (ou-1234-zxcv) as its example. The shared account path just never calls them.

So Security (ou-abcd-1a2b3c4d) never equals Security, the comparison returns false, and you get told you are trying to change an OU you never touched.

The fix is to write the bare name:

ManagedOrganizationalUnit = "Security"
Enter fullscreen mode Exit fullscreen mode

I am not the first person to land here. A user called nronnei worked this out and left a comment on issue #31 in February 2024, which is where I eventually found confirmation. Their version also blanked out all three SSOUser fields. Mine did not. I passed real SSO values and the import succeeded anyway, so if you have seen that advice, the blanking is not required, at least not on 1.18.1.

Worth saying plainly: a misleading error message is a bug in its own right. "Cannot change OU" sends you hunting through your Organizations layout, auditing SCPs, questioning your own OU tree. The actual problem is a string format four calls down the stack.

The account the internet says is impossible

With Log Archive and Audit in, I went for the management account.

If you search for this, you will find issue #119. In June 2023 a user reported hitting a PutAlternateContact permissions failure, and added that AWS Support had told them importing the CT management account was not possible.

That comment has sat there, unanswered, for three years. It is the answer most people find. It is probably the answer that stopped you.

It is stale. Two reasons.

First, that failure was never coming from AFT. I grepped the full source tarballs for 1.6.0, 1.9.0, 1.11.1, 1.18.1 and 1.21.1. The string AlternateContact appears in none of them. The word alternate does not appear anywhere in core AFT 1.18.1 at all. The role that user named is not an AFT resource. Something in their own provisioning customizations was setting alternate contacts, and that is what blew up.

Second, get_shared_ids has listed the CT management account since 1.4.0, as above. The support answer and the framework disagree. The framework is the one that runs.

I ran it. The Step Function went green in three minutes and thirty seven seconds, all eight steps clean:

  • persist metadata
  • create role
  • tag account
  • account metadata SSM
  • AFT features
  • provisioning customizations
  • create pipeline
  • notify success

I checked the account's alternate contacts before and after. Unchanged.

But it needed one more thing that no document mentions.

Trap 2: the OU is called "Root", literally

My management account does not sit in an OU. It sits directly under the organization root, which is where Control Tower puts it and where it stays.

ManagedOrganizationalUnit is a required field. It has no OU. So what do you put?

The answer is in organizations.py, in a function called get_ou_from_account_id. It looks up the account's parent. If that parent is of type ROOT, it calls list_roots() and returns the first root, using that root's Name field.

AWS Organizations names the root Root. Every time, every org. So:

ManagedOrganizationalUnit = "Root"
Enter fullscreen mode Exit fullscreen mode

The literal string. It matches because ou_contains_account compares against that returned name, and that returned name is Root.

Does this still hold on the current release? Yes. The logic is byte for byte identical in 1.18.1 and in 1.21.1, the latest at the time of writing. It only moved from line 196 to line 204. Cite the function name rather than the line number if you write this down, because the line number drifts and the function has not.

Here is the finished request, with identifiers replaced:

module "ct_management" {
  source = "./modules/aft-account-request"

  control_tower_parameters = {
    AccountEmail = "aws@example.com"
    AccountName  = "root"
    # Sits directly under the org root. AFT resolves it via
    # get_ou_from_account_id, which returns the root's Name
    # from list_roots(): the literal string "Root".
    ManagedOrganizationalUnit = "Root"
    SSOUserEmail              = "infra@example.com"
    SSOUserFirstName          = "Platform"
    SSOUserLastName           = "Team"
  }

  account_tags = {
    Project = "aft"
    Owner   = "infra@example.com"
  }

  change_management_parameters = {
    change_requested_by = "Your Name"
    change_reason       = "Import CT Management into AFT"
  }

  account_customizations_name = "CT_MANAGEMENT"
  custom_fields               = {}
}
Enter fullscreen mode Exit fullscreen mode

The OU shape it is matching against looks like this:

Root  (r-abcd)
├── management account        <-- ManagedOrganizationalUnit = "Root"
├── Security  (ou-abcd-1a2b3c4d)
│   ├── Audit                 <-- ManagedOrganizationalUnit = "Security"
│   └── Log Archive           <-- ManagedOrganizationalUnit = "Security"
└── Sandbox  (ou-abcd-5e6f7g8h)
    └── sandbox               <-- "Sandbox (ou-abcd-5e6f7g8h)"
Enter fullscreen mode Exit fullscreen mode

Three accounts, three different spellings of one field:

  • Nested OU: Sandbox (ou-abcd-5e6f7g8h), name plus ID
  • Shared account OU: Security, bare name only
  • Management account: Root, the literal string

That is the part worth remembering.

What to check after it goes green

A green Step Function is not the same as a working setup. Four things I verified, and would verify again:

  • Alternate contacts on the management account. This is the thing people fear AFT will stamp on. Record them before, compare after. Mine were untouched.
  • The customization pipeline exists. Look for a CodePipeline named after the account. If the Step Function succeeded but no pipeline appeared, the import did not really land.
  • DetectChanges is false on that pipeline, and that is correct. Shared account pipelines are started by the Step Function through StartPipelineExecution, not by git pushes. Seeing false there sends people hunting for a broken webhook that was never supposed to exist.
  • Your SCPs still apply. Importing an account into AFT changes nothing about Organizations policy. Confirm rather than assume.

One genuine limitation, stated in the source itself: you cannot move a shared account to a different OU through AFT. That Unsupported action error is real when you actually try it. It only lies when the true cause is a formatting mismatch.

The takeaway

The specific answer is two strings. Bare OU name for shared accounts, and the literal Root for the management account. That is it. That is the whole wall.

The general lesson is the one I keep relearning. A support answer from 2023 and a GitHub comment with no reply are not evidence about software that ships releases every few months. The framework had already grown the feature the answer said did not exist. Fifteen minutes reading shared_account.py beat three years of accumulated consensus.

How much of your infrastructure backlog is parked behind an answer nobody has rechecked?

If you are sitting on a pile of hand built accounts and putting off AFT because you think brownfield adoption is a rewrite: it is not. It is a set of account request files and two string formats nobody wrote down.

Go read the source. The answer is usually already in there.

Verified on AFT 1.18.1, and re-checked against 1.21.1.

Cover photo by Jeff on Unsplash.

Top comments (0)