Skip to main content

Account Factory List

account_factory_list is the account map: the single source of truth for which member accounts exist, where they sit in the OU tree, and what account-specific values their baseline receives.

It lives in the management account's NTC Account Factory stack, so adding an account, moving it to another OU, or granting it an exemption all happen through a pull request.

File Layout​

The account map is kept as JSON in the root of the Account Factory stack, usually one file per baseline scope, named account_list_<scope_name>.json:

account_list_core.json
account_list_sandbox.json
account_list_workloads.json

Grouping the accounts by the scope whose baseline they receive keeps a pull request limited to one set of accounts and the diff readable as the organization grows.

Account Fields​

{
"account_name": "aws-example-prod",
"account_email": "accounts+aws-example-prod@example.com",
"ou_path": "/root/workloads/prod",
"close_on_deletion": true,
"account_tags": {
"AccountOwner": "owner@example.com",
"AccountType": "workload"
},
"alternate_contacts": [
{
"type": "SECURITY",
"name": "Security Contact",
"email_address": "security@example.com"
}
],
"customer_values": {}
}
FieldTypeRequiredDefaultDescription
account_namestringyes-Account name in AWS Organizations. Unique across all files.
account_emailstringyes-Root email of the account. Unique across all files and unused elsewhere in the organization.
ou_pathstringyes-Target OU, e.g. /root/workloads/prod. The OU must already exist and can nest at most five levels below root.
close_on_deletionboolyes-true closes the AWS account when the entry is removed. false only drops it from Terraform management.
ignore_naming_conventionsboolnofalseExempts this account from the optional account_factory_naming_conventions regex checks.
account_tagsmap(string)no{}Tags applied to the account in AWS Organizations. Non-string values are stringified, so false is stored as "false".
alternate_contactslist(object)no[]See below.
customer_valuesobjectno{}Per-account values handed to the baseline. See customer_values.

These fields are validated in the Account Factory stack, so a duplicate name, a malformed email or a missing OU fails the plan in the pull request.

Changing account_name or ou_path

Renaming an account or moving it to an OU of another baseline scope changes the key of its baseline state. See Moving and Renaming Accounts before changing account_name, account_email or ou_path of an existing account.

Alternate Contacts​

FieldTypeRequiredDefault
typestringyes-
namestringyes-
email_addressstringyes-
titlestringnoMx
phone_numberstringno00000000000

type is one of SECURITY, OPERATIONS or BILLING, at most one contact per type.

customer_values​

Free-form per-account values, reaching baseline templates as var.current_account_customer_values. Use it for anything a custom template should branch on.

It must be an object. Use {} when an account has none.

One key is reserved: account_baseline_overrides. Everything else is yours.

account_baseline_overrides​

Baseline templates are configured once per scope, which is what makes them consistent. Individual accounts sometimes need a different value or a documented exemption: an image builder account that cannot use EBS default encryption, a legacy workload that still needs IMDSv1, an account recording fewer AWS Config resource types to control cost.

"customer_values": {
"account_baseline_overrides": {
"account_hardening": {
"ebs_encryption_by_default": { "enabled": false }
}
}
}

The aws_config and account_hardening templates support overrides today.

This is not a switch the member account controls

The account map lives in the management account's Account Factory stack, so an exemption is granted centrally through a pull request and stays visible in code review. An administrator inside the member account cannot exempt their own account, and cannot disable a control by editing it in the console either, because the baseline re-runs on schedule and reverts the drift.

Precedence​

The account map override wins over the scope value, merged shallowly per control. An override that sets only one attribute leaves the others at the scope value.

Where a wrong value surfaces​

Overrides are validated, but not in the Account Factory stack. To the Account Factory, customer_values is free-form, so a misspelled control or an invalid value passes the management stack plan and merges into the pull request without complaint.

The validation lives inside the generated baseline file. The failure therefore appears later, when the baseline runs for that account, and it fails the baseline plan with every finding in one message:

Invalid account_baseline_overrides.account_hardening for account aws-example-test:
unknown control 'ebs_encryption_by_defualt'; wrong type for 'iam_password_policy.max_password_age
(expected number)'; invalid value: ec2_imdsv2.http_tokens must be one of required, optional,
no-preference

Only that account is affected. Nothing is applied, and other accounts in the same scope continue to deploy normally.

aws_config​

Only config_recording_mode can be overridden per account.

AttributeDefaultAccepted values
frequencyCONTINUOUSCONTINUOUS, DAILY
resource_types[]List of AWS::<service>::<type> strings
exclusion_by_resource_types[]List of AWS::<service>::<type> strings
recording_mode_override.frequencyDAILYCONTINUOUS, DAILY
recording_mode_override.resource_types[]List of AWS::<service>::<type> strings

resource_types and exclusion_by_resource_types cannot both be set, AWS rejects that recording group with InvalidRecordingGroupException. A type named in exclusion_by_resource_types is not recorded at all, so naming the same type in recording_mode_override.resource_types has no effect.

account_hardening​

Every control accepts enabled to exempt the account from it, plus the settings below.

ControlAttributeDefaultAccepted values
ssm_document_public_sharingsetting_valueDisableDisable, Enable
ssm_automation_log_destinationsetting_valueCloudWatchCloudWatch
ec2_imdsv2http_tokensrequiredrequired, optional, no-preference
ec2_imdsv2http_endpointno-preferenceenabled, disabled, no-preference
ec2_imdsv2http_put_response_hop_limit-11 to 64, or -1 for no account-level preference
ec2_imdsv2instance_metadata_tagsno-preferenceenabled, disabled, no-preference
ebs_encryption_by_default--enabled only
ebs_snapshot_block_public_accessstateblock-all-sharingblock-all-sharing, block-new-sharing
iam_password_policyminimum_password_length186 to 128
iam_password_policyrequire_lowercase_characterstruetrue, false
iam_password_policyrequire_numberstruetrue, false
iam_password_policyrequire_uppercase_characterstruetrue, false
iam_password_policyrequire_symbolstruetrue, false
iam_password_policyallow_users_to_change_passwordtruetrue, false
iam_password_policypassword_reuse_prevention241 to 24
iam_password_policymax_password_age901 to 1095
s3_account_public_access_blockblock_public_aclstruetrue, false
s3_account_public_access_blockblock_public_policytruetrue, false
s3_account_public_access_blockignore_public_aclstruetrue, false
s3_account_public_access_blockrestrict_public_bucketstruetrue, false

CloudWatch is the only value the SSM API accepts for ssm_automation_log_destination. To switch that control off for an account, set "enabled": false rather than looking for another setting value.

Loosening a default can fail the control it remediates

The defaults satisfy the cited Security Hub controls. These values still apply cleanly but no longer pass:

  • any s3_account_public_access_block flag set to false - S3.1 requires all four to be true
  • iam_password_policy.password_reuse_prevention other than 24 - IAM.16 requires exactly 24
  • iam_password_policy.max_password_age above 90 - IAM.17 requires 90 days or less
  • ec2_imdsv2.http_tokens other than required - EC2.8 requires IMDSv2

Limitations​

A control disabled for the whole scope cannot be enabled for a single account. The override has no effect and the baseline run reports a check warning instead of failing, because the same account entry may be intended for another scope in which the control is enabled.

For aws_config, the scope-level config_recording_mode_account_override input is deprecated. Move those entries to customer_values.account_baseline_overrides.aws_config.config_recording_mode.

Example​

account_list_workloads.json, showing every account_hardening control on the first account and an aws_config override on the second:

[
{
"account_name": "aws-example-test",
"account_email": "accounts+aws-example-test@example.com",
"ou_path": "/root/workloads/test",
"close_on_deletion": true,
"account_tags": {
"AccountOwner": "owner@example.com",
"AccountType": "workload",
"ManagedBy": "OpenTofu"
},
"alternate_contacts": [
{
"type": "SECURITY",
"name": "Security Contact",
"email_address": "security@example.com"
}
],
"customer_values": {
"account_baseline_overrides": {
"account_hardening": {
"ssm_document_public_sharing": {
"enabled": true,
"setting_value": "Enable"
},
"ssm_automation_log_destination": {
"enabled": true,
"setting_value": "CloudWatch"
},
"ec2_imdsv2": {
"enabled": true,
"http_tokens": "optional",
"http_endpoint": "enabled",
"http_put_response_hop_limit": 3,
"instance_metadata_tags": "enabled"
},
"ebs_encryption_by_default": {
"enabled": true
},
"ebs_snapshot_block_public_access": {
"enabled": true,
"state": "block-new-sharing"
},
"iam_password_policy": {
"enabled": true,
"minimum_password_length": 20,
"require_lowercase_characters": false,
"require_numbers": false,
"require_uppercase_characters": false,
"require_symbols": false,
"allow_users_to_change_password": false,
"password_reuse_prevention": 12,
"max_password_age": 60
},
"s3_account_public_access_block": {
"enabled": true,
"block_public_acls": false,
"block_public_policy": false,
"ignore_public_acls": false,
"restrict_public_buckets": false
}
}
}
}
},
{
"account_name": "aws-example-legacy-test",
"account_email": "accounts+aws-example-legacy-test@example.com",
"ou_path": "/root/workloads/test",
"close_on_deletion": true,
"account_tags": {
"AccountOwner": "owner@example.com",
"AccountType": "workload",
"ManagedBy": "OpenTofu"
},
"alternate_contacts": [
{
"type": "SECURITY",
"name": "Security Contact",
"email_address": "security@example.com"
}
],
"customer_values": {
"account_baseline_overrides": {
"aws_config": {
"config_recording_mode": {
"exclusion_by_resource_types": [
"AWS::EC2::NetworkInterface",
"AWS::EC2::Volume",
"AWS::Config::ResourceCompliance"
]
}
}
}
}
}
]

The first account spells out every control and attribute so the full shape is visible. Nothing is mandatory: name only the attributes that should differ and omit the rest, as the second account does. Anything left out keeps its scope value, and enabled only needs to be set when exempting the account from a control.