Introduction
AWS Cost Categories help organize cloud spending by mapping costs to your business structure using rules. You can define rules based on accounts, tags, services, and more. See the official documentation for details.
This article explores an approach to automatically align AWS Cost Categories with your AWS Organizations Organizational Unit (OU) structure. We'll look at the reasoning behind this method and demonstrate how to implement it using a sample Python script.
Considerations
Important Security Note
- This script is a sample. Test thoroughly before use in production.
- It requires execution in the Management Account to access necessary Organizations and Cost Explorer APIs.
- Use Least Privilege: The Management Account is critical. Do not run scripts with administrative privileges. Create a dedicated IAM role with only the minimum required permissions (see README) and use that role for execution (e.g., in CloudShell, EC2, Lambda).
Approach Details
- Automation Need: OU structures change. Manual Cost Category updates are impractical. This script automates synchronization.
-
Implementation: Cost Category rules cannot directly target OUs. This script uses the following logic:
- List all accounts via
ListAccounts
. - For each account, find its OU path using
ListParents
. - Assign the account to a category name derived from its OU path up to a specified
depth
(e.g.,OU1-OU1A
). Each account belongs only to its deepest relevant category. - Generate Cost Category rules mapping the category name (
Value
) to the list of associated account IDs (Dimensions
).
- List all accounts via
- Execution: Uses Python3.x/Boto3 Assumes execution from an environment like AWS CloudShell. Can be adapted for scheduled Lambda execution (requires changes for parameter input and considering execution limits).
Script Features
-
Hierarchical Categories: Creates names like
Level1OU-Level2OU-...
. -
Depth Control: A
depth
argument sets the granularity.
The Script
The complete Python script is available on GitHub.
Repository:
https://github.com/shu85t/PutOuCostCategory
Prerequisites
- Python: Version 3.9 or later.
-
Boto3: AWS SDK for Python (
pip install boto3
). Ensure you are using a recent version. - AWS Account: Access to the Management Account of your AWS Organization.
Execution Environment
- This script needs to run in an environment with credentials for the Management Account of your AWS Organization, possessing the necessary IAM permissions (see below).
- A primary intended environment is AWS CloudShell accessed while logged into the Management Account.
- It can also run on an EC2 instance or in an AWS Lambda function within the Management Account, provided the execution role has the required permissions.
Usage
Command Line Execution
python3 put_ou_cost_category.py
Arguments
-
(Required): The name of the Cost Category to create or update (e.g.,OrganizationStructure
,OUHierarchy
). -
(Required): The effective start month inYYYY-MM
format (e.g.,2025-04
). The script uses the first day of this month (UTC midnight) for the API call. -
(Required): An integer (1 or greater) specifying the maximum depth of the OU hierarchy to create categories for.-
1
: Creates categories forRoot
and first-level OUs. -
2
: Creates categories forRoot
, first-level OUs, and second-level OUs (e.g.,OU1-OU1A
). - Accounts are always assigned to their deepest category up to the specified depth.
-
Log Level Configuration
Control logging verbosity using the LOG_LEVEL
environment variable. Default is INFO
.
# Example: Run with DEBUG logs
export LOG_LEVEL=DEBUG
python3 put_ou_cost_category.py MyOUCategory 2025-04 2
# Or temporarily for one command
LOG_LEVEL=DEBUG python3 put_ou_cost_category.py MyOUCategory 2025-04 2
IAM Permissions
The IAM principal (user or role) running this script needs the following permissions:
Required Actions:
organizations:ListAccounts
organizations:ListParents
organizations:DescribeOrganizationalUnit
organizations:ListRoots
ce:ListCostCategoryDefinitions
ce:CreateCostCategoryDefinition
ce:UpdateCostCategoryDefinition
Example Results
Here's how it works with a sample OU structure.
Sample OU Structure
Root
├── Management Account
│
├── Management OU
│ ├── Management Tool Account1
│ └── Management Tool Account2
│
├── Sandbox OU (No direct accounts)
│
├── Security OU
│ ├── Audit Account
│ └── Log Archive Account
│
└── SDLC OU
├── Dev OU
│ └── Workload Dev Account
└── Stg OU
└── Workload Staging Account
Result with depth=1
Command:
python3 put_ou_cost_category.py OUStructure 2025-01 1
Result:
Result with depth=2
Command:
python3 put_ou_cost_category.py OUStructure 2025-01 2
Result:
How the Script Works
Get organization structure
# --- Core Functions ---
def get_organization_structure(max_depth):
""" Retrieves Org structure assigning accounts to deepest category up to max_depth. Returns dict or None. Propagates exceptions. """
logger.info(f"Fetching organization structure (assigning accounts to deepest path up to depth {max_depth})...")
...
- It starts by listing all accounts using
list_accounts
. - For each account, it traces the full path of parent OUs back to the Root using the
list_parents
API. - It retrieves OU names using
describe_organizational_unit
(names are cached locally viaget_ou_name
to improve efficiency). - Based on the OU path and the provided
max_depth
, it constructs the final category name (e.g.,L1OU-L2OU
). - Crucially, each account is assigned uniquely to the single category representing its deepest OU placement within the depth limit.
- Finally, it returns a Python dictionary where keys are the generated category names and values are lists of the corresponding account IDs.
Build cost category rules
def build_cost_category_rules(org_structure):
""" Builds rule list. Skips categories with empty accounts. Propagates exceptions. """
logger.info("Building Cost Category rules...")
...
- It takes the input dictionary (mapping category names to account lists) and translates each category with accounts into a rule object formatted for the API.
- Returns a list containing only the rules generated for categories that actually have assigned accounts.
Put cost category
def put_cost_category(cost_category_name, rules, default_value, effective_start_iso_str):
""" Creates/Updates Cost Category. Returns True on success. Raises exceptions on failure. Logs raw parameters. """
- It first checks if a Cost Category with the specified
cost_category_name
already exists usingfind_cost_category_arn
. Based on the result, it prepares the necessary parameters for either thecreate_cost_category_definition
orupdate_cost_category_definition
API call. - Before calling the API, it performs pre-checks on the number of rules and accounts per rule against documented limits.
- If validation passes, it executes the appropriate API call (create or update) to apply the changes in AWS.
Refelence
This time, I wrote the code in Python and am using boto3. Since APIs are defined for each service [within boto3], I'm including a link to the SDK documentation for reference.
- Boto3(Python SDK)
As a side note, taking the time to carefully read through the API's defined arguments and responses – even the ones you don't end up using – is very helpful for understanding the AWS service itself, its behavior, and its various options.
Conclusion
This script offers a method to automatically align AWS Cost Categories with your AWS Organizations OU structure.
This provides a useful perspective for cost analysis, allowing you to understand costs based on the OU level categorization of accounts.
Using this OU structure categorization, I noticed some unexpected costs in one area. After investigating, I identified an OpenSearch instance that could likely be optimized to reduce costs.