この記事は、HashiCorp Japan Advent Calendar 2025 6 日目の記事です。
昔々、あるところに...
全社的なセキュリティ向上プロジェクトの責任者に任命されたエンジニアがおりました。
その施策の一つとして、HashiCorp が提供する Vault を利用して、各所に散在するシークレットを一元管理するソリューションを構築することにしました。
Vault を運用して数ヶ月、中々思うように利用が進まないことを疑問に思い、AWS コンソールにログインして AWS Secrets Manager を覗いてみると、なんとそこには大量のシークレットがあるではありませんか。
これでは Vault を導入した意味がありません。このような脱法シークレット管理を防ぐために、エンジニアはある対策を講じることにしました。
何を作るか
AWS Secrets Manager にシークレットが登録されたことを検知して、そのシークレットを削除して代わりに Vault にぶち込んであげるイベント駆動型シークレット自動移行ワークフローです。なんと親切なことでしょう。
ざっくりとしたアーキテクチャ図がこちらです。
Amazon EventBridge を使ってシークレット登録のイベントを拾い、それをトリガーに AWS Step Functions のステートマシンを実行します。Vault へのシークレットの登録など細かいワークフローはステートマシンの中で定義してあります。
以下が、ステートマシンのフロー図です。
途中細かく例外処理や変数の設定などを行っていますが、実行する主なステップとしては、
- Secrets Manager からシークレットを取得
- Vault にログイン
- Vault にシークレットを登録
- Secrets Manager からシークレットを削除
の4つです。
順に細かく見ていきましょう。
コードの全体は GitHub にあります。
イベントルールの実装
ほとんどの AWS サービスの各種 API の実行イベントは、EventBridge を使って捕捉することができます。今回捕捉するのは、AWS Secrets Manager の CreateSecret イベントです。
今回はすべてのインフラを Terraform で実装しています。EventBridge のイベントルールの定義はこちらです。
resource "aws_cloudwatch_event_rule" "secret_created" {
name = "${local.prefix}-secrets-created"
description = "Capture secret creation in Secrets Manager"
event_pattern = jsonencode({
"source" : ["aws.secretsmanager"],
"detail-type" : ["AWS API Call via CloudTrail"],
"detail" : {
"eventSource" : ["secretsmanager.amazonaws.com"],
"eventName" : ["CreateSecret"],
}
})
}
上記のイベントパターンとマッチするイベントをトリガーに、Step Functions のステートマシンを実行するようにします。
resource "aws_cloudwatch_event_target" "invoke_sfn" {
rule = aws_cloudwatch_event_rule.secret_created.name
target_id = "InvokeStateMachine"
arn = aws_sfn_state_machine.workflow.arn
role_arn = aws_iam_role.trigger_sfn_by_event.arn
}
参照ばかりで抜粋では分かりにくいかもしれないので、コード全体は GitHub から確認してください。
ここでの注意点としては、EventBridge のサービスロールにステートマシンを実行するための states:StartExecution の権限を付与しておくことです。
ワークフローの実装
1. イベントペイロードの確認と変数の設定
最初の SetEventVariables ステートでは、トリガーとなったイベントから後続の処理で必要なパラメータを取り出し、ステートマシンの変数にアサインしています。
実際の CreateSecret のイベントペイロードは、以下のようになります(一部マスクしてあります)。これが最初のステートの Input としてそのまま流れてくるので、必要なデータを取り出して変数にアサインします。
{
"version": "0",
"id": "a77250af-25fa-faec-df93-66c4fa90393c",
"detail-type": "AWS API Call via CloudTrail",
"source": "aws.secretsmanager",
"account": "909xxxxxxxxx",
"time": "2025-12-19T12:37:24Z",
"region": "ap-northeast-1",
"resources": [],
"detail": {
"eventVersion": "1.11",
"userIdentity": {
"type": "AssumedRole",
"principalId": "AROA5HMETT5CYFTF5QH37:terraform-run-ksKJKUx5K354LYtF",
"arn": "arn:aws:sts::909xxxxxxxxx:assumed-role/tfc-role/terraform-run-ksKJKUx5K354LYtF",
"accountId": "909xxxxxxxxx",
"accessKeyId": "ASIA5HMETT5CRPLD5PQU",
"sessionContext": {
"sessionIssuer": {
"type": "Role",
"principalId": "AROA5HMETT5CYFTF5QH37",
"arn": "arn:aws:iam::909xxxxxxxxx:role/tfc-role",
"accountId": "909xxxxxxxxx",
"userName": "tfc-role"
},
"webIdFederationData": {
"federatedProvider": "arn:aws:iam::909xxxxxxxxx:oidc-provider/app.terraform.io",
"attributes": {}
},
"attributes": {
"creationDate": "2025-12-19T12:37:23Z",
"mfaAuthenticated": "false"
}
}
},
"eventTime": "2025-12-19T12:37:24Z",
"eventSource": "secretsmanager.amazonaws.com",
"eventName": "CreateSecret",
"awsRegion": "ap-northeast-1",
"sourceIPAddress": "x.x.x.x",
"userAgent": "APN/1.0 HashiCorp/1.0 Terraform/1.14.2 (+https://www.terraform.io) terraform-provider-aws/6.26.0 (+https://registry.terraform.io/providers/hashicorp/aws) aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.24.11 md/GOOS#linux md/GOARCH#amd64 api/secretsmanager#1.40.5 m/q",
"requestParameters": {
"name": "sample",
"clientRequestToken": "terraform-20251219123724092000000001",
"description": "",
"forceOverwriteReplicaSecret": false
},
"responseElements": {
"arn": "arn:aws:secretsmanager:ap-northeast-1:909xxxxxxxxx:secret:sample-HVOMh2"
},
"requestID": "f992e45f-74a9-4c91-8726-fc2a59618187",
"eventID": "9db2d515-1979-4c59-8c64-97e202368abe",
"readOnly": false,
"eventType": "AwsApiCall",
"managementEvent": true,
"recipientAccountId": "909xxxxxxxxx",
"eventCategory": "Management",
"tlsDetails": {
"tlsVersion": "TLSv1.3",
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "secretsmanager.ap-northeast-1.amazonaws.com",
"keyExchange": "x25519"
}
}
}
後続のステートでもいくつか変数を定義していきますが、ここで定義する変数は登録されたシークレットの名前と ARN のみです。
SetEventVariables:
Assign:
SECRET_NAME.$: $.detail.requestParameters.name
SECRET_ID.$: >-
States.Format('arn:aws:secretsmanager:{}:{}:secret:{}', $.detail.awsRegion, $.account, $.detail.requestParameters.name)
Step Functions で変数が使えるようになったのマジででかい。昔はステップごとに Input と Output を職人芸のようにこねこねしなきゃいけなかったけど、今回はこの変数機能を使い倒していきます。
2. シークレットの取得
GetSecretFromSecretsManager ステートで、登録されたシークレットを取得します。AWS SDK 統合を利用しているので、ノーコードで実装できます。パラメータの SecretId には、先ほど定義した変数を指定します。
GetSecretFromSecretsManager:
Next: SecretFound?
Parameters:
SecretId.$: $SECRET_ID
Resource: arn:aws:states:::aws-sdk:secretsmanager:getSecretValue
Type: Task
レスポンスにはユーザーが登録したシークレットの中身が入っています。この値をそのまま Vault にシークレットとして登録する流れになります。
{
"Arn": "arn:aws:secretsmanager:ap-northeast-1:909xxxxxxxxxx:secret:sample-HVOMh2",
"CreatedDate": "2025-12-19T12:37:25.274Z",
"Name": "sample",
"SecretString": "{\"English\":\"Hello world\",\"Germany\":\"Hallo Welt\",\"Spanish\":\"Hola Mundo\"}",
"VersionId": "terraform-20251219123725161800000002",
"VersionStages": [
"AWSCURRENT"
]
}
この次のステートで例外処理として、シークレットが取得できたことを確認すると同時に、シークレットの値を変数にアサインしています。
SecretFound?:
Choices:
- Assign:
SECRET_VALUE.$: $.SecretString
Variable: $.Arn
IsPresent: true
Next: LoginVault
Default: NotFound
Type: Choice
3. Vault の認証
Vault の認証には AWS auth method を採用しました。OIDC フェデレーションを利用して AWS と Vault の間に信頼関係を確立することで、静的なクレデンシャルを設定することなく、AWS 上のワークロードから Vault のトークンを取得することができるようになります。
ID プロバイダーの設定や Auth method の有効化は割愛し、ロールの設定箇所だけ確認してみます。
resource "vault_aws_auth_backend_role" "sfn_role" {
backend = vault_auth_backend.plugin_wif.path
role = local.vault_role_name
auth_type = "iam"
token_ttl = 60
token_max_ttl = 120
token_policies = [vault_policy.sfn_policy.name]
bound_iam_principal_arns = [aws_lambda_function.vault_login.role]
depends_on = [vault_aws_auth_backend_client.plugin_wif]
}
resource "vault_policy" "sfn_policy" {
name = "${local.prefix}-sfn"
policy = <<EOT
path "kv/*" {
capabilities = ["create", "read", "update", "patch", "delete", "list"]
}
EOT
"sfn_role" が今回認証に利用するロールで、タイプとして iam を使用しています。AWS Auth method は、EC2 インスタンスからメタデータを使用して認証する方式と、任意の IAM エンティティを使用して認証する方式の2つをサポートしています。今回は Lambda 関数を使って認証を行うため、iam となります。また、Lamdab 関数の実行ロールに対してのみこのロールの利用を許可する bound_iam_principal_arns パラメータも設定しています。
Vault ポリシーについては、シークレットの登録に利用する KV secrets engine のパスに対する CRUDL 権限を付与しています。
次に、Lambda 関数の定義です。
data "archive_file" "lambda_zip" {
type = "zip"
source_file = "${path.module}/src/bootstrap"
output_path = "${path.module}/dist/function.zip"
# depends_on = [terraform_data.go_build]
}
resource "aws_lambda_function" "vault_login" {
filename = data.archive_file.lambda_zip.output_path
function_name = "${local.prefix}-vault-login"
role = aws_iam_role.lambda.arn
runtime = "provided.al2023"
handler = "bootstrap"
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
timeout = 5
environment {
variables = {
VAULT_ADDR = var.vault_addr
VAULT_AWS_AUTH_ROLE = local.vault_role_name
}
}
}
今回は GO 言語を使用して実装したので、デプロイ前にビルドが必要になります。本来はビルドとデプロイで個別にパイプラインを組むべきですが、簡略化のために手元でビルドしてから terraform apply を実行することにしました。
GOOS=linux GOARCH=amd64 go build -tags lambda.norpc -o bootstrap main.go
OS 専用ランタイムで動かすためのお作法が入っています。詳しくはドキュメントをご覧ください。
または、今回は HCP Terraform を使っているので難しいのですが、ローカルで Terraform を実行する場合は以下のように provisioner を使うのも手かもしれません。
resource "terraform_data" "go_build" {
triggers_replace = {
src = filebase64sha256("src/main.go")
}
provisioner "local-exec" {
working_dir = "src"
command = <<EOT
GOOS=linux GOARCH=amd64 go build -tags lambda.norpc -o bootstrap main.go
EOT
}
}
さて、次に Lambda 関数の実装を見ていきましょう。
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"github.com/aws/aws-lambda-go/lambda"
vault "github.com/hashicorp/vault/api"
auth "github.com/hashicorp/vault/api/auth/aws"
)
func getSecretWithAWSAuthIAM() (string, error) {
config := vault.DefaultConfig()
config.Address = os.Getenv("VAULT_ADDR")
client, err := vault.NewClient(config)
if err != nil {
return "", fmt.Errorf("unable to initialize Vault client: %w", err)
}
awsAuth, err := auth.NewAWSAuth(
auth.WithRole(os.Getenv("VAULT_AWS_AUTH_ROLE")),
)
if err != nil {
return "", fmt.Errorf("unable to initialize AWS auth method: %w", err)
}
authInfo, err := client.Auth().Login(context.Background(), awsAuth)
if err != nil {
return "", fmt.Errorf("unable to login to AWS auth method: %w", err)
}
if authInfo == nil {
return "", fmt.Errorf("no auth info was returned after login")
}
token := authInfo.Auth.ClientToken
fmt.Println("Login succeeded")
return token, nil
}
func init() {}
func handleRequest(ctx context.Context, event json.RawMessage) (string, error) {
token, err := getSecretWithAWSAuthIAM()
if err != nil {
log.Fatal("%w", err)
return "", err
}
return token, nil
}
func main() {
lambda.Start(handleRequest)
}
やけに長いですが重要なのはここだけです。
// 上記で作成したロールを指定
awsAuth, err := auth.NewAWSAuth(
auth.WithRole(os.Getenv("VAULT_AWS_AUTH_ROLE")),
)
// 認証成功時にレスポンスに含まれる Vault トークンを返す
token := authInfo.Auth.ClientToken
fmt.Println("Login succeeded")
return token, nil
Lambda から返された戻り値は変数にアサインし、以降のステートで利用します。
Assign:
VAULT_TOKEN.$: $.Payload
4. Vault にシークレットを登録
このワークフローのメインどころです。これまでに取得してきたシークレットの名前と値、Vault トークンを使って、Vault の API を呼び出します。
まずはステートマシンの定義を見てみましょう。Step Functions には HTTP タスクというノーコードで API を呼び出せる機能があるので、これを使います。
PutSecretToVault:
Next: SecretCreated?
Parameters:
ApiEndpoint.$: >-
States.Format('${vault_addr}/v1/kv/data/{}',$SECRET_NAME)
Headers:
X-Vault-Token.$: $VAULT_TOKEN
InvocationConfig:
ConnectionArn: ${vault_event_conection}
Method: POST
RequestBody:
data.$: States.StringToJson($SECRET_VALUE)
Resource: arn:aws:states:::http:invoke
この定義にはいくつかポイントがあるので解説します。
HTTP タスクを使うためには、EventBridge Connection というリソースを作成して指定する必要があります。これは元々 EventBridge API Destination という機能のために使われていたもので、Step Functions の HTTP タスクでも流用されているのですが、ターゲットとなる API の認可を管理するための仕組みです。
認可方式として Basic 認証、API キー、OAuth に対応しているため、シンプルにやるのであれば API キーの方式で X-Vault-Token ヘッダーを指定すればよいのですが、この値はリソース作成時に指定する必要があるため、今回のように動的に認証して一時トークンを払い出す方式にはマッチしません。
また、都度 Connection リソースを更新して一時トークンをインジェクションする仕組みも考えられますが、複数イベントが同時に発生した際のロックなども考えると手間が増えるため、Connection では値は指定せずに別の方法で受け渡すことにしました(* 値が空だと登録できないためダミーの値を入れています)。
resource "aws_cloudwatch_event_connection" "vault" {
name = "${local.prefix}-vault-server"
description = "A connection to Vault server"
authorization_type = "API_KEY"
auth_parameters {
api_key {
key = "Dummy"
value = "Dummy"
}
}
}
ではどうやって受け渡しているかというと、すでに上に書いていますが Headers でリクエスト時に追加のヘッダーとして付与するだけです。
Headers:
X-Vault-Token.$: $VAULT_TOKEN
しかし、この方法は本来は推奨されません。EventBridge Connection で設定した認証情報は内部的に暗号化されて Secrets Manager でセキュアに管理されるため、本来はそちらを使うべきなのですが、このトークンは TTL が 60 秒と短いですし、デモなので今回は許容しているというだけです。用法用量を守って正しくお使いください。
5. シークレットの削除
Vault にシークレットが登録できたことをレスポンスの StatusCode == 200 で判定したら、最後に Secrets Manager からシークレットを排除します。
DeleteSecret:
Type: Task
Parameters:
SecretId.$: $SECRET_ID
Resource: arn:aws:states:::aws-sdk:secretsmanager:deleteSecret
Next: SecretPurgedFromSecretsManager?
冒頭の getSecret を deleteSecret に変えただけなので、特に目新しいところはないと思います。
まとめ
これでワークフローは完成です!
テストの結果、Secrets Manager にシークレットが登録されてからわずか5秒程度で元々のシークレットは消え去り、Vault からなら取得できるようになりました。
個人的には Vault の...というよりは Step Functions がいかに便利かというような記事になってしまった感は否めないですが、たくさん便利な機能があるので Vault 使ってみてください。
以上、レジデントソリューションアーキテクトの sakuraya がお送りしました。
参考: 今回使用したサンプルコード







