AWS Organizations
With --org, cloudg map starts at the management account, finds every member account and maps them all, with the OU tree, SCPs and Control Tower around them.
Mapping one account at a time misses what holds an organization together: which OU an account sits in, which SCPs restrict it, which Control Tower controls apply, and which roles in one account trust another. cloudg map --org reads the organization first, picks the accounts you asked for, assumes a role in each one, and maps all of them into a single inventory map. Accounts hang under their OUs, SCPs and controls point at what they govern, and cross-account trust becomes an edge you can query with cloudg deps.
Every call cloudg makes here is read-only: Describe*, List* and Get* against Organizations and Control Tower, then the usual read calls inside each member account.
What happens during discovery¶
sequenceDiagram
participant C as cloudg
participant S as STS
participant O as Organizations
participant T as Control Tower
participant M as Member account
C->>S: GetCallerIdentity
C->>O: DescribeOrganization, ListRoots
C->>O: Walk OUs and accounts, list policies and targets
C->>O: Trusted services, delegated administrators
C->>T: ListLandingZones, probing likely home regions
T-->>C: Landing zone, enabled controls and baselines
Note over C: Filter accounts by OU, exclusions, status
loop Each selected account and region
C->>S: AssumeRole into the member role
S-->>C: Temporary credentials
C->>M: Deep collectors (read-only)
end
Organization discovery, then one role assumption per member account and region
In order:
sts:GetCallerIdentityrecords which account is running the discovery, andorganizations:DescribeOrganizationreturns the organization ID, the management account and the feature set. If this fails, the caller is not in an organization or can't read it.- From each root, cloudg walks down with
ListAccountsForParentandListOrganizationalUnitsForParent, recording every account's parent and OU path, likeRoot/Workloads/Prod. - For each policy type enabled on the root (SCPs, RCPs, tag, backup and AI services opt-out policies), it lists the policies and the roots, OUs and accounts each one is attached to.
- It lists trusted service access and the delegated administrators of GuardDuty, Security Hub, Inspector, Config, Access Analyzer, Macie, Detective, Firewall Manager and IAM Identity Center. An access denial ends that loop quietly.
- With Control Tower detection on, it looks for a landing zone (see Control Tower below). The landing zone manifest is parsed for governed regions and the log archive, audit, Config aggregator and backup accounts, then dropped.
- The account list is filtered, and collection fans out with one AssumeRole per account and region.
If discovery fails, the map doesn't stop. cloudg records organizations: FAILED in coverage, logs the reason, and maps the caller's account alone. The CLI's failed-collectors warning at the end of the run names it.
Permissions for the caller¶
Run cloudg map --org with credentials for the management account. Organizations discovery also works from a delegated administrator, but the Control Tower APIs only answer in the management account.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOrganization",
"Effect": "Allow",
"Action": [
"organizations:Describe*",
"organizations:List*",
"controltower:List*",
"controltower:Get*"
],
"Resource": "*"
},
{
"Sid": "AssumeMemberRole",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::*:role/cloudg-readonly"
}
]
}
The caller's own account is mapped too, with the caller's credentials and not through a member role (member roles such as AWSControlTowerExecution don't exist in the management account). So the caller also needs read access in its own account; attaching SecurityAudit and ViewOnlyAccess to it is the simplest way.
Deploy a read-only member role¶
cloudg assumes one role, by name, in every member account. The default is AWSControlTowerExecution, because Control Tower creates it in every account it enrolls.
AWSControlTowerExecution has admin rights
AWSControlTowerExecution carries administrator permissions. cloudg only reads, but anything that can run cloudg with management-account credentials could do more with that role. The read-only role Control Tower creates, aws-controltower-ReadOnlyExecutionRole, can't be used instead: it only trusts a role in the audit account. For production, deploy your own read-only role to every account and pass it with --org-role.
A service-managed CloudFormation StackSet deploys the role to every account in the OUs you target, and to accounts that join those OUs later.
Save the template¶
AWSTemplateFormatVersion: "2010-09-09"
Description: Read-only role that cloudg assumes to map this account
Parameters:
ManagementAccountId:
Type: String
AllowedPattern: '^[0-9]{12}$'
Description: Account that runs cloudg map --org
Resources:
CloudgReadOnlyRole:
Type: AWS::IAM::Role
Properties:
RoleName: cloudg-readonly
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
AWS: !Sub "arn:aws:iam::${ManagementAccountId}:root"
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/SecurityAudit
- arn:aws:iam::aws:policy/job-function/ViewOnlyAccess
Outputs:
RoleArn:
Value: !GetAtt CloudgReadOnlyRole.Arn
Keep the role at the root path. cloudg builds the ARN as arn:aws:iam::<account>:role/<name>, so a role created under a path such as /audit/ would not be found by its bare name.
The trust policy above lets any principal in the management account that has sts:AssumeRole permission assume the role. To pin it to the one role cloudg runs as, add a Condition with ArnEquals on aws:PrincipalArn.
Turn on trusted access for StackSets¶
Run this once, in the management account:
Create the stack set¶
aws cloudformation create-stack-set \
--stack-set-name cloudg-readonly \
--template-body file://cloudg-readonly-role.yaml \
--parameters ParameterKey=ManagementAccountId,ParameterValue=123456789012 \
--capabilities CAPABILITY_NAMED_IAM \
--permission-model SERVICE_MANAGED \
--auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false
Deploy it to your OUs¶
IAM roles are global, so deploy to a single region. Targeting the root ID (r-...) covers every OU; list OU IDs instead to limit it.
aws cloudformation create-stack-instances \
--stack-set-name cloudg-readonly \
--deployment-targets OrganizationalUnitIds=r-7h3o \
--regions us-east-1
aws cloudformation list-stack-instances --stack-set-name cloudg-readonly
Service-managed StackSets never deploy to the management account itself. cloudg doesn't need them to, since it maps the management account with the caller's own credentials.
To map Kubernetes workloads inside EKS clusters as well, each cluster needs an access entry for cloudg-readonly with the AmazonEKSViewPolicy. The inventory mapping guide has the two aws eks commands.
Map the organization¶
from cloudg import CloudGConfig
from cloudg.inventory import InventoryMapper
config = CloudGConfig(providers=["aws"])
config.aws.profile = "management"
config.aws.regions = ["ALL"]
org = config.aws.organization
org.enabled = True
org.role_name = "cloudg-readonly"
org.include_ous = ["Workloads"]
org.exclude_accounts = ["111122223333"]
mapper = InventoryMapper(config)
inventory = mapper.map_inventory_sync()
paths = inventory.export("./reports")
topology = mapper.organization # None when discovery failed
if topology is not None:
print(topology.organization_id, len(topology.accounts), "accounts")
print("Control Tower:", topology.control_tower_enabled, topology.governed_regions)
print(paths.get("organization"))
With --org, the CLI's configuration panel shows "Organization: discover all accounts" (plus the OU filter, if any) and the member role it will use. After the run an "Organization" table lists the organization ID, the number of accounts and OUs, whether Control Tower was found and the governed regions, ahead of the usual inventory summary.
Choose the accounts¶
By default every ACTIVE account in the organization is mapped, the management account included. These flags narrow it down:
| Flag | Config key | Effect |
|---|---|---|
--ou |
aws.organization.include_ous |
Only accounts under this OU, nested OUs included. Takes an OU ID, an OU ARN, or a name (case-insensitive). Repeatable. |
--exclude-account |
aws.organization.exclude_accounts |
Skip this account ID. Repeatable. |
--accounts |
aws.accounts |
Comma-separated IDs. With --org, the discovered list is intersected with this one. Without --org, it needs --role-name (or aws.role_name), the role to assume in each account; cloudg map and cloudg run stop with exit status 2 when it is missing. |
| none | aws.organization.include_management_account |
true by default. |
| none | aws.organization.include_suspended |
false by default, so SUSPENDED and PENDING_CLOSURE accounts are skipped. |
# the Workloads OU and everything below it, minus one sandbox
cloudg map -p aws --org --org-role cloudg-readonly --ou Workloads --exclude-account 111122223333
# two OUs by ID
cloudg map -p aws --org --org-role cloudg-readonly --ou ou-7h3o-ljevwujv --ou ou-7h3o-bqhq9yxz
OU names are matched against the whole tree and the first match wins. If two OUs share a name (a Prod under Workloads and another under Sandbox), pass the ID. An --ou value that matches no OU is an error: the run stops with an "Inventory mapping failed" panel and exit status 1, and the message names the unmatched value and lists every root and OU of the organization as name (id), so you can copy the right one.
Control Tower and regions¶
Control Tower detection is on by default (aws.organization.control_tower). cloudg calls controltower:ListLandingZones region by region until it finds one: first the region given with --ct-home-region, then the session's region, then the usual home regions in this order: us-east-1, us-east-2, us-west-2, eu-west-1, eu-central-1, eu-west-2, ap-southeast-2, ap-northeast-1, ap-southeast-1, ca-central-1, ap-south-1, eu-north-1, sa-east-1. If your home region isn't on that list, pass it:
--ct-home-region also sets the region of the discovery session. Without it, regions where the lookup fails are only logged at debug level. With --ct-home-region set, every failed lookup is kept in the topology's errors list and coverage shows controltower: PARTIAL.
When a landing zone is found and you passed --regions all, cloudg maps only the governed regions instead of every enabled region (aws.organization.use_governed_regions, on by default). An explicit region list is always used as given, and so is the default us-east-1 when you pass no --regions at all.
What lands on the map¶
With aws.organization.map_structure on (the default), the topology becomes assets and edges next to the collected resources:
| Asset | Type | Edges |
|---|---|---|
| The organization | ORGANIZATION |
the management account MANAGES it |
| Each root and OU | ORG_UNIT |
its parent CONTAINS it |
| Each account | CLOUD_ACCOUNT |
its OU CONTAINS it (ORG_CONTAINS_ACCOUNT) |
| Each SCP, RCP, tag, backup or AI opt-out policy | ORG_POLICY |
GOVERNS each target; SCP_RESTRICTS for SCPs |
| The landing zone | LANDING_ZONE |
MANAGES the organization and each shared account |
| Each enabled control and baseline | GUARDRAIL |
GOVERNS the OU it is enabled on |
Account nodes use arn:aws:iam::<id>:root as their identifier, the same string IAM trust policies and resource policies use for an account principal. So when a role in one account trusts another account of the organization, the IAM_TRUST edge starts at that account's node on the map instead of at an external placeholder, and cloudg deps lists it among the cross-account edges. Account metadata records whether it is the management account, its Control Tower role (log_archive, audit and so on) and the services it is delegated administrator for.
How member accounts are assumed¶
For each selected account and each region, cloudg builds a session from your base credentials (profile, keys, web identity or the default chain) and then calls sts:AssumeRole on arn:aws:iam::<account>:role/<role>. The role name comes from --org-role, else aws.organization.role_name, else --role-name / aws.role_name, else AWSControlTowerExecution. The session name is aws.role_session_name (cloudg-scan by default), the duration one hour, and aws.external_id is sent when set.
An account whose role can't be assumed records sts_assume_role: FAILED for that region, and nothing else is collected there. The rest of the organization carries on.
When aws.role_arn is set, it is assumed first and the member role from there (role chaining), with or without a web identity token file. That fits a hub-and-spoke setup: a central audit role in the security account, trusted by the member roles. The member roles' trust policies must then name the role_arn role, or its account, rather than the account your base credentials come from. The account role_arn lives in is collected with the role_arn session itself, since member roles usually do not exist there.
inventory-organization.json¶
A map made with --org writes the whole topology to inventory-organization.json, and InventoryResult.load() reads it back as inventory.organization. Abridged:
{
"organization_id": "o-1wietecbs4",
"management_account_id": "123456789012",
"caller_account_id": "123456789012",
"feature_set": "ALL",
"ous": {
"ou-7h3o-bqhq9yxz": {"id": "ou-7h3o-bqhq9yxz", "name": "Prod", "parent_id": "ou-7h3o-ljevwujv",
"path": ["Root", "Workloads", "Prod"], "is_root": false}
},
"accounts": {
"552289857375": {"id": "552289857375", "name": "prod-app", "status": "ACTIVE",
"parent_id": "ou-7h3o-bqhq9yxz", "ou_path": ["Root", "Workloads", "Prod"]}
},
"policies": [
{"id": "p-FullAWSAccess", "name": "FullAWSAccess", "type": "SERVICE_CONTROL_POLICY",
"aws_managed": true, "targets": ["r-7h3o"]}
],
"delegated_administrators": {"guardduty": ["333333333333"]},
"landing_zone": {"version": "3.3", "status": "ACTIVE", "driftStatus": {"status": "IN_SYNC"}},
"governed_regions": ["us-east-1", "eu-west-1"],
"shared_accounts": {"log_archive": "222222222222", "audit": "333333333333"},
"enabled_controls": [],
"control_tower_region": "us-east-1",
"errors": [],
"control_tower_enabled": true
}
accounts holds every account in the organization, before any filter, so the file doubles as an account inventory. A quick listing by OU:
import json
from collections import defaultdict
with open("./reports/inventory-organization.json") as f:
org = json.load(f)
by_ou = defaultdict(list)
for account in org["accounts"].values():
by_ou["/".join(account["ou_path"])].append(f'{account["id"]} {account["name"]} {account["status"]}')
for path in sorted(by_ou):
print(path)
for line in sorted(by_ou[path]):
print(" ", line)
Configuration¶
The same settings live under aws.organization in config.yaml, for runs that pass -c config.yaml. The flags override them.
aws:
profile: management
regions: [ALL]
organization:
enabled: true
role_name: cloudg-readonly # default: aws.role_name, then AWSControlTowerExecution
include_ous: [Workloads] # OU IDs, ARNs or names, nested OUs included
exclude_accounts: ["111122223333"]
include_management_account: true
include_suspended: false
use_governed_regions: true # regions [ALL] -> Control Tower governed regions
control_tower: true # map the landing zone, controls and baselines
home_region: null # Control Tower home region, auto-detected
map_structure: true # org, OU, account, SCP and control nodes on the map
| Key | Default | Flag |
|---|---|---|
enabled |
false |
--org / --no-org |
role_name |
null |
--org-role |
include_ous |
[] |
--ou |
exclude_accounts |
[] |
--exclude-account |
include_management_account |
true |
none |
include_suspended |
false |
none |
use_governed_regions |
true |
none |
control_tower |
true |
none |
home_region |
null |
--ct-home-region |
map_structure |
true |
none |
Discovery on its own¶
discover_organization() works without a mapping run. It takes a boto3 session and returns an OrganizationTopology:
import boto3
from cloudg.inventory import discover_organization
session = boto3.Session(profile_name="management", region_name="us-east-1")
topology = discover_organization(session, control_tower=True)
print(topology.organization_id, "managed by", topology.management_account_id)
print("Control Tower:", topology.control_tower_enabled, topology.governed_regions)
for account in topology.accounts.values():
print(account.id, account.name, account.status, "/".join(account.ou_path))
for policy in topology.policies:
print(policy.type, policy.name, "->", policy.targets)
workloads = topology.target_accounts(include_ous=["Workloads"], include_management_account=False)
print("would map:", workloads)
assets = topology.to_assets() # organization, OUs, accounts, policies, landing zone, controls
It raises RuntimeError when the caller can't describe or list the organization. Other failures, such as a Control Tower call that was denied, end up in topology.errors.
Troubleshooting¶
| What you see | Likely cause | Fix |
|---|---|---|
Only one account mapped, organizations in the failed-collectors line |
the credentials aren't from the management account or a delegated administrator, or lack organizations:List* |
run with management-account credentials |
sts_assume_role failed for some accounts |
the member role is missing there, or its trust policy doesn't name the caller's account | check the StackSet instances for those accounts |
sts_assume_role failed for the management account when running from a delegated administrator |
the StackSet never deploys to the management account, and AWSControlTowerExecution doesn't exist there |
--exclude-account it, set include_management_account: false, or create the role there by hand |
| Control Tower reported as absent | running from a delegated administrator, or the home region isn't in the probe list | run from the management account; pass --ct-home-region |
| "OU filter ... matched no OU in the organization" | a typo in --ou, or an OU name that does not exist |
pick the name or ID from the list of known OUs in the message |
| Far fewer accounts than expected | an --ou value matched a different OU of the same name |
use OU IDs |
Fewer regions than --regions all would give |
Control Tower governed regions replaced all |
set use_governed_regions: false, or pass the regions explicitly |
-
What a mapping run collects in each account.
-
Query cross-account edges on the finished map.
-
Profiles, OIDC and role assumption for the base credentials.
-
Every option of the command.