9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS CDK integ-testsを活用した「データ境界」S3アクセステストの自動化

Posted at

はじめに

2024年2月14日に、Security JAWS【第32回】で以下を発表しました。

上記では、AWS CDK integ-tests を使ったデータ境界のアクセステストについて、終盤に少しだけ紹介しました。アイデンティティベースポリシーやリソースベースポリシーなどを複雑に組み合わせた場合、机上でアクセス可否を正確に把握するのは難しいです。そこで、データ境界におけるアクセステストで integ-test を活用できないか検討しました。

本記事では、本テーマを深堀して解説します。

本記事のポイント

integ-tests で AWS API の Access Denied を確認したい場合は、Lambda 関数を使用します。しかし、integ-tests でテストケースを組む場合は、アクセステストに限定せず、データ入出力に着目した正常処理の一環で権限を確認するのがよさそうです。

前提

想定読者

  • IaC の統合テスト自動化に興味がある人
  • データ境界のアクセステストに興味がある人
  • AWS CDK の概念・基本構文を理解している人1

AWS CDK Integration Tests とは

AWS CDK Integration Tests(integ-tests)とは、AWS CDK で定義したリソースのテストを自動化する仕組みです。integ-tests を使うと、CDK のスタックを実際の AWS 環境にデプロイして、その挙動を検証することができます。テストの前後にスナップショットを取るため、デプロイしたリソースの差分を確認することもできます。

AWS CDK の開発者にとって、インフラストラクチャの品質を高める有用なツールとして、integ-tests は近年注目されています。特に、@aws-cdk ライブラリのアルファモジュール2など、リソース定義が抽象化された実験的な Construct で、活用されている印象です。

データ境界(Data Perimeter)とは

重要データを保護するための予防的ガードレールです。データ境界は、「①信頼されたアイデンティティ」・「②想定されたネットワーク場所」・「③信頼されたリソース」、これら3つの要素で構成されます。

以下イメージ図の通り、データ境界を正しく取り入れると、自社管理外AWS環境へのデータ流出を防ぐことができます。

defining-aws-perimeter
出典: Building a Data Perimeter on AWS

過去の記事で、当社のデータ境界事例を紹介しましたので、以下もご参考ください。

実行環境

No. ライブラリ バージョン
1 aws-cdk 2.126.0
2 @aws-cdk/integ-runner 2.126.0-alpha.0
3 @aws-cdk/integ-tests-alpha 2.126.0-alpha.0
4 @aws/pdk 0.23.2
5 jest 29.7.0

背景

データ境界の悩み

データ境界の設計・構築において、S3 などのセキュリティ設定を確認するためにはアクセステストが必要です。
しかし、アクセステストをシステムテスト工程などで案件終盤にまとめて行うという方法には問題があります。

アクセステストでは、IAM ポリシー・VPC エンドポイントポリシー・S3 バケットポリシーなど、複雑なポリシーの組み合わせによって決まる多様なアクセスパターンを検証する必要があります。そのため、テストケースの数が多くなり、テストの実施や結果の分析・報告に多くの工数がかかります。さらに、テスト中に問題が発生した場合、修正や再テストにも時間がかかり、納期への影響が懸念されます。

筆者が過去に携わった案件では、このような方法でアクセステストを行っていましたが、開発の早い段階でテストを自動化できれば、後工程で同じ内容を行う必要がなくなり、効率的になると考えました。そこで、AWS CDK integ-tests の導入を検討しました。

integ-tests で解決したいこと

integ-tests で解決したいことは、データ境界のアクセステストを自動化し、品質を向上させるとともに、工数や納期のリスクを低減することです。

integ-tests を使えば、CDK のスタックを実環境へデプロイして、S3 へのアクセスが想定通りに制限されているかどうかを確認できます。また、テスト実行時に Cloudformation テンプレートがスナップショットとして保存されるため、差分を比較することで、データ境界の変更点や影響範囲を把握できます。これにより、データ境界の設計・構築・テストのフローを効率化し、品質を担保することができるのではと考えました。

検証内容

閉域構成の VPC・Lambda 関数・S3 バケットを中心とした以下の構成に対して、今回は7パターンのテストケースを試します。

構成図

構成図

権限設計

各種オブジェクト操作に対する S3 バケットの権限設計は、以下の通りです。

  • 許可
    • VPC 内から特定 S3 バケットへの ① Put, ② Get, ③ Delete 操作
    • VPC 外から特定 S3 バケットへの ④ List 操作
  • 拒否
    • VPC 外から特定 S3 バケットへの ⑤ Put, ⑥ Get, ⑦ Delete 操作

クレデンシャル漏洩時を想定して、アイデンティティ側では上記操作に対応する権限を保持しているものとします。仮に、VPC 外のアクセス者がオブジェクトへのフルアクセス権限をもっていたとしても、S3 バケットポリシーで拒否することを想定しています。

本来なら、以前の記事で紹介した IAM ポリシーVPC エンドポイントポリシーなど、VPC 内から他アカウントへの持ち出しにも触れたかったのですが、記事尺の都合で割愛します。

テストケース

前述の権限設計に沿って、以下テストケースを設定します。

正常系

以下処理が正常終了すること。※statusCode: 200

① VPC 内 Lambda 関数から、S3 バケットへオブジェクト格納(Put)
② VPC 内 Lambda 関数から、S3 バケットのオブジェクト取得(Get)
③ VPC 内 Lambda 関数から、S3 バケットのオブジェクト削除(Delete)
④ VPC 外 SDK から、S3 バケットのオブジェクト一覧取得(List)

異常系

以下処理で「Access Denied」が発生すること。※statusCode: 500

⑤ VPC 外 SDK から、S3 バケットへのオブジェクト格納(Put)
⑥ VPC 外 SDK から、S3 バケットのオブジェクト取得(Get)
⑦ VPC 外 SDKから、S3 バケットのオブジェクト削除(Delete)

実装

プロダクトコード

構成図をベースに、データ境界の構成を定義したスタックは以下の通りです。

./lib/s3AccessStack.ts
import * as path from "path";
import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import * as logs from "aws-cdk-lib/aws-logs";
import { Construct } from "constructs";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";

export class S3AccessStack extends cdk.Stack {
  public readonly bucket: s3.IBucket;
  public readonly bucketName: string;
  public readonly bucketArn: string;
  public readonly s3ObjectVpcFunctionName: string;
  public readonly denyS3Policy: iam.PolicyStatement;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC (Private Subnet のみ, NAT Gateway なしで構成)
    const vpc = new ec2.Vpc(this, "MyVpc", {
      ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/24"),
      subnetConfiguration: [
        {
          cidrMask: 25,
          name: "isolated",
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
      maxAzs: 2,
      natGateways: 0,
    });

    // VPC エンドポイントポリシー (S3 Gateway)
    const s3VpcEndpoint = vpc.addGatewayEndpoint("S3VpcEndpoint", {
      service: ec2.GatewayVpcEndpointAwsService.S3,
    });
    // 自アカウント以外のクレデンシャルを全て拒否
    s3VpcEndpoint.addToPolicy(
      new iam.PolicyStatement({
        principals: [new iam.AnyPrincipal()],
        effect: iam.Effect.DENY,
        actions: ["*"],
        resources: ["*"],
        conditions: {
          StringNotEquals: {
            "aws:PrincipalAccount": this.account,
          },
        },
      }),
    );
    // デフォルト許可
    s3VpcEndpoint.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        principals: [new iam.AnyPrincipal()],
        actions: ["*"],
        resources: ["*"],
      }),
    );

    // S3 バケット
    this.bucket = new s3.Bucket(this, "MyBucket", {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      encryption: s3.BucketEncryption.S3_MANAGED,
      versioned: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });
    this.bucketArn = this.bucket.bucketArn;
    this.bucketName = this.bucket.bucketName;

    // S3 バケットポリシー(Object 読み書きは特定のVPCエンドポイント以外、拒否)
    this.bucket.addToResourcePolicy(
      new iam.PolicyStatement({
        principals: [new iam.AnyPrincipal()],
        effect: iam.Effect.DENY,
        actions: ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
        resources: [`${this.bucketArn}/*`],
        conditions: {
          StringNotEquals: {
            "aws:sourceVpce": s3VpcEndpoint.vpcEndpointId,
          },
        },
      }),
    );

    // Lambda 関数用 IAM ポリシー (自アカウント以外へのS3バケットアクセスを拒否)
    this.denyS3Policy = new iam.PolicyStatement({
      effect: iam.Effect.DENY,
      actions: ["s3:*"],
      resources: ["*"],
      conditions: {
        StringNotEquals: {
          "s3:ResourceAccount": this.account,
        },
      },
    });

    // Lambda 関数用 CloudWatch ロググループ
    const logGroup = new logs.LogGroup(this, "MyLogGroup", {
      retention: logs.RetentionDays.ONE_DAY,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // VPC 内 Lambda 関数 (S3バケットのオブジェクトを読み書き)
    const s3ObjectVpcFunction = new NodejsFunction(this, "LambdaVpcFunction", {
      runtime: lambda.Runtime.NODEJS_LATEST,
      handler: "handler",
      entry: path.join(__dirname, "lambda/s3ObjectFunction.ts"),
      vpc,
      environment: {
        BUCKET_NAME: this.bucketName,
      },
      logGroup: logGroup,
      timeout: cdk.Duration.seconds(30),
      initialPolicy: [this.denyS3Policy],
    });
    this.s3ObjectVpcFunctionName = s3ObjectVpcFunction.functionName;
    this.bucket.grantReadWrite(s3ObjectVpcFunction);
  }
}

Lambda 関数の実装は以下の通りです。
event のactionType: "Put" | "Delete" | "Get"で受け取った内容を基に、S3 オブジェクトの処理を行います。

./lib/lambda/s3ObjectFunction.ts
import {
  S3Client,
  PutObjectCommand,
  DeleteObjectCommand,
  GetObjectCommand,
} from "@aws-sdk/client-s3";

const s3Client = new S3Client({});

export interface ActionProps {
  actionType: "Put" | "Delete" | "Get";
}

export interface Response {
  statusCode: number;
  body: string;
}

export async function handler(event: ActionProps): Promise<Response> {
  const bucketName = process.env.BUCKET_NAME;
  const fileName = "test.txt";

  if (!event.actionType) {
    return {
      statusCode: 400,
      body: "イベントパラメータactionTypeが設定されていません。",
    };
  }
  if (!bucketName) {
    return {
      statusCode: 500,
      body: "環境変数BUCKET_NAMEが設定されていません。",
    };
  }

  try {
    if (event.actionType === "Put") {
      // S3バケットにテキストファイルを作成
      const putCommand = new PutObjectCommand({
        Bucket: bucketName,
        Key: fileName,
        Body: "これはテストファイルです。",
      });
      await s3Client.send(putCommand);
      console.log(`${fileName} was successfully created in ${bucketName}.`);
    } else if (event.actionType === "Delete") {
      // 作成したテキストファイルを削除
      const deleteCommand = new DeleteObjectCommand({
        Bucket: bucketName,
        Key: fileName,
      });
      await s3Client.send(deleteCommand);
      console.log(`${fileName} was successfully deleted from ${bucketName}.`);
    } else if (event.actionType === "Get") {
      // 指定されたテキストファイルをS3から取得
      const getCommand = new GetObjectCommand({
        Bucket: bucketName,
        Key: fileName,
      });
      await s3Client.send(getCommand);
      console.log(`${fileName} was successfully retrieved from ${bucketName}.`);
    }

    // 正常終了のステータスコード200を返す
    return {
      statusCode: 200,
      body: `${fileName}${event.actionType}が成功しました。`,
    };
  } catch (error) {
    console.error("Error occurred:", error);
    let errorMessage = "不明なエラーが発生しました。";
    if (error instanceof Error) {
      errorMessage = error.message;
    }

    // エラー時のステータスコードとメッセージを返す
    return {
      statusCode: 500,
      body: errorMessage,
    };
  }
}

テストコード

前述のテストケースから、まずは正常系と異常系を一つずつ定義します。

(正常系)
① VPC 内 Lambda 関数から、S3 バケットへオブジェクト格納(Put)

(異常系)
⑥ VPC 外 SDK から、S3 バケットのオブジェクト取得(Get)

上記をテストコードに反映します。テストコードの全量は以下の通りです3

./test/integ.s3Access.ts
import "source-map-support/register";
import {
  IntegTest,
  ExpectedResult,
  InvocationType,
  Match,
} from "@aws-cdk/integ-tests-alpha";
import { Aspects } from "aws-cdk-lib";
import { S3AccessStack } from "../lib/s3AccessStack";
import { ApplyDestroyPolicyAspect } from "./helper";
import { PDKNag, AwsPrototypingChecks } from "@aws/pdk/pdk-nag";

// CDK App for Integration Tests
const app = PDKNag.app({
  nagPacks: [new AwsPrototypingChecks()],
});

// Stack under test
const testStack = new S3AccessStack(app, "S3IntegTestStack");
Aspects.of(testStack).add(new ApplyDestroyPolicyAspect());

// Initialize Integ Test construct
const integ = new IntegTest(app, "S3AccessTest", {
  testCases: [testStack],
  cdkCommandOptions: {
    destroy: {
      args: {
        force: true,
      },
    },
  },
  regions: [testStack.region],
});

const assertion = integ.assertions
  /** 正常系 **/
  // ① VPC 内 Lambda 関数から、S3 バケットへオブジェクト格納(Put) 
  .invokeFunction({
    functionName: testStack.s3ObjectVpcFunctionName,
    invocationType: InvocationType.REQUEST_RESPONSE,
    payload: JSON.stringify({ actionType: "Put" }),
  })
  .expect(
    ExpectedResult.objectLike({
      Payload: Match.serializedJson({
        statusCode: 200,
        body: "test.txtのPutが成功しました。",
      }),
    }),
  )
  /** 異常系 **/
  // ⑥ VPC 外 SDK から、S3 バケットのオブジェクト取得(Get)
  .next(
    integ.assertions
      .awsApiCall("S3", "GetObject", {
        Bucket: testStack.bucketName,
        Key: "test.txt",
      })
      .expect(
        ExpectedResult.exact({
          statusCode: 500,
          body: "Access Denied",
        }),
      ),
  )

// GetObject 実行時に以下権限が必要
assertion.provider.addToRolePolicy({
  Effect: "Allow",
  Action: ["s3:ListBucket"],
  Resource: [testStack.bucketArn],
});

Lambda 関数の実行には、integ-tests-alpha モジュールの invokeFunction を使用しています。同様に、AWS SDK の API 実行には、awsApiCall を使用しています。

なお、テストコード中の helper(ヘルパークラス)については、以下記事を参考にさせていただきました4

テスト実行

本テストは東京リージョンのみの実行で十分なため、integ.config.jsonで、以下のように設定します5

integ.config.json
{
  "maxWorkers": 10,
  "parallelRegions": [
    "ap-northeast-1"
  ],
  "clean": true
}

その後、前述のテストコードを$ npm run integ-test --verbose で実行すると、想定通り VPC 外 SDK の GetObject でAccess Deniedが発生します。しかし、以下メッセージの通り、Access Denied のタイミングでスタック作成が中断され、テスト全体が失敗となってしまいます。

(抜粋) integ-test 実行結果
Failed resources:
S3AccessTestDefaultTestDeployAssert492B647F | 6:29:52 AM | CREATE_FAILED        | Custom::DeployAssert@SdkCallS3GetObject  | S3AccessTest/DefaultTest/DeployAssert/AwsApiCallS3GetObject8fe18d2025ee8b5efe1d42291ed35a5c/Default/Default (AwsApiCallS3GetObject8fe18d2025ee8b5efe1d42291ed35a5c) Received response status [FAILED] from custom resource. Message returned: Access Denied (RequestId: 419a6c49-b637-4320-a733-3cbaf6df9f33)

 ❌  S3AccessTest/DefaultTest/DeployAssert failed: Error: The stack named S3AccessTestDefaultTestDeployAssert492B647F failed to deploy: CREATE_FAILED (The following resource(s) failed to create: [AwsApiCallS3GetObject8fe18d2025ee8b5efe1d42291ed35a5c]. )
    at FullCloudFormationDeployment.monitorDeployment (/home/ec2-user/environment/S3AccessIntegTests/node_modules/aws-cdk/lib/index.js:428:10615)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async Object.deployStack2 [as deployStack] (/home/ec2-user/environment/S3AccessIntegTests/node_modules/aws-cdk/lib/index.js:431:196745)
    at async /home/ec2-user/environment/S3AccessIntegTests/node_modules/aws-cdk/lib/index.js:431:178714

 ❌ Deployment failed: Error: The stack named S3AccessTestDefaultTestDeployAssert492B647F failed to deploy: CREATE_FAILED (The following resource(s) failed to create: [AwsApiCallS3GetObject8fe18d2025ee8b5efe1d42291ed35a5c]. )
    at FullCloudFormationDeployment.monitorDeployment (/home/ec2-user/environment/S3AccessIntegTests/node_modules/aws-cdk/lib/index.js:428:10615)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async Object.deployStack2 [as deployStack] (/home/ec2-user/environment/S3AccessIntegTests/node_modules/aws-cdk/lib/index.js:431:196745)
    at async /home/ec2-user/environment/S3AccessIntegTests/node_modules/aws-cdk/lib/index.js:431:178714

The stack named S3AccessTestDefaultTestDeployAssert492B647F failed to deploy: CREATE_FAILED (The following resource(s) failed to create: [AwsApiCallS3GetObject8fe18d2025ee8b5efe1d42291ed35a5c]. )
  FAILED     integ.s3Access-S3AccessTest/DefaultTest (undefined/ap-northeast-1) 1489.448s
      Integration test failed: Error: Command exited with status 1

Test Results: 

Tests:    1 failed, 1 total
Error: Some integration tests failed!
    at main (/home/ec2-user/environment/S3AccessIntegTests/node_modules/@aws-cdk/integ-runner/lib/index.js:10401:15)
npm verb exit 1
npm verb code 1

当初の狙いとしては、AwsApiCall で Access Deniedになることを確認したかったのですが、仕様により不可でした。

AwsApiCall の仕様

integ-tests-alpha モジュール の AwsApiCall では、裏側で State Machine と CloudFormation カスタムリソースが動いています。以下のような StateMachnie がデプロイされ、task からはCloudFormation カスタムリソース(Lambda 関数)が実行されます。

AwsApiCallのState Machnie

つまり、AwsApiCall で API を実行する実体は、CloudFormation カスタムリソース(Lambda 関数)です。AWS API から Accees Denied が返された場合、カスタムリソースの実行がエラーとなり、テスト全体が失敗とみなされてしまいます。

代替方法

AwsApiCall を利用する代わりに、自作の Lambda 関数でエラーハンドリングを行えば、 Accees Denied の期待値チェックが可能です。修正後の構成イメージは以下の通りです。

構成図(修正版)

幸い、既存の Lambda 関数ではエラーハンドリングを実装しているため、本ケースでは簡単に AwsApiCall を置換できます。

まずは、テストコードで、専用スタックと共に VPC 外 Lambda 関数を追加します。

(抜粋)./test/integ.s3Access.ts
// Stack under test
const testStack = new S3AccessStack(app, "S3IntegTestStack");
Aspects.of(testStack).add(new ApplyDestroyPolicyAspect());

+ // テスト用のスタック
+ const lambdaTestStack = new Stack(app, "LambdaTestStack")
+
+ // テスト用の VPC 外 Lambda 関数 (ソースコードは VPC内 Lambda 関数と同じ)
+ const s3TestObjectFunction = new NodejsFunction(lambdaTestStack, "Function", {
+  runtime: Runtime.NODEJS_LATEST,
+  handler: "handler",
+  entry: join(__dirname, "../lib/lambda/s3ObjectFunction.ts"),
+  environment: {
+    BUCKET_NAME: testStack.bucketName,
+  },
+  timeout: Duration.seconds(30),
+  initialPolicy: [testStack.denyS3Policy],
+ });
+ testStack.bucket.grantReadWrite(s3TestObjectFunction);

次に、AwsApiCall の代わりに上記の Lambda 関数を使用するように修正します。ついでに、その他のテストケースも追加します。

修正後のテストコード全量は以下の通りです。

./test/integ.s3Access.ts
import "source-map-support/register";
import {
  IntegTest,
  ExpectedResult,
  InvocationType,
  Match,
} from "@aws-cdk/integ-tests-alpha";
import { Aspects, Duration, Stack } from "aws-cdk-lib";
import { S3AccessStack } from "../lib/s3AccessStack";
import { ApplyDestroyPolicyAspect } from "./helper";
import { PDKNag, AwsPrototypingChecks } from "@aws/pdk/pdk-nag";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { join } from "path";

// CDK App for Integration Tests
const app = PDKNag.app({
  nagPacks: [new AwsPrototypingChecks()],
});

// Stack under test
const testStack = new S3AccessStack(app, "S3IntegTestStack");
Aspects.of(testStack).add(new ApplyDestroyPolicyAspect());

// テスト用のスタック
const lambdaTestStack = new Stack(app, "LambdaTestStack")

// テスト用の VPC 外 Lambda 関数 (ソースコードは VPC内 Lambda 関数と同じ)
const s3TestObjectFunction = new NodejsFunction(lambdaTestStack, "Function", {
  runtime: Runtime.NODEJS_LATEST,
  handler: "handler",
  entry: join(__dirname, "../lib/lambda/s3ObjectFunction.ts"),
  environment: {
    BUCKET_NAME: testStack.bucketName,
  },
  timeout: Duration.seconds(30),
  initialPolicy: [testStack.denyS3Policy],
});
testStack.bucket.grantReadWrite(s3TestObjectFunction);

// Initialize Integ Test construct
const integ = new IntegTest(app, "S3AccessTest", {
  testCases: [testStack],
  cdkCommandOptions: {
    destroy: {
      args: {
        force: true,
      },
    },
  },
  regions: [testStack.region],
});

const assertion = integ.assertions
  /** 正常系 **/
  // ① VPC 内 Lambda 関数から、S3 バケットへオブジェクト格納(Put) 
  .invokeFunction({
    functionName: testStack.s3ObjectVpcFunctionName,
    invocationType: InvocationType.REQUEST_RESPONSE,
    payload: JSON.stringify({ actionType: "Put" }),
  })
  .expect(
    ExpectedResult.objectLike({
      Payload: Match.serializedJson({
        statusCode: 200,
        body: "test.txtのPutが成功しました。",
      }),
    }),
  )
  // ② VPC 内 Lambda 関数から、S3 バケットのオブジェクト取得(Get)
  .next(
    integ.assertions
      .invokeFunction({
        functionName: testStack.s3ObjectVpcFunctionName,
        invocationType: InvocationType.REQUEST_RESPONSE,
        payload: JSON.stringify({ actionType: "Get" }),
      })
      .expect(
        ExpectedResult.objectLike({
          Payload: Match.serializedJson({
            statusCode: 200,
            body: "test.txtのGetが成功しました。",
          }),
        }),
      ),
  )
  // ④ VPC 外 SDK から、S3 バケットのオブジェクト一覧取得(List)
  .next(
    integ.assertions
      .awsApiCall("S3", "listObjectsV2", {
        Bucket: testStack.bucketName,
        MaxKeys: 10,
      })
      .expect(
        ExpectedResult.objectLike({
          KeyCount: 1,
        }),
      ),
  )
  /** 異常系 **/
  // ⑤ VPC 外 SDK から、S3 バケットへのオブジェクト格納(Put)
  .next(
    integ.assertions
      .invokeFunction({
        functionName: s3TestObjectFunction.functionName,
        invocationType: InvocationType.REQUEST_RESPONSE,
        payload: JSON.stringify({ actionType: "Put" }),
      })
      .expect(
        ExpectedResult.objectLike({
          Payload: Match.serializedJson({
            statusCode: 500,
            body: "Access Denied",
          }),
        }),
      ),
  )
  // ⑥ VPC 外 SDK から、S3 バケットのオブジェクト取得(Get)
  .next(
    integ.assertions
      .invokeFunction({
        functionName: s3TestObjectFunction.functionName,
        invocationType: InvocationType.REQUEST_RESPONSE,
        payload: JSON.stringify({ actionType: "Get" }),
      })
      .expect(
        ExpectedResult.objectLike({
          Payload: Match.serializedJson({
            statusCode: 500,
            body: "Access Denied",
          }),
        }),
      ),
  )
  // ⑦ VPC 外 SDKから、S3 バケットのオブジェクト削除(Delete)
  .next(
    integ.assertions
      .invokeFunction({
        functionName: s3TestObjectFunction.functionName,
        invocationType: InvocationType.REQUEST_RESPONSE,
        payload: JSON.stringify({ actionType: "Delete" }),
      })
      .expect(
        ExpectedResult.objectLike({
          Payload: Match.serializedJson({
            statusCode: 500,
            body: "Access Denied",
          }),
        }),
      ),
  )
  /** 正常系 **/
  // ③ VPC 内 Lambda 関数から、S3 バケットのオブジェクト削除(Delete)
  .next(
    integ.assertions
      .invokeFunction({
        functionName: testStack.s3ObjectVpcFunctionName,
        invocationType: InvocationType.REQUEST_RESPONSE,
        payload: JSON.stringify({ actionType: "Delete" }),
      })
      .expect(
        ExpectedResult.objectLike({
          Payload: Match.serializedJson({
            statusCode: 200,
            body: "test.txtのDeleteが成功しました。",
          }),
        }),
      ),
  );

// listObjectsV2 実行時に以下権限が必要
assertion.provider.addToRolePolicy({
  Effect: "Allow",
  Action: ["s3:ListBucket"],
  Resource: [testStack.bucketArn],
});

再テスト

修正後のソースコードで、再度$ npm run integ-test --verboseを実行します。

integ-test 再実行結果
$ npm run integ-test --verbose
npm verb cli /home/ec2-user/.nvm/versions/node/v20.10.0/bin/node /home/ec2-user/.nvm/versions/node/v20.10.0/bin/npm
npm info using npm@10.2.5
npm info using node@v20.10.0
npm verb title npm run integ-test
npm verb argv "run" "integ-test" "--loglevel" "verbose"
npm verb logfile logs-max:10 dir:/home/ec2-user/.npm/_logs/2024-02-16T06_57_36_477Z-
npm verb logfile /home/ec2-user/.npm/_logs/2024-02-16T06_57_36_477Z-debug-0.log

> s3_access_integ_tests@0.1.0 integ-test
> integ-runner --update-on-failed


Verifying integration test snapshots...

  NEW        integ.s3Access 6.469s

Snapshot Results: 

Tests:    1 failed, 1 total
Failed: /home/ec2-user/environment/S3AccessIntegTests/test/integ.s3Access.ts

Running integration tests for failed tests...

Running in parallel across regions: ap-northeast-1
Running test /home/ec2-user/environment/S3AccessIntegTests/test/integ.s3Access.ts in ap-northeast-1
  SUCCESS    integ.s3Access-S3AccessTest/DefaultTest 1641.057s
       AssertionResultsLambdaInvoke32c691bb91ab1266f58f360b380cfec9 - success
      AssertionResultsAwsApiCallS3listObjectsV231a20247e0c6166531da1535f7faad19 - success
      AssertionResultsLambdaInvoke3b480b2ff3060d5c202b2f919adb31db - success
      AssertionResultsLambdaInvokec136c8f5886bda90375379519dc94ad4 - success
      AssertionResultsLambdaInvokedf7dee97be682c60f3c1fb166bde8bd3 - success
      AssertionResultsLambdaInvokeb3c17106438cf24c7654e8514b8807c6 - success
      AssertionResultsLambdaInvoke4e9f1b5eeb9072724cd7ed002c5cf07f - success

Test Results: 

Tests:    1 passed, 1 total
npm verb exit 0
npm info ok

無事にテストが完了しました。

気づき

データ境界のアクセステストを integ-tests で自動化できました。
本検証を通じて、いくつか気づいた点を紹介します。

実行時間の長さ・バラつき

テスト実行時間の長さ・バラつきが最も気になりました。

以下は、integ-tests の実行結果を古いものから順に並べた一覧です。実行結果が FAILED の行では改修前コードを使用し、SUCCESS の行では改修後コードを使用しています。前提として、どの実行も既存のスナップショットは含まれておらず、初回実行扱いです。

No. 実行結果 実行時間 実行時に指定したコマンドオプション6
1 SUCCESS 1640.426s --update-on-failed
2 SUCCESS 1645.487s --update-on-failed --verbose
3 SUCCESS 1671.227s --update-on-failed --verbose --force
4 FAILED 1525.069s --update-on-failed
5 SUCCESS 1640.746s --update-on-failed
6 FAILED 1489.448s --update-on-failed --verbose
7 SUCCESS 1641.057s --update-on-failed --verbose
8 FAILED 1519.893s --update-on-failed --verbose --force
9 SUCCESS 1645.754s --update-on-failed --verbose --force
10 FAILED 1479.239s --update-on-failed --verbose --force

SUCCESS では、約 27 分の実行時間がかかっています。FAILED でも約 25 分かかっています。正直、かなり長いと感じました。

少しでも実行時間を減らすため、SUCCESS の改修後コードを修正します。以下の通り、テスト用のスタックを削除してプロダクトコードのスタックに VPC 外 Lambda 関数を紐づけるようにしました7

(抜粋)./test/integ.s3Access.ts
// Stack under test
const testStack = new S3AccessStack(app, "S3IntegTestStack");
Aspects.of(testStack).add(new ApplyDestroyPolicyAspect());

- // テスト用のスタック
- const lambdaTestStack = new Stack(app, "LambdaTestStack")
-
// テスト用の VPC 外 Lambda 関数 (ソースコードは VPC内 Lambda 関数と同じ)
- const s3TestObjectFunction = new NodejsFunction(lambdaTestStack, "Function", {
+ const s3TestObjectFunction = new NodejsFunction(testStack, "Function", {
  runtime: Runtime.NODEJS_LATEST,
  handler: "handler",
  entry: join(__dirname, "../lib/lambda/s3ObjectFunction.ts"),
  environment: {
    BUCKET_NAME: testStack.bucketName,
  },
  timeout: Duration.seconds(30),
  initialPolicy: [testStack.denyS3Policy],
});
testStack.bucket.grantReadWrite(s3TestObjectFunction);

上記変更後の実行結果は以下の通りです。
興味深い結果が得られたので、先ほどより多くの回数を試行しました。

No. 実行結果 実行時間 実行時に指定したコマンドオプション6
11 FAILED 1526.389s --update-on-failed --verbose --force
12 SUCCESS 522.644s --update-on-failed --verbose --force
13 SUCCESS 1564.147s --update-on-failed --verbose
14 SUCCESS 1558.117s --update-on-failed --verbose --force
15 FAILED 1529.687s --update-on-failed --verbose
16 SUCCESS 1552.283s --update-on-failed --verbose
17 FAILED 439.47s --update-on-failed --verbose
18 SUCCESS 477.236s --update-on-failed --verbose --force
19 SUCCESS 1558.456s --update-on-failed --verbose --force
20 SUCCESS 1557.837s --update-on-failed --verbose --force
21 FAILED 444.607s --update-on-failed
22 SUCCESS 1562.898s --update-on-failed
23 SUCCESS 1528.179s --update-on-failed
24 FAILED 1546.43s --update-on-failed
25 FAILED 1529.887s --update-on-failed --force
26 SUCCESS 1526.005s --update-on-failed --force

SUCCESS が僅かに短くなり、約 25 分の実行時間でした。しかし、実行結果に関わらず、なぜか 7~9 分程度の実行時間になる場合があります。(太字箇所)

SUCCESS で実行時間が短かった No.12 と No.18 には、直前が FAILED という共通点があるのですが、実行時間の相関につながるものを見つけることはできませんでした。試行錯誤したものの、本事象を再現できず、なぜ実行時間が短縮になるのかは分かりませんでした。

実行時間を考慮すると、以下記事でも紹介されている通り、ハッピーパスに絞ったケースが良いと感じました。

これだけ待ってからテストがFailするとけっこうがっかりします。(わたしだけではないはず)

CDKで配備する構成をとらえて、いわゆるハッピーパスに絞ったテストを作成するのがよいと思います。

様々なアイデンティティ(IAM ロール)を想定したアクセステスト

実際のアクセスは、Lambda 関数だけでなく、Fargate や Glue Job など様々なアイデンティティが S3 バケットへアクセスします。従って、それらのアイデンティティに紐づく IAM ロールを使って、VPC 内外から API を実行できれば嬉しいです。しかし、検証時点の integ-tests では、そのような機能が見当たりません。テスト用リソースに同じ IAM 権限を割り当てるなど、個別の作り込みが必要そうです。

データ境界の各種ポリシーが固まっていない場合

データ境界の各種ポリシーを設計・実装するのは、少々慣れが必要です。
各種ポリシーが固まっていない状態であれば integ-tests を使用せず、通常通りスタックを実環境へデプロイして、シェルやコンソールなどで手動確認する方が早そうだと感じました。

既に CI/CD パイプラインがある場合

開発の早い段階で、既に CI/CD パイプラインがあるなら、integ-tests を使わずにパイプライン上でテストを実行するのも有効だと感じました。

結論

データ境界で integ-tests を利用する場合、データ入出力に着目した正常処理の一環で、権限が適切であるか確認するのがよいと感じました。つまり、正常アクセスのみの確認であれば、integ-tests が有用だと思います。なぜならテスト実行時間が長く、 Acccess Denied の確認に Lambda 関数が必要となってしまうため、細かなアクセステストで生産性向上が期待できないためです。

AWS 公式の以下サンプルでも、大きな単位でのデータ入出力に着目したテストケースで、サービス間の連携が意図した通りであるか確認していました。このケースが通るのであれば、当然ながら各コンポーネントが正常アクセスの権限を保有しているということになります。

公式サンプル構成
出典: AWS CDK アプリケーションのためのインテグレーションテストの作成と実行

異常アクセスである Access Denied を確認したい場合は、CI/CD パイプライン上でのテストや手動実行など integ-tests 以外の手段を採用する方がよさそうです。

おわりに

本記事では、integ-tests を活用したデータ境界のアクセステスト例を紹介しました。正常系/異常系アクセスの観点に限定した integ-tests の利用は、現状だと厳しそうです。

実行時間の長さなど気になる点もありましたが、実環境でテストをできるのは integ-tests ならではの魅力です。integ-tests はまだアルファモジュールなので、今後の発展が楽しみですね・・・!

本記事がどなたかのお役になれば幸いです。

参考資料

integ-tests

データ境界

  1. AWS CDK の解説については、AWS Black Belt Online Seminar AWS CDK 概要 (Basic #1)をご参照ください。

  2. https://aws.amazon.com/jp/blogs/news/experimental-construct-libraries-are-now-available-in-aws-cdk-v2/

  3. 公式のサンプルコードでは、cdk-nag を利用していますが、本コードではライト版の pdk-nag を採用しています。

  4. 素晴らしい知見をご共有いただき、ありがとうございました!

  5. integ.config.jsonの詳細については、GitHubをご参照ください。

  6. 実行時間とコマンドオプションの関係性を調べるため、掲載しました。計測時間を計測し始めた当初、特にオプションを意識していなかったたため、オプションに偏りがある点はご容赦ください。 2

  7. テスト独立性の観点でオススメできないので、本変更はあくまで検証用途とご理解ください。

9
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?