Automate AWS DevOps Agent Onboarding at the OU Level Using StackSets and EventBridge
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:
- An IAM role allowing DevOps Agent to access resources
- 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
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:CreateServiceLinkedRoleis required for Resource Explorer — it's not included in the managedAIDevOpsAgentAccessPolicy.
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?
CreateAccountResultcatches new accounts.MoveAccountcatches 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
| Decision | Rationale |
|---|---|
| 180s wait | StackSets 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 retries | CreateAccountResult fires before the account moves to an OU. Retries ensure we only associate accounts in the target OU. |
| Idempotency | Both triggers may fire for the same account. Lambda handles ConflictException gracefully — first call succeeds (201), second returns "already_exists". |
| MoveAccount via CLI | Avoids 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
| Component | Purpose | Deployment |
|---|---|---|
| StackSet | IAM role to new accounts | CloudFormation (service-managed) |
| EventBridge (CreateAccountResult) | Trigger on new account | CloudFormation |
| EventBridge (MoveAccount) | Trigger on account move to OU | CLI |
| Lambda | Call AssociateService API | CloudFormation |
Once deployed, any account that joins the target OU is automatically onboarded to DevOps Agent within ~3 minutes.
Related Resources
- Amazon DevOps Agent User Guide
- AWS CloudFormation StackSets
- Amazon EventBridge Rules
- Best Practices for Deploying AWS DevOps Agent in Production
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.
- Language
- English
Relevant content
- Accepted Answer
asked 2 months ago
- Accepted Answer
asked 2 years ago
- Accepted Answer
asked 3 months ago
