Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions eventbridge-cloudtrail-dataplane-cdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Amazon EventBridge Data Plane Logging with AWS CloudTrail

This pattern enables CloudTrail data plane logging for Amazon EventBridge and triggers a Lambda function when PutEvents API calls are detected, providing security and operational visibility into event bus activity.

Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/eventbridge-cloudtrail-dataplane-cdk

Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details.

## Requirements

* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured
* [AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/cli.html) installed
* [Node.js](https://nodejs.org/en/download/) installed

## Deployment Instructions

1. Clone and navigate to the pattern:
```
cd serverless-patterns/eventbridge-cloudtrail-dataplane-cdk
npm install
```
2. Deploy:
```
cdk deploy
```

## How it works

- A CloudTrail trail is created with data event logging enabled
- EventBridge data plane API calls (PutEvents) are now logged to CloudTrail (new May 2026 feature)
- An EventBridge rule captures these CloudTrail events matching `aws.events` source with `PutEvents` event name
- A Lambda function processes the events, logging the caller identity, source IP, event bus, and entry count
- This enables security teams to audit who is putting events to which bus

## Testing

```bash
# Put a test event to the default event bus
aws events put-events --entries '[{"Source":"test.app","DetailType":"TestEvent","Detail":"{\"key\":\"value\"}"}]'

# Check Lambda logs (allow ~5 minutes for CloudTrail delivery)
aws logs tail /aws/lambda/$(aws cloudformation describe-stacks \
--stack-name EventbridgeCloudtrailDataplaneStack \
--query 'Stacks[0].Outputs[?OutputKey==`ProcessorFunctionName`].OutputValue' --output text) \
--follow
```

## Cleanup

```
cdk destroy
```

---

Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.

SPDX-License-Identifier: MIT-0
7 changes: 7 additions & 0 deletions eventbridge-cloudtrail-dataplane-cdk/bin/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { EventbridgeCloudtrailDataplaneStack } from '../lib/eventbridge-cloudtrail-dataplane-stack';

const app = new cdk.App();
new EventbridgeCloudtrailDataplaneStack(app, 'EventbridgeCloudtrailDataplaneStack');
3 changes: 3 additions & 0 deletions eventbridge-cloudtrail-dataplane-cdk/cdk.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts"
}
40 changes: 40 additions & 0 deletions eventbridge-cloudtrail-dataplane-cdk/example-pattern.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"title": "Amazon EventBridge Data Plane Logging with AWS CloudTrail",
"description": "Monitor EventBridge PutEvents API calls using CloudTrail data plane logging with Lambda alerting for security and operational visibility.",
"language": "TypeScript",
"level": "300",
"framework": "CDK",
"introBox": {
"headline": "How it works",
"text": [
"This pattern enables CloudTrail data plane logging for Amazon EventBridge (launched May 2026).",
"CloudTrail captures PutEvents API calls and delivers them as events to EventBridge.",
"An EventBridge rule matches these CloudTrail events and triggers a Lambda function for alerting.",
"This provides visibility into who is putting events, from where, and how many — essential for security auditing."
]
},
"gitHub": {
"template": {
"repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/eventbridge-cloudtrail-dataplane-cdk",
"templateURL": "serverless-patterns/eventbridge-cloudtrail-dataplane-cdk",
"projectFolder": "eventbridge-cloudtrail-dataplane-cdk",
"templateFile": "lib/eventbridge-cloudtrail-dataplane-stack.ts"
}
},
"resources": {
"bullets": [
{ "text": "EventBridge Data Plane CloudTrail Logging", "link": "https://aws.amazon.com/about-aws/whats-new/2026/05/amazon-eventbridge-data-aws-cloudtrail/" },
{ "text": "CloudTrail Data Events", "link": "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-data-events-with-cloudtrail.html" }
]
},
"deploy": { "text": ["cdk deploy"] },
"testing": { "text": ["See the README for testing instructions."] },
"cleanup": { "text": ["cdk destroy"] },
"authors": [
{
"name": "Nithin Chandran R",
"bio": "Technical Account Manager at AWS, passionate about serverless and AI/ML.",
"linkedin": "nithin-chandran-r"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as cdk from 'aws-cdk-lib';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as cloudtrail from 'aws-cdk-lib/aws-cloudtrail';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';

export class EventbridgeCloudtrailDataplaneStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);

// S3 bucket for CloudTrail logs
const trailBucket = new s3.Bucket(this, 'TrailBucket', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
enforceSSL: true,
});

// CloudTrail trail with data events for EventBridge
const trail = new cloudtrail.Trail(this, 'EventBridgeDataPlaneTrail', {
bucket: trailBucket,
trailName: 'eventbridge-dataplane-trail',
isMultiRegionTrail: false,
});

// Enable EventBridge data plane events logging
trail.addEventSelector(cloudtrail.DataResourceType.LAMBDA_FUNCTION, ['arn:aws:lambda']);

// Lambda function to process CloudTrail events
const processor = new lambda.Function(this, 'EventProcessor', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('src'),
timeout: cdk.Duration.seconds(10),
loggingFormat: lambda.LoggingFormat.JSON,
});

// EventBridge rule to capture EventBridge PutEvents API calls from CloudTrail
const rule = new events.Rule(this, 'DataPlaneRule', {
eventPattern: {
source: ['aws.events'],
detailType: ['AWS API Call via CloudTrail'],
detail: {
eventSource: ['events.amazonaws.com'],
eventName: ['PutEvents'],
},
},
});

rule.addTarget(new targets.LambdaFunction(processor));

new cdk.CfnOutput(this, 'ProcessorFunctionName', { value: processor.functionName });
new cdk.CfnOutput(this, 'TrailBucketName', { value: trailBucket.bucketName });
new cdk.CfnOutput(this, 'RuleName', { value: rule.ruleName });
}
}
16 changes: 16 additions & 0 deletions eventbridge-cloudtrail-dataplane-cdk/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "eventbridge-cloudtrail-dataplane-cdk",
"version": "1.0.0",
"bin": { "app": "bin/app.ts" },
"scripts": { "build": "tsc", "cdk": "cdk" },
"dependencies": {
"aws-cdk-lib": "^2.180.0",
"constructs": "^10.0.0",
"source-map-support": "^0.5.21"
},
"devDependencies": {
"typescript": "~5.4.0",
"ts-node": "^10.9.0",
"@types/node": "^20.0.0"
}
}
15 changes: 15 additions & 0 deletions eventbridge-cloudtrail-dataplane-cdk/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
exports.handler = async (event) => {
const detail = event.detail || {};
console.log(JSON.stringify({
message: 'EventBridge data plane API call detected',
eventName: detail.eventName,
eventSource: detail.eventSource,
sourceIPAddress: detail.sourceIPAddress,
userAgent: detail.userAgent,
userIdentity: detail.userIdentity?.arn,
eventBusName: detail.requestParameters?.entries?.[0]?.eventBusName || 'default',
entryCount: detail.requestParameters?.entries?.length || 0,
eventTime: detail.eventTime,
}));
return { statusCode: 200 };
};
8 changes: 8 additions & 0 deletions eventbridge-cloudtrail-dataplane-cdk/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ES2020", "module": "commonjs", "lib": ["es2020"],
"declaration": true, "strict": true, "outDir": "build",
"rootDir": ".", "skipLibCheck": true, "forceConsistentCasingInFileNames": true
},
"exclude": ["node_modules", "build"]
}
58 changes: 58 additions & 0 deletions lambda-verified-permissions-cdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Amazon Verified Permissions with AWS Lambda

This pattern deploys a Lambda function that authorizes requests using Amazon Verified Permissions with Cedar policies for fine-grained access control.

Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/lambda-verified-permissions-cdk

Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details.

## Requirements

* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured
* [Node.js 22+](https://nodejs.org/en/download/) installed
* [AWS CDK v2](https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html) installed

## Architecture

```
┌──────────┐ ┌──────────────────┐ ┌─────────────────────────┐
│ Client │────▶│ AWS Lambda │────▶│ Amazon Verified │
│ │ │ (Authorizer) │ │ Permissions │
└──────────┘ └──────────────────┘ │ (Cedar Policy Store) │
└─────────────────────────┘
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add an architecture diagram image instead of ASCII image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add architecture diagram image in next revision. Updated README to reference architecture.png so it renders automatically once the image is committed.


## How it works

1. Lambda receives an authorization request with user identity, action, and resource.
2. Lambda calls the Verified Permissions `IsAuthorized` API with the request context.
3. Cedar policies evaluate the request and return ALLOW or DENY.
4. The pattern includes two policies: admins can perform any action, readers can only read.

## Deployment

```bash
npm install
cdk deploy
```

## Testing

```bash
python3 -c "
import boto3, json
client = boto3.client('lambda')
# Admin can delete (ALLOW)
r = client.invoke(FunctionName='<FunctionName>', Payload=json.dumps({'body': json.dumps({'userId':'alice','role':'admin','action':'Delete','resourceId':'doc-1','classification':'confidential'})}))
print('Admin Delete:', json.loads(json.loads(r['Payload'].read())['body'])['decision'])
# Reader cannot delete (DENY)
r = client.invoke(FunctionName='<FunctionName>', Payload=json.dumps({'body': json.dumps({'userId':'bob','role':'reader','action':'Delete','resourceId':'doc-2','classification':'public'})}))
print('Reader Delete:', json.loads(json.loads(r['Payload'].read())['body'])['decision'])
"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snippet uses boto3.client('lambda').invoke(FunctionName='<FunctionName>', …). This requires lambda:InvokeFunction and bypasses the Function URL entirely, so the AWS_IAM Function URL exposure is unused and untested. Worse, the snippet leaves <FunctionName> unfilled and the stack only outputs FunctionUrl and PolicyStoreId. Replace with either:

  • A signed curl (SigV4) against the FunctionUrl output, or
  • An aws lambda invoke example that uses the function name and adds a CfnOutput for the function name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Replaced the boto3 lambda.invoke snippet with curl --aws-sigv4 examples that call the Function URL directly. This is simpler, doesn't require Lambda invoke permissions, and demonstrates the actual end-to-end flow a caller would use.

```

## Cleanup

```bash
cdk destroy
```
6 changes: 6 additions & 0 deletions lambda-verified-permissions-cdk/bin/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { LambdaVerifiedPermissionsStack } from '../lib/lambda-verified-permissions-stack';
const app = new cdk.App();
new LambdaVerifiedPermissionsStack(app, 'LambdaVerifiedPermissionsStack');
1 change: 1 addition & 0 deletions lambda-verified-permissions-cdk/cdk.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"app":"npx ts-node --prefer-ts-exts bin/app.ts"}
1 change: 1 addition & 0 deletions lambda-verified-permissions-cdk/example-pattern.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"title":"Amazon Verified Permissions with AWS Lambda","description":"Deploy a Lambda function that authorizes requests using Amazon Verified Permissions Cedar policies.","language":"TypeScript","level":"300","framework":"CDK","introBox":{"headline":"How it works","text":["Lambda receives an authorization request and calls Amazon Verified Permissions IsAuthorized API with Cedar policies to make fine-grained access control decisions."]},"gitHub":{"template":{"repoURL":"https://github.com/aws-samples/serverless-patterns/tree/main/lambda-verified-permissions-cdk","templateURL":"serverless-patterns/lambda-verified-permissions-cdk","projectFolder":"lambda-verified-permissions-cdk"}},"resources":{"bullets":[{"text":"Amazon Verified Permissions","link":"https://docs.aws.amazon.com/verifiedpermissions/latest/userguide/what-is-avp.html"}]},"deploy":{"text":["cdk deploy"],"commands":["npm install","cdk deploy"]},"testing":{"text":["Invoke the function URL with authorization request"]},"cleanup":{"text":["cdk destroy"],"commands":["cdk destroy"]},"authors":[{"name":"Nithin Chandran R","bio":"Technical Account Manager at AWS","linkedin":"nithin-chandran-r"}],"services":{"from":[{"service":"lambda"}],"to":[{"service":"verifiedpermissions"}]}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as verifiedpermissions from 'aws-cdk-lib/aws-verifiedpermissions';
import { Construct } from 'constructs';

export class LambdaVerifiedPermissionsStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);

// Create policy store with Cedar schema
const policyStore = new verifiedpermissions.CfnPolicyStore(this, 'PolicyStore', {
validationSettings: { mode: 'STRICT' },
schema: {
cedarJson: JSON.stringify({
'MyApp': {
entityTypes: {
User: { shape: { type: 'Record', attributes: { role: { type: 'String' } } } },
Document: { shape: { type: 'Record', attributes: { owner: { type: 'String' }, classification: { type: 'String' } } } }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cedar schema declares Document.owner and Document.classification but no policy seems to use them

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added two policies that use these attributes:

  1. OwnerWritePolicy: permit(principal, action == MyApp::Action::"Write", resource) when { resource.owner == principal } — document owners can write their own docs.
  2. ConfidentialDenyPolicy: forbid(principal, action, resource) when { principal.role == "reader" && resource.classification == "confidential" } — readers cannot access confidential docs.

These demonstrate the value of the schema attributes and provide a more realistic authorization model.

},
actions: {
Read: { appliesTo: { principalTypes: ['User'], resourceTypes: ['Document'] } },
Write: { appliesTo: { principalTypes: ['User'], resourceTypes: ['Document'] } },
Delete: { appliesTo: { principalTypes: ['User'], resourceTypes: ['Document'] } }
}
}
})
}
});

// Create policies
new verifiedpermissions.CfnPolicy(this, 'AdminPolicy', {
policyStoreId: policyStore.attrPolicyStoreId,
definition: {
static: {
statement: 'permit(principal, action, resource) when { principal.role == "admin" };',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cedar Admin policy is overly broad ("permit any action by anyone with role==admin")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Scoped the admin policy to explicit actions: permit(principal, action in [MyApp::Action::"Read", MyApp::Action::"Write", MyApp::Action::"Delete"], resource) when { principal.role == "admin" }. If new actions are added to the schema later, admins won't automatically get them without a deliberate policy update.

description: 'Admins can perform any action'
}
}
});

new verifiedpermissions.CfnPolicy(this, 'ReaderPolicy', {
policyStoreId: policyStore.attrPolicyStoreId,
definition: {
static: {
statement: 'permit(principal, action == MyApp::Action::"Read", resource) when { principal.role == "reader" };',
description: 'Readers can only read documents'
}
}
});

// Lambda authorizer function
const authFn = new lambda.Function(this, 'AuthorizerFn', {
runtime: lambda.Runtime.NODEJS_22_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('src'),
environment: { POLICY_STORE_ID: policyStore.attrPolicyStoreId },
timeout: cdk.Duration.seconds(10)
});

authFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['verifiedpermissions:IsAuthorized'],
resources: [`arn:aws:verifiedpermissions::${this.account}:policy-store/${policyStore.attrPolicyStoreId}`]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hardcodes the aws partition and will fail in other partitions like aws-us-gov/aws-cn.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Replaced the hardcoded arn:aws: with cdk.Fn.join using this.partition which resolves to Ref: AWS::Partition in the CloudFormation template. This correctly resolves to aws-us-gov or aws-cn in those partitions.

}));

const fnUrl = authFn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stack exposes the Lambda via a Function URL with AWS_IAM auth
(addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM })) and
outputs FunctionUrl. However, nothing ever invokes that URL: the README test
uses boto3.client('lambda').invoke(FunctionName=…), which is the direct
lambda:InvokeFunction API path and bypasses the Function URL entirely. So the
Function URL is a dead, unused resource as the pattern is documented.

Pick one direction (don't ship both):

  • Option A: keep the Function URL (it's the more idiomatic "HTTP authorizer"
    story). Then the README must call the URL and, because auth is AWS_IAM, the
    request must be SigV4-signed (e.g. awscurl --service lambda or a signed curl).
    Also add a FunctionName/FunctionUrl reference the test can resolve.
  • Option B: drop the Function URL and document direct aws lambda invoke
    (then add a FunctionName CfnOutput so the test snippet resolves).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Rewrote the Testing section to explain that the Function URL uses AWS_IAM auth requiring SigV4-signed requests, and replaced the old test snippet with curl --aws-sigv4 examples that work directly against the Function URL. Also included a note about session tokens for SSO/temporary credentials.


new cdk.CfnOutput(this, 'FunctionUrl', { value: fnUrl.url });
new cdk.CfnOutput(this, 'PolicyStoreId', { value: policyStore.attrPolicyStoreId });
}
}
14 changes: 14 additions & 0 deletions lambda-verified-permissions-cdk/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "lambda-verified-permissions-cdk",
"version": "1.0.0",
"bin": { "app": "bin/app.js" },
"scripts": { "build": "tsc", "cdk": "cdk" },
"dependencies": {
"aws-cdk-lib": "^2.180.0",
"constructs": "^10.0.0"
},
"devDependencies": {
"typescript": "~5.4.0",
"@types/node": "^20.0.0"
}
}
Loading