テスターにIAMユーザー発行せずにLambdaバッチを実行してもらう方法

## はじめに

テスト時にデータ作ったからバッチのLambdaを実行して欲しいと依頼されるのってどこでもありますよね。
IAMユーザーを発行しても良いけど、一時的に、そのためだけに、って思うと微妙ですよね。

ということで、簡単な仕組みを用意したので記事に残しておきます。

## バッチ実行イメージ

### 画面イメージ

対象のバッチを選択して、実行するためだけの簡単な画面を用意します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284278/ecd2d267-ee37-4f07-9a9d-fe25a19c638b.png)

### AWS構成

CloudFrontからLambdaの関数URLを呼び出し、そのLambdaがEventBridgeが呼び出したかのように、バッチのLambdaを非同期で呼び出します。

なお、CloudFrontにはWAFでIP制限を行い、CloudFrontからS3とLambdaの関数URLはOACでアクセスを制限します。

![aws.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284278/bce2fdaf-264f-46ab-9fff-12bbda543b60.png)

## CloudFront設定

### オリジン設定

細かいところは書きませんが基本は以下のみです

- S3とLambdaの関数URLをオリジンに追加
- ポイントとしてはS3とLambdaにOACを設定して各ポリシーでアクセス許可を設定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284278/351f863b-9fab-416e-91ca-5c91f3fa5d3c.png)

#### S3のバケットポリシー追加
```
{
    "Version": "2008-10-17",
    "Id": "PolicyForCloudFrontPrivateContent",
    "Statement": [
        {
            "Sid": "AllowCloudFrontServicePrincipal",
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::<YOUR_BUCKET_NAME>/*",
            "Condition": {
                "StringEquals": {
                  "AWS:SourceArn": "arn:aws:cloudfront::<YOUR_AWS_ACCOUNT_ID>:distribution/<YOUR_DISTRIBUTION_ID>"
                }
            }
        }
    ]
}
```
    
#### Lambdaのリソースベースのポリシー追加（Lambda→設定→アクセス権限から設定でもOK）
```
aws lambda add-permission \
--statement-id "AllowCloudFrontServicePrincipal" \
--action "lambda:InvokeFunctionUrl" \
--principal "cloudfront.amazonaws.com" \
--source-arn "arn:aws:cloudfront::<YOUR_AWS_ACCOUNT_ID>:distribution/<YOUR_DISTRIBUTION_ID>" \
--region "ap-northeast-1" \
--function-name <YOUR_FUNCTION_NAME>
```

### ビヘイビア設定

LambdaをAPIとしてパス定義して、それ以外のデフォルトをS3に設定しておきます。
（パスは独自にしているので塗りつぶしてあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284278/998bd40e-1dca-4c5f-84ae-c3f5be3b7d4f.png)

S3に設定しているレスポンスヘッダーのポリシーは、ブラウザキャッシュをして欲しくないので以下の設定入れています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284278/30d9f77c-9aca-4933-a80b-64bda5ec80cc.png)

## Lambda周り

### 設定

- LambdaのIAMロールにLambda実行権限の追加
- 環境変数に許可するLambdaの先頭文字列を設定

### 実装

受け付けた情報で、基本はそのままLambdaを非同期実行するための仕組みです。

内部の人しか使わないとはいえ、許可するLambda名の先頭文字を環境変数に設定しておくことで、変なLambdaを呼び出さないようにしています。
（プロジェクトでLambdaの命名規則がちゃんとしていれば、そのまま使えるはずです

```javascript: index.js
const { LambdaClient, InvokeCommand } = require('@aws-sdk/client-lambda')

// 環境変数から許可する Lambda の先頭一致パターン（空文字は除外）
const allowedPrefixes = [process.env.ALLOWED_PREFIX1, process.env.ALLOWED_PREFIX2, process.env.ALLOWED_PREFIX3].filter(p => p)

const client = new LambdaClient({ region: process.env.AWS_REGION })

// プロキシ Lambda のハンドラー
module.exports.handler = async (event) => {
  // Function URL 経由の場合、event.body に JSON 文字列が格納されるためパース
  let body = event
  if (event.body) {
    try {
      body = JSON.parse(event.body)
    } catch (error) {
      return {
        statusCode: 400,
        body: 'JSON のパースに失敗しました'
      }
    }
  }

  // リクエストボディから lambdaName, alias, parameters を取得
  const { lambdaName, alias, parameters } = body
  if (!lambdaName || parameters === undefined) {
    return {
      statusCode: 400,
      body: 'lambdaName と parameters は必須です'
    }
  }

  // 指定された lambdaName が許可されたプレフィックスのいずれかで始まっているかチェック
  const isAllowed = allowedPrefixes.some(prefix => lambdaName.startsWith(prefix))
  if (!isAllowed) {
    return {
      statusCode: 403,
      body: '指定された Lambda の呼び出しは許可されていません'
    }
  }

  try {
    // エイリアスが指定されていれば Qualifier を追加
    const invokeParams = {
      FunctionName: lambdaName,
      InvocationType: 'Event',
      Payload: JSON.stringify(parameters),
      ...(alias ? { Qualifier: alias } : {})
    }
    const command = new InvokeCommand(invokeParams)
    const response = await client.send(command)
    return {
      statusCode: 200,
      body: JSON.stringify({
        message: 'Lambda を非同期実行しました',
        response: response
      })
    }
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify({
        message: 'Lambda の実行中にエラーが発生しました',
        error: error.message
      })
    }
  }
}
```

## S3コンテンツ

### バッチ設定準備

HTML上から選択できる情報をJSONファイルで用意しておきます。
これを画面表示時にロードして、選択肢として設定することになります。

基本的に項目名のままとなりますが、`parameters`に関してはEventBridgeから渡されるPayloadを確認して設定する必要があります。

```json: batches.json
[
  {
    "displayName": "バッチ実行テスト用（Hello World）",
    "description": "Hello World を出力するサンプルバッチです。\nこのバッチはサンプルとして利用できます。",
    "lambdaName": "test-hoge-hello",
    "alias": "",
    "parameters": {
      "jobDefinition": "example-job-def",
      "jobQueue": "example-job-queue",
      "containerOverrides": {
        "command": ["echo", "Hello World"]
      }
    }
  }
]
```

### HTML実装

HTMLは簡単なものを作成しています。

注意点としてはCloudFront経由で関数URLのGETを呼び出す際には特に何も考えなくて良いのですが、POSTで通信する際は、SHA-256でハッシュ値を計算して`x-amz-content-sha256`ヘッダーに設定する必要がある点に注意してください。

AWS公式ドキュメントにはCloudFront経由でLambda関数URL（OAC設定）で呼び出す際に、以下の注意事項が記載されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284278/47c1379f-4482-48f8-be05-99053c7a2af9.png)

詳しくは↓こちらを読んでもらえればと。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html

```html: index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>バッチ実行テスト</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      margin: 0;
      padding: 0;
      background: #f5f5f5;
    }
    header {
      background: #1976d2;
      color: #fff;
      padding: 1rem;
      text-align: center;
      font-size: 1.5rem;
    }
    .container {
      padding: 2rem;
      max-width: 800px;
      margin: 2rem auto 0;
      background: #fff;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }
    .form-group {
      margin-bottom: 1.5rem;
    }
    label {
      display: block;
      margin-bottom: 0.5rem;
      font-weight: bold;
    }
    select, textarea, button {
      width: 100%;
      padding: 0.5rem;
      font-size: 1rem;
      box-sizing: border-box;
    }
    button {
      background: #1976d2;
      color: #fff;
      border: none;
      cursor: pointer;
    }
    button:hover {
      background: #125ea0;
    }
    #result {
      margin-top: 1rem;
      padding: 1rem;
      border: 1px solid #ddd;
      background: #fafafa;
      white-space: pre-wrap;
    }
    /* バッチ説明表示用。改行を反映するために pre-wrap を指定 */
    #batchDescription {
      padding: 0.5rem;
      background: #eef;
      border: 1px solid #ccd;
      margin-top: 0.5rem;
      font-size: 0.9rem;
      color: #333;
      white-space: pre-wrap;
    }
  </style>
</head>
<body>
  <header>バッチ実行テスト</header>
  <div class="container">
    <div class="form-group">
      <label for="batchSelect">バッチ名</label>
      <select id="batchSelect">
        <option value="">未選択</option>
      </select>
    </div>
    <!-- バッチの説明を表示する領域 -->
    <div class="form-group">
      <label for="batchDescription">バッチ説明</label>
      <div id="batchDescription">バッチを選択してください</div>
    </div>
    <div class="form-group">
      <label for="jsonInput">パラメータ (JSON)</label>
      <textarea id="jsonInput" rows="10" placeholder="バッチ選択時に初期値が表示されます" disabled></textarea>
    </div>
    <div class="form-group">
      <button id="executeBtn">バッチ実行</button>
    </div>
    <div id="result"></div>
  </div>

  <script>
    const batchSelect = document.getElementById('batchSelect')
    const jsonInput = document.getElementById('jsonInput')
    const executeBtn = document.getElementById('executeBtn')
    const resultDiv = document.getElementById('result')
    const batchDescriptionDiv = document.getElementById('batchDescription')
    let batchData = []

    // batches.json を読み込み、プルダウンに選択肢を追加
    fetch('batches.json')
      .then(response => response.json())
      .then(data => {
        batchData = data
        data.forEach((item, index) => {
          const option = document.createElement('option')
          option.value = index
          option.textContent = item.displayName
          batchSelect.appendChild(option)
        })
      })
      .catch(error => {
        console.error('batches.json 読込エラー:', error)
      })

    // プルダウン選択時：選択されたバッチの JSON パラメータと説明を表示、テキストエリアを有効化
    batchSelect.addEventListener('change', () => {
      const idx = batchSelect.value
      if (idx === "") {
        jsonInput.value = ""
        jsonInput.disabled = true
        batchDescriptionDiv.textContent = "バッチを選択してください"
      } else {
        const selectedBatch = batchData[idx]
        jsonInput.value = JSON.stringify(selectedBatch.parameters, null, 2)
        jsonInput.disabled = false
        batchDescriptionDiv.textContent = selectedBatch.description || ""
      }
    })

    // SHA-256 ハッシュ計算用のヘルパー関数
    async function computeSHA256(message) {
      const encoder = new TextEncoder()
      const data = encoder.encode(message)
      const hashBuffer = await crypto.subtle.digest('SHA-256', data)
      const hashArray = Array.from(new Uint8Array(hashBuffer))
      const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
      return hashHex
    }

    // バッチ実行ボタン押下時：API (/api/batch-exec) を呼び出す
    executeBtn.addEventListener('click', async () => {
      if (batchSelect.value === "") {
        alert("バッチ名を選択してください")
        return
      }
      resultDiv.textContent = "実行中..."

      const selectedBatch = batchData[batchSelect.value]
      // payload の作成。alias も含める
      const payload = {
        lambdaName: selectedBatch.lambdaName,
        alias: selectedBatch.alias,  // エイリアスを送信
        parameters: (() => {
          try {
            return JSON.parse(jsonInput.value)
          } catch (e) {
            return {}
          }
        })()
      }
      const payloadString = JSON.stringify(payload)
      // ペイロードの SHA-256 ハッシュ値を計算
      const hashHex = await computeSHA256(payloadString)

      fetch('/api/batch-exec', {
        method: 'POST',
        headers: { 
          'Content-Type': 'application/json',
          'x-amz-content-sha256': hashHex
        },
        body: payloadString
      })
      .then(response => {
        const status = response.status
        return response.text().then(text => ({ status, text }))
      })
      .then(({status, text}) => {
        resultDiv.textContent = "HTTPステータスコード: " + status + "\nレスポンス内容:\n" + text
      })
      .catch(error => {
        resultDiv.textContent = "エラー発生: " + error
      })
    })
  </script>
</body>
</html>
```

## 最後に

実際には、これらの設定はCloudFormationで管理しているので、数か月前の記憶を頼りに書いてみたのですが少し間違った内容になっているかもしれません。
（だいたい、やっていることは伝わればOKかなというザックリな気持ちで書いてあります...