Secrets Managerの変更管理を安全に行いたい

## 概要
環境変数を管理するために`Secrets Manager`を利用していますが、運用上の課題として①プレーンテキストでの直接編集によるオペレーションミスのリスクや、②変更履歴が確認しにくいということがありました。  
これらの課題を解決するために、`Secrets Manager`の値を安全に管理・操作できるシェルツールを作成してみました。

[![Image from Gyazo](https://i.gyazo.com/a8d43fe5735a63fdc61b38066adc4545.png)](https://gyazo.com/a8d43fe5735a63fdc61b38066adc4545)

## 解決したい課題
### その1 プレーンテキストの環境変数を修正したくない
数値や真偽値など文字列以外を保存している`Secrets Manager`を変更する場合、コンソールからだとプレーンテキストを修正することになると思います。

[![Image from Gyazo](https://i.gyazo.com/fb71dc524fea01bea8bec33cda5260be.png)](https://gyazo.com/fb71dc524fea01bea8bec33cda5260be)

このケースだと手動でJSONを修正することになるので、形が崩れたりするオペミスを引き起こす懸念があります。

[![Image from Gyazo](https://i.gyazo.com/ba941b5120fe6dabe8ad2514526ec258.png)](https://gyazo.com/ba941b5120fe6dabe8ad2514526ec258)

### その2 万が一に備えて過去の値を確認できるようにしたい
誤って値を削除してしまったり、誤った値で更新してしまったり。
このようなケースが発生したときに備えて、`Secrets Manager`の値がいつどのように変更されたのか、変更履歴を確認できるようにしておきたいたいです。
コンソールから編集を行うと`AWSPREVIOUS`と`AWSCURRENT`がどんどん上書きされていきますが、タイムスタンプ情報を残すことでいつどのような更新が行われたかを振り返れるようにしておきたいです。

## 作成したもの
対話形式で`Secrets Manager`の値をCRUDできるシェルを作成しました。
基本的にはコンソール上から`Secrets Manager`は触らずに、全てシェル経由で操作するという運用方針です。

### 現在の環境変数の確認
シェルを起動して`0`で現在の値を確認できるようにしました。
[![Image from Gyazo](https://i.gyazo.com/3f999296b2093807e242d722d6565747.png)](https://gyazo.com/3f999296b2093807e242d722d6565747)

### 環境変数の追加/修正
シェルを起動して`1`で値の追加、修正をできるようにしました。

[![Image from Gyazo](https://i.gyazo.com/d142a557484e3cf08797badd31890777.jpg)](https://gyazo.com/d142a557484e3cf08797badd31890777)

[![Image from Gyazo](https://i.gyazo.com/e65537a188496accf114b1fe32585094.png)](https://gyazo.com/e65537a188496accf114b1fe32585094)

新規のKEYを入力すると追加、既存のKEYを入力すると更新します。
不用意に更新が行われないように、更新内容のdiffを表示して本当に更新するかを尋ねてから更新を実行します。

ここで下記のように`AWSCURRENT`のほかにタイムスタンプで`--version-stages`を保存することによって、変更履歴として保存しています。
```shell
  TIMESTAMP=$(date +"%Y-%m-%d-%H:%M:%S")

  # 変更履歴を残すためにステージ情報用の値を保存
  aws secretsmanager put-secret-value \
    --secret-id "$SECRETS_NAME" \
    --secret-string "$updated_secret" \
    --version-stages "$TIMESTAMP" \
    --profile "$AWS_PROFILE"
  if [[ $? -ne 0 ]]; then
    echo "エラー: '$TIMESTAMP'ステージのシークレット更新に失敗しました"
    exit 1
  fi

  # AWSCURRENT用の値を保存
  aws secretsmanager put-secret-value \
    --secret-id "$SECRETS_NAME" \
    --secret-string "$updated_secret" \
    --version-stages "AWSCURRENT" \
    --profile "$AWS_PROFILE"
  if [[ $? -ne 0 ]]; then
    echo "エラー: AWSCURRENTステージのシークレット更新に失敗しました"
    exit 1
  fi
```

### 環境変数の削除
シェルを起動して`2`で値の削除をできるようにしました。

[![Image from Gyazo](https://i.gyazo.com/3fcbd9f16328ffc89c363cd4925cfd25.jpg)](https://gyazo.com/3fcbd9f16328ffc89c363cd4925cfd25)

動きはほとんど追加/更新と変わりません。
削除したいKEYを入力して削除します。
不用意に削除が行われないように、削除内容のdiffを表示して本当に削除するかを尋ねてから削除を実行します。

追加/更新と同じようにタイムスタンプで`--version-stages`を保存することによって、こちらも変更履歴として保存しています。

### 変更履歴の確認
シェルを起動して`3`で変更履歴の確認をできるようにしました。

[![Image from Gyazo](https://i.gyazo.com/c72cac5f4af49c2bde5dde639e885ab6.jpg)](https://gyazo.com/c72cac5f4af49c2bde5dde639e885ab6)

いつ保存されたのか、バージョンID、を確認をできます。
ここで表示されたバージョンIDを次の箇所に入力することで、そのバージョンID時の環境変数を振り返ることができます。

[![Image from Gyazo](https://i.gyazo.com/50c48ae927b53c54d9af276d17474f00.png)](https://gyazo.com/50c48ae927b53c54d9af276d17474f00)

## シェルコード全文
```sh
#!/bin/bash

# 必須環境変数のチェック
if [[ "$AWS_PROFILE" == "" ]] || [[ "$SECRETS_NAME" == "" ]]; then
  echo "必須の環境変数が設定されていません。"
  echo "AWS_PROFILE: $AWS_PROFILE"
  echo "SECRETS_NAME: $SECRETS_NAME"
  exit 1
fi

# 最新の Secrets Manager の値を取得する関数
get_current_secret() {
  echo ""
  echo "現在のシークレットを取得中..."

  CURRENT_SECRET=$(aws secretsmanager get-secret-value \
    --secret-id "$SECRETS_NAME" \
    --query SecretString \
    --output text \
    --profile "$AWS_PROFILE" | jq -c '.')
  if [[ $? -ne 0 ]]; then
    echo "エラー: シークレット '$SECRETS_NAME' を JSON としてパースできませんでした。"
    exit 1
  fi
}

# Secrets Manager の更新処理を行う関数
update_secret() {
  local updated_secret=$1
  TIMESTAMP=$(date +"%Y-%m-%d-%H:%M:%S")

  # 変更履歴を残すためにステージ情報用の値を保存
  aws secretsmanager put-secret-value \
    --secret-id "$SECRETS_NAME" \
    --secret-string "$updated_secret" \
    --version-stages "$TIMESTAMP" \
    --profile "$AWS_PROFILE"
  if [[ $? -ne 0 ]]; then
    echo "エラー: '$TIMESTAMP'ステージのシークレット更新に失敗しました"
    exit 1
  fi

  # AWSCURRENT用の値を保存
  aws secretsmanager put-secret-value \
    --secret-id "$SECRETS_NAME" \
    --secret-string "$updated_secret" \
    --version-stages "AWSCURRENT" \
    --profile "$AWS_PROFILE"
  if [[ $? -ne 0 ]]; then
    echo "エラー: AWSCURRENTステージのシークレット更新に失敗しました"
    exit 1
  fi

  echo "シークレット '$SECRETS_NAME' をバージョンステージ '$TIMESTAMP' と 'AWSCURRENT' に正常に更新しました。"
}

# 変更差分を表示する関数
calculate_diff() {
  local old_secret=$1
  local new_secret=$2
  local diff_type=$3 # "added/changed" または "removed"

  case $diff_type in
    "added/changed")
      jq -n --argjson old "$old_secret" --argjson new "$new_secret" '
        {
          added: ([$new | to_entries[] | select(.key as $k | $old[$k] == null)] | from_entries // {}),
          changed: ([$new | to_entries[] | select(.key as $k | $old[$k] != null and $old[$k] != .value)] 
            | map({(.key): {old: $old[.key], new: .value}}) 
            | add // {})
        } | del(.added | select(length == 0)) | del(.changed | select(length == 0))
      '
      ;;
    "removed")
      jq -n --argjson old "$old_secret" --argjson new "$new_secret" '
        {
          removed: ([$old | to_entries[] | select(.key as $k | $new[$k] == null)] | from_entries)
        }
      '
      ;;
    *)
      echo "{}"
      ;;
  esac
}

while true; do
  # 操作メニューの表示
  echo "Secrets Manager 操作メニュー"
  echo "0: 現在の値を確認"
  echo "1: 環境変数の追加・更新 (KEYとVALUEを入力)"
  echo "2: 環境変数の削除"
  echo "3: バージョンを指定して値を取得"
  echo "4: 終了"
  echo "選択してください (0-4):"
  read -r ACTION

  # 選択によって分岐
  case $ACTION in
    0)
      get_current_secret
      echo "現在の値:"
      echo "$CURRENT_SECRET" | jq
      echo ""
      ;;
    1)
      echo ""
      echo "環境変数の追加・更新を行います。"
      get_current_secret
      NEW_VALUES="{}"
      while true; do
        echo ""
        echo "環境変数のKEYを入力してください:"
        read -r INPUT_KEY
        KEY=$(echo "$INPUT_KEY" | xargs)

        echo ""
        echo "環境変数のVALUEを入力してください(数値や真偽値を文字列として扱う場合はダブルクオーテーションで囲んでください):"
        read -r INPUT_VALUE
        VALUE=$(echo "$INPUT_VALUE" | xargs)

        if [[ "$VALUE" =~ ^\".*\"$ ]]; then
          # ダブルクオーテーションで囲まれている場合は文字列として扱う
          PROCESSED_VALUE=$VALUE
        elif [[ "$VALUE" =~ ^[0-9]+$ ]]; then
          # 数値として扱う
          PROCESSED_VALUE=$VALUE
        elif [[ "$VALUE" == "true" || "$VALUE" == "false" ]]; then
          # 真偽値として扱う
          PROCESSED_VALUE=$VALUE
        else
          # その他の場合は文字列として扱う（自動的にダブルクオーテーションで囲む）
          PROCESSED_VALUE="\"$VALUE\""
        fi

        NEW_VALUES=$(echo "$NEW_VALUES" | jq ". + {\"$KEY\": $PROCESSED_VALUE}")

        while true; do
          echo ""
          echo "まだ追加/更新しますか？ (yes/no):"
          read -r CONTINUE
          if [[ "$CONTINUE" == "no" ]]; then
            break 2 # ループを抜けて追加/更新を終了
          elif [[ "$CONTINUE" == "yes" ]]; then
            break # ループを抜けて次の入力を求める
          else
            echo "無効な入力です。yes または no を入力してください。"
          fi
        done
      done

      UPDATED_SECRET=$(echo "$CURRENT_SECRET" | jq ". + $NEW_VALUES")
      DIFF=$(calculate_diff "$CURRENT_SECRET" "$UPDATED_SECRET" "added/changed")

      echo "更新する内容:"
      echo "$UPDATED_SECRET" | jq
      echo "変更された部分:"
      echo "$DIFF" | jq

      echo ""
      echo "シークレット '$SECRETS_NAME' をこの内容で更新しますか？ (yes/no):"
      read -r CONFIRM

      if [[ "$CONFIRM" != "yes" ]]; then
        echo "操作がキャンセルされました。"
        echo ""
        continue
      fi
      update_secret "$UPDATED_SECRET"
      ;;
    2)
      echo "シークレットに保存された環境変数を削除します。"
      get_current_secret
      echo "現在の値:"
      echo "$CURRENT_SECRET" | jq
      echo "削除したい環境変数のKEYを入力してください:"
      read -r INPUT_KEY
      KEY=$(echo "$INPUT_KEY" | xargs)

      if ! echo "$CURRENT_SECRET" | jq -e "has(\"$KEY\")" > /dev/null; then
        echo "エラー: 指定されたKEY '$KEY' は存在しません。"
        echo ""
        continue
      fi

      UPDATED_SECRET=$(echo "$CURRENT_SECRET" | jq "del(.\"$KEY\")")
      DIFF=$(calculate_diff "$CURRENT_SECRET" "$UPDATED_SECRET" "removed")

      echo "更新する内容:"
      echo "$UPDATED_SECRET" | jq
      echo "変更された部分:"
      echo "$DIFF" | jq

      echo ""
      echo "シークレット '$SECRETS_NAME' をこの内容で更新しますか？ (yes/no):"
      read -r CONFIRM

      if [[ "$CONFIRM" != "yes" ]]; then
        echo "操作がキャンセルされました。"
        echo ""
        continue
      fi
      update_secret "$UPDATED_SECRET"
      ;;
    3)
      echo "バージョン一覧を取得中..."
      NEXT_TOKEN=""
      ALL_VERSIONS="[]"

      while : ; do
        # NEXT_TOKENを利用して全てのデータを取得する
        RESULT=$(aws secretsmanager list-secret-version-ids \
          --secret-id "$SECRETS_NAME" \
          --output json \
          --profile "$AWS_PROFILE" \
          ${NEXT_TOKEN:+--next-token $NEXT_TOKEN})

        # ページング結果を結合
        PAGE_VERSIONS=$(echo "$RESULT" | jq '.Versions | map({VersionId, CreatedDate, VersionStages})')
        ALL_VERSIONS=$(echo "$ALL_VERSIONS $PAGE_VERSIONS" | jq -s 'add')

        # 次のトークンを取得
        NEXT_TOKEN=$(echo "$RESULT" | jq -r '.NextToken // empty')

        # トークンが空ならループを終了
        if [[ -z "$NEXT_TOKEN" ]]; then
          break
        fi
      done

      # 全データをソートして表示
      SORTED_VERSIONS=$(echo "$ALL_VERSIONS" | jq 'sort_by(.CreatedDate)')
      echo "$SORTED_VERSIONS" | jq

      echo ""
      echo "特定バージョンIDを指定して値を取得します。"
      echo "VersionIdを入力してください:"
      read -r INPUT_VERSION_ID
      VERSION_ID=$(echo "$INPUT_VERSION_ID" | xargs)
      VALUE=$(aws secretsmanager get-secret-value \
        --secret-id "$SECRETS_NAME" \
        --version-id "$VERSION_ID" \
        --query SecretString \
        --output text \
        --profile "$AWS_PROFILE")
      if [[ $? -ne 0 ]]; then
        echo "指定されたバージョンの値が見つかりませんでした"
        echo ""
        continue
      fi

      echo "指定されたバージョンの値:"
      echo "$VALUE" | jq
      ;;
    4)
      echo "終了します。"
      exit 0
      ;;
    *)
      echo "無効な値です。正しい値を入力してください"
      echo ""
      ;;
  esac
done
```