Skip to content

Automate AWS DevOps Agent Onboarding at the OU Level Using StackSets and EventBridge

8 minute read
Content level: Advanced
0

Adopting Amazon DevOps Agent across multiple AWS accounts requires deploying an IAM role and creating an association for each secondary account. StackSets automates the IAM role but cannot create associations leaving a manual gap. This article eliminates that gap using StackSets, EventBridge and Lambda for zero-touch onboarding: when an account joins a target OU, the role deploys automatically and the association is created within ~3 minutes with no human intervention.

Overview

When adopting Amazon DevOps Agent across multiple AWS accounts, you need two things for each secondary (source) account:

  1. An IAM role allowing DevOps Agent to access resources
  2. An association between the account and your Agent space

StackSets can automate the IAM role deployment across an OU, but cannot create the association. This leaves a manual gap for every new account.

This article closes that gap with StackSets, EventBridge and Lambda for zero-touch onboarding.

Architecture

Architecture Diagram

Flow: Account created/moved into OU → StackSets deploys IAM role → EventBridge triggers Lambda → Lambda waits, verifies, then calls AssociateService API.

Prerequisites

  • AWS Organization with a target OU for DevOps Agent accounts
  • DevOps Agent space created in your management account
  • CloudFormation StackSets with service-managed permissions enabled
  • CloudTrail enabled (default for Organizations)

Step 1: Deploy the IAM Role via StackSets

Template: devops-agent-service-account.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: DevOps Agent secondary account IAM role (deployed via StackSets)

Parameters:
  MonitoringAccountId:
    Type: String
    AllowedPattern: '^\d{12}$'
  AgentSpaceArn:
    Type: String
    Description: 'Format: arn:aws:aidevops:<region>:<account-id>:agentspace/<space-id>'

Resources:
  DevOpsAgentSecondaryRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: AmazonDevOpsAgentRole-SecondaryAccount
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: aidevops.amazonaws.com
            Action: 'sts:AssumeRole'
            Condition:
              StringEquals:
                'aws:SourceAccount': !Ref MonitoringAccountId
              ArnLike:
                'aws:SourceArn': !Ref AgentSpaceArn
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AIDevOpsAgentAccessPolicy
      Policies:
        - PolicyName: AllowResourceExplorerSLR
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: iam:CreateServiceLinkedRole
                Resource:
                  - !Sub 'arn:aws:iam::${AWS::AccountId}:role/aws-service-role/resource-explorer-2.amazonaws.com/AWSServiceRoleForResourceExplorer'

Note: The inline policy for iam:CreateServiceLinkedRole is required for Resource Explorer — it's not included in the managed AIDevOpsAgentAccessPolicy.

Deploy

# Enable trusted access (one-time)
aws organizations enable-aws-service-access \
    --service-principal member.org.stacksets.cloudformation.amazonaws.com

# Create StackSet
aws cloudformation create-stack-set \
    --stack-set-name devops-agent-ou-deployment \
    --template-body file://devops-agent-service-account.yaml \
    --parameters \
        ParameterKey=MonitoringAccountId,ParameterValue=111122223333 \
        ParameterKey=AgentSpaceArn,ParameterValue=arn:aws:aidevops:us-east-1:111122223333:agentspace/your-space-id \
    --permission-model SERVICE_MANAGED \
    --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false \
    --capabilities CAPABILITY_NAMED_IAM

# Deploy to target OU
aws cloudformation create-stack-instances \
    --stack-set-name devops-agent-ou-deployment \
    --deployment-targets OrganizationalUnitIds='["<your-ou-id>"]' \
    --regions '["us-east-1"]'

Verify

aws cloudformation list-stack-instances \
    --stack-set-name devops-agent-ou-deployment \
    --query 'Summaries[].[Account,Status,StackInstanceStatus.DetailedStatus]' \
    --output table

Step 2: Deploy the Auto-Association Lambda

Template: devops-agent-auto-association.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: Auto-associates new accounts with DevOps Agent space

Parameters:
  AgentSpaceId:
    Type: String
  TargetOUId:
    Type: String
  MonitoringAccountId:
    Type: String
  SecondaryRoleName:
    Type: String
    Default: AmazonDevOpsAgentRole-SecondaryAccount
  WaitTimeSeconds:
    Type: Number
    Default: 180

Resources:
  NewAccountRule:
    Type: AWS::Events::Rule
    Properties:
      Name: DevOpsAgent-AutoAssociate-NewAccount
      EventPattern:
        source:
          - aws.organizations
        detail-type:
          - AWS Service Event via CloudTrail
        detail:
          eventName:
            - CreateAccountResult
          serviceEventDetails:
            createAccountStatus:
              state:
                - SUCCEEDED
      State: ENABLED
      Targets:
        - Id: AssocLambda
          Arn: !GetAtt AssociationLambda.Arn

  LambdaInvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref AssociationLambda
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt NewAccountRule.Arn

  AssociationLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: DevOpsAgent-AutoAssociate
      Runtime: python3.12
      Handler: index.handler
      Timeout: 600
      MemorySize: 128
      Environment:
        Variables:
          AGENT_SPACE_ID: !Ref AgentSpaceId
          TARGET_OU_ID: !Ref TargetOUId
          MONITORING_ACCOUNT_ID: !Ref MonitoringAccountId
          SECONDARY_ROLE_NAME: !Ref SecondaryRoleName
          WAIT_TIME_SECONDS: !Ref WaitTimeSeconds
      Role: !GetAtt LambdaExecutionRole.Arn
      Code:
        ZipFile: |
          import boto3, json, os, time, logging

          logger = logging.getLogger()
          logger.setLevel(logging.INFO)

          def handler(event, context):
              logger.info(f"Event: {json.dumps(event)}")
              account_id = extract_account_id(event)
              if not account_id:
                  return {"status": "error", "message": "No account ID"}

              wait_time = int(os.environ.get('WAIT_TIME_SECONDS', 180))
              logger.info(f"Waiting {wait_time}s for StackSets...")
              time.sleep(wait_time)

              target_ou = os.environ['TARGET_OU_ID']
              for attempt in range(3):
                  if is_account_in_ou(account_id, target_ou):
                      break
                  logger.info(f"OU check attempt {attempt+1}/3, waiting 30s...")
                  time.sleep(30)
              else:
                  return {"status": "skipped", "reason": "not in target OU"}

              if not verify_stackset(account_id):
                  time.sleep(60)
                  if not verify_stackset(account_id):
                      return {"status": "error", "message": "StackSet not ready"}

              return create_association(account_id)

          def extract_account_id(event):
              detail = event.get('detail', {})
              req_params = detail.get('requestParameters') or {}
              if req_params.get('accountId'):
                  return req_params['accountId']
              svc = detail.get('serviceEventDetails') or {}
              status = svc.get('createAccountStatus') or {}
              if status.get('accountId'):
                  return status['accountId']
              return None

          def is_account_in_ou(account_id, ou_id):
              try:
                  org = boto3.client('organizations')
                  parents = org.list_parents(ChildId=account_id)
                  return any(p['Id'] == ou_id for p in parents.get('Parents', []))
              except Exception as e:
                  logger.error(f"OU check error: {e}")
                  return True

          def verify_stackset(account_id):
              try:
                  cf = boto3.client('cloudformation')
                  resp = cf.list_stack_instances(
                      StackSetName='devops-agent-ou-deployment',
                      StackInstanceAccount=account_id
                  )
                  return any(i.get('Status') == 'CURRENT' for i in resp.get('Summaries', []))
              except Exception as e:
                  logger.error(f"StackSet check error: {e}")
                  return False

          def create_association(account_id):
              try:
                  client = boto3.client('devops-agent')
                  space_id = os.environ['AGENT_SPACE_ID']
                  role_name = os.environ['SECONDARY_ROLE_NAME']
                  role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"
                  resp = client.associate_service(
                      agentSpaceId=space_id,
                      serviceId="aws",
                      configuration={'sourceAws': {
                          'accountId': account_id,
                          'accountType': 'source',
                          'assumableRoleArn': role_arn
                      }}
                  )
                  logger.info(f"Success: {json.dumps(resp, default=str)}")
                  return {"status": "success", "account_id": account_id}
              except Exception as e:
                  if 'ConflictException' in str(e):
                      return {"status": "already_exists", "account_id": account_id}
                  logger.error(f"Association error: {e}")
                  return {"status": "error", "message": str(e)}

  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: DevOpsAgent-AutoAssociate-LambdaRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: DevOpsAgentAssociationPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - aidevops:AssociateService
                  - aidevops:GetAssociation
                  - aidevops:ListAssociations
                Resource: !Sub arn:aws:aidevops:${AWS::Region}:${AWS::AccountId}:agentspace/${AgentSpaceId}
              - Effect: Allow
                Action: iam:PassRole
                Resource: '*'
                Condition:
                  StringEquals:
                    iam:PassedToService: aidevops.amazonaws.com
              - Effect: Allow
                Action:
                  - organizations:ListParents
                  - organizations:DescribeAccount
                Resource: '*'
              - Effect: Allow
                Action: cloudformation:ListStackInstances
                Resource: !Sub arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stackset/devops-agent-ou-deployment:*

  LambdaLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /aws/lambda/DevOpsAgent-AutoAssociate
      RetentionInDays: 14

Deploy

aws cloudformation create-stack \
    --stack-name devops-agent-auto-association \
    --template-body file://devops-agent-auto-association.yaml \
    --parameters \
        ParameterKey=AgentSpaceId,ParameterValue=your-space-id \
        ParameterKey=TargetOUId,ParameterValue=<your-ou-id> \
        ParameterKey=MonitoringAccountId,ParameterValue=111122223333 \
    --capabilities CAPABILITY_NAMED_IAM

Add the MoveAccount EventBridge Rule (via CLI)

aws events put-rule \
    --name DevOpsAgent-AutoAssociate-MoveAccount \
    --event-pattern '{
      "source": ["aws.organizations"],
      "detail-type": ["AWS API Call via CloudTrail"],
      "detail": {
        "eventSource": ["organizations.amazonaws.com"],
        "eventName": ["MoveAccount"]
      }
    }' \
    --state ENABLED

LAMBDA_ARN=$(aws lambda get-function --function-name DevOpsAgent-AutoAssociate \
    --query 'Configuration.FunctionArn' --output text)

aws events put-targets --rule DevOpsAgent-AutoAssociate-MoveAccount \
    --targets "Id=AssocLambda,Arn=$LAMBDA_ARN"

RULE_ARN=$(aws events describe-rule --name DevOpsAgent-AutoAssociate-MoveAccount \
    --query 'Arn' --output text)

aws lambda add-permission --function-name DevOpsAgent-AutoAssociate \
    --statement-id MoveAccountTrigger --action lambda:InvokeFunction \
    --principal events.amazonaws.com --source-arn $RULE_ARN

Why two rules? CreateAccountResult catches new accounts. MoveAccount catches existing accounts moved into the OU. Together they cover both scenarios.

Why is MoveAccount separate? Some accounts have a CloudFormation validation hook (AWS::EarlyValidation::PropertyValidation) that rejects "AWS API Call via CloudTrail" event patterns in templates. Deploying via CLI bypasses this.

Step 3: Test

# Create a new account
aws organizations create-account \
    --email devops-test@example.com \
    --account-name "devops-agent-test"

# Once SUCCEEDED, move to OU
aws organizations move-account \
    --account-id <new-account-id> \
    --source-parent-id r-1234 \
    --destination-parent-id <your-ou-id>

# Watch logs
aws logs tail /aws/lambda/DevOpsAgent-AutoAssociate --follow

Successful execution log

[INFO] Event: {"detail-type": "AWS API Call via CloudTrail",
  "detail": {"eventName": "MoveAccount",
    "requestParameters": {"accountId": "444455556666",
      "destinationParentId": "<your-ou-id>"}}}

[INFO] Waiting 180s for StackSets...

[INFO] Success: {"ResponseMetadata": {"HTTPStatusCode": 201},
  "association": {
    "agentSpaceId": "<your-agent-space-id>",
    "status": "valid",
    "associationId": "<your-association-id>",
    "configuration": {"sourceAws": {
      "accountId": "444455556666",
      "assumableRoleArn": "arn:aws:iam::444455556666:role/AmazonDevOpsAgentRole-SecondaryAccount"
    }}
  }}

Verify

aws devops-agent list-associations --agent-space-id your-space-id

Key Design Decisions

DecisionRationale
180s waitStackSets needs 1-3 min to deploy the IAM role. Without the wait, AssociateService fails because the role doesn't exist yet.
OU membership check with retriesCreateAccountResult fires before the account moves to an OU. Retries ensure we only associate accounts in the target OU.
IdempotencyBoth triggers may fire for the same account. Lambda handles ConflictException gracefully — first call succeeds (201), second returns "already_exists".
MoveAccount via CLIAvoids EarlyValidation hook issues in CloudFormation for CloudTrail API call event patterns.
or {}** in extract_account_id**CreateAccountResult events set requestParameters to null (not missing). Using .get('requestParameters') or {} prevents AttributeError.

Control Tower Variant

For Control Tower environments, add a third trigger:

aws events put-rule --name DevOpsAgent-AutoAssociate-ControlTower \
    --event-pattern '{
      "source": ["aws.controltower"],
      "detail-type": ["AWS Service Event via CloudTrail"],
      "detail": {
        "eventName": ["CreateManagedAccount"],
        "serviceEventDetails": {"createManagedAccountStatus": {"state": ["SUCCEEDED"]}}
      }
    }' --state ENABLED

Then add the Lambda as a target (same pattern as MoveAccount rule).

Cleanup

aws cloudformation delete-stack --stack-name devops-agent-auto-association
aws events remove-targets --rule DevOpsAgent-AutoAssociate-MoveAccount --ids AssocLambda
aws events delete-rule --name DevOpsAgent-AutoAssociate-MoveAccount
aws cloudformation delete-stack-instances --stack-set-name devops-agent-ou-deployment \
    --deployment-targets OrganizationalUnitIds='["<your-ou-id>"]' \
    --regions '["us-east-1"]' --no-retain-stacks
aws cloudformation delete-stack-set --stack-set-name devops-agent-ou-deployment

Summary

ComponentPurposeDeployment
StackSetIAM role to new accountsCloudFormation (service-managed)
EventBridge (CreateAccountResult)Trigger on new accountCloudFormation
EventBridge (MoveAccount)Trigger on account move to OUCLI
LambdaCall AssociateService APICloudFormation

Once deployed, any account that joins the target OU is automatically onboarded to DevOps Agent within ~3 minutes.

Related Resources


About The Author

Naveen Kumar Jindal is a Senior Technical Account Manager within AWS Enterprise Support, where he supports Global Financial Services (GFS) customers. With over 12 years of industry experience, Naveen specializes in AIOps, cloud optimization, and observability, helping organizations build intelligent operations practices and maximize the value of their AWS investments. He works closely with customers on operational excellence strategies, cost governance, and adopting AI-driven approaches to manage complex cloud environments at scale.