entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

Google Cloud Secret Manager を使って機密情報の保存をセキュアにした話

はじめましての方ははじめまして。21新卒エンジニアの Javakky です。 突然ですが質問です!みなさんは本番環境で利用するDBのアクセス情報などの機密情報はどこに保存していますか?弊チームでは、社内でホスティングしているGitLabを利用しており、リポジトリの閲覧権限がチームメンバーにしか付与されていないことから長年プロジェクトの config ファイルに平文で保存されていました。 しかし、それはつまりリポジトリを clone してしまえば機密情報を全て保存できてしまうということでもあります。今回は、 Secret Manager というサービスを利用して機密情報にたいして権限管理を行えるようにしたレポートです。

Secret Manager とは?

Secret Manager は Google Cloud のプロダクトの一つで、APIキーなどの機密情報を保存するためのストレージシステムです。 64KiB 以内のデータ (文字列) に名前をつけて管理することができ、暗号鍵を自身で用意することや有効期限を指定することもできます。

Cloud Key Management

導入検討時に候補としてあがったものとしては、 Google Cloud Key Management があります。こちらは機密情報そのものを管理するサービスではなく、暗号化のための鍵を生成・管理してくれるサービスになります。 今回は、リポジトリ内に暗号化したデータを大量に持ちたくない (何のデータか目視チェックしづらい) という理由から Secret Manager を選択しました。

Secret Manager からデータを取得する

弊チームでは Scala を使っており、Google Cloud のサービスは Java 向けの SDK が用意されているのでそちらを利用しました。

libraryDependencies += "com.google.cloud" % "google-cloud-secretmanager" % "2.1.3"

Google Cloud に認証する

弊チームのシステムは本番環境 (サーバー)・検証環境 (サーバー)・開発環境 (ローカル) の3つの環境で動作しているため、サーバー上で動かす場合とローカルで動かす場合に別の認証方法が必要でした。 サーバー上で Google Cloud サービスを利用する場合、サービスアカウントという仮想マシンなどに割り当てることができる特別なアカウントを用います。 しかし、認証に利用するアカウントを全てサービスアカウントにしてしまうと開発メンバーのローカルPCにもアカウントを発行する必要が出てしまうので、開発環境では個人の Google アカウントで認証できるようにしました。

サービスアカウントを利用した認証

Google Cloud ドキュメントの サービス アカウントとして認証する の項目を元に、生成した JSON ファイルをサーバー上に設置して変数 GOOGLE_APPLICATION_CREDENTIALS を設定しました。

ユーザーアカウントを利用した認証

Google Cloud CLI をインストールする にある通り、ローカルの環境に Google Cloud CLI をインストールして、 gcloud auth application-default login コマンドを利用して認証しました。

下記コマンドを入力するとブラウザから Google のページが開くので許可を選択することで認証情報が作成されます。 /Users/${User}/.config/gcloud/application_default_credentials.json にファイルが生成されていれば完了です。

gcloud auth application-default login

機密情報にアクセスする

シークレットの作成とアクセスを Scala で書き直すことでアクセスするクラスを実装しました。 accessSecretVersion を呼び出す際に名前とバージョンを渡すだけで機密情報を取得することができます。この通信は非同期化されていないため、並列アクセスなどを行う場合には呼び出し元で実装する必要があります。

package secretmanager
import com.google.cloud.secretmanager.v1.{SecretManagerServiceClient, SecretVersionName}
import secretmanager.{SecretManagerApi, SecretManagerFailGetValueException}

import javax.inject.Inject
import scala.util.Using
import scala.util.control.NonFatal

/** [[SecretManagerServiceClient]] のラッパー */
class SecretManagerApiImpl @Inject()(secretConfig: SecretManagerConfig) extends SecretManagerApi {
  def accessSecretVersion(name: String, version: Int): String = {
    Using.resource(SecretManagerServiceClient.create) { client =>
      val secretVersionName = SecretVersionName.of(secretConfig.project, name, version.toString)
      val response = client.accessSecretVersion(secretVersionName)
      // 本番環境では取得したシークレットを表示、出力等しないこと
      val payload = Some(response.getPayload.getData.toStringUtf8)
      payload.getOrElse(throw new SecretManagerFailGetValueException("Secret Manager から値の取得に失敗しました"))
    }
  }
}

これによって Secret Manager に保存された機密情報を取得することができるようになりました。では、このメソッドはいつ呼び出すことにすればよいでしょうか? 例えば利用する度に呼び出す実装にすることで、メモリ上にも長時間機密情報を保存する必要がなくなりよりセキュアになると言えます。 一方で、毎回呼び出す方式では、次のような問題が考えられます。

そこで、サーバーサイドアプリケーションの立ち上げ時にのみアクセスを行い、そのデータを使い回すことにしました。具体的な実装としては、得られた機密情報をそれぞれの Config クラスのフィールドとしてセットして、各 Controller の立ち上げ時にDIされるようになっています。 サーバーサイドアプリケーションの立ち上げは基本的にはデプロイ時にのみ行われるため、シークレットの削除や Google Cloud の障害があった場合には立ち上げ失敗により開発者が問題に気づくことができるようになっています。

開発モードでのキャッシュ

Play Framework を利用したプロジェクトでは、ローカル開発時にホットリロードを有効にすることができる開発モードがあります。しかし、この手法ではコードの変更時にアプリケーションが毎回リロードされ、機密情報の読み込みも毎回発生することになってしまいます。これに対処するため、チームメンバーの toshi がインメモリキャッシュを利用した機密情報の使い回しを実装しました。

方針としては静的フィールドとして宣言した ConcurrentHashMap に機密情報を保存して使い回すという単純なものです。ただ、playframework/Reloader.scala に記載がある通り、ユーザークラスを定義する reloader.currentApplicationClassLoader はリロードする際に作成されてしまうため、ユーザークラスとしてではなく依存ライブラリとして親クラスローダーから読み込んでいます。

project.settings(
  // devCacheのjarを開発モードでライブラリとして読む。
  play.sbt.PlayInternalKeys.playDependencyClasspath ++= Seq(
    // devCacheサブプロジェクトでpackageタスクを実行した時に作成できるjarのパス
    (devCache / Compile / _root_.sbt.Keys.`package`).value
  ),
)

lazy val devCache = project.settings(
  commonSettings,
  name := "dev-cache"
)
package devcache;

import java.util.concurrent.ConcurrentHashMap;

public class DevCache {

    private static final ConcurrentHashMap<String, String> underlying = new ConcurrentHashMap<>();

    public static void put(String key, String value) {
        underlying.put(key, value);
    }

    public static String get(String key) {
        return underlying.get(key);
    }

}
class SecretManagerCache(classLoader: ClassLoader) {
  private def loadCacheClass() = {
    for {
      // アプリケーションクラスローダーの親クラスローダーを取得する
      parent <- Option(classLoader.getParent)
      cls <-
        try {
          // `applicationLoader` でキャッシュデータを保存するクラスをロードする。
          // devcache.DevCache は親クラスローダーが見つけられるようにソースコードとしてではなく依存ライブラリとして読み込む。
          Some(parent.loadClass("devcache.DevCache"))
        } catch {
          case _: ClassNotFoundException =>
            // 本番環境ではdevcache.DevCacheはデプロイされないのでロードできない。なのでキャッシュも効かない。
            None
        }
    } yield cls
  }

  /** キャッシュデータを取得する */
  def getCache(name: String, version: Int): Option[String] = {
    for {
      cls <- loadCacheClass()
      // リフレクションで DevCache#getを呼び出す
      m <- Option(cls.getMethod("get", classOf[String]))
      v <- Option(m.invoke(null, cacheKey(name, version)).asInstanceOf[String])
    } yield v
  }

  /** キャッシュを保存する */
  def putCache(name: String, version: Int, value: String): Unit = {
    for {
      cls <- loadCacheClass()
      // リフレクションで DevCache#putを呼び出す
      m <- Option(cls.getMethod("put", classOf[String], classOf[String]))
    } {
      m.invoke(null, cacheKey(name, version), value)
    }
  }

  private def cacheKey(name: String, version: Int): String = s"$name:$version"
}

これを実装したことで、 Secret Manager の機密情報もキャッシュできるようになりました。

package secretmanager
import javax.inject.Inject
import scala.util.Using

import com.google.cloud.secretmanager.v1.{SecretManagerServiceClient, SecretVersionName}

class SecretManagerApiImpl @Inject() (secretConfig: SecretManagerConfig, classLoader: ClassLoader)
    extends SecretManagerApi {
  private val parentClassLoaderCache = new SecretManagerCache(classLoader)

  override def accessSecretVersion(name: String, version: Int): String = {
    // キャッシュが存在しなければ
    parentClassLoaderCache.getCache(name, version).getOrElse {
      Using.resource(SecretManagerServiceClient.create) { client =>
        val secretVersionName = SecretVersionName.of(secretConfig.project, name, version.toString)

        val response = client.accessSecretVersion(secretVersionName)
        // 本番環境では取得したシークレットを表示、出力等しないこと
        val payload = response.getPayload.getData.toStringUtf8

        // キャッシュに書き込む
        parentClassLoaderCache.putCache(name, version, payload)

        payload
      }
    }
  }

}

まとめ

Secret Manager を利用することで平文保存されていた機密情報を権限管理できるようになりました。また、クラスローダーを利用したキャッシュによって開発モードでもアクセス回数の削減ができました。 「機密情報、平文だったりしない?」の一言にギクっとした方は費用と相談しつつ検討してみてはいかがでしょうか?

icon
javakky
決済周りの改善を中心に働いている2021年入社エンジニア。その名の通りJavaが好きなことで有名(?)で、最近はScalaを使える部署へ入ったらしい。