AWS CDKとSDKでAmazon S3の署名付きURLを取得するAPIを作成してみた

2023.02.21

CX事業本部Delivery部のアベシです。

API経由でS3のデータを利用する事を想定して署名付きURLを使用してみました。この記事ではその際に必要なAWSリソースをCDKAWS SDK for JavaScript v3で構築したので紹介いたします。 S3の署名付きURLはパブリックには公開しないバケットのオブジェクトを共有するために使用できます。 このURLは指定した期間のみ有効です。指定した有効期限が切れると発行したURLは無効となってアクセスしてもオブジェクトを取得できません。

注意点

今回の構成で作られるAPIのエンドポイントは、知られてしまうと誰でも署名付きURLを作れることになりますので、API GatewayにはAPIキーや認証認可の仕組みを取り入れて運用するのが良いと思われます。

動作の概要

署名付きURLを取得するまでの動きは以下のようになっています。

  1. GETメソッドでAPI Gatewayにコール
  2. Lambda関数でS3に保存されたオブジェクトの署名付きURLを作成
  3. レスポンスとして署名付きURLを取得
  4. URLにアクセスしてオブジェクトを表示(今回はpngの画像データを使ってます)

構成のイメージ

動作環境

項目名 バージョン
mac OS Ventura 13.2
npm 9.4.2
AWS CDK 2.64.0
AWS SDK 3.272.0

CDKのコード紹介

API GatewayやLambdaはこちら

lib/presigned-url-generate-api-stack.ts

import {
  Stack,
  StackProps,
  aws_apigateway,
  aws_iam,
  aws_lambda_nodejs,
  aws_s3,
} from 'aws-cdk-lib';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

interface PresignedUrlGenerateApiStackProps extends StackProps {
  dataBucket: aws_s3.Bucket;
}
export class PresignedUrlGenerateApiStack extends Stack {
  constructor(
    scope: Construct,
    id: string,
    props: PresignedUrlGenerateApiStackProps,
  ) {
    super(scope, id, props);

    const restApiName = `presigned-url-generate-restAPI-get-presigned-url`;
    const restAPI = new aws_apigateway.RestApi(this, restApiName, {
      restApiName: restApiName,
      deployOptions: {
        stageName: 'V1',
      },
    });

    const getPresignedURLFuncName = `get-presigned-url-s3-object-func`;
    const getPresignedURLFunc = new aws_lambda_nodejs.NodejsFunction(
      this,
      getPresignedURLFuncName,
      {
        functionName: getPresignedURLFuncName,
        entry: 'src/lambda/handlers/get-presigned-url-s3-object.ts',
        runtime: Runtime.NODEJS_18_X,
        environment: {
          BUCKET_NAME: props.dataBucket.bucketName,
        },
      },
    );

    getPresignedURLFunc.addToRolePolicy(
      new aws_iam.PolicyStatement({
        resources: [`${props.dataBucket.bucketArn}`],
        actions: ['s3:GetBucket*', 's3:List*'],
      }),
    );

    getPresignedURLFunc.addToRolePolicy(
      new aws_iam.PolicyStatement({
        resources: [`${props.dataBucket.bucketArn}/*`],
        actions: ['s3:getObject'],
      }),
    );

    const restApiData = restAPI.root.addResource('data');
    const restApiDataName = restApiData.addResource('{object_key}');

    restApiDataName.addMethod(
      'GET',
      new aws_apigateway.LambdaIntegration(getPresignedURLFunc),
    );
  }
}

API GatewayやLambdaのCDKコードの解説

Lambdaに関する部分

NodejsFunctionクラスを使用してLambda関数の作成しています。 ランタイムのNode.jsはLambdaで使える最新のバージョン18を使用しています。

ちなみにバージョン16のサポートが割と早めに終了する予定なので、できるだけ18を使用していただけた方がよりセキュリティの面から言っても安全かと思います。

    const getPresignedURLFunc = new aws_lambda_nodejs.NodejsFunction(
      this,
      getPresignedURLFuncName,
      {
        functionName: getPresignedURLFuncName,
        entry: 'src/lambda/handlers/get-presigned-url-s3-object.ts',
        runtime: Runtime.NODEJS_18_X,
        environment: {
          BUCKET_NAME: props.dataBucket.bucketName,
        },
      },
    );

次に必要な権限を付与しています。 下段の2.ではLambdaにgetObject権限を付与していますが、この権限が無いLambda関数で作られた署名付きURLにはアクセスしてもオブジェクトが取得できませんのでご注意いただければと思います。権限が無くてもURLが作れるのでハマりポイントになりそうかと思いました。

1.  getPresignedURLFunc.addToRolePolicy(
      new aws_iam.PolicyStatement({
        resources: [`${props.dataBucket.bucketArn}`],
        actions: ['s3:GetBucket*', 's3:List*'],
      }),
    );
2.  getPresignedURLFunc.addToRolePolicy(
      new aws_iam.PolicyStatement({
        resources: [`${props.dataBucket.bucketArn}/*`],
        actions: ['s3:getObject'],
      }),
    );

API Gatewayに関する部分

今回はRestAPIで作成しています。

    const restAPI = new aws_apigateway.RestApi(this, restApiName, {
      restApiName: restApiName,
      deployOptions: {
        stageName: 'V1',
      },
    });

object_keyというリソースを追加し、ここにオブジェクトのキーをパスパラメーターとして指定してGETリクエストする形です。

    const restApiData = restAPI.root.addResource('data');
    const restApiDataName = restApiData.addResource('{object_key}');

Lambdaプロキシ統合を使用しております。 先程作成したLambda関数のリソースを指定しています。

    restApiDataName.addMethod(
      'GET',
      new aws_apigateway.LambdaIntegration(getPresignedURLFunc),//←ここでLambdaのリソースを指定
    );

S3バケットの作成はこちら

lib/presigned-url-generate-bucket-stack.ts

import { Stack, StackProps, aws_s3, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class PresignedUrlGenerateBucketStack extends Stack {
  public readonly dataBucket: aws_s3.Bucket; // LambdaとAPI Gatewayのスタックにこちらで作ったバケットを参照させるための記述です
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);
    const dataBucket = new aws_s3.Bucket(
      this,
      `presigned-url-generate-data-bucket`,
      {
        bucketName: `presigned-url-generate-data-bucket-${
          Stack.of(this).account // この記述で先のアカウントIDを取得してできます
        }`,
        removalPolicy: RemovalPolicy.RETAIN,
        blockPublicAccess: aws_s3.BlockPublicAccess.BLOCK_ALL,
      },
    );
    this.dataBucket = dataBucket;
  }
}

S3のCDKコードの解説

以下はLambdaとAPI Gatewayのスタックにこちらで作ったバケットを参照させるための記述です。

  public readonly dataBucket: aws_s3.Bucket;
  ...
  this.dataBucket = dataBucket;

一点注意していただきたいのは、以下のプロパティ設定でブロックパブリックアクセスのBLOCK_ALLを有効にすることです。今回は元々パブリック・アクセスを禁止する要件で構築していましたが、他の構成でも基本的にこちらはBLOCK_ALLにしていただくことをおすすめします。

blockPublicAccess: aws_s3.BlockPublicAccess.BLOCK_ALL,

こちらを有効にしていない場合、Security Hubの標準的セキュリティ基準のAWS Foundational Security Best Practicesの以下のチェックに引っかかります。SeverityはHighとなります。

また、AWSは2023年4月より、新規S3バケットのブロックパブリックアクセスをデフォルトで有効化する動きを取っています。

Lambda関数はこちら

src/lambda/handlers/get-presigned-url-s3-object.ts

import { generatePresignedURL } from '../infrastructures/s3-generate-presigned-url';
import { z } from 'zod';

interface Event {
  pathParameters: {
    object_key: string;
  };
}

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

const Scheme = z.object({
  object_key: z.string().min(1),
});

export const handler = async (event: Event): Promise<Response> => {
  try {
    console.log('Event', JSON.stringify(event, undefined, 2));
    const pathParameters = Scheme.strict().safeParse(event.pathParameters);
    if (!pathParameters.success) {
      console.log('Validation error', pathParameters.error);
      return { statusCode: 400 };
    }
    const response = await generatePresignedURL(pathParameters.data.object_key);
    return {
      statusCode: 200,
      body: JSON.stringify(response),
    };
  } catch (error) {
    console.log('Error', JSON.stringify(error, undefined, 2));
    return { statusCode: 500 };
  }
};

S3のSDKを使用して署名付きURLを取得する部分

src/lambda/infrastructures/s3-generate-presigned-url.ts

import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { TaskTableError } from './errors/task-table-error';

const region = process.env.AWS_REGION as string;
const bucketName = process.env.BUCKET_NAME as string;
const s3Client = new S3Client({ region: region });

/**
 * 署名付きURLの取得
 * @param object_key
 */
export const generatePresignedURL = async (
  object_key: string,
): Promise<string> => {
  try {
    const bucketParams = {
      Bucket: bucketName,
      Key: `${object_key}`,
      Body: 'BODY',
    };

    const command = new GetObjectCommand(bucketParams);
    const response = await getSignedUrl(s3Client, command, {
      expiresIn: 3600,
    });
    return response;
  } catch (e) {
    throw new TaskTableError(`Error of ${bucketName}`, e as Error);
  }
};

Lambda関数のコード解説

署名付きURLを取得するために使用しているSDKモジュールが@aws-sdk/s3-request-presignerです。
getSignedUrl関数を使用して実際に取得する際に、expiresInプロパティで署名付きURLの有効期限を指定します。今回の場合だと3600秒有効となります。

  const response = await getSignedUrl(s3Client, command, {
    expiresIn: 3600,
  });

デプロイ後の動作確認

作成されたバケットに適当な画像をアップロードします。

作成されたエンドポイントにGETリクエストします。
期待通りに署名付きURLを取得できました。(画像右側)

このURLにアクセスするとアップロードした画像が見れます。

余談ですが、APIコールにはVSコードのExtensionsのThunder Clientを使用しました。VSコード上でAPIコールを試すことができますのでおすすめです。postmanと似たような感覚で簡単に使えました。

以上。