├── .eslintignore ├── .gitignore ├── .npmignore ├── README.md ├── bin └── event-bridge-org.ts ├── cdk.json ├── global-bus-logs.png ├── jest.config.js ├── local-global-eventbridge.png ├── package-lock.json ├── package.json ├── putevents.json ├── requirements.txt ├── src ├── delivery-handler.ts ├── event-util.ts ├── lambda-common.ts └── order-handler.ts ├── stacks ├── base-stack.ts ├── base-stage.ts ├── bus-stack.ts ├── bus-stage.ts ├── delivery-stack.ts ├── delivery-stage.ts ├── order-stack.ts ├── order-stage.ts └── pipeline-stack.ts ├── test ├── event-bridge-org.test.ts └── integration │ └── test_events.py └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | **/dist/ 3 | **/.nyc_output 4 | **/coverage 5 | **/.serverless 6 | **/.webpack 7 | **/*.json 8 | **/*.js 9 | cdk.out/** 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | .vscode 10 | .eslintcache 11 | 12 | cdk*.context.json 13 | venv/ 14 | *.pyc 15 | .DS_Store 16 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cross-account / cross-domain EventBridge backbone example 2 | 3 | This is a complete example of using EventBridge in multiple accounts to publish cross-domain application events. 4 | 5 | This architecture is detailed in 👉 [this blog post](https://dev.to/eoinsha/how-to-use-eventbridge-as-a-cross-account-event-backbone-5fik). 6 | 7 | It uses the example of a simple e-commerce application: 8 | * A customer creates an order which is handled by an order service in its own account. 9 | * Delivery of orders is handled by a separate service in a different account. 10 | 11 | This example uses AWS CDK to create all resources and [CDK Pipelines](https://docs.aws.amazon.com/cdk/v2/guide/cdk_pipeline.html) to deploy everything. 12 | 13 | To use this example, three accounts are required: 14 | 15 | 1. A bus account for the global EventBridge bus 16 | 2. An order service account 17 | 3. A delivery service account 18 | 19 | Cross-domain events are achieved using EventBridge in the following way. 20 | 1. Applications publish events to the global EventBridge bus (cross-account). 21 | 2. The global bus forwards events to every application account's local custom EventBridge bus (except the event sender's account) 22 | 3. If application wish to respond to an event, they create a rule in their local bus. 23 | 24 | For event observability, each bus also forwards all events to a CloudWatch log group in the same account. 25 | 26 | ![AWS architecture diagram](./local-global-eventbridge.png) 27 | 28 | The flow of events for the simple order creation use case is as follows. 29 | 30 | 1. An HTTP POST API is used to create an order. The backing Lambda function generates an order ID and sends an `Order.Created` event to the global bus 31 | 2. The delivery service picks up the `Order.Created` event from its local bus, processes the order (since this is an example, we just use a five-second sleep!), and sends a `Delivery.Updated` event including all the important delivery details to the global bus. 32 | 3. The order service picks up the `Delivery.Updated` event from its local bus, and finally sends an `Order.Updated` event to the global bus. 33 | 34 | The code is commented to help explain the resources used and flow of events. 35 | 36 | | CDK Stack | Lambda functions | 37 | |----------|----------------| 38 | | [bus-stack.ts](./stacks/bus-stack.ts) | _N/A_ | 39 | | [order-stack.ts](./stacks/order-stack.ts) | [order-handler.ts](./src/order-handler.ts) | 40 | | [delivery-stack.ts](./stacks/delivery-stack.ts) | [delivery-handler.ts](./src/delivery-handler.ts) | 41 | 42 | ## Setup 43 | 1. Install the CDK and application modules: 44 | ``` 45 | npm install -g aws-cdk 46 | npm install 47 | ``` 48 | 49 | 2. Set some environment variables for the four accounts. 50 | 51 | We will use three accounts for each stage (bus, order and delivery) as well as a deployment (CI/CD) account. 52 | The CDK code uses [CDK context variables](https://docs.aws.amazon.com/cdk/v2/guide/context.html) to read all account IDs. 53 | 54 | Create a file in the root called `cdk.context.json` and populate it with the following: 55 | ``` 56 | { 57 | "bus-account": "", 58 | "order-service-account": "", 59 | "delivery-service-account": "", 60 | "cicd-account": "" 61 | } 62 | ``` 63 | 64 | **NOTE:** You can use the same account for everything if you want to skip the overhead of multiple accounts for development environments! 65 | 66 | 3. CDK Bootstrap each account. This example uses [named profiles](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html) to load credentials for each account. Replace the `--profile` value with the correct profile in each case, or use credentials in `AWS_` environment variables. 67 | When the three application accounts are being bootstrapped, we are allowing the CICD account to be trusted, and therefore allow each account's CDK deployment role to be assumed. 68 | 69 | ``` 70 | cdk bootstrap --profile busAccount.AdministratorAccess --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess --trust= aws:/// 71 | cdk bootstrap --profile orderServiceAccount.AdministratorAccess --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess --trust=aws:/// 72 | 73 | cdk bootstrap --profile deliveryServiceAccount.AdministratorAccess --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess --trust=aws:/// 74 | 75 | cdk bootstrap --profile cicdAccount.AdministratorAccess aws:/// 76 | ``` 77 | 78 | ## Deployment 79 | Once the boostrapping phase is successful, you can deploy the CI/CD pipeline. 80 | 81 | _Note:_ You could choose to skip the deployment pipeline and manually `cdk deploy` each stack (`BusStack`, `OrderServiceStack` and `DeliveryServiceStack`). This approach creates a CDK pipeline (using CodePipeline) to do that for you, taking care of setup and subsequent continuous deployment. 82 | 83 | ``` 84 | cdk deploy --profile cideAccount.AdministratorAccess PipelineStack 85 | ``` 86 | 87 | ## Usage 88 | Create a test order: 89 | ``` 90 | curl -X POST https://.execute-api.eu-west-1.amazonaws.com/prod 91 | ``` 92 | Replace `` with the ID of the OrderService API. This is output when you deploy `OrderServiceStack`, so you can retrieve it from the `Outputs` section of the CloudFormation stack in the order service account. 93 | 94 | Verify that all events have been sent by checking the latest entries in the Global Bus logs in the bus account. You should see the three events as shown in this screenshot: 95 | 96 | ![CloudWatch Logs Insights showing the three events in the global bus log](./global-bus-logs.png) 97 | 98 | ## Testing 99 | 100 | An [IATK](https://github.com/awslabs/aws-iatk) integration test is provided in [](./test/integration/test_events.py). This is a work in progress. 🙂 101 | 102 | ## Cleaning up 103 | If you are using this example as the basis for your own architecture, great! [Let me know](#contact) how it goes. 104 | Otherwise, you might want to clean up your resources. You can do that by deleting the stack from each of the four accounts in the CloudFormation console. 105 | 106 | ## Contact ✉️ 107 | Let me know what you think and if you are using this example to create your own cross-account bus. Reach out on [Twitter](https://twitter.com/eoins), [LinkedIn](https://www.linkedin.com/in/eoins/) or [GitHub](https://github.com/eoinsha). -------------------------------------------------------------------------------- /bin/event-bridge-org.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register' 3 | import * as cdk from 'aws-cdk-lib' 4 | import { PipelineStack } from '../stacks/pipeline-stack' 5 | import { BusStage } from '../stacks/bus-stage' 6 | import { OrderStage } from '../stacks/order-stage' 7 | import { DeliveryStage } from '../stacks/delivery-stage' 8 | 9 | const ORDER_SERVICE_IDENTIFIER = 'order-service' 10 | const DELIVERY_SERVICE_IDENTIFIER = 'delivery-service' 11 | 12 | const app = new cdk.App() 13 | 14 | const cicdAccount = app.node.tryGetContext('cicd-account') 15 | const busAccount = app.node.tryGetContext('bus-account') 16 | const orderAccount = app.node.tryGetContext('order-service-account') 17 | const deliveryAccount = app.node.tryGetContext('delivery-service-account') 18 | 19 | const busStage = new BusStage(app, 'BusStage', { 20 | env: { 21 | account: busAccount, 22 | region: app.region 23 | }, 24 | applicationAccountByIdentifier: { 25 | [ORDER_SERVICE_IDENTIFIER]: orderAccount, 26 | [DELIVERY_SERVICE_IDENTIFIER]: deliveryAccount 27 | } 28 | }) 29 | 30 | const orderStage = new OrderStage(app, 'OrderStage', { 31 | env: { 32 | account: orderAccount, 33 | region: app.region 34 | }, 35 | identifier: ORDER_SERVICE_IDENTIFIER, 36 | busAccount, 37 | }) 38 | 39 | const deliveryStage = new DeliveryStage(app, 'DeliveryStage', { 40 | env: { 41 | account: deliveryAccount, 42 | region: app.region 43 | }, 44 | identifier: DELIVERY_SERVICE_IDENTIFIER, 45 | busAccount, 46 | }) 47 | 48 | const pipelineStack = new PipelineStack(app, 'PipelineStack', { 49 | env: { 50 | account: app.node.tryGetContext(`cicd-account`), 51 | region: app.region 52 | }, 53 | stages: [ 54 | busStage, 55 | orderStage, 56 | deliveryStage, 57 | ], 58 | accounts: { 59 | 'cicd-account': cicdAccount, 60 | 'bus-account': busAccount, 61 | 'order-service-account': orderAccount, 62 | 'delivery-service-account': deliveryAccount, 63 | } 64 | }) 65 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/event-bridge-org.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 25 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 26 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 27 | "@aws-cdk/core:target-partitions": [ 28 | "aws", 29 | "aws-cn" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /global-bus-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fourTheorem/cross-account-eventbridge/fc75bee26a13bd7602a03db77bda77b8286b3411/global-bus-logs.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /local-global-eventbridge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fourTheorem/cross-account-eventbridge/fc75bee26a13bd7602a03db77bda77b8286b3411/local-global-eventbridge.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cross-account-eventbridge", 3 | "version": "0.1.0", 4 | "bin": { 5 | "event-bridge-org": "bin/event-bridge-org.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "npm run test:lint", 11 | "format": "eslint --cache --fix .", 12 | "test:lint": "eslint .", 13 | "cdk": "cdk" 14 | }, 15 | "devDependencies": { 16 | "@types/aws-lambda": "^8.10.93", 17 | "@types/jest": "^26.0.10", 18 | "@types/node": "10.17.27", 19 | "@types/uuid": "^8.3.4", 20 | "@typescript-eslint/eslint-plugin": "^6.12.0", 21 | "aws-cdk": "^2.110.0", 22 | "esbuild": "^0.14.25", 23 | "eslint": "^8.54.0", 24 | "eslint-config-standard-with-typescript": "^40.0.0", 25 | "eslint-plugin-import": "^2.29.0", 26 | "eslint-plugin-n": "^16.3.1", 27 | "eslint-plugin-promise": "^6.1.1", 28 | "jest": "^29.7.0", 29 | "ts-jest": "^29.1.1", 30 | "ts-node": "^10.9.1", 31 | "typescript": "^5.3.2" 32 | }, 33 | "dependencies": { 34 | "@aws-lambda-powertools/logger": "^1.16.0", 35 | "@aws-lambda-powertools/metrics": "^1.16.0", 36 | "@aws-lambda-powertools/tracer": "^1.16.0", 37 | "@aws-sdk/client-eventbridge": "^3.54.0", 38 | "@aws-sdk/client-ssm": "^3.54.0", 39 | "@middy/core": "^5.3.5", 40 | "aws-cdk-lib": "^2.110.0", 41 | "case": "^1.6.3", 42 | "constructs": "^10.0.0", 43 | "pino": "^7.8.1", 44 | "source-map-support": "^0.5.16" 45 | } 46 | } -------------------------------------------------------------------------------- /putevents.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Source": "order.service", 4 | "Detail": "{ \"key1\": \"value1\", \"key2\": \"value2\" }", 5 | "DetailType": "Order.Created", 6 | "EventBusName": "global-bus" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-xdist 3 | boto3 4 | parametrized 5 | requests 6 | aws-iatk 7 | -------------------------------------------------------------------------------- /src/delivery-handler.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | import { v4 as uuidv4 } from 'uuid' 3 | 4 | import { EventBridgeHandler } from 'aws-lambda' 5 | import { Tracer } from '@aws-lambda-powertools/tracer' 6 | import { EventSender } from './event-util' 7 | import { middify } from './lambda-common' 8 | 9 | const { SERVICE_IDENTIFIER } = process.env 10 | 11 | if (!SERVICE_IDENTIFIER) { 12 | throw new Error('SERVICE_IDENTIFIER env var is required') 13 | } 14 | const log = pino({ name: SERVICE_IDENTIFIER }) 15 | const tracer = new Tracer({ serviceName: SERVICE_IDENTIFIER }) 16 | const eventSender = new EventSender(SERVICE_IDENTIFIER, tracer) 17 | 18 | /** 19 | * Order Delivery processing - handle EventBridge events for Order.Created 20 | * and emit a Delivery.UpdatedEvent 21 | */ 22 | export const handleOrderCreated: EventBridgeHandler = middify(async function (event, context) { 23 | log.info({ event }) 24 | 25 | const order = event.detail.data 26 | 27 | // Sleep to simulate some delivery processing 28 | await new Promise((resolve) => { 29 | setTimeout(resolve, 5000) 30 | }) 31 | 32 | const deliveryUpdate = { 33 | order, 34 | deliveredAt: Date.now(), 35 | deliveryId: uuidv4() 36 | } 37 | await eventSender.send('Delivery.Updated', deliveryUpdate) 38 | }) 39 | -------------------------------------------------------------------------------- /src/event-util.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge' 3 | 4 | const log = pino({ name: 'event-sender' }) 5 | 6 | const { BUS_ARN } = process.env 7 | 8 | import { Tracer } from '@aws-lambda-powertools/tracer' 9 | 10 | /** 11 | * Utility to create EventBridge events in a consistent format 12 | * 13 | * @returns An EventSender 14 | */ 15 | export class EventSender { 16 | 17 | serviceName: string 18 | tracer: Tracer 19 | 20 | constructor(serviceName: string, tracer: Tracer) { 21 | this.serviceName = serviceName 22 | this.tracer = tracer 23 | } 24 | 25 | /** 26 | * Send an event to EventBridge 27 | */ 28 | send (detailType: string, data: object) { 29 | const client = this.tracer.captureAWSv3Client(new EventBridgeClient({})) 30 | const params = { 31 | Entries: [{ 32 | EventBusName: BUS_ARN, 33 | Source: this.serviceName, 34 | DetailType: detailType, 35 | Detail: JSON.stringify({ 36 | data, 37 | meta: {} 38 | }) 39 | }] 40 | } 41 | log.info({ params }, 'Sending events') 42 | return client.send(new PutEventsCommand(params)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lambda-common.ts: -------------------------------------------------------------------------------- 1 | import { Logger, injectLambdaContext } from '@aws-lambda-powertools/logger' 2 | import { Metrics, logMetrics } from '@aws-lambda-powertools/metrics' 3 | import { Tracer, captureLambdaHandler } from '@aws-lambda-powertools/tracer' 4 | import middy from '@middy/core' 5 | import { Handler } from 'aws-lambda' 6 | 7 | // Exported powertools instances for use anywhere within a Lambda function implementation 8 | export const logger = new Logger() 9 | export const tracer = new Tracer() 10 | export const metrics = new Metrics() 11 | 12 | /** 13 | * Create a wrapped Lambda Function handler with injected powertools logger, tracer and metrics 14 | * 15 | * @param handler The undecorated Lambda Function handler 16 | * @returns A 'middified' handler 17 | */ 18 | export const middify = (handler: Handler) => { 19 | return middy(handler) 20 | .use(injectLambdaContext(logger, { logEvent: true })) 21 | .use(logMetrics(metrics)) 22 | .use(captureLambdaHandler(tracer)) 23 | } 24 | 25 | export const POWERTOOLS_METRICS_NAMESPACE = 'CrossAccountEventBackbone' -------------------------------------------------------------------------------- /src/order-handler.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { APIGatewayProxyHandler, EventBridgeHandler } from 'aws-lambda' 4 | import { Tracer } from '@aws-lambda-powertools/tracer' 5 | import { EventSender } from './event-util' 6 | import { middify } from './lambda-common' 7 | 8 | const { SERVICE_IDENTIFIER } = process.env 9 | 10 | if (!SERVICE_IDENTIFIER) { 11 | throw new Error('SERVICE_IDENTIFIER env var is required') 12 | } 13 | 14 | const log = pino({ name: SERVICE_IDENTIFIER }) 15 | const tracer = new Tracer({ serviceName: SERVICE_IDENTIFIER }) 16 | const eventSender = new EventSender(SERVICE_IDENTIFIER, tracer) 17 | 18 | /** 19 | * HTTP POST /order handling. Create an order and post it in an 'Order.Created' event 20 | */ 21 | export const handleOrderCreate: APIGatewayProxyHandler = middify(async function handleOrderCreate (event){ 22 | log.info({ event }) 23 | 24 | const orderId = uuidv4() 25 | const order = { 26 | orderId, 27 | createdAt: Date.now() 28 | } 29 | 30 | await eventSender.send('Order.Created', order) 31 | return { 32 | statusCode: 201, 33 | body: JSON.stringify(order) 34 | } 35 | }) 36 | 37 | /** 38 | * Handle EventBridge events indicating a delivery update for an order. 39 | * An 'Order.Updated' event is emitted to indicate that the order is delivered. 40 | */ 41 | export const handleDeliveryUpdate: EventBridgeHandler = middify(async function handleDeliveryUpdate (event, context) { 42 | log.info({ event }) 43 | 44 | const { order, deliveredAt } = event.detail.data 45 | 46 | const updatedOrder = { 47 | ...order, 48 | deliveredAt, 49 | updatedAt: Date.now() 50 | } 51 | 52 | await eventSender.send('Order.Updated', updatedOrder) 53 | return updatedOrder 54 | }) 55 | -------------------------------------------------------------------------------- /stacks/base-stack.ts: -------------------------------------------------------------------------------- 1 | import { CfnOutput, Stack, StackProps } from 'aws-cdk-lib'; 2 | import { EventBus, IEventBus } from 'aws-cdk-lib/aws-events'; 3 | import * as iam from 'aws-cdk-lib/aws-iam' 4 | import * as logs from 'aws-cdk-lib/aws-logs' 5 | import * as events from 'aws-cdk-lib/aws-events' 6 | import { CloudWatchLogGroup as LogGroupTarget } from 'aws-cdk-lib/aws-events-targets' 7 | import { Construct } from 'constructs'; 8 | 9 | export interface BaseStackProps extends StackProps { 10 | busAccount: string 11 | identifier: string 12 | } 13 | 14 | /** 15 | * Base stack class used for any application requiring a local bus 16 | * with logs and permissions to receive events from the global bus. 17 | */ 18 | export abstract class BaseStack extends Stack { 19 | localBus: IEventBus 20 | globalBus: IEventBus 21 | globalBusPutEventsStatement: iam.PolicyStatement 22 | 23 | constructor(scope: Construct, id: string, props: BaseStackProps) { 24 | super(scope, id, props) 25 | 26 | const globalBusArn = `arn:aws:events:${this.region}:${props.busAccount}:event-bus/global-bus` 27 | this.globalBus = EventBus.fromEventBusArn(this, 'GlobalBus', globalBusArn) 28 | 29 | /** 30 | * This is a reusable policy statement that allows Lambda functions to publish events 31 | * to the global bus 32 | */ 33 | this.globalBusPutEventsStatement = new iam.PolicyStatement({ 34 | actions: ['events:PutEvents'], 35 | resources: [globalBusArn], 36 | }) 37 | 38 | const busLogGroup = new logs.LogGroup(this, 'LocalBusLogs', { 39 | retention: logs.RetentionDays.ONE_WEEK, 40 | }) 41 | 42 | const localBus = new events.EventBus(this, 'LocalBus', { eventBusName: `local-bus-${props.identifier}` }) 43 | new events.CfnEventBusPolicy(this, 'LocalBusPolicy', { 44 | eventBusName: localBus.eventBusName, 45 | statementId: `local-bus-policy-stmt-${props.identifier}`, 46 | statement: { 47 | Principal: { AWS: this.globalBus.env.account }, 48 | Action: 'events:PutEvents', 49 | Resource: localBus.eventBusArn, 50 | Effect: 'Allow' 51 | } 52 | }) 53 | 54 | new CfnOutput(this, 'localBusName', { 55 | value: localBus.eventBusName, 56 | }) 57 | 58 | new events.Rule(this, 'LocalLoggingRule', { 59 | eventBus: localBus, 60 | ruleName: 'local-logging', 61 | eventPattern: { 62 | source: [{ prefix: '' }] as any[] // Match all 63 | } 64 | }).addTarget(new LogGroupTarget(busLogGroup)) 65 | 66 | this.localBus = localBus 67 | } 68 | } -------------------------------------------------------------------------------- /stacks/base-stage.ts: -------------------------------------------------------------------------------- 1 | import { Stage, StageProps } from 'aws-cdk-lib' 2 | import { Construct } from 'constructs' 3 | import { BaseStack } from './base-stack' 4 | 5 | export interface BaseStageProps extends StageProps { 6 | busAccount: string 7 | identifier: string 8 | } 9 | 10 | export abstract class BaseStage extends Stage { 11 | stack: BaseStack 12 | 13 | constructor(scope: Construct, id: string, props: BaseStageProps) { 14 | super(scope, id, props) 15 | } 16 | } -------------------------------------------------------------------------------- /stacks/bus-stack.ts: -------------------------------------------------------------------------------- 1 | import { CfnOutput, Stack, StackProps } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import * as events from 'aws-cdk-lib/aws-events' 4 | import * as logs from 'aws-cdk-lib/aws-logs' 5 | import * as Case from 'case' 6 | import { 7 | EventBus as EventBusTarget, 8 | CloudWatchLogGroup as LogGroupTarget 9 | } from 'aws-cdk-lib/aws-events-targets' 10 | import { EventBus } from 'aws-cdk-lib/aws-events'; 11 | 12 | interface BusStackProps extends StackProps { 13 | applicationAccountByIdentifier: Record 14 | } 15 | 16 | /** 17 | * Stack to create a global EventBus. All applications post cross-domain events to this bus. 18 | * This bus also has rules to forward global events to local buses in each application account 19 | * where rules can be created to handle events in each application. 20 | */ 21 | export class BusStack extends Stack { 22 | bus: events.EventBus 23 | 24 | constructor(scope: Construct, id: string, props: BusStackProps) { 25 | super(scope, id, props); 26 | 27 | const busLogGroup = new logs.LogGroup(this, 'GlobalBusLogs', { 28 | retention: logs.RetentionDays.ONE_WEEK, 29 | }) 30 | 31 | const bus = new events.EventBus(this, 'Bus', { 32 | eventBusName: 'global-bus', 33 | }) 34 | 35 | new CfnOutput(this, 'globalBusName', { 36 | value: bus.eventBusName 37 | }) 38 | 39 | new events.CfnEventBusPolicy(this, 'BusPolicy', { 40 | eventBusName: bus.eventBusName, 41 | statementId: 'global-bus-policy-stmt', 42 | statement: { 43 | Principal: { AWS: Object.values(props?.applicationAccountByIdentifier) }, 44 | Action: 'events:PutEvents', 45 | Resource: bus.eventBusArn, 46 | Effect: 'Allow' 47 | } 48 | }) 49 | 50 | new events.Rule(this, 'BusLoggingRule', { 51 | eventBus: bus, 52 | eventPattern: { 53 | source: [{ 'prefix': ''}] as any[] // Match all 54 | }, 55 | targets: [new LogGroupTarget(busLogGroup)] 56 | }) 57 | 58 | // Create forwarding rules to forward events to a local bus in a different account 59 | // and ensure the global bus has permissions to receive events from such accounts. 60 | for (const [identifier, applicationAccount] of Object.entries(props.applicationAccountByIdentifier)) { // Set used to handle same account used by multiple services 61 | const normalisedIdentifier = Case.pascal(identifier) 62 | const localBusArn = `arn:aws:events:${this.region}:${applicationAccount}:event-bus/local-bus-${identifier}` 63 | const rule = new events.Rule(this, `globalTo${normalisedIdentifier}`, { 64 | eventBus: bus, 65 | ruleName: `globalTo${normalisedIdentifier}`, 66 | eventPattern: { 67 | source: [{ 'anything-but': identifier }] as any[] 68 | } 69 | }) 70 | rule.addTarget(new EventBusTarget(EventBus.fromEventBusArn(this, `localBus${normalisedIdentifier}`, localBusArn))) 71 | } 72 | this.bus = bus 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /stacks/bus-stage.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs' 2 | import { Stage, StageProps } from 'aws-cdk-lib' 3 | import { BusStack } from './bus-stack' 4 | 5 | interface BusStageProps extends StageProps { 6 | applicationAccountByIdentifier: Record 7 | } 8 | 9 | export class BusStage extends Stage { 10 | 11 | constructor(scope: Construct, id: string, props: BusStageProps) { 12 | super(scope, id, props) 13 | 14 | const busStack = new BusStack(this, 'BusStack', { 15 | ...props, 16 | applicationAccountByIdentifier: props.applicationAccountByIdentifier, 17 | }) 18 | } 19 | } -------------------------------------------------------------------------------- /stacks/delivery-stack.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import * as events from 'aws-cdk-lib/aws-events' 3 | import { LambdaFunction as LambdaFunctionTarget } from 'aws-cdk-lib/aws-events-targets' 4 | import * as nodeLambda from 'aws-cdk-lib/aws-lambda-nodejs' 5 | import * as lambda from 'aws-cdk-lib/aws-lambda' 6 | import { BaseStack, BaseStackProps } from './base-stack' 7 | import { CfnOutput, Duration } from 'aws-cdk-lib'; 8 | import { RetentionDays } from 'aws-cdk-lib/aws-logs'; 9 | import { POWERTOOLS_METRICS_NAMESPACE } from '../src/lambda-common'; 10 | 11 | /** 12 | * Application to handle deliveries for orders 13 | * 14 | * When an Order.Created event is received, this application "delivers" the order 15 | */ 16 | export class DeliveryServiceStack extends BaseStack { 17 | 18 | localBus: events.EventBus 19 | identifier: string 20 | 21 | constructor(scope: Construct, id: string, props: BaseStackProps) { 22 | super(scope, id, props); 23 | this.identifier = props.identifier 24 | this.createOrderDeliveryFunction() 25 | } 26 | 27 | createOrderDeliveryFunction() { 28 | const orderDeliveryFunction = new nodeLambda.NodejsFunction(this, 'OrderDeliveryFunction', { 29 | entry: './src/delivery-handler.ts', 30 | runtime: lambda.Runtime.NODEJS_18_X, 31 | handler: 'handleOrderCreated', 32 | environment: { 33 | BUS_ARN: this.globalBus.eventBusArn, 34 | SERVICE_IDENTIFIER: this.identifier, 35 | POWERTOOLS_SERVICE_NAME: 'OrderDelivery', 36 | POWERTOOLS_METRICS_NAMESPACE 37 | }, 38 | timeout: Duration.seconds(10), 39 | logRetention: RetentionDays.ONE_WEEK, 40 | tracing: lambda.Tracing.ACTIVE, 41 | }) 42 | orderDeliveryFunction.addToRolePolicy(this.globalBusPutEventsStatement) 43 | 44 | // The delivery function reacts to orders being created 45 | const orderDeliveryRule = new events.Rule(this, 'OrderDeliveryRule', { 46 | eventBus: this.localBus, 47 | ruleName: 'order-delivery-rule', 48 | eventPattern: { 49 | detailType: ['Order.Created'], 50 | }, 51 | }) 52 | orderDeliveryRule.addTarget(new LambdaFunctionTarget(orderDeliveryFunction)) 53 | 54 | new CfnOutput(this, 'orderDeliveryRule', { 55 | value: orderDeliveryRule.ruleName 56 | }) 57 | 58 | new CfnOutput(this, 'orderDeliveryRuleTarget', { 59 | value: 'Target0' 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /stacks/delivery-stage.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs' 2 | import { Stage, StageProps } from 'aws-cdk-lib' 3 | import { DeliveryServiceStack } from './delivery-stack' 4 | import { EventBus } from 'aws-cdk-lib/aws-events' 5 | import { BaseStage, BaseStageProps } from './base-stage' 6 | 7 | export class DeliveryStage extends BaseStage { 8 | 9 | constructor(scope: Construct, id: string, props: BaseStageProps) { 10 | super(scope, id, props) 11 | 12 | const deliveryServiceStack = new DeliveryServiceStack(this, 'DeliveryServiceStack', { 13 | ...props, 14 | env: { 15 | account: this.node.tryGetContext('delivery-service-account'), 16 | region: this.region 17 | }, 18 | identifier: 'delivery-service', 19 | busAccount: props.busAccount 20 | }) 21 | } 22 | } -------------------------------------------------------------------------------- /stacks/order-stack.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import * as events from 'aws-cdk-lib/aws-events' 3 | import { LambdaFunction as LambdaFunctionTarget } from 'aws-cdk-lib/aws-events-targets' 4 | import * as nodeLambda from 'aws-cdk-lib/aws-lambda-nodejs' 5 | import * as lambda from 'aws-cdk-lib/aws-lambda' 6 | import * as apigw from 'aws-cdk-lib/aws-apigateway' 7 | import { BaseStack, BaseStackProps } from './base-stack'; 8 | import { RetentionDays } from 'aws-cdk-lib/aws-logs'; 9 | import { CfnOutput } from 'aws-cdk-lib'; 10 | import { POWERTOOLS_METRICS_NAMESPACE } from '../src/lambda-common'; 11 | 12 | /** 13 | * Application to manage customer order 14 | * 15 | * An HTTP endpoint is created to receive orders from customers. 16 | * When an order is created, an Order.Created event is sent. 17 | * When a delivery update is received, the order is updated and an Order.Updated event is sent. 18 | */ 19 | export class OrderServiceStack extends BaseStack { 20 | localBus: events.EventBus 21 | identifier: string 22 | 23 | constructor(scope: Construct, id: string, props: BaseStackProps) { 24 | super(scope, id, props); 25 | this.identifier = props.identifier 26 | this.createOrderCreateFunction() 27 | this.createDeliveryUpdateFunction() 28 | } 29 | 30 | createOrderCreateFunction() { 31 | const createOrderFunction = new nodeLambda.NodejsFunction(this, 'CreateOrderFunction', { 32 | entry: './src/order-handler.ts', 33 | runtime: lambda.Runtime.NODEJS_18_X, 34 | handler: 'handleOrderCreate', 35 | environment: { 36 | BUS_ARN: this.globalBus.eventBusArn, 37 | SERVICE_IDENTIFIER: this.identifier, 38 | POWERTOOLS_SERVICE_NAME: 'OrderCreate', 39 | POWERTOOLS_METRICS_NAMESPACE 40 | }, 41 | logRetention: RetentionDays.ONE_WEEK, 42 | tracing: lambda.Tracing.ACTIVE, 43 | }) 44 | createOrderFunction.addToRolePolicy(this.globalBusPutEventsStatement) 45 | const api = new apigw.RestApi(this, 'OrderApi', { restApiName: 'order' }) 46 | api.root.addMethod('POST', new apigw.LambdaIntegration(createOrderFunction)) 47 | 48 | new CfnOutput(this, 'apiEndpoint', { 49 | value: `https://${api.restApiId}.execute-api.${this.region}.${this.urlSuffix}/${api.deploymentStage.stageName}` 50 | }) 51 | } 52 | 53 | createDeliveryUpdateFunction() { 54 | const deliveryUpdateFunction = new nodeLambda.NodejsFunction(this, 'DeliveryUpdateFunction', { 55 | entry: './src/order-handler.ts', 56 | runtime: lambda.Runtime.NODEJS_18_X, 57 | handler: 'handleDeliveryUpdate', 58 | environment: { 59 | BUS_ARN: this.globalBus.eventBusArn, 60 | SERVICE_IDENTIFIER: this.identifier, 61 | POWERTOOLS_SERVICE_NAME: 'DeliveryUpdate', 62 | POWERTOOLS_METRICS_NAMESPACE 63 | }, 64 | logRetention: RetentionDays.ONE_WEEK, 65 | tracing: lambda.Tracing.ACTIVE, 66 | }) 67 | deliveryUpdateFunction.addToRolePolicy(this.globalBusPutEventsStatement) 68 | 69 | // React to delivery events 70 | const deliveryEventsRule = new events.Rule(this, 'DeliveryHandlingRule', { 71 | eventBus: this.localBus, 72 | ruleName: 'order-service-rule', 73 | eventPattern: { 74 | detailType: ['Delivery.Updated'], 75 | } 76 | }) 77 | deliveryEventsRule.addTarget(new LambdaFunctionTarget(deliveryUpdateFunction)) 78 | 79 | new CfnOutput(this, 'deliveryEventsRule', { 80 | value: deliveryEventsRule.ruleName 81 | }) 82 | 83 | new CfnOutput(this, 'deliveryEventsRuleTarget', { 84 | value: 'Target0' 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /stacks/order-stage.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs' 2 | import { OrderServiceStack } from './order-stack' 3 | import { BaseStage, BaseStageProps } from './base-stage' 4 | 5 | export class OrderStage extends BaseStage { 6 | 7 | constructor(scope: Construct, id: string, props: BaseStageProps) { 8 | super(scope, id, props) 9 | 10 | const orderServiceStack = new OrderServiceStack(this, 'OrderServiceStack', { 11 | ...props, 12 | env: { 13 | account: this.node.tryGetContext('order-service-account'), 14 | region: this.region 15 | }, 16 | busAccount: props.busAccount, 17 | identifier: 'order-service' 18 | }) 19 | } 20 | } -------------------------------------------------------------------------------- /stacks/pipeline-stack.ts: -------------------------------------------------------------------------------- 1 | import { aws_codestarconnections as codeStartConnections, pipelines, Stack, StackProps, Stage } from "aws-cdk-lib" 2 | import { Construct } from "constructs" 3 | 4 | interface PipelineStackProps extends StackProps { 5 | stages: Stage[], 6 | accounts: Record, 7 | } 8 | 9 | export class PipelineStack extends Stack { 10 | constructor(scope: Construct, id: string, props: PipelineStackProps) { 11 | super(scope, id, props); 12 | 13 | const codeStarConnection = new codeStartConnections.CfnConnection(this, 'CodeStarConnection', { 14 | connectionName: 'GitHubConnection', 15 | providerType: 'GitHub' 16 | }) 17 | 18 | const cdkContextArgs = Object.entries(props.accounts).map(([key, value]) => `-c ${key}=${value}`).join(' ') 19 | 20 | const pipeline = new pipelines.CodePipeline(this, 'Pipeline', { 21 | crossAccountKeys: true, 22 | synth: new pipelines.ShellStep('Synth', { 23 | input: pipelines.CodePipelineSource.connection('fourTheorem/cross-account-eventbridge', 'main', { 24 | connectionArn: codeStarConnection.ref, 25 | triggerOnPush: true 26 | }), 27 | installCommands: ['npm i -g npm@9'], 28 | commands: [ 29 | 'npm ci', 30 | 'npm run build', 31 | `npx cdk ${cdkContextArgs} synth`, 32 | ], 33 | }), 34 | }) 35 | const wave = pipeline.addWave('ApplicationWave') 36 | for (const stage of props.stages) { 37 | wave.addStage(stage) 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /test/event-bridge-org.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as EventBridgeOrg from '../lib/event-bridge-org-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/event-bridge-org-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new EventBridgeOrg.EventBridgeOrgStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/integration/test_events.py: -------------------------------------------------------------------------------- 1 | import time 2 | import json 3 | 4 | import aws_iatk 5 | import requests 6 | import pytest 7 | 8 | created_by_tag_value = "event-bridge-org-int-tests" 9 | delivery_service_stack_name = 'DeliveryStage-DeliveryServiceStack' 10 | order_service_stack_name = 'OrderStage-OrderServiceStack' 11 | bus_stack_name = 'BusStage-BusStack' 12 | 13 | region = 'eu-west-1' 14 | 15 | @pytest.fixture(scope='module') 16 | def iatk(): 17 | iatk = aws_iatk.AwsIatk(profile='dev2.DevAdministratorAccess', region=region) 18 | remove_listeners(iatk) # Clean up from abandoned previous runs 19 | yield iatk 20 | remove_listeners(iatk) 21 | 22 | 23 | def test_order_delivery(iatk: aws_iatk.AwsIatk): 24 | """ Check whether delivery events are received when an order is created """ 25 | # Get EventBridge resource identifiers from the Delivery Service stack 26 | delivery_outputs = iatk.get_stack_outputs( 27 | delivery_service_stack_name, 28 | output_names=['localBusName', 'orderDeliveryRule', 'orderDeliveryRuleTarget'] 29 | ).outputs 30 | 31 | delivery_local_bus_name = delivery_outputs['localBusName'] 32 | order_delivery_rule = delivery_outputs['orderDeliveryRule'].split('|')[-1] 33 | order_delivery_rule_target = delivery_outputs['orderDeliveryRuleTarget'] 34 | 35 | # Get resource identifiers from the Order Service stack 36 | order_outputs = iatk.get_stack_outputs( 37 | order_service_stack_name, 38 | output_names=['apiEndpoint', 'localBusName', 'deliveryEventsRule', 'deliveryEventsRuleTarget'] 39 | ).outputs 40 | 41 | api_endpoint = order_outputs['apiEndpoint'] 42 | 43 | # Set up an IATK listener for EventBridge rules on the Delivery Service 44 | delivery_listener_id = iatk.add_listener( 45 | event_bus_name=delivery_local_bus_name, 46 | rule_name=order_delivery_rule, 47 | target_id=order_delivery_rule_target, 48 | tags={"CreatedBy": created_by_tag_value}, 49 | ).id 50 | 51 | # Create an order with the Order Service 52 | response = requests.post(api_endpoint) 53 | order_id = response.json()['orderId'] 54 | assert order_id is not None 55 | trace_id = response.headers['x-amzn-trace-id'] 56 | 57 | # Check whether the delivery service received the Order.Created event 58 | def event_assertion(event: str): 59 | payload = json.loads(event) 60 | assert payload['detail-type'] == "Order.Created" 61 | assert payload['detail']['data']['orderId'] == order_id 62 | 63 | assert iatk.wait_until_event_matched(delivery_listener_id, event_assertion) 64 | 65 | def trace_assertion(trace: aws_iatk.GetTraceTreeOutput): 66 | trace_tree = trace.trace_tree 67 | assert [[seg.origin for seg in path] for path in trace_tree.paths] == [ 68 | ['AWS::Lambda', 'AWS::Lambda::Function', 'AWS::Events', 69 | 'AWS::Lambda', 'AWS::Lambda::Function', 'AWS::Events', 70 | 'AWS::Lambda', 'AWS::Lambda::Function', 'AWS::Events'] 71 | ] 72 | assert trace_tree.source_trace.duration < 20 73 | 74 | # This sleep should not be required for retry_get_trace_tree_until but it seems to mitigate 75 | # https://github.com/awslabs/aws-iatk/issues/106 76 | time.sleep(15) 77 | 78 | assert iatk.retry_get_trace_tree_until( 79 | tracing_header=trace_id, 80 | assertion_fn=trace_assertion, 81 | timeout_seconds=60, 82 | ) 83 | 84 | 85 | def remove_listeners(iatk): 86 | """ Remove all listeners created by integration tests 87 | """ 88 | iatk.remove_listeners( 89 | tag_filters=[ 90 | aws_iatk.RemoveListeners_TagFilter( 91 | key="CreatedBy", 92 | values=[created_by_tag_value], 93 | ) 94 | ] 95 | ) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "ES2022" 5 | ], 6 | "noImplicitAny": true, 7 | "strictNullChecks": true, 8 | "noImplicitThis": true, 9 | "alwaysStrict": true, 10 | "noUnusedLocals": false, 11 | "noUnusedParameters": false, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": false, 14 | "inlineSources": true, 15 | "inlineSourceMap": true, 16 | "experimentalDecorators": true, 17 | "strictPropertyInitialization": false, 18 | "typeRoots": [ 19 | "./node_modules/@types" 20 | ], 21 | "incremental": true, 22 | "target": "es2020", 23 | "module": "es2020", 24 | "declaration": true, 25 | "composite": true, 26 | "strict": true, 27 | "moduleResolution": "node", 28 | "esModuleInterop": true, 29 | "skipLibCheck": true, 30 | "forceConsistentCasingInFileNames": true, 31 | "preserveConstEnums": true, 32 | "resolveJsonModule": true, 33 | "rootDir": "." 34 | }, 35 | "exclude": [ 36 | "node_modules", 37 | "cdk.out" 38 | ] 39 | } 40 | --------------------------------------------------------------------------------