パブリックなS3とそのアクセスログが検索可能な環境を作るCDK

# はじめに
静的コンテンツを公開するパブリックバケットを作る機会がありましたので、勉強がてらAWS CDKで作ってみました。

# 概要
構成は以下になります。
- 公開バケット
- アクセスログ格納バケット
    - 公開バケットのS3アクセスログの出力先
- アクセスログ検索サービス
    - 図中ではAthenaですが、実際は以下
        - Athena::WorkGroup
        - Glue::Database
        - Glue::Table
- Athenaの結果の格納先
- ユーザ
    - 公開バケットへコンテンツの追加・削除が可能
    - Athenaに対して、問い合わせ可能

図示すると、以下の感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/553680/81d80564-d4a9-196c-2107-c395664a698a.png)


# 作り方

## 環境準備など
以前に書いた、以下の記事を参考にしてください。

https://qiita.com/a_b_/items/15a35108ad4a8a38346f

## コードなど

バケット名は、cdk.jsonで定義して、それを読み込ませる形にしました。
```json:cdk.json
  "context": {
    , "bucketName": "<ユニークになる文字列>"
```

CDKのコードは以下になります。
<details><summary>クリックで表示</summary><div>

```ts:lib/create-publicbucket-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam'
import { Duration } from 'aws-cdk-lib';
import * as athena from 'aws-cdk-lib/aws-athena';
import * as glue from 'aws-cdk-lib/aws-glue';

export class CreatePublicbucketStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    // read parameter
    const bucketName = this.node.tryGetContext('bucketName');
    // accountId
    const accountId = cdk.Aws.ACCOUNT_ID;
    
    // log bucket
    const logBucket = new s3.Bucket(this, 'LogBucket',{
      bucketName: bucketName + '-log',
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      encryption: s3.BucketEncryption.S3_MANAGED
    });
    // アクセスログ保存のため、S3からのPUTを許可
    logBucket.addToResourcePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['s3:PutObject'],
        principals: [ new iam.ServicePrincipal('s3.amazonaws.com') ],
        resources: [`${logBucket.bucketArn}/*`],
        conditions:{
          StringEquals: {
            's3:x-amz-acl' : 'bucket-owner-full-control'
          }
        }
      })
    );
    
    // S3 bucket with public read access
    const publicBucket = new s3.Bucket(this, 'PublicBucket',{
        bucketName: bucketName,
        removalPolicy: cdk.RemovalPolicy.DESTROY,
        autoDeleteObjects: true,
        publicReadAccess: true,
        serverAccessLogsBucket: logBucket,
        serverAccessLogsPrefix: 'access-logs/'
    });
    // 公開設定
    publicBucket.addToResourcePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['s3:GetObject'],
        principals: [new iam.AnyPrincipal() ],
        resources: [`${publicBucket.bucketArn}/*`]
      })
    );
        
    // Athena
    //// result bucket
    const athenaResultBucket = new s3.Bucket(this, 'AthenaQueryResultBucket',{
      bucketName: 'aws-athena-query-results-' + bucketName,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      lifecycleRules:[{
        expiration: Duration.days(7)
      }]
    });
    
    //// WorkGroup
    const workGroup = new athena.CfnWorkGroup(this,'AthenaWorkGroup',{
      name: 'athena-work-group-' + bucketName,
      recursiveDeleteOption: true,
      state: 'ENABLED',
      workGroupConfiguration:{
        resultConfiguration:{
          outputLocation: `s3://${athenaResultBucket.bucketName}/athena-results/`
        }
      }
    });
    
    //// Database
    const database = new glue.CfnDatabase(this,'GlueDatabase',{
      catalogId: accountId,
      databaseInput: {
        name: 'glue-database-' + bucketName
      }
    });
    
    //// Athena table
    const accessLogsTable = new glue.CfnTable(this,'AccessLogsTable',{
      catalogId: accountId,
      databaseName: database.ref,
      tableInput: {
        name: `glue-table-${bucketName}-log`,
        storageDescriptor: {
          columns:[
            {name: 'bucketowner', type: 'string' },
            {name: 'bucket_name', type: 'string' },
            {name: 'requestdatetime', type: 'string' },
            {name: 'remoteip', type: 'string' },
            {name: 'requester', type: 'string' },
            {name: 'requestid', type: 'string' },
            {name: 'operation', type: 'string' },
            {name: 'key', type: 'string' },
            {name: 'request_uri', type: 'string' },
            {name: 'httpstatus', type: 'string' },
            {name: 'errorcode', type: 'string' },
            {name: 'bytessent', type: 'bigint' },
            {name: 'objectsize', type: 'bigint' },
            {name: 'totaltime', type: 'string' },
            {name: 'turnaroundtime', type: 'string' },
            {name: 'referrer', type: 'string' },
            {name: 'useragent', type: 'string' },
            {name: 'versionid', type: 'string' },
            {name: 'hostid', type: 'string' },
            {name: 'sigv', type: 'string' },
            {name: 'ciphersuite', type: 'string' },
            {name: 'authtype', type: 'string' },
            {name: 'endpoint', type: 'string' },
            {name: 'tlsversion', type: 'string' },
            {name: 'accesspointarn', type: 'string' },
            {name: 'aclrequired', type: 'string' }
          ],
          location: `s3://${logBucket.bucketName}/access-logs/`,
          inputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
          outputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
          serdeInfo: {
            serializationLibrary: 'org.apache.hadoop.hive.serde2.RegexSerDe',
            parameters: {
                'input.regex': '([^ ]*) ([^ ]*) \\[(.*?)\\] ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) (\"[^\"]*\"|-) (-|[0-9]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) (\"[^\"]*\"|-) ([^ ]*)(?: ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*))?.*$'
            }
          }
          
        }
      }
    });
    
    // create user
    const uploadUser = new iam.User(this,'uploadUser');
    //// allow S3 upload
    uploadUser.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['s3:ListAllMyBuckets'],
      resources: ['*']
    }));
    uploadUser.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['s3:ListBucket'],
      resources: [publicBucket.bucketArn]
    }));
    uploadUser.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['s3:*Object'],
      resources: [publicBucket.bucketArn + '/*']
    }));
    //// Athena fullaccess
    uploadUser.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonAthenaFullAccess')
    );
    uploadUser.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['s3:GetObject', 's3:ListBucket'],
      resources: [logBucket.bucketArn + '*']
    }));
    
  }
}
```
</div></details>

Athenaの、S3 サーバーのアクセスログの定義については、以下を参考にしました。

https://repost.aws/ja/knowledge-center/analyze-logs-athena

上記からS3アクセスログの内容が増えていましたので、以下を参考に追加しています。

https://docs.aws.amazon.com/AmazonS3/latest/userguide/LogFormat.html

またAthena結果の出力先バケット名の先頭に`aws-athena-query-results-`を付けています。これはユーザに付与しているAWSのマネージドポリシー`AmazonAthenaFullAccess`にてデフォルトで定義されている、「Athenaのクエリ結果格納先としての許可に対して、既に設定されているリソース」になります。
```json:AmazonAthenaFullAccess(一部)
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetBucketLocation",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads",
                "s3:ListMultipartUploadParts",
                "s3:AbortMultipartUpload",
                "s3:CreateBucket",
                "s3:PutObject",
                "s3:PutBucketPublicAccessBlock"
            ],
            "Resource": [
                "arn:aws:s3:::aws-athena-query-results-*"
            ]
        }
```
出力先バケットを上記に当てはまらない名前にした場合は、このポリシーを自作して、ユーザに付与してください。

# おわりに
ChatGPTが流行っていますので、いい機会と思い補助してもらいながらCDKを書いてみました。
元々CFnをやっていてある程度の目星がついた状況下でしたので、ChatGPTの間違いを修正しながら作ることができました。
なので、ChatGPTの誤りに気付ける程度の事前知識は必要と感じています。