AWS Cross-Account Architecture for On-Premises CMDB Integration
To enable an on-premises CMDB application to inventory resources across multiple AWS accounts, use cross-account IAM role assumption. Create a single IAM user in a central account with only sts:AssumeRole permissions, then deploy a read-only role to all member accounts using CloudFormation StackSets. This approach eliminates the need for IAM users and access keys in each account.
Introduction
When your organization runs workloads across multiple AWS accounts, keeping your Configuration Management Database (CMDB) up-to-date becomes a challenge. How do you give an on-premises application access to inventory resources in 10, 50, or even hundreds of AWS accounts without creating a security nightmare?
The answer is cross-account IAM role assumption - a pattern that allows a single set of credentials to access multiple accounts securely. This post walks through the architecture, implementation with CloudFormation StackSets, and a complete Python example for your CMDB integration.
Architecture Overview
The key principle is simple: never create IAM users or access keys in each account. Instead, use a single IAM user in a central account with permission only to assume roles, then deploy a read-only role to all member accounts.
The central IAM user has minimal permissions - only sts:AssumeRole. All actual resource access happens through the assumed role in each member account.
The architecture consists of:
- On-Premises CMDB Application - Stores credentials for the central account IAM user and assumes roles into each member account
- Central Account - Contains the IAM user with only AssumeRole permissions
- Member Accounts - Each contains a CMDBReadRole that trusts the central account
Authentication Options
Option 1: IAM User + Cross-Account Roles (Recommended)
Create a single IAM user in your central/management account with minimal permissions (only sts:AssumeRole). The user assumes a read-only role deployed to each member account.
Pros:
- Simple to set up and understand
- Only one set of credentials to manage
- Minimal permissions in central account
- Easy to audit and rotate
Cons:
- Long-lived access keys
- Keys must be stored securely on-prem
- Manual key rotation required
Option 2: IAM Roles Anywhere (More Secure)
Uses your on-premises PKI/certificates instead of static access keys. Your CMDB app authenticates using X.509 certificates signed by your CA.
Pros:
- No long-lived access keys
- Certificate-based authentication
- Temporary credentials only
- Leverages existing PKI infrastructure
Cons:
- More complex setup
- Requires PKI/CA infrastructure
- Additional AWS configuration
Implementation Steps
Step 1: Create IAM User in Central Account
Create a service account with minimal permissions - only the ability to assume the CMDBReadRole in other accounts.
IAM User Policy (cmdb-assume-role-policy):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAssumeRoleInMemberAccounts",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::*:role/CMDBReadRole"
}
]
}
AWS CLI Commands:
# Create the IAM user
aws iam create-user --user-name cmdb-service-account
# Create and attach the policy
aws iam create-policy \
--policy-name cmdb-assume-role-policy \
--policy-document file://cmdb-assume-role-policy.json
aws iam attach-user-policy \
--user-name cmdb-service-account \
--policy-arn arn:aws:iam::CENTRAL_ACCOUNT_ID:policy/cmdb-assume-role-policy
# Create access keys (store these securely!)
aws iam create-access-key --user-name cmdb-service-account
Step 2: Create CloudFormation Template for Cross-Account Role
This template defines the IAM role that will be deployed to each member account.
cmdb-role.yaml:
AWSTemplateFormatVersion: '2010-09-09'
Description: Cross-account read-only role for CMDB application
Parameters:
TrustedAccountId:
Type: String
Description: Account ID where the CMDB service account resides
AllowedPattern: '^\d{12}$'
ConstraintDescription: Must be a valid 12-digit AWS account ID
TrustedUserName:
Type: String
Default: cmdb-service-account
Description: Name of the IAM user allowed to assume this role
Resources:
CMDBReadRole:
Type: AWS::IAM::Role
Properties:
RoleName: CMDBReadRole
Description: Read-only role for CMDB application to inventory AWS resources
MaxSessionDuration: 3600
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowCMDBServiceAccount
Effect: Allow
Principal:
AWS: !Sub 'arn:aws:iam::${TrustedAccountId}:user/${TrustedUserName}'
Action: sts:AssumeRole
Condition:
StringEquals:
'sts:ExternalId': 'cmdb-external-id-change-me'
ManagedPolicyArns:
- arn:aws:iam::aws:policy/ReadOnlyAccess
Tags:
- Key: Purpose
Value: CMDB-Inventory
- Key: ManagedBy
Value: CloudFormation-StackSet
CMDBCustomPolicy:
Type: AWS::IAM::Policy
Properties:
PolicyName: CMDBAdditionalPermissions
Roles:
- !Ref CMDBReadRole
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowOrganizationsRead
Effect: Allow
Action:
- organizations:Describe*
- organizations:List*
Resource: '*'
Outputs:
RoleArn:
Description: ARN of the CMDB read role
Value: !GetAtt CMDBReadRole.Arn
Export:
Name: !Sub '${AWS::StackName}-RoleArn'
RoleName:
Description: Name of the CMDB read role
Value: !Ref CMDBReadRole
Step 3: Deploy Role to All Accounts Using StackSets
Use CloudFormation StackSets to deploy the role template to all accounts in your organization automatically.
# Enable trusted access for StackSets (run once from management account)
aws organizations enable-aws-service-access \
--service-principal stacksets.cloudformation.amazonaws.com
# Create the StackSet
aws cloudformation create-stack-set \
--stack-set-name CMDBReadRole \
--description "Deploy CMDB read-only role to all member accounts" \
--template-body file://cmdb-role.yaml \
--parameters \
ParameterKey=TrustedAccountId,ParameterValue=123456789012 \
ParameterKey=TrustedUserName,ParameterValue=cmdb-service-account \
--permission-model SERVICE_MANAGED \
--auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false \
--capabilities CAPABILITY_NAMED_IAM
# Deploy to all accounts in the organization
aws cloudformation create-stack-instances \
--stack-set-name CMDBReadRole \
--deployment-targets OrganizationalUnitIds=ou-xxxx-xxxxxxxx \
--regions us-east-1 \
--operation-preferences \
FailureTolerancePercentage=10,MaxConcurrentPercentage=25
Replace ou-xxxx-xxxxxxxx with your root OU ID to deploy to all accounts, or specify individual OU IDs to target specific accounts. New accounts added to the OU will automatically receive the role.
Step 4: Configure CMDB Application
Configure your on-premises CMDB application to use the credentials and assume roles in each account.
Python Example:
import boto3
from botocore.exceptions import ClientError
# Configuration
CENTRAL_ACCOUNT_CREDS = {
'aws_access_key_id': 'AKIA...', # From secrets manager
'aws_secret_access_key': '...', # From secrets manager
}
EXTERNAL_ID = 'cmdb-external-id-change-me'
ROLE_NAME = 'CMDBReadRole'
REGIONS = ['us-east-1', 'us-west-2']
def get_member_accounts():
"""Get all active accounts in the organization."""
session = boto3.Session(**CENTRAL_ACCOUNT_CREDS)
org = session.client('organizations')
accounts = []
paginator = org.get_paginator('list_accounts')
for page in paginator.paginate():
for account in page['Accounts']:
if account['Status'] == 'ACTIVE':
accounts.append({
'id': account['Id'],
'name': account['Name'],
'email': account['Email']
})
return accounts
def assume_role(account_id):
"""Assume CMDBReadRole in the specified account."""
session = boto3.Session(**CENTRAL_ACCOUNT_CREDS)
sts = session.client('sts')
try:
response = sts.assume_role(
RoleArn=f'arn:aws:iam::{account_id}:role/{ROLE_NAME}',
RoleSessionName='CMDBInventorySession',
ExternalId=EXTERNAL_ID,
DurationSeconds=3600
)
return response['Credentials']
except ClientError as e:
print(f"Failed to assume role in {account_id}: {e}")
return None
def get_client(service, credentials, region='us-east-1'):
"""Create a boto3 client using assumed role credentials."""
return boto3.client(
service,
aws_access_key_id=credentials['AccessKeyId'],
aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token=credentials['SessionToken'],
region_name=region
)
def inventory_account(account_id, account_name):
"""Inventory VPCs and subnets in an account."""
credentials = assume_role(account_id)
if not credentials:
return None
inventory = {
'account_id': account_id,
'account_name': account_name,
'vpcs': [],
'subnets': [],
'instances': []
}
for region in REGIONS:
ec2 = get_client('ec2', credentials, region)
# Get VPCs
vpcs = ec2.describe_vpcs()['Vpcs']
for vpc in vpcs:
vpc_name = next(
(t['Value'] for t in vpc.get('Tags', []) if t['Key'] == 'Name'),
'N/A'
)
inventory['vpcs'].append({
'region': region,
'vpc_id': vpc['VpcId'],
'name': vpc_name,
'cidr': vpc['CidrBlock'],
'state': vpc['State']
})
# Get Subnets
subnets = ec2.describe_subnets()['Subnets']
for subnet in subnets:
subnet_name = next(
(t['Value'] for t in subnet.get('Tags', []) if t['Key'] == 'Name'),
'N/A'
)
inventory['subnets'].append({
'region': region,
'subnet_id': subnet['SubnetId'],
'name': subnet_name,
'vpc_id': subnet['VpcId'],
'cidr': subnet['CidrBlock'],
'az': subnet['AvailabilityZone'],
'available_ips': subnet['AvailableIpAddressCount']
})
# Get EC2 Instances
reservations = ec2.describe_instances()['Reservations']
for res in reservations:
for inst in res['Instances']:
inst_name = next(
(t['Value'] for t in inst.get('Tags', []) if t['Key'] == 'Name'),
'N/A'
)
inventory['instances'].append({
'region': region,
'instance_id': inst['InstanceId'],
'name': inst_name,
'type': inst['InstanceType'],
'state': inst['State']['Name'],
'private_ip': inst.get('PrivateIpAddress'),
'public_ip': inst.get('PublicIpAddress'),
'vpc_id': inst.get('VpcId'),
'subnet_id': inst.get('SubnetId')
})
return inventory
def main():
"""Main inventory collection."""
print("Discovering accounts in organization...")
accounts = get_member_accounts()
print(f"Found {len(accounts)} active accounts")
all_inventory = []
for account in accounts:
print(f"\nInventorying {account['name']} ({account['id']})...")
inventory = inventory_account(account['id'], account['name'])
if inventory:
all_inventory.append(inventory)
print(f" - {len(inventory['vpcs'])} VPCs")
print(f" - {len(inventory['subnets'])} Subnets")
print(f" - {len(inventory['instances'])} Instances")
return all_inventory
if __name__ == '__main__':
inventory = main()
Security Best Practices
| Practice | Description |
|---|---|
| Use External ID | Add an external ID to the role trust policy to prevent confused deputy attacks |
| Minimal Permissions | The central IAM user should ONLY have sts:AssumeRole permission - nothing else |
| Secure Key Storage | Store access keys in a secrets manager (HashiCorp Vault, CyberArk, etc.), never in code or config files |
| Regular Key Rotation | Rotate access keys at least every 90 days. Automate this process if possible |
| ReadOnly Access | The CMDBReadRole should only have read permissions. Never grant write access for inventory purposes |
| Session Duration | Keep assumed role session duration short (1 hour). The app can re-assume as needed |
| CloudTrail Logging | Ensure CloudTrail is enabled to audit all AssumeRole calls and API activity |
| IP Restrictions | Consider adding IP conditions to the role trust policy to only allow assumption from known on-prem IPs |
Optional: IP-Restricted Trust Policy
For additional security, restrict role assumption to specific IP addresses:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::CENTRAL_ACCOUNT_ID:user/cmdb-service-account"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "cmdb-external-id-change-me"
},
"IpAddress": {
"aws:SourceIp": [
"203.0.113.0/24",
"198.51.100.10/32"
]
}
}
}
]
}
Monitoring & Troubleshooting
Check StackSet Deployment Status
# List stack instances
aws cloudformation list-stack-instances \
--stack-set-name CMDBReadRole
# Check for failures
aws cloudformation list-stack-instances \
--stack-set-name CMDBReadRole \
--filters Name=DETAILED_STATUS,Values=FAILED
Test Role Assumption
# Test assuming role from CLI
aws sts assume-role \
--role-arn arn:aws:iam::MEMBER_ACCOUNT_ID:role/CMDBReadRole \
--role-session-name TestSession \
--external-id cmdb-external-id-change-me \
--profile central-account-profile
CloudTrail Query for AssumeRole Events
-- Athena query for AssumeRole events
SELECT
eventtime,
useridentity.arn as caller_arn,
requestparameters.rolearn as assumed_role,
sourceipaddress,
errorcode,
errormessage
FROM cloudtrail_logs
WHERE eventsource = 'sts.amazonaws.com'
AND eventname = 'AssumeRole'
AND requestparameters.rolearn LIKE '%CMDBReadRole%'
ORDER BY eventtime DESC
LIMIT 100;
Conclusion
This architecture provides a secure, scalable way for your on-premises CMDB application to access all AWS accounts with minimal credential management:
- One IAM user in a central account with minimal permissions
- One set of access keys to manage and rotate
- Roles deployed automatically via CloudFormation StackSets
- New accounts automatically included through StackSet auto-deployment
The key takeaway: resist the temptation to create IAM users in each account. Cross-account role assumption is the AWS-recommended pattern for this use case, and combined with StackSets, it scales to hundreds of accounts with minimal operational overhead.
For even stronger security, consider using IAM Roles Anywhere with your existing PKI infrastructure to eliminate long-lived access keys entirely.