A
Arun's Blog
← Back to all posts

AWS Cross-Account Architecture for On-Premises CMDB Integration

AWSSecurityArchitecture
TL;DR

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.

Key Principle

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:

  1. On-Premises CMDB Application - Stores credentials for the central account IAM user and assumes roles into each member account
  2. Central Account - Contains the IAM user with only AssumeRole permissions
  3. 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
Note

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.

Pro Tip

For even stronger security, consider using IAM Roles Anywhere with your existing PKI infrastructure to eliminate long-lived access keys entirely.