📝

CloudTrailのデータイベントの設定をAWS CDKのL1コンストラクトでやる

2024/05/10に公開
  • サンプルコードの動作確認バージョン
    • Node.js: 20.11.0
    • AWS CDK: 2.138.0
    • TypeScript: ~5.4.5

はじめに

AWS CloudTrail では管理イベントとデータイベントの2種類を取得することができ、デフォルトでは管理イベントは取得されるものの、データイベントは明示的に設定しない限り取得されません。

参考:Logging management events - AWS CloudTrail

今回、ひょんなことから開発しているサービスに CloudTrail を導入することになり、また要件としては S3 バケットと DynamoDB のデータイベントを取得する必要がありました。


また設定方法としては、今回インフラは IaC として AWS CDK を使用していたため、自然な流れで CloudTrail も CDK で実装することになりました。

ここで補足をしておくと、AWS CDK ではほぼ CloudFormation と同等の L1 コンストラクトと、より抽象化された L2 コンストラクトがあり、どちらかを選んで実装することになります。
(実際は L3 コンストラクトも存在しますが、このあたりの細かい説明は今回は省略します。気になる方はこちら等を参照してください。)


CloudTrail に話を戻すと、
CloudTrail の L2 コンストラクトとしてはTrailクラスが存在します。
しかしながら、Trailクラスは以下の通り S3 のデータイベントには対応していますが、DynamoDB のデータイベントには対応していないようでした。

TrailクラスのaddEventSelectorメソッドの説明>

This method adds an Event Selector for filtering events that match either S3 or Lambda function operations.
(引用:API Reference


よって、やむなしで L1 コンストラクトを使って CloudTrail の設定を行うことになったのですが、
思ったより L1 コンストラクトを使った実装についてネットに情報が落ちておらず、やや苦労したので記事にしてみました。

この記事がどなたかの助けになればうれしいです!


サンプルコード

まずはbin/配下にapp.tsを作成し、
データイベントを記録する S3 バケットと DynamoDB を作成するスタックと、
CloudTrail の設定を行うスタックをそれぞれ作成します。

bin/app.ts
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";

import { CloudTrailStack } from "../lib/cloudtrail-stack";
import { S3AndDynamoStack } from "../lib/s3-dynamodb-stack";

const app = new cdk.App();

// データイベントを記録するS3バケットとDynamoDBを作成するStack
const s3AndDynamoStack = new S3AndDynamoStack(app, "S3AndDynamoStack", {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: "ap-northeast-1",
  },
});

// CloudTrailの設定を行うStack
new CloudTrailStack(app, `CloudTrailStack`, {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: "ap-northeast-1",
  },
  sampleBucket: s3AndDynamoStack.sampleBucket,
  sampleTable: s3AndDynamoStack.sampleTable,
});

続いて、S3 と DynamoDB のスタックの中身を設定していきます。
S3 と DynamoDB の設定は適当なので適宜要件に合わせて変更してください。

lib/s3-dynamodb-stack.ts
import { RemovalPolicy, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
import * as cdk from "aws-cdk-lib";
import {
  BlockPublicAccess,
  Bucket,
  BucketEncryption,
  ObjectOwnership,
} from "aws-cdk-lib/aws-s3";

export interface S3AndDynamoStackProps extends StackProps {}

export class S3AndDynamoStack extends cdk.Stack {
  readonly sampleBucket: Bucket;
  readonly sampleTable: Table;
  constructor(scope: Construct, id: string, props: S3AndDynamoStackProps) {
    super(scope, id, props);

    // S3バケットを作成(設定は適宜変更してください)
    const sampleBucket = new Bucket(this, "SampleBucket", {
      encryption: BucketEncryption.S3_MANAGED,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      removalPolicy: RemovalPolicy.DESTROY,
      objectOwnership: ObjectOwnership.OBJECT_WRITER,
      autoDeleteObjects: true,
    });

    // DynamoDBテーブルを作成(設定は適宜変更してください)
    const sampleTable = new Table(this, "sampleTable", {
      partitionKey: { name: "SampleId", type: AttributeType.STRING },
      sortKey: { name: "SampleSortKey", type: AttributeType.NUMBER },
      billingMode: BillingMode.PAY_PER_REQUEST,
      removalPolicy: RemovalPolicy.DESTROY,
    });
    this.sampleBucket = sampleBucket;
    this.sampleTable = sampleTable;
  }
}

次がこの記事のメインとなる、CloudTrail のスタックの中身を設定していきます。

注意点として、
CloudTrail の L1 コンストラクトであるCfnTrail(API Reference)にはデータイベントを指定するためのセレクターが以下の2種類存在します。

  • eventSelectors
  • advancedEventSelectors

eventSelectorsでは S3 については指定したバケットのみデータイベントの取得対象とすることができますが、
API Reference を見る限り DynamoDB については指定したテーブルのみを指定することはできず、アカウント内のすべてのテーブルが対象となるようです。

そのため、今回はadvancedEventSelectorsを使用することにしました。

また、advancedEventSelectorsを使う上で地味に引っかかったポイントとしては、
advancedEventSelectorsで使用する Property のAdvancedFieldSelectorPropertyドキュメントを見てみると、「Equalsというプロパティを使え」という文言が何箇所かあるのですが、おそらくEqualsではなくEqualToの誤記のようでした。

L2 コンストラクトでは勝手に内部でやってくれていたバケットポリシーや IAM ロールの作成も L1 コンストラクトでは明示的に必要になるため、それらの実装も行っています。

lib/cloudtrail-stack.ts
import { StackProps } from "aws-cdk-lib";
import * as cloudtrail from "aws-cdk-lib/aws-cloudtrail";
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as logs from "aws-cdk-lib/aws-logs";
import {
  BlockPublicAccess,
  Bucket,
  BucketEncryption,
  ObjectOwnership,
} from "aws-cdk-lib/aws-s3";
import * as iam from "aws-cdk-lib/aws-iam";
import { Table } from "aws-cdk-lib/aws-dynamodb";

export interface CloudTrailStackProps extends StackProps {
  sampleBucket: Bucket;
  sampleTable: Table;
}

export class CloudTrailStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: CloudTrailStackProps) {
    super(scope, id, props);

    // CloudTrailがログを書き込むS3バケット
    const cloudTrailBucket = new Bucket(this, "CloudTrailBucket", {
      encryption: BucketEncryption.S3_MANAGED,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      objectOwnership: ObjectOwnership.OBJECT_WRITER,
      autoDeleteObjects: true,
    });

    // CloudTrailがS3とCloudWatchにログを書き込むためのバケットポリシー, IAM Rollを設定
    const policyStatementGetBucketAcl = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      principals: [new iam.ServicePrincipal("cloudtrail.amazonaws.com")],
      actions: ["s3:GetBucketAcl"],
      resources: [
        cloudTrailBucket.bucketArn,
        `${cloudTrailBucket.bucketArn}/*`,
      ],
    });
    const policyStatementPutObject = new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      principals: [new iam.ServicePrincipal("cloudtrail.amazonaws.com")],
      actions: ["s3:PutObject"],
      resources: [
        cloudTrailBucket.bucketArn,
        `${cloudTrailBucket.bucketArn}/*`,
      ],
      conditions: {
        StringEquals: {
          "s3:x-amz-acl": "bucket-owner-full-control",
        },
      },
    });
    cloudTrailBucket.addToResourcePolicy(policyStatementGetBucketAcl);
    cloudTrailBucket.addToResourcePolicy(policyStatementPutObject);

    const trailRole = new iam.Role(this, "trailRole", {
      assumedBy: new iam.ServicePrincipal("cloudtrail.amazonaws.com"),
    });
    cloudTrailBucket.grantWrite(trailRole);
    trailRole.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchLogsFullAccess")
    );

    const logGroup = new logs.LogGroup(this, "LogGroup", {
      logGroupName: "CloudTrailSample",
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    new cloudtrail.CfnTrail(this, "CloudTrail", {
      isLogging: true,
      s3BucketName: cloudTrailBucket.bucketName,
      cloudWatchLogsLogGroupArn: logGroup.logGroupArn,
      cloudWatchLogsRoleArn: trailRole.roleArn,
      includeGlobalServiceEvents: true,
      isMultiRegionTrail: true,
      // S3とDynamoDBのデータイベントを取得するための設定
      advancedEventSelectors: [
        {
          name: "DynamoDBDataEvents",
          fieldSelectors: [
            {
              field: "eventCategory",
              equalTo: ["Data"],
            },
            {
              field: "resources.type",
              equalTo: ["AWS::DynamoDB::Table"],
            },
            {
              field: "resources.ARN",
              // 指定したテーブルのみをデータイベント取得の対象とする
              equalTo: [props.sampleTable.tableArn],
            },
          ],
        },
        {
          name: "S3BucketDataEvents",
          fieldSelectors: [
            {
              field: "eventCategory",
              equalTo: ["Data"],
            },
            {
              field: "resources.type",
              equalTo: ["AWS::S3::Object"],
            },
            {
              field: "resources.ARN",
              // 指定したバケットのみをデータイベント取得の対象とする
              // NOTE: バケット内のすべてのオブジェクトを対象にするためstartsWithを使用
              //       ARNの末尾の"/"が無いとエラー
              startsWith: [props.sampleBucket.bucketArn + "/"],
            },
          ],
        },
      ],
      trailName: "SampleCloudTrail",
    });
  }
}

おわりに

AWS CDK の L1 コンストラクトを使って CloudTrail のデータイベントの設定を行う方法について紹介しました。
AWS CDK のバージョンアップによって、将来的に L2 コンストラクトでも同様の設定が簡単にできるようになる未来もあるので、その際はこの記事はきれいさっぱり忘れてもらうのが良いかと思います。

最後に、
CloudTrail は思いの他高額になることが多いらしく、
実際に今回設定を行ったサービスでも安いとは言えない金額になっていたため、設定後のコストの監視はしっかりと行った方がよいかもしれません。

Discussion