KINTO Tech Blog
DBRE

データベースのパスワードを安全にローテーションする仕組みの導入

Cover Image for データベースのパスワードを安全にローテーションする仕組みの導入

こんにちは、KINTO テクノロジーズ (以下 KTC) DBRE のあわっち(@_awache) です。

今回は AWS の提供するシークレットローテーションの機能を利用して、主に Aurora MySQL に登録されている データベースユーザーのパスワードを安全にローテーションする仕組みを導入したのでその導入方法やつまずいた点、さらに周辺で開発したものを全てまとめて紹介させていただきます。

かなり長いブログなので先に簡単に要約を記載させていただきます。

全体まとめ

背景

KTC ではデータベースユーザーのパスワードを一定期間でローテーションすることが義務付けられることとなった

ソリューション

検討

  • MySQL Dual Password:MySQL 8.0.14以降で利用可能なDual Password機能を使い、プライマリとセカンダリのパスワードを設定
  • AWS Secrets Managerのローテーション機能:Secrets Managerを使い、パスワードの自動更新とセキュリティの強化を実現

採用

設定の容易さと網羅性のために、AWS Secrets Managerのローテーション機能を採用

プロジェクトの開始

プロジェクトの開始にあたり、インセプションデッキを作成し、コスト、セキュリティ、リソースに対する責任分解点を明確化した

プロジェクト内で開発したもの

Lambda 関数

AWS の提供するシークレットローテーションの仕組みを単純に使うだけでは KTC の要件に合わない部分が多くあったため、運用面を検討した結果、多くの Lambda 関数を作成する必要があった

  1. シングルユーザー戦略用 Lambda 関数
    • 目的:単一のユーザーに対してパスワードをローテーション
    • 設定:Secrets Manager に設定される。シークレットのローテーションを指定した時間に実行し、パスワードを更新する
  2. 交代ユーザー戦略用 Lambda 関数
    • 目的:2つのユーザーを交互に更新し、高可用性を確保
    • 設定:Secrets Manager に設定される。ローテーションの初回で2つ目のユーザー(クローン)を作成し、以降のローテーションでパスワードを切り替える
  3. シークレットローテーション結果通知用 Lambda 関数
    • 目的:シークレットローテーションの結果を通知
    • トリガー:CloudTrail イベント(RotationStarted、RotationSucceeded、RotationFailed)
    • 機能:DynamoDB にローテーション結果を保存し、Slack に通知。通知時に Slack のタイムスタンプを使用してスレッドに追記
  4. ローテーション結果格納用 DynamoDB 管理 Lambda 関数
    • 目的:ローテーションの結果を DynamoDB に格納し、エビデンスとしてセキュリティチームに提出
    • 機能:CloudTrailのイベントをトリガーに Lambda を実行し、ローテーション結果を DynamoDB に保存。保存したデータを基に SLI 通知を行う
  5. SLI 通知用 Lambda 関数
    • 目的:ローテーション状況を監視し、SLI 通知を行う
    • 機能:DynamoDB から情報を取得し、シークレットローテーションの進行状況を監視。必要に応じて Slack に通知
  6. ローテーションスケジュール決定のための Lambda 関数
    • 目的:各 DBClusterID に対してローテーションの実行時間を決定
    • 機能:既存のシークレットローテーション設定情報を基に、新しいスケジュールを生成し DynamoDB に保存。ローテーションウィンドウとウィンドウ期間を設定
  7. ローテーション設定適用のための Lambda 関数
    • 目的:決定したスケジュールを Secrets Manager に適用
    • 機能:DynamoDB から取得した情報を基に、指定の時間でシークレットローテーションを設定

シークレットローテーション登録ツール

実際の登録にはローカルから実行できるツールを別途開発した

  • Secrets Rotation スケジュール設定ツール
    • 目的:データベースユーザーごとにシークレットローテーションのスケジュールを設定
    • 機能:DynamoDB に保存された情報を基に、指定された DBClusterID と DBUser の組み合わせに対して、シークレットローテーションの設定を適用

最終的な全体アーキテクチャ

もっとシンプルにできるかと思ったが想像以上に複雑に。。

全体像

結果

  • シークレットローテーションの全プロセスを自動化し、セキュリティと管理の手間を削減
  • 全体のアーキテクチャを構築し、ガバナンス制約を満たすシステムを実現
  • KTCは、シークレットローテーションを利用して、安全で効率的なデータベース運用を目指し、さらなる改善を続けていく

改めまして、ここから本編に入りたいと思います。

Introduction

KTC ではデータベースユーザーのパスワードを一定の短い期間でローテーションすることが義務付けられることとなりました。ただパスワードのローテーションと言っても簡単に行えるものではありません。

データベースユーザーのパスワードを変更するためにはシステムを停止し、データベース側のパスワード変更を行った上でシステムの設定ファイル等を変更し、動作確認をする必要があります。ただデータベースユーザーのパスワード変更をするというだけにも関わらず、直接的な価値を提供しないサービス停止を伴うメンテナンス作業を行う必要があります。これをごく短い一定期間ごとに全てのサービスで実施するのは非常に煩わしいと思います。

今回はこの課題をどのように解決したのか、具体的な事例を含めて紹介させていただきます。

ソリューションの検討

今回は大きく2つのソリューションを検討しました。

  1. MySQL Dual Password の機能を使用
  2. Secrets Manager のローテーション機能を活用する

MySQL Dual Password

MySQL 8.0.14 以降 MySQL では Dual Password 機能を利用することができます。この機能を利用することで、プライマリとセカンダリの二つのパスワードを設定し、システムやアプリケーションの停止時間なしにパスワードの変更を行うことが可能となります。

Dual Password 機能を使うための簡単な手順は下記の通りです。

  1. ALTER USER 'user'@'host' IDENTIFIED BY 'new_password' RETAIN CURRENT PASSWORD;で新しいプライマリパスワードを設定し、現在のパスワードをセカンダリとして保持する
  2. 全てのアプリケーションが新しいパスワードで接続するように更新する
  3. ALTER USER 'user'@'host' DISCARD OLD PASSWORD;でセカンダリパスワードを破棄する

Secrets Manager のローテーション機能

AWS Secrets Manager はシークレットの定期的な自動更新をサポートしています。シークレットローテーションを有効にすることで手動でのパスワード管理の負担を軽減できるだけでなく、セキュリティ強化にも大きく寄与できます。

シークレットローテーションを有効にするにはシークレットにローテーションポリシーを設定し、ローテーション用の Lambda 関数を指定する必要があります。

ローテーション設定画面

  • Lambda ローテーション関数
    • ローテーション関数を作成
      • AWS によって提供されたコードを自動デプロイすることが可能なので個別に Lambda 関数を作成せずともすぐに利用することができます
    • アカウントからローテーション関数を使用
      • 独自で作成した Lambda 関数を使用することができます。もしくは上記の「ローテーション関数を作成」で作成した関数を再利用したい場合にこちらを選択することが可能です
  • ローテーション戦略
    • シングルユーザー
      • 一つのユーザーに対してパスワードローテーションを行う方式です
      • ローテーション中にデータベース接続は維持され、適切な再試行戦略によって認証情報の更新とデータベースへのアクセス拒否リスクを低減することが可能です
      • 新しい接続はローテーション後に新しい認証情報(パスワード)を使用する必要があります
    • 交代ユーザー
      • この交代ユーザー戦略はマニュアルを見てもイメージが掴みづらいものでした、が頑張って言語化すると下記のような形になるかなと思います
        • 1つのシークレット内で 2つのユーザーの認証情報(ユーザー名とパスワードの組み合わせ) を交互に更新し、最初のローテーションで 2つ目のユーザー(クローン)を作成し、以降のローテーションでパスワードを切り替える方式です
        • データベースの高可用性を必要とするアプリケーションに適しており、ローテーション中もアプリケーションは引き続き有効な認証情報セットを取得可能です
        • クローンユーザーが元のユーザーと同じアクセス権を持つため、権限変更時には両ユーザーの権限を同期させる必要があるので注意が必要となります
      • イメージを載せてみます
        • ローテーション前後の変化
          • ローテーション実行前後
            • 少しわかりづらいのですが、上記の図のようにパスワードローテーションが走るとユーザー名に「_clone」が付きます。
            • 初回の場合は、データベース側にも既存のユーザーと同じ権限を持った別のユーザーが作成されます
            • 2回目以降のローテーションではそれを使い回してパスワード更新をし続ける形になります
          • 交代ユーザー

ソリューションの決定

私たちは下記の理由から Secrets Manager のローテーション機能を使用することを決定しました。

  1. 設定の容易さ
    • MySQL Dual Password
      • パスワード変更用のスクリプトを準備した上で、変更された内容をアプリケーションに反映する必要がある
    • Secrets Manager のローテーション機能
      • サービスが必ず Secrets Manager から接続情報を取得している前提であればプロダクト側は特にコード修正等は必要ない
  2. 網羅性
    • MySQL Dual Password
      • MySQL 8.0.14 以降 (Aurora 3.0以降) にのみ対応
    • Secrets Manager のローテーション機能
      • KTC で扱っている全ての RDBMS に対応
        • Amazon Aurora
        • Redshift
      • データベース Password 以外にも対応
        • プロダクトで使用する API Key なども対応可能

プロジェクトの開始に向けて

プロジェクトを開始するに当たって自分たちが何をして何をしないのか、の輪郭を掴むため私たちは最初にコスト、セキュリティ、リソースに対する責任分解点の明確化とやるべきことの設定、インセプションデッキを作成しています。

こちらを簡単に紹介させていただきます。

責任分解点

項目 プロダクト DBRE
コスト • DB パスワード格納用の Secrets Manager のコスト • シークレットローテーションを行うための仕組みに関するコストは DBRE で負担する
セキュリティ • この仕組みを使うプロダクトは必ず Secrets Manager から データベース接続情報を取得しなければならない
• ローテーションが行われた後、次のローテーションが行われるまでにアプリケーションの再デプロイなどで Secrets Manager から接続情報を取得し直さなければならない
• 会社で定められたガバナンス制約の基準内にローテーションが完了すること
• シークレットローテーションの実績を必要に応じてセキュリティチームに提供できること
• 履歴管理等の目的でパスワードを平文で保存しないこと
• ローテーションに必要な仕組みのセキュリティが十分であること
リソース • データベースに登録されたユーザーは必ず Secrets Manager で管理されていること • シークレットローテーションで実施されるリソースは必要最小限な状態で実行されること

やるべきこと

  • 会社で定められたガバナンス制約の基準内にシークレットローテーションが行われること
  • シークレットローテーションの開始、終了、成功、失敗を検知し、それを担当プロダクトに通知すること
  • シークレットローテーションが失敗した場合にプロダクトへの影響がない状態でリカバリを完了すること
  • 同じ DB Cluster に登録されているユーザーに設定されるローテーションのタイミングは同じであること
  • 会社で定められたガバナンス制約の基準にどれだけ則っているかがわかること

インセプションデッキ (一部)

  • 我々はなぜここにいるのか
    • 我々は、会社のセキュリティポリシーに準拠し、データベースのパスワードを一定期間内に自動でローテーションするシステムを開発し、導入するためにここにいます。
    • この自動化プロセスは、セキュリティの強化、管理の手間の軽減、およびコンプライアンスの遵守を目的としています。
    • このプロジェクトは、DBREチームによって主導され、AWSのローテーション戦略を利用することで、安全かつ効率的なパスワード管理を実現します。
  • エレベーターピッチ
    • セキュリティ違反のリスクを軽減し、コンプライアンス要件を維持したい
    • プロダクト担当者およびセキュリティグループ向けの、
    • シークレットローテーションというサービスは、
    • データベースパスワード管理ツールです。
    • これは自動化されたセキュリティ強化と管理の手間を削減する機能があり、
    • MySQL の Dual Password とは違って、
    • AWS の提供するすべての RDBMS に適用する機能が備わっています。
    • そしてAWSのサービスを利用する企業だからこそ、最新のクラウド技術を駆使し、柔軟かつスケーラブルなセキュリティ対策を提供し、企業のデータ保護基準に応えることができます。

PoC

PoC を行うため、自分たちの検証環境にシークレットローテーションに必要なリソース(DB Cluster / Secrets) を作成し、コンソールからローテーションの仕組みを実施したところすんなりと実用に適しているということが見て取れたのでこれはすぐに提供できる、と大きな期待を持てました。

ただ。。この時の私は知らなかったのです、この後に起こる困難 (悲劇) を。。。

アーキテクチャ

シークレットローテーションを提供するためにはこれだけでは不十分なので通知の仕組みをユーザーに提供する必要があります。この仕組みを載せたアーキテクチャを簡単に紹介させていただきます。

シークレットローテーションの全体像

全体アーキテクチャ

  • シークレットローテーションは各 Secrets Manager に登録されたシークレット毎に実行されます
    • わかりやすいように 1ヶ月毎の更新を例にします
      • この場合 1ヶ月に 1度ローテーション実行となることで最大 2ヶ月は同じパスワードを利用することが可能となります
      • その間になんらかのリリースに伴うデプロイのし直しをするだけで気づいたら会社の定めるローテーションルールに乗っかっている状態を実現することができます
  • ローテーションの結果を DynamoDB へ格納
    • シークレットローテーションではステータスを下記のタイミングで CloudTrail にイベントが書き込まれます

    • これらのイベントをトリガーとして通知用の Lambda が実行されるように CloudWatch Event を設定します

      • 下記は実際に利用している Terraform のコードの一部です
        cloudwatch_event_name        = "${var.environment}-${var.sid}-cloudwatch-event"
        cloudwatch_event_description = "Secrets Manager Secrets Rotation. (For ${var.environment})"
        event_pattern = jsonencode({
          "source" : ["aws.secretsmanager"],
          "$or" : [{
            "detail-type" : ["AWS API Call via CloudTrail"]
            }, {
            "detail-type" : ["AWS Service Event via CloudTrail"]
          }],
          "detail" : {
            "eventSource" : ["secretsmanager.amazonaws.com"],
            "eventName" : [
              "RotationStarted",
              "RotationFailed",
              "RotationSucceeded",
              "TestRotationStarted",
              "TestRotationSucceeded",
              "TestRotationFailed"
            ]
          }
        })
      
    • 格納されたローテーション結果はエビデンスとしてセキュリティチームに提出するという用途にも活用されます

ここまでの部分を反映したアーキテクチャは下記のようになります。

シークレットローテーションのみのアーキテクチャ

機能提供に伴って準備が必要な主な AWS リソース

  • 交代ユーザー戦略適用のための Lambda 関数 (MySQL 用と Redshift 用で別々の Lambda が必要)
    • Secrets Manager に設定する交代ユーザー用 Lambda 関数
      • 社内のインフラ構築ルールに準拠するため、Lambda 関数設定や IAM 等、AWS によって自動で生成される Lambda 関数では対応しきれない要素が多くあったため、自分たちで作成
  • シングルユーザー戦略適用のための Lambda 関数 (MySQL 用と Redshift 用で別々の Lambda が必要)
    • Secrets Manager に設定するシングルユーザー用 Lambda 関数
      • 管理者用ユーザー用のパスワードには交代ユーザー戦略の適用ができない
  • シークレットローテーション結果通知用 Lambda 関数
    • シークレットローテーションによってローテーションされたことを通知する仕組みは自分たちで用意する必要がある
      • CloudTrail に状態や結果が格納されますのでそれをトリガーとして Slack 通知する
      • イベントトリガーで実行すると Lambda は別々に実行されることに注意
  • ローテーション結果格納用 DynamoDB
    • ローテーション結果を DynamoDB に格納
    • 通知をする際にどの Slack 通知の関連なのかを明確にし、Slack のスレッドに格納するため TimeStamp も同時に格納

シークレットローテーション用の Lambda 関数を自分たちで管理した理由

前提として私たちは AWS が提供している Lambda を活用しています。

上述したとおりAWS によって提供されたコードを自動デプロイすることが可能なので個別に Lambda 関数を作成せずともすぐに利用することができます。

ただ、私たちは一度コードセットを自分たちのリポジトリに commit した上で terraform で構築しています。

その主な理由は下記のとおりです。

  1. KTC の AWS アカウントには複数のサービスが共存している
    • 複数のサービスが同じ AWS アカウント上に共存していると IAM の権限が強くなりすぎてしまう
    • また複数のリージョンにまたがってサービスを展開している
      • Lambda はクロスリージョンで実行することができないため、同じコードを Terraform を活用し複数のリージョンにデプロイする必要がある
  2. シークレットローテーション設定対象のデータベースユーザーの数が多い
    • DB Cluster 数: 200弱、DBユーザー数: 1000弱
    • 全てのシークレットに手動で構築していくのは管理工数が非常に大きくなってしまう
  3. 社内ルールの適用
    • IAM だけでなく、Tag の設定が必須となる
      • 個別で自動作成してしまうとその後 Tag を設定する、という作業が必要となる
  4. タイミングによって AWS 側で提供するコードがアップデートされる
    • AWS が提供するコードなので当然これは発生し得ます
    • 場合によってはそのアップデートによってトラブルが発生してしまうこともある可能性があります

いくつか書きましたが簡単に言うと社内管理上自分たちでコード管理できた方が都合が良かった、と言うことになります。

Secrets Rotation 用の Lambda 関数を自分たちで管理する方法

ここは本当に大変でした。

最初は AWS から Lambda コードのサンプルが出ているので簡単にいくかと思ったのですがこれをベースにデプロイを行っても様々なエラーが発生してしまいました。自分たちの検証環境ではうまく行っても特定の DB Cluster でのみ発生するエラーなどもあり困難を極めました。

コンソールから自動生成したコードでは発生せず安定していたためこれをうまく活用できるようにする必要があります。

やり方としてはいくつかあるのですが、私たちが試した方法を共有します。

  1. サンプルコードからデプロイする方法を模索する
    • コードそのものは上述のリンクから確認することができます
    • ただし、必要なモジュールをバージョンも含めて全て合わせるのは困難です、またこの Lambda コードは割と頻繁に更新されているのでそれに追従する必要があります
      • これはちょっと大変だったので断念しました
      • さらにこのコードを管理し続けるとなるならば自分たちで別の方法で内製した方がいい気がしました
  2. シークレットローテーション関数をコンソールから自動生成した上でその Lambda コードをダウンロードする
    • 毎回コードを自動生成した上でそれをローカルにダウンロードし、自分たちの Lambda に適用する方法でそこまで難しくはありません
    • ただし、自動生成するタイミングで既存で動いているコードとダウンロードしたコードが変わってしまう可能性があります
      • これでも良かったのですが、コード変更を毎回最新化するために一度デプロイをしなければいけないのは自動化するためには少し億劫でした
  3. シークレットローテーション関数をコンソールから自動生成したときに裏側で実行される CloudFormation のテンプレートからそのデプロイ方法を確認する
    • コンソールから自動生成すると裏側で AWS の用意した CloudFormation が走ります
    • この時のテンプレートを確認すると AWS が自動生成するコードの S3 のパスを取得することができます。

S3 内にある Zip ファイルを直接取得することで毎回シークレットローテーションのコードを生成するプロセスを削減するメリットを考えると 3 の方法が最も効率的かなと考え今回はこちらを採用しました。

実際に S3 からダウンロードするスクリプトは下記のとおりです。

#!/bin/bash

set -eu -o pipefail

# Navigate to the script directory
cd "$(dirname "$0")"

source secrets_rotation.conf

# Function to download and extract the Lambda function from S3
download_and_extract_lambda_function() {
    local s3_path="$1"
    local target_dir="../lambda-code/$2"
    local dist_dir="${target_dir}/dist"

    echo "Downloading ${s3_path} to ${target_dir}/lambda_function.zip..."

    # Remove existing lambda_function.zip and dist directory
    rm -f "${target_dir}/lambda_function.zip"
    rm -rf "${dist_dir}"

    if ! aws s3 cp "${s3_path}" "${target_dir}/lambda_function.zip"; then
        echo "Error: Failed to download file from S3."
        exit 1
    fi

    echo "Download complete."

    echo "Extracting lambda_function.zip to ${dist_dir}..."
    mkdir -p "${dist_dir}"
    unzip -o "${target_dir}/lambda_function.zip" -d "${dist_dir}"
    cp -p "${target_dir}/lambda_function.zip" "${dist_dir}/lambda_function.zip"
    echo "Extraction complete."
}

# Create directories if they don't exist
mkdir -p ../lambda-code/mysql-single-user
mkdir -p ../lambda-code/mysql-multi-user
mkdir -p ../lambda-code/redshift-single-user
mkdir -p ../lambda-code/redshift-multi-user

# Download and extract Lambda functions
download_and_extract_lambda_function "${MYSQL_SINGLE_USER_S3_PATH}" "mysql-single-user"
download_and_extract_lambda_function "${MYSQL_MULTI_USER_S3_PATH}" "mysql-multi-user"
download_and_extract_lambda_function "${REDSHIFT_SINGLE_USER_S3_PATH}" "redshift-single-user"
download_and_extract_lambda_function "${REDSHIFT_MULTI_USER_S3_PATH}" "redshift-multi-user"

echo "Build complete."

デプロイの際にこのスクリプトを流せばコードの最新化が可能となります。逆に言うとこのスクリプトを実行しなければコード自体はこれまで動いていたものを継続して使用し続けることができます。

シークレットローテーション結果通知用 Lambda 関数と DynamoDB

シークレットローテーション結果通知は CloudTrail の PUT をトリガーとして実行されます。シークレットローテーションの Lambda に手を加えればもうちょっと簡単にできたかと思うのですがそれでは何のために AWS の提供しているコードを最大限利用しようとしていたのか分かりません。

開発する前の私は PUT トリガーで通知を行えばいいだけ、と簡単に考えていました。ただ、そんなに甘くはありませんでした。

ここで再度全体像を確認してみましょう。

全体アーキテクチャ

通知としては下記のように開始時に Slack の通知用のスレッドを作り、終了時にはそのスレッドに追記する形で通知を行います。

Slack 通知

今回利用するイベントは下記のとおりです。

  • 処理開始時のイベント
    • Cloud Trail に PUT されるイベント: RotationStarted
  • 処理終了時のイベント
    • 処理成功時に Cloud Trail に PUT されるイベント: RotationSucceeded
    • 処理失敗時に Cloud Trail に PUT されるイベント: RotationSucceeded

処理開始時のイベントである RotationStarted の際にはその Slack のタイムスタンプを DynamoDB に格納し、それを使うことでスレッドに追記することができます。

これを考慮すると DynamoDB がどの単位でユニークになるかを検討する必要があります。結果としては Secrets Manager の SecretID、そして次回のローテーション予定日を組み合わせることでユニークにすることとしました。

DynamoDB の構成の主要なカラム構成は下記のとおりです。 (実際にはもっと多く、様々な情報を入れています)

  • SecretID: パーテションキー
  • NextRotationDate: ソートキー
    • 次回ローテーション予定日、describe で取得可能
  • SlackTS: RotationStarted のイベントの際、Slack で最初に送ったタイムスタンプ
    • このタイムスタンプを利用することで Slack のスレッドに追記することができる
  • VersionID: RotationStarted のイベントの際の SecretID のバージョン
    • 万が一トラブルが発生した場合すぐに戻せるように一つ前のバージョンを保持しておくことでローテーション前のパスワード情報を復元することが可能

最も困った点は、シークレットローテーションの一回の処理の中で複数回 Cloud Trail に PUT するため、ステップごとに別々の Lambda が起動されることです。頭では理解していたものの、これは実際には非常に面倒でした。

そのため下記を考慮しなければなりませんでした。

  • シークレットローテーションの処理自体は非常に高速な処理
    • Cloud Trail に PUT されるタイミングが RotationStarted と RotationSucceeded (もしくはRotationFailed) でほぼ同じくらいなので通知用の Lambda の実行もほぼ同時に 2回流れることになる
    • 通知用の Lambda では Slack 通知や DynamoDB への登録も行っているため、RotationStarted の処理が完了する前に処理終了時のイベントが流れてしまうことがある
      • これが発生するとどのスレッドに送るべきなのかが定まらず新規で Slack に投稿されてしまう

解決の方法としてはシンプルにイベント名が RotationStarted でなかった場合、Slack に通知する処理を数秒待つ、ということで対応しました。

設定ミス等でシークレットローテーションが失敗してしまうことがあります。ほとんどの場合は DB のパスワードが更新される前にエラーとなるのでプロダクトにすぐに影響があるわけではありません。

その際には下記のコマンドでリカバリを実施します。

# VersionIdsToStages が AWSPENDING のバージョン ID を取得
$ aws secretsmanager describe-secret --secret-id ${secret_id} --region ${region}
    - - - - - - - - - - Versions 出力例 - - - - - - - - - -
        "Versions": [
        {
            "VersionId": "7c9c0193-33c8-3bae-9vko-4129589p114bb",
            "VersionStages": [
                "AWSCURRENT"
            ],
            "LastAccessedDate": "2022-08-30T09:00:00+09:00",
            "CreatedDate": "2022-08-30T12:53:12.893000+09:00",
            "KmsKeyIds": [
                "DefaultEncryptionKey"
            ]
        },
        {
            "VersionId": "cb804c1c-6d1r-4ii3-o48b-17f638469318",
            "VersionStages": [
                "AWSPENDING"
            ],
            "LastAccessedDate": "2022-08-30T09:00:00+09:00",
            "CreatedDate": "2022-08-30T12:53:22.616000+09:00",
            "KmsKeyIds": [
                "DefaultEncryptionKey"
            ]
        }
    ],
    - - - - - - - - - - - - - - - - - - - - - - - - 

# 該当のバージョンを削除
$ aws secretsmanager update-secret-version-stage --secret-id ${secret_id} --remove-from-version-id ${version_id} --version-stage AWSPENDING --region ${region}

# コンソールから該当のシークレットを 「すぐにローテーションさせる」

今のところ発生はしていないのですが、万が一トラブルが発生し、DB のパスワード変更がされてしまった時には下記のコマンドを実行し、過去のパスワードを取得します。

とはいえこちらも交代ユーザーローテーションなのですぐにプロダクトから データベースに接続できなくなるわけではなく、次のローテーションが実行されるまでは基本的には問題ないと考えています。

$ aws secretsmanager get-secret-value --secret-id ${secret_id} --version-id ${version_id} --region ${region} --query 'SecretString' --output text | jq .

# user と password は aws secretsmanager get-secret-value で取得したパラメータを設定する
$ mysql --defaults-extra-file=/tmp/.${管理用DBユーザー名}.cnf -e "ALTER USER ${user} IDENTIFIED BY '${password}'

# 接続確認
$ mysql --defaults-extra-file=/tmp/.user.cnf -e "STATUS"

ここまででやるべきことのうち、下記を達成する基盤を作ることができました。

  • シークレットローテーションの開始、終了、成功、失敗を検知し、それを担当プロダクトに通知すること
  • シークレットローテーションが失敗した場合にプロダクトへの影響がない状態でリカバリを完了すること

私たちの戦いはここでは終わらなかった

上記で主要機能を構築できたのですが、私たちがやるべきことはあと3つ残っています。

  • 会社で定められたガバナンス制約の基準内にシークレットローテーションが行われること
  • 同じ DB Cluster に登録されているユーザーに設定されるローテーションのタイミングは同じであること
  • 会社で定められたガバナンス制約の基準にどれだけ則っているかがわかること

これらを実現するために周辺の機能を開発する必要がありました。

会社で定められたガバナンス制約の基準にどれだけ則っているかがわかる仕組みの構築

これでやることは簡単に言うと全ての DB Cluster に存在するすべてのユーザーのリストを取得すること、そしてそのユーザーのパスワードの更新日がガバナンスで定められた期間内であるか、を確認することです。

各 DB Cluster にログインして下記のクエリを実行することで、ユーザーごとのパスワードの最終更新日を取得することができます。

mysql> SELECT User, password_last_changed FROM mysql.user;
+----------------+-----------------------+
| User           | password_last_changed |
+----------------+-----------------------+
| rot_test       | 2024-06-12 07:08:40   |
| rot_test_clone | 2024-07-10 07:09:10   |
            :
            :
            :
            :
            :
            :
            :
            :
+----------------+-----------------------+
10 rows in set (0.00 sec)

これをすべての DB Cluster で実行するわけですが、私たちはすでに日次ですべての DB Cluster のメタ情報を取得し、ER図や my.cnf を自動生成したり、不適切な設定が DB に存在していないかをチェックするスクリプトを実行しています。

ここにユーザー一覧とパスワードの最終更新日を取得して DynamoDB に保存する、という処理を追加するだけで解決できました。

DynamoDB の構成の主要なカラム構成は下記のとおりです。

  • DBClusterID: パーテションキー
  • DBUserName: ソートキー
  • PasswordLastChanged: パスワード最終更新日

実際には

  1. RDS を使用する上で自分たちがコントロールしない、自動的に作成されるユーザー
  2. シークレットローテーションの機能によって作成される「_clone」という名前を持つユーザー

を弾く必要があります。そのため本当に必要なデータは下記のクエリで取得しています。

SELECT
	CONCAT_WS(',', IF(RIGHT(User, 6) = '_clone', LEFT(User, LENGTH(User) - 6), User), Host, password_last_changed)
FROM
	mysql.user
WHERE
	User NOT IN ('AWS_COMPREHEND_ACCESS', 'AWS_LAMBDA_ACCESS', 'AWS_LOAD_S3_ACCESS', 'AWS_SAGEMAKER_ACCESS', 'AWS_SELECT_S3_ACCESS', 'AWS_BEDROCK_ACCESS', 'rds_superuser_role', 'mysql.infoschema', 'mysql.session', 'mysql.sys', 'rdsadmin', '');

その上で DynamoDB の情報を集計する SLI 用の Lambda を作りました。結果としてはこんな形で出力しています。

SLI 通知

こちらの出力内容は下記のとおりです。

  • Total Items: すべての DB Cluster に存在するすべてのユーザーの数
  • Secrets Exist Ratio: KINTO テクノロジーズで使用する Secrets Manager に登録する命名規則にあった SecretID が存在する割合
  • Rotation Enabled Ratio: シークレットローテーションの機能が有効化されている割合
  • Password Change Due Ratio: 会社のガバナンスルールに則っているユーザーの割合

重要なことは Password Change Due Ratio が 100 % になることです。ここが満たされさえすればシークレットローテーションの機能を使う必要もありません。

この SLI 通知の仕組みによって下記を達成することができました。

  • 会社で定められたガバナンス制約の基準にどれだけ則っているかがわかること

同じ DB Cluster に登録されているユーザーに設定されるローテーションを同じタイミングにするための仕組み

これを仕組み化するためには二つのコードセットを書く必要がありました。

  1. DBClusterID 毎のローテーション実行時間を決定させるための仕組み
  2. Secrets Manager に上記で決定した時間でローテーションを設定するための仕組み

それぞれについて説明します。

DBClusterID 毎のローテーション実行時間を決定させるための仕組み

前提としてシークレットローテーションの実行時間はローテーションウィンドウと呼ばれるスケジュールで記載することができます。ローテーションウィンドウの記載方式と用途は大きく下記の2つです。

  • rate 式
    • ローテーション間隔を指定の日数で実行したい場合に使用
  • cron 式
    • 特定の曜日、特定の時間に実行したいなど少し細かく指定をしたい場合はこちらを使用

私たちは平日日中帯に実行したかったこともあり、cron 式を用いて設定することとしました。

もう一つ設定すべき点はローテーションで設定する「ウィンドウ期間」です。これら二つを組み合わせてある程度ローテーションの実行タイミングをコントロールすることができます。

ローテーションウィンドウとウィンドウ期間の関係は下記のとおりです。

  1. ローテーションウィンドウは開始時間ではなくローテーションが完了する時間
  2. ウィンドウ期間はローテーションウィンドウで設定された時間に対してどれくらいの猶予を持たせて実行をするか
  3. ウィンドウ期間のデフォルトは 24時間

つまり、ローテーションウィンドウを毎月第4火曜日の10:00 に設定して、ウィンドウ期間を何も指定しない(24時間)とシークレットローテーションが実行されるタイミングは

毎月第4月曜日の 10:00 ~ 毎月第4火曜日の 10:00 の間のいずれかで実行されることになる

となります。これは直感的に難しいのですが、この関係を理解していないと予想もしないタイミングでシークレットローテーションが実行されてしまいます。

以上の前提を念頭に置きつつ、要件を下記のとおり定めました。

  • DBClusterID 毎に、複数の DB ユーザーのローテーションが同じ時間帯に実行される
  • ウィンドウ期間は 3時間とする
    • あまり短いタイミングで設定すると万が一トラブルが発生した時のリカバリまでの時間帯に同時多発的に問題が出てしまう可能性がある
  • 実行のタイミングは平日火曜から金曜の 09:00 ~ 18:00 の間とする
    • 月曜日は祝日の可能性が高いため実行しない
    • ウィンドウ期間の時間を 3時間で固定することとするため、cron 式に設定できるのは 12:00 ~ 18:00 の 6時間
    • cron 式に設定できるのは UTC のみ
  • 可能な限り実行のタイミングをバラバラに設定する
    • 同じタイミングで多くのシークレットローテーションが走ると各種 API の制限に影響を与えてしまう可能性がある
    • 何かしらのエラーが発生した場合、一気にアラートが来ることで対応に追われてしまう

Lambda 処理全体の流れとしては下記のような形になります。

  • データの取得:
    • DynamoDBから DBClusterID のリストを取得
    • DynamoDBから既存のシークレットローテーションの設定情報を取得
  • スケジュールの生成:
    • 週、曜日、時間のすべての組み合わせ(スロット)を初期化
    • 対象のDBClusterID が既存のシークレットローテーションの設定情報に存在しないか確認
      • 存在していたらその DBClusterID を既存のシークレットローテーションの設定情報と同じスロットに埋め込む
    • 新しい DBClusterID をスロットに均等に分配する
      • スロットに空きがあればそこに新しいデータを追加し、空きがなければ次のスロットにデータを追加
    • DBClusterID のリストの最後まで繰り返し実行
  • データの保存:
    • 既存のデータと重複しない新しいシークレットローテーションの設定情報をフィルタリングして保存します。
  • エラーハンドリングと通知:
    • 重大なエラーが発生した場合、Slackにエラーメッセージを送信して通知します。

これによって格納される DynamoDB のカラムは下記のとおりです。

  • DBClusterID: パーテションキー
  • CronExpression: シークレットローテーションに設定する cron 式

少し分かりづらいのですがイメージとしては下記のような状態になるようにしています。

スロット投入イメージ

ここまでで DBClusterID 毎のローテーション実行時間を決定させるための仕組みができました。

これでは実際にシークレットローテーションの設定をすることはできません。なので実際にシークレットローテーションを設定する仕組みが必要となります。

Secrets Manager に上記で決定した時間でローテーションを設定するための仕組み

私たちはシークレットローテーションの仕組みだけが会社のガバナンスを守る手段だと思っていません。重要なことは会社で定められたガバナンス制約の基準が満たされていることです。そのためこの仕組みを必ず使う、という強制力を持たせるのではなく DBRE が考えた最も安全で最も簡単な仕組みとしてユーザーが使いたいと思えば使ってもらえる、そんな仕組みです。

もしかしたら DBCluster にあるユーザーの中でこのユーザーはシークレットローテーションで、このユーザーは別の方法で自分たちで管理したい、そのような要望が出てくる可能性もあります。

これを満たすためには必要な DBClusterID に紐づく データベースユーザーの単位でシークレットローテーションの設定をするコマンドラインツールが必要となります。

私たちは DBRE として日頃から行う作業をコマンドライン化した dbre-toolkit というツールを開発していました。例えば Point In Time Restore を簡単に行えるツール、Secrets Manager にある DB 接続ユーザーの情報を取得して defaults-extra-file を作成するツールなどがパッケージ化されて一つにまとまっているものです。

今回はここに一つサブコマンドを追加しました。

% dbre-toolkit secrets-rotation -h
2024/08/01 20:51:12 dbre-toolkit version: 0.0.1
指定された Aurora Cluster に紐づく Secrets Rotation スケジュールに基づいて
Secrets Rotation を設定するコマンドです。

Usage:
  dbre-toolkit secrets-rotation [flags]

Flags:
  -d, --DBClusterId string   [Required] 対象サービスの DBClusterId
  -u, --DBUser string        [Required] 対象の DBUser
  -h, --help                 help for secrets-rotation

ここで指定された DBClusterID と DBUser の組み合わせを DynamoDB から取得してその情報を Secrets Manager に登録することでシークレットローテーションの設定を完了させる、というものです。

これによって下記を達成することができました。

  • 会社で定められたガバナンス制約の基準内にシークレットローテーションが行われること
  • 同じ DB Cluster に登録されているユーザーに設定されるローテーションのタイミングは同じであること

そしてここまでやってようやく自分たちが定めたやるべきことの全てを完了させることができました。

まとめ

ここまで私たちが実現したことは下記のとおりです。

  • シークレットローテーションの開始、終了、成功、失敗を検知し、それを担当プロダクトに通知すること
    • CloudTrail に Put されるイベントを検知して適切に通知する仕組みの開発
  • シークレットローテーションが失敗した場合にプロダクトへの影響がない状態でリカバリを完了すること
    • トラブル対応手順を準備
      • シークレットローテーションの仕組みを理解することで、基本的にはシークレットローテーションが行われて即座に致命的なエラーになる可能性は少ないことがわかった
  • 会社で定められたガバナンス制約の基準内にシークレットローテーションが行われること
    • SLI 通知用の仕組みの開発
    • シークレットローテーションの設定を確実にできるような設定ツールの開発
  • 同じ DB Cluster に登録されているユーザーに設定されるローテーションのタイミングは同じであること
    • DBClusterID 単位でシークレットローテーションに設定する cron 式を DynamoDB に保存する仕組みを開発
  • 会社で定められたガバナンス制約の基準にどれだけ則っているかがわかること
    • SLI 通知用の仕組みの開発

全体像としては下記のような形になりました。

全体像

想像以上に複雑です。。私たちはマネージドでシークレットローテーションを実現することをある意味簡単に考えすぎていたとも言えます。

AWS の提供するシークレットローテーションの機能は単純に利用するだけならすぐにできるとても強力な仕組みです。

ただ、私たちが実現したいことを本気でやろうとすると一筋縄ではいかず様々な要素を自分たちで内製する必要があります。ここに至るまでには本当にさまざまなトライアンドエラーがありました。

こうしてできたシークレットローテーションの仕組みを利用して KTC の データベースが誰でも簡単に、そして誰も気にしなくてもいい感じに安全に運用し続けられる、そんな環境を作っていければと思っています。

KINTO テクノロジーズ DBRE チームでは一緒に働いてくれる仲間を絶賛募集中です!カジュアル面談も歓迎ですので、 少しでも興味を持っていただけた方はお気軽に X の DM 等でご連絡ください。併せて、弊社の採用 X アカウント もよろしければフォローお願いします!

Facebook

関連記事 | Related Posts

We are hiring!

【DBRE】DBRE G/東京・名古屋・大阪

DBREグループについてKINTO テクノロジーズにおける DBRE は横断組織です。自分たちのアウトプットがビジネスに反映されることによって価値提供されます。

【データエンジニア】分析G/東京・名古屋・大阪

分析グループについてKINTOにおいて開発系部門発足時から設置されているチームであり、それほど経営としても注力しているポジションです。決まっていること、分かっていることの方が少ないぐらいですので、常に「なぜ」を考えながら、未知を楽しめるメンバーが集まっております。