DEV Community

Cover image for Automating Transit Gateway Attachment Tagging Using EventBridge and Lambda
Santanu Das
Santanu Das

Posted on • Edited on

Automating Transit Gateway Attachment Tagging Using EventBridge and Lambda

In AWS, tags (like Name etc.) are added directly to the Transit Gateway attachment resource. Even if the transit gateway itself is shared across accounts (via AWS Resource Access Manager – RAM), each participating account owns and manages its own attachment (VPC, VPN, Direct Connect, or Peering attachment etc.). Since we have moved to the multi-account Organization, over the time we collected many VPCs from multiple accounts and as they started connecting through a shared Transit Gateway, problem started identifying which attachemnt from which VPC, when looking in the TGW attachments console.

Here’s how we add a Name tag to a shared transit gateway attachment to solve that issue, at zenler.com.

🧩 Problem Have (had)

In our multi-account AWS environment, we rely on Transit Gateway (TGW) to connect shared networking components with application VPCs. However, when a TGW attachment is created across accounts (e.g., by Terraform in a requester account), the attachment in the TGW owner account is created without tags.

This caused several issues:

  • Attachments appeared as untagged resources in the TGW owner account.
  • Reporting and cost-allocation automation relying on tags failed.
  • Manual tagging was error-prone and inconsistent.

We needed an automated, cross-account, event-driven mechanism to apply consistent tags to new TGW attachments as soon as they are created or accepted.

🕵️‍♀️ Underlying issues

By default:

  • The requester’s VPC tags do not propagate to the TGW attachment.
  • TGW create/accept API calls are management events, recorded only in CloudTrail, not directly visible to EventBridge unless CloudTrail is integrated.
  • The TGW attachment resource resides in the TGW owner account, so tagging must happen there, not in the requester’s account.

⚙️ Solution Overview

We implemented an EventBridge + Lambda automation that listens for CloudTrail management events related to TGW attachments and automatically applies tags from the requester’s VPC.
TGW auto-tagging flow


Event Flow Diagram:
CloudTrail → EventBridge → Lambda → AssumeRole → Tag TGW attachment
Event Flow

Workflow Summary:

  • CloudTrail logs CreateTransitGatewayVpcAttachment and AcceptTransitGatewayVpcAttachment events.
  • CloudTrail is configured to send events to EventBridge.
  • An EventBridge rule filters TGW attachment events.
  • The rule triggers a Lambda function in the TGW owner account.
  • The Lambda:
    • Parses the CloudTrail event (supports both new and old formats).
    • Identifies the requester account and VPC ID.
    • Assumes a cross-account role into the requester account.
    • Retrieves VPC tags and applies them to the TGW attachment, adding a suffix to the Name tag.

🗺️ Architecture Diagram

TGW auto-tagging

🧰 Implementation Details

When a requester account creates a TGW VPC attachment, the TGW owner account receives the attachment resource without any inherited tags. This function then listens to CloudTrail → EventBridge events, to identify the new TGW attachment(s), and automatically applies the VPC tags from the requester’s account to the corresponding TGW attachment in the owner’s account.

1️⃣ CloudTrail Configuration:

  • Multi-region trail enabled (optional, if needed).
  • Management events (IncludeManagementEvents = true).
  • Integrated with EventBridge (event_bus_name = "default").

2️⃣ EventBridge Rule Filter

The EventBridge rule filter defines which AWS CloudTrail events should trigger the Lambda function, making sure ensures it's only invoked when Transit Gateway (TGW) attachments are created or accepted — not for every EC2 API call.

{
  "source": ["aws.ec2"],
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventSource": ["ec2.amazonaws.com"],
    "eventName": [
      "CreateTransitGatewayVpcAttachment",
      "AcceptTransitGatewayVpcAttachment"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

3️⃣ Event Sample

The filter is evaluated each time CloudTrail logs a new management event and inspects each incoming event, like the one below:

{
  "version": "0",
  "id": "test-id",
  "detail-type": "AWS API Call via CloudTrail",
  "source": "aws.ec2",
  "account": "123456789012",
  "time": "2025-09-29T10:05:01Z",
  "region": "eu-west-2",
  "resources": [],
  "detail": {
    "eventVersion": "1.10",
    "userIdentity": {
      "type": "AWSAccount",
      "principalId": "AROA6Q5A2P2LQJEJCKFPD:aws-go-sdk-1759140300350852736",
      "accountId": "123456789012"
    },
    "eventTime": "2025-09-29T10:05:01Z",
    "eventSource": "ec2.amazonaws.com",
    "eventName": "CreateTransitGatewayVpcAttachment",
    "awsRegion": "eu-west-2",
    "sourceIPAddress": "25.xxx.xxx.xxx",
    "userAgent": "APN/1.0 HashiCorp/1.0 Terraform/1.11.0 (+https://www.terraform.io) terraform-provider-aws/6.2.0 (+https://registry.terraform.io/providers/hashicorp/aws) aws-sdk-go-v2/1.36.5 ua/2.1 os/linux lang/go#1.24.4 md/GOOS#linux md/GOARCH#arm64 api/ec2#1.229.0 m/i",
    "requestParameters": {
      "CreateTransitGatewayVpcAttachmentRequest": {
        "Options": {
          "Ipv6Support": "disable",
          "ApplianceModeSupport": "enable",
          "DnsSupport": "enable"
        },
        "TransitGatewayId": "tgw-87654321b6418c2bc",
        "VpcId": "vpc-34239b0b1106a01f0",
        "TagSpecifications": {
          "ResourceType": "transit-gateway-attachment",
          "tag": 1,
          "Tag": [
            {
              "Value": "Santanu Das",
              "tag": 1,
              "Key": "Author"
            },
            {
              "Value": "zenler",
              "tag": 2,
              "Key": "CostCentre"
            },
            { .... }
          ]
        },
        "SubnetIds": [
          {
            "tag": 1,
            "content": "subnet-04b7a3c2512345678"
          },
          {
            "tag": 2,
            "content": "subnet-12345678017c583d0"
          }
        ]
      }
    },
    "responseElements": {
      "CreateTransitGatewayVpcAttachmentResponse": {
        "xmlns": "http://ec2.amazonaws.com/doc/2016-11-15/",
        "transitGatewayVpcAttachment": {
          "tagSet": "HIDDEN_DUE_TO_SECURITY_REASONS",
          "creationTime": "2025-09-29T10:05:01.000Z",
          "transitGatewayAttachmentId": "tgw-attach-0b575dfd1fcbdbf6b",
          "transitGatewayId": "tgw-87654321b6418c2bc",
          "vpcId": "vpc-34239b0b1106a01f0",
          "options": {
            "applianceModeSupport": "enable",
            "securityGroupReferencingSupport": "enable",
            "dnsSupport": "enable",
            "ipv6Support": "disable"
          },
          "state": "pending",
          "vpcOwnerId": "123456789012",
          "subnetIds": {
            "item": [
              "subnet-04b7a3c2512345678",
              "subnet-12345678017c583d0"
            ]
          }
        },
        "requestId": "11a9ce7e-1f5d-4c69-86cf-d4043309f8ad"
      }
    },
    "requestID": "11a9ce7e-1f5d-4c69-86cf-d4043309f8ad",
    "eventID": "498e93ad-edc8-415d-be8e-7420d71c4b10",
    "readOnly": false,
    "eventType": "AwsApiCall",
    "managementEvent": true,
    "recipientAccountId": "123456789013",
    "sharedEventID": "facd716b-20c2-4496-9109-d10c4965bbf3",
    "eventCategory": "Management",
    "tlsDetails": {
      "tlsVersion": "TLSv1.3",
      "cipherSuite": "TLS_AES_128_GCM_SHA256",
      "clientProvidedHostHeader": "ec2.eu-west-2.amazonaws.com"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

 4️⃣ Lambda Function

  • Receives only those events related to TGW attachment operations.
  • Handles both CreateTransitGatewayVpcAttachmentResponse and AcceptTransitGatewayVpcAttachmentResponse.
  • Fallback for legacy format transitGatewayAttachment.
  • Detects requester account dynamically via userIdentity.accountId.
  • Cross-account role assumption for tag retrieval.
  • Appends suffix to Name tag and applies in TGW owner account.

🐍 The Function Code

ℹ️ The code below is written according to the event as sampled above. It may need to readjusting the part under # Handle new CloudTrail Create or Accept responses

import os

class Config:
    def __init__(self):
        self.CROSS_ROLE_NAME = os.environ.get('XACC_ROLE_NAME', 'xaccount-tgw-autotag-Role')
        self.TAG_NAME_PREFIX = os.environ.get('TAG_NAME_PREFIX', 'TGW-Attachment')
        self.TAG_NAME_SUFFIX = os.environ.get('TAG_NAME_SUFFIX', 'xacc-att')

# Usage
config = Config()

=================================================================

import boto3
import os, json, logging
from config import config

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

DEFAULT_NAME_PREFIX = config.TAG_NAME_PREFIX
NAME_TAG_SUFFIX     = config.TAG_NAME_SUFFIX
CROSS_ROLE_NAME     = config.CROSS_ROLE_NAME

def assume_role(account_id, role_name):
    sts = boto3.client("sts")
    role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"

    logger.info(f"Attempting to assume role {role_arn}")
    try:
        response = sts.assume_role(
            RoleArn=role_arn,
            RoleSessionName="TGWAutotagSession"
        )
        creds = response["Credentials"]
        assumed_session = boto3.Session(
            aws_access_key_id=creds["AccessKeyId"],
            aws_secret_access_key=creds["SecretAccessKey"],
            aws_session_token=creds["SessionToken"],
        )
        # Debug identity
        identity = assumed_session.client("sts").get_caller_identity()
        logger.info(f"Assumed role identity: {identity}")
        return assumed_session
    except Exception as e:
        logger.error(f"Failed to assume role {role_arn}: {str(e)}")
        raise

def handler(event, context):
    logger.info("Received event: %s", json.dumps(event))

    try:
        detail = event["detail"]["responseElements"]

        # Handle new CloudTrail Create or Accept responses
        if "CreateTransitGatewayVpcAttachmentResponse" in detail:
            tgw_attach = detail["CreateTransitGatewayVpcAttachmentResponse"]["transitGatewayVpcAttachment"]
        elif "AcceptTransitGatewayVpcAttachmentResponse" in detail:
            tgw_attach = detail["AcceptTransitGatewayVpcAttachmentResponse"]["transitGatewayVpcAttachment"]

        # Otherwise, Fallback: older CloudTrail format
        elif "transitGatewayAttachment" in detail:
            tgw_attach = detail["transitGatewayAttachment"]

        else:
            raise KeyError("No recognizable TGW attachment block in event")

        attachment_id = tgw_attach["transitGatewayAttachmentId"]
        vpc_id = tgw_attach.get("vpcId") or tgw_attach.get("resourceId")

        # requester_account from New or fallback to Old format
        if "userIdentity" in event["detail"] and "accountId" in event["detail"]["userIdentity"]:
            requester_account = event["detail"]["userIdentity"]["accountId"]  # new format
        else:
            requester_account = event["account"]  # old format

    except Exception as e:
        logger.error(f"Could not parse event for attachment: {str(e)}")
        return

    logger.info(f"Processing TGW Attachment: {attachment_id} from account {requester_account}, VPC {vpc_id}")

    # Step 1: Assume requester role
    try:
        requester_session = assume_role(requester_account, CROSS_ROLE_NAME)
        ec2_requester = requester_session.client("ec2")
        vpc = ec2_requester.describe_vpcs(VpcIds=[vpc_id])["Vpcs"][0]
        vpc_tags = vpc.get("Tags", [])
        logger.info(f"Requester VPC tags: {vpc_tags}")
    except Exception as e:
        logger.error(f"Failed to fetch VPC tags from requester account {requester_account}: {str(e)}")
        return

    # Step 2: Build tags
    if vpc_tags:
        tags_to_copy = []
        for tag in vpc_tags:
            key = tag["Key"]
            value = tag["Value"]

            # If it's the Name tag, add suffix
            if key == "Name":
                value = f"{value}-{NAME_TAG_SUFFIX}"

            tags_to_copy.append({"Key": key, "Value": value})
    else:
        tags_to_copy = [{"Key": "Name", "Value": f"{DEFAULT_NAME_PREFIX}-{vpc_id}"}]

    # Print out final tags before applying
    logger.info(f"Final tags_to_copy for TGW attachment {attachment_id}: {tags_to_copy}")

    # Step 3: Apply tags in TGW owner account
    try:
        ec2_owner = boto3.client("ec2")
        ec2_owner.create_tags(Resources=[attachment_id], Tags=tags_to_copy)
        logger.info(f"✅ Successfully tagged TGW attachment {attachment_id}")
    except Exception as e:
        logger.error(f"❌ Failed to tag TGW attachment {attachment_id}: {str(e)}")
Enter fullscreen mode Exit fullscreen mode

⚙️ Configuration Parameters

Variable Description Example
TAG_NAME_PREFIX Default prefix if no VPC name exists auto-tgw
TAG_NAME_SUFFIX Suffix appended to “Name” tag -xacc-att
CROSS_ROLE_NAME IAM role assumed in requester account TGWAutoTagCrossAccountRole

🧩 Core Functions

1️⃣ assume_role(account_id, role_name)
Assumes the specified IAM role in the target account and returns a boto3 session object.

Responsibilities:

  • Builds role ARN dynamically: arn:aws:iam:::role/
  • Uses sts.assume_role for temporary credentials.
  • Returns a boto3.Session with assumed credentials.

Logging:

  • Logs identity confirmation from sts.get_caller_identity() for verification.

2️⃣ handler(event, context)
Main entry point for the Lambda function.

Primary Responsibilities:

  • Parse CloudTrail event for:
    • TGW Attachment ID
    • VPC ID
    • Requester Account ID
  • Assume cross-account IAM role into requester account.
  • Retrieve tags from requester VPC.
  • Construct tags_to_copy list.
  • Apply tags to TGW attachment in the TGW owner account.

 🔍 Code Flow Explanation

Event Parsing

1️⃣ Handles multiple CloudTrail formats for backward compatibility:

if "CreateTransitGatewayVpcAttachmentResponse" in detail:
    tgw_attach = detail["CreateTransitGatewayVpcAttachmentResponse"]["transitGatewayVpcAttachment"]
elif "AcceptTransitGatewayVpcAttachmentResponse" in detail:
    tgw_attach = detail["AcceptTransitGatewayVpcAttachmentResponse"]["transitGatewayVpcAttachment"]
elif "transitGatewayAttachment" in detail:  # legacy format
    tgw_attach = detail["transitGatewayAttachment"]
Enter fullscreen mode Exit fullscreen mode

2️⃣ Determines requester account:

if "userIdentity" in event["detail"] and "accountId" in event["detail"]["userIdentity"]:
    requester_account = event["detail"]["userIdentity"]["accountId"]
else:
    requester_account = event["account"]
Enter fullscreen mode Exit fullscreen mode

Cross-Account Tag Retrieval

requester_session = assume_role(requester_account, CROSS_ROLE_NAME)
ec2_requester = requester_session.client("ec2")
vpc = ec2_requester.describe_vpcs(VpcIds=[vpc_id])["Vpcs"][0]
vpc_tags = vpc.get("Tags", [])
Enter fullscreen mode Exit fullscreen mode

Tag Construction

1️⃣ Adds suffix to Name tag:

for tag in vpc_tags:
    if tag["Key"] == "Name":
        tag["Value"] = f"{tag['Value']}-{NAME_TAG_SUFFIX}"
Enter fullscreen mode Exit fullscreen mode

2️⃣ Fallback when no tags exist:

tags_to_copy = [{"Key": "Name", "Value": f"{DEFAULT_NAME_PREFIX}-{vpc_id}"}]
Enter fullscreen mode Exit fullscreen mode

🔐 IAM Requirements

TGW Owner Account (Lambda Execution Role)
1️⃣ MUST allow:

{
  "Action": [
    "ec2:CreateTags",
    "sts:AssumeRole"
  ],
  "Resource": "*"
}
Enter fullscreen mode Exit fullscreen mode

Requester Account (Cross-Account Role)
1️⃣ MUST trust TGW Owner account’s Lambda role:

{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::<tgw-owner-account>:role/<lambda-exec-role>" },
  "Action": "sts:AssumeRole"
}
Enter fullscreen mode Exit fullscreen mode

2️⃣ and MUST allow:

{
  "Action": "ec2:DescribeVpcs",
  "Resource": "*"
}
Enter fullscreen mode Exit fullscreen mode

 🧪 Testing Approach

  • Use Lambda test console with real CloudTrail event payloads.
  • Verify:
    1. Event parsing logs (attachment ID, requester account, VPC ID)
    2. Successful assume role identity.
    3. Correct tag propagation in TGW console.

🧱 Putting Together in Terraform

The code-snippet below are purely for reference purpose. Our TF projects are bootstraped from Terragrunt and it's a modified version of copy/paste from there, which may not exactly fit in every other development environment.

ℹ️ This work-flow is part of our in-house VPC module, where it does the TGW attachemnt during the VPC creation. The condition var.run_acc != var.tgw_owner_acc determins whether to execute the associated TF resource in the currently running_account or not. provider = aws.tgw switches to the TGW owner's account for the deployment

1️⃣ Lambda Excution Role/Policy

# ------------------------------------------------------
# Policy to attach to Lambda excution Role
# ------------------------------------------------------
data "aws_iam_policy_document" "tgw_owner_acc" {
  count = var.run_acc != var.tgw_owner_acc ? 1 : 0

  statement {
    actions = [
      "ec2:CreateTags",
      "ec2:DescribeTransitGatewayAttachments"
    ]
    effect    = "Allow"
    resources = ["*"]
    sid       = "AllowLambdaCreateTgwAttachmentTags"
  }

  statement {
    actions   = ["sts:AssumeRole"]
    effect    = "Allow"
    resources = [aws_iam_role.tgw_this_acc[local.vpc_name_pfx].arn]
    sid       = "AllowLambdaToAssumeRole"
  }
}

// Generate the policy from above document
resource "aws_iam_policy" "tgw_owner_acc" {
  for_each = var.run_acc != var.tgw_owner_acc ? toset(
    values(var.env_tgw_info)[*].owner_name
  ) : []
  name        = "${lower(each.value)}-${upper(var.run_acc)}-tgw-autotag-Policy"
  path        = "/"
  description = "IAM policy for logging from a lambda"
  policy      = data.aws_iam_policy_document.tgw_owner_acc[0].json

  tags = merge(
    var.extra_tags,
    {
      Name     = "${each.value}-tgw-autotag-Policy"
      Module   = var.tf_module_name
      Resource = "aws_iam_policy.tgw_xacc_autotag"
    }
  )
  provider = aws.tgw
}

// Attach policy to lambda excution Role
resource "aws_iam_role_policy_attachment" "tgw_owner_acc" {
  for_each = var.run_acc != var.tgw_owner_acc ? toset(
    values(var.env_tgw_info)[*].owner_name
  ) : []
  role       = "${lower(each.value)}-${var.lambda_role_suffix}"
  policy_arn = aws_iam_policy.tgw_owner_acc[each.value].arn
  provider   = aws.tgw
}
Enter fullscreen mode Exit fullscreen mode

2️⃣ IAM Role in Requester's Account

# ------------------------------------------------------
# Local IAM role for Lambda to assume
# ------------------------------------------------------
resource "aws_iam_role" "tgw_this_acc" {
  for_each = var.run_acc != var.tgw_owner_acc ? toset(
    [local.vpc_name_pfx]
  ) : []
  #name = "${each.value}-tgw-autotag-Role"
  name = var.xacc_tgw_role_name

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Effect = "Allow",
        Principal = {
          AWS = [
            for ID in values(var.env_tgw_info)[*].owner_id :
            "arn:aws:iam::${ID}:root"
          ]
        },
        Action = "sts:AssumeRole"
      }
    ]
  })

  tags = merge(
    var.extra_tags,
    {
      Name     = "${each.value}-tgw-autotag-Role"
      Module   = var.tf_module_name
      Resource = "aws_iam_role.tgw_xacc_autotag"
    }
  )
}

//
data "aws_iam_policy_document" "tgw_this_acc" {
  statement {
    actions   = ["ec2:DescribeVpcs"]
    effect    = "Allow"
    resources = ["*"]
    sid       = "AllowLambdaReadOnlyVpcAccess"
  }
}

resource "aws_iam_policy" "tgw_this_acc" {
  for_each = var.run_acc != var.tgw_owner_acc ? toset(
    [local.vpc_name_pfx]
  ) : []
  name        = "${each.value}-tgw-autotag-Policy"
  path        = "/"
  description = "IAM policy for logging from a lambda"
  policy      = data.aws_iam_policy_document.tgw_this_acc.json

  tags = merge(
    var.extra_tags,
    {
      Name     = "${each.value}-tgw-autotag-Policy"
      Module   = var.tf_module_name
      Resource = "aws_iam_policy.tgw_xacc_autotag"
    }
  )
}

// Attach policy to lambda Role
resource "aws_iam_role_policy_attachment" "tgw_this_acc" {
  for_each = var.run_acc != var.tgw_owner_acc ? toset(
    [local.vpc_name_pfx]
  ) : []
  role       = aws_iam_role.tgw_this_acc[each.value].name
  policy_arn = aws_iam_policy.tgw_this_acc[each.value].arn
}
Enter fullscreen mode Exit fullscreen mode

3️⃣ Core Lambda Resources

# ------------------------------------------------------
# Lambda function resource
# ------------------------------------------------------
data "archive_file" "tgw_autotag" {
  count       = var.run_acc == var.tgw_owner_acc ? 1 : 0
  type        = "zip"
  output_path = "${path.module}/autotag_lambda.zip"

  source {
    content  = file("${path.module}/lambda/function.py")
    filename = "function.py"
  }
  source {
    content  = file("${path.module}/lambda/config.py")
    filename = "config.py"
  }
}

// CloudWatch for Lambda function
resource "aws_cloudwatch_log_group" "tgw_autotag" {
  count = var.run_acc == var.tgw_owner_acc ? 1 : 0
  name  = "/aws/lambda/${local.vpc_name_pfx}-tgw-autotag"
  #
  retention_in_days = 14
}

// Lambda-function
resource "aws_lambda_function" "tgw_autotag" {
  count            = var.run_acc == var.tgw_owner_acc ? 1 : 0
  filename         = data.archive_file.tgw_autotag[0].output_path
  function_name    = "${local.vpc_name_pfx}-tgw-autotag"
  handler          = "function.handler"
  role             = var.acc_lambda_role_arn
  runtime          = "python3.12"
  timeout          = 12
  source_code_hash = data.archive_file.tgw_autotag[0].output_base64sha256

  # Advanced logging controls (optional)
  logging_config {
    log_format            = "JSON"
    application_log_level = "INFO"
    system_log_level      = "WARN"
  }

  # Environment variables for config.py
  environment {
    variables = {
      TAG_NAME_PREFIX = local.vpc_name_pfx
      TAG_NAME_SUFFIX = "att-xacc"
      XACC_ROLE_NAME  = var.xacc_tgw_role_name
    }
  }

  # Direct dependency
  depends_on = [
    aws_cloudwatch_log_group.tgw_autotag,
  ]
}
Enter fullscreen mode Exit fullscreen mode

4️⃣ CW Logs Group for CloudTrail

resource "aws_cloudwatch_log_group" "ct_tgw_autotag" {
  count = var.run_acc == var.tgw_owner_acc ? 1 : 0
  name  = "/aws/cloudtrail/${local.vpc_name_pfx}-tgw-autotag"
  #
  retention_in_days = 14
}

// IAM role for CloudTrail to write to CloudWatch Logs
resource "aws_iam_role" "ct_tgw_autotag" {
  count = var.run_acc == var.tgw_owner_acc ? 1 : 0
  name  = "${lower(var.aws_acc_name)}-cloudtrail-assume-Role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Service = "cloudtrail.amazonaws.com"
      }
      Action = "sts:AssumeRole"
    }]
  })
}

//
resource "aws_iam_role_policy" "ct_tgw_autotag" {
  count = var.run_acc == var.tgw_owner_acc ? 1 : 0
  role  = aws_iam_role.ct_tgw_autotag[0].id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "${aws_cloudwatch_log_group.ct_tgw_autotag[0].arn}:*"
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

5️⃣ CloudTrail + EventBridge Integration

resource "aws_cloudtrail" "tgw_autotag" {
  count                 = var.run_acc == var.tgw_owner_acc ? 1 : 0
  name                  = "${lower(var.aws_acc_name)}-tgw-attachment-trail"
  enable_logging        = true
  s3_bucket_name        = var.svc_logging_bucket.name
  is_multi_region_trail = false

  cloud_watch_logs_group_arn    = "${aws_cloudwatch_log_group.ct_tgw_autotag[0].arn}:*"
  cloud_watch_logs_role_arn     = aws_iam_role.ct_tgw_autotag[0].arn
  include_global_service_events = true

  # This is the key part: enable EventBridge integration
  event_selector {
    read_write_type           = "All"
    include_management_events = true

    data_resource {
      type   = "AWS::Lambda::Function"
      values = [aws_lambda_function.tgw_autotag[0].arn]
    }
  }

  tags = {
    Module   = var.tf_module_name
    Resource = "aws_cloudtrail.tgw_autotag"
  }
}
Enter fullscreen mode Exit fullscreen mode

6️⃣ EventBridge Rule & Target

resource "aws_cloudwatch_event_rule" "acer_autotag" {
  count       = var.run_acc == var.tgw_owner_acc ? 1 : 0
  name        = "${lower(var.aws_acc_name)}-tgw-attachment-created"
  description = "Triggers when TGW attachments are created"
  event_pattern = jsonencode({
    source      = ["aws.ec2"],
    detail-type = ["AWS API Call via CloudTrail"],
    detail = {
      eventSource = ["ec2.amazonaws.com"],
      eventName = [
        "AcceptTransitGatewayVpcAttachment",
        "CreateTransitGatewayVpcAttachment",
      ]
    }
  })
}

// Lambda target
resource "aws_cloudwatch_event_target" "acet_autotag" {
  count     = var.run_acc == var.tgw_owner_acc ? 1 : 0
  rule      = aws_cloudwatch_event_rule.acer_autotag[0].name
  target_id = "TGWAutotagLambda"
  arn       = aws_lambda_function.tgw_autotag[0].arn
}

// allow EventBridge
resource "aws_lambda_permission" "alp_autotag" {
  count         = var.run_acc == var.tgw_owner_acc ? 1 : 0
  statement_id  = "AllowExecutionFromEventBridge"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.tgw_autotag[0].function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.acer_autotag[0].arn
}
Enter fullscreen mode Exit fullscreen mode

🌱 Conclusion: Benefits

Benefit Description
Operational Consistency Ensures every TGW attachment inherits tags from its requester VPC.
Cross-Account Governance Uses IAM roles and least-privilege policies to enforce separation of duties.
Accurate Cost Allocation Tags align with Cost Center, Environment, and Ownership standards.
Event-Driven Scalability Fully serverless; no polling or scheduled jobs.
Future-Proof Handles both legacy and new CloudTrail event formats.

📊 End Result


The ones with -att are the native to the TGW owner's account and the ones with -att-xacc are cross-account from requesters' account.

🚀 Future Enhancements

  • Add SNS notifications for tagging failures.
  • Extend to tag DeleteTransitGatewayVpcAttachment cleanup events.
  • Integrate with AWS Service Catalog TagOptions for standardization.
  • Publish metrics via CloudWatch Insights for audit reporting.

Top comments (0)