使用 HashiCorp Vault Agent 存取機敏性資料

在現代應用程式開發中,如何安全地管理機敏資料(如資料庫密碼、API 金鑰)是一大挑戰,上篇已經介紹怎麼建立 AppRole、Policy、Secret。這篇文章將介紹如何利用 HashiCorp Vault、HashiCorp Vault Agent,打造一套自動化且安全的機敏資料存取流程。


 

Vault Agent 是 HashiCorp Vault 提供的一個客戶端守護程序(Client Daemon),它的主要功能和特點如下:

核心功能:

  • Auto-Auth(自動認證):自動處理與 Vault 的認證過程,並管理 token 的更新
  • Cache(快取):在本地快取 Vault 響應,減少對 Vault 服務器的請求
  • Template(模板):將 Vault 中的密碼渲染到檔案系統中

Auto-Auth:

  • 自動處理初始認證過程
  • 自動更新認證 token
  • 支援多種認證方法(如 AppRole、AWS、Azure、GCP 等)
  • 確保應用程式始終有有效的認證憑證

Cache 功能:

  • 在本地快取 Vault 響應
  • 減少對 Vault 服務器的直接請求
  • 提高應用程式性能
  • 支援在網路中斷時使用快取的數據

Template 功能:

  • 將 Vault 中的秘密渲染到檔案系統
  • 支援動態更新:當秘密更改時自動更新檔案
  • 支援多種模板格式
  • 可以觸發應用程式重新載入

使用場景:

  • 容器環境(Docker、Kubernetes)
  • 雲端環境(AWS、Azure、GCP)
  • 微服務架構
  • 需要動態秘密管理的應用程式

開發環境

  • Windows 11 Home
  • Windows Terminal 1.20.11781.0
  • Vault 1.17.6
  • Rider 2025.1.2
  • .NET 8

流程圖

這是透過 AI 根據專案產生出來的循序圖,希望可以協助大家理解整個流程

 

建立 Vault Server

vault server -dev

取得 Root Token 

 

Admin

建立機敏性資料

建立的流程如下

  1. 啟用 KV v2 秘密引擎和 AppRole 認證方法
  2. 建立機敏性資料
  3. 建立 Client 的 AppRole 和 Policies
  4. 建立 Admin 的 AppRole 和 Policies
public async Task SetupVaultAsync()
{
    // 啟用 KV v2 秘密引擎和 AppRole 認證方法
    await EnableAppRoleAsync();
    await EnableSecretAsync();

    // 建立機敏性資料
    await SetupSecretAsync();

    // 建立 Client 的 AppRole 和 Policies
    await SetupPolicesAsync(SecretPolicies);
    await SetupAppRoleAsync(SecretPolicies);

    // 建立 Admin 的 AppRole 和 Policies
    await SetupPolicesAsync(AdminPolicies);
    await SetupAppRoleAsync(AdminPolicies);
}

 

Policy

有以下兩個 policy

app-admin policy:用這個 policy 頒發 admin token,admin token 只能有產生 secret id/role id 這兩個行為

Note:避免將 root token 給非管理者使用。

app-dev policy:用這個 policy,讀取 dev/data/db/connection/* 以下所有的資源。

private const string SecretRootPathName = "dev";

public readonly List<Secret> Secrets =
[
    new Secret("Account", "root"),
    new Secret("Password", "123456"),
];

public readonly Dictionary<string, string> AdminPolicies = new()
{
    ["app-admin"] = """
                    # 允許為特定 AppRole 產生 secret id/role id
                    path "auth/approle/role/+/secret-id" {
                      capabilities = ["create", "update"]
                    }

                    path "auth/approle/role/+/role-id" {
                      capabilities = ["read"]
                    }
                    """
};

public readonly Dictionary<string, string> SecretPolicies = new()
{
    ["app-dev"] = $$"""
                    path "{{SecretRootPathName}}/data/db/connection/*" {
                      capabilities = ["read"]
                    }
                    """,
};

 

建立 Policy

private async Task SetupPolicesAsync(Dictionary<string, string> policies)
{
    // 建立政策
    foreach (var (policyName, policyContent) in policies)
    {
        Console.WriteLine($"建立 {policyName} 政策...");
        var writePolicyResult = await _vaultApiClient.WritePolicyAsync(policyName, policyContent);
        Console.WriteLine($"政策 {policyName} 建立完成");
    }
}

 

AppRole

啟用 AppRole

private async Task EnableAppRoleAsync()
{
    try
    {
        Console.WriteLine("啟用 AppRole 認證方法...");
        var enableAuthMethodResult = await _vaultApiClient.EnableAuthMethodAsync("approle");
        Console.WriteLine("AppRole 認證方法啟用完成");
    }
    catch (Exception e)
    {
        Console.WriteLine($"AppRole 可能已經啟用: {e.Message}");
    }
}

 

建立 AppRole

private async Task SetupAppRoleAsync(Dictionary<string, string> policies)
{
    foreach (var (roleName, _) in policies)
    {
        Console.WriteLine($"建立 {roleName} 的 AppRole...");
        var createAppRoleResult = await _vaultApiClient.CreateAppRoleAsync(
            roleName: roleName,
            policies: roleName,
            tokenTtl: "1h",
            tokenMaxTtl: "4h"
        );
        Console.WriteLine($"{roleName} 的 AppRole 建立完成");
    }
}

 

主程序

  1. 建立 Policy、AppRole、Secret Data
  2. 取得 Admin Token
  3. 測試 Admin Token 是否有效
using System.Text.Json.Nodes;
using Lab.HashiCorpVault.Client;

namespace Lab.HashiCorpVault.Admin;

class Program
{
    private static string VaultServer = "http://127.0.0.1:8200";
    private static string VaultRootToken = "你的 Vault Root Token";
    
    static async Task Main(string[] args)
    {
        Console.WriteLine("Setup Vault Starting...");
        Console.Write("Please enter your Vault root token: ");
        string vaultRootToken = Console.ReadLine() ?? VaultRootToken;
        
        if (string.IsNullOrWhiteSpace(vaultRootToken))
        {
            Console.WriteLine("Error: Vault token cannot be empty.");
            return;
        }
        
        var vaultApiClient = new VaultApiClient(new HttpClient
        {
            BaseAddress = new Uri(VaultServer),
        }, vaultRootToken);
        var setup = new VaultAppRoleSetup2(vaultApiClient,vaultRootToken);

        // var setup = new VaultAppRoleSetup(VaultServer, vaultRootToken);
        
        await setup.SetupVaultAsync();
        // 建立管理者 Token
        var adminTokens = await setup.CreateAdminToken();
        
        // 測試使用管理者 Token 取得 app-dev 的 AppRole 認證資訊
        string adminToken = adminTokens["app-admin"];
        var id = await setup.GetAppRoleCredentialsAsync(adminToken, "app-dev");
        
        Console.WriteLine($"Role ID: {id.RoleId}, Secret ID: {id.SecretId}");
        
        // 印出管理者 Token
        string tokenString = string.Join(", ", adminTokens.Select(t => $"{t.Key} token: {t.Value}"));
        Console.WriteLine($"{tokenString}");
        Console.WriteLine("Setup Vault Completed.");
    }
}

 

啟動專案

輸入 Root Token,取得 Admin Token

 

Agent

新增一個 Console 專案,加入 vault-agent-config.hcl、dev-db-config.ctmpl

vault-agent-config.hcl

配置 auto_auth、cache、template,以及 vault agent 的 位置為 0.0.0.0:8100

# Vault Agent 配置
exit_after_auth = false
pid_file = "./vault-agent.pid"

auto_auth {
    method "approle" {
        mount_path = "auth/approle"
        config = {
            role_id_file_path = "role_id"
            secret_id_file_path = "secret_id"
        }
    }

    sink "file" {
        config = {
            path = "vault-token"
        }
    }
}

# 快取配置
cache {
    use_auto_auth_token = true
}

# 範本配置 - 用於獲取實際的秘密
template {
    source      = "dev-db-config.ctmpl"
    destination = "dev-db-config.json"
}

vault {
    address = "http://127.0.0.1:8200"
}

listener "tcp" {
    address = "0.0.0.0:8100"
    tls_disable = true
}

 

dev-db-config.ctmpl

{{ with secret "dev/data/db/connection/identity" }}
{
    "account": "{{ .Data.data.Account }}",
    "password": "{{ .Data.data.Password }}"
}
{{ end }}

 

設定 Vault Agent 主流程

  1. 通過 Admin Token 取得 Role ID 和 Secret ID 認證信息
  2. 將認證信息寫入文件
  3. 啟用 Vault Agent
public async Task SetupVaultAgentAsync()
{
    // 取得 Role ID 和 Secret ID(這步驟通常由管理員執行)
    var (roleId, secretId) = await GetAppRoleCredentials(AppRoleName);
    // 將認證信息寫入文件(實際環境中應該通過安全的方式傳遞)
    await SaveCredentialsForVaultAgent(AppRoleName, roleId, secretId);

    // 啟動 Vault Agent
    await StartVaultAgent();
}

 

ROLE_ID_FILE、SECRET_ID_FILE 這兩個檔案需要對應到 vault-agent-config.hcl

private async Task SaveCredentialsForVaultAgent(string roleName, string roleId, string secretId)
{
    Console.WriteLine("儲存認證資訊供 Vault Agent 使用...");

    // 建立目錄
    var credentialsDir = Path.Combine("vault-credentials", roleName);
    if (!Directory.Exists(credentialsDir))
    {
        Directory.CreateDirectory(credentialsDir);
    }
    credentialsDir = "";
    // 儲存 Role ID 和 Secret ID
    await File.WriteAllTextAsync(Path.Combine(credentialsDir, ROLE_ID_FILE), roleId);
    await File.WriteAllTextAsync(Path.Combine(credentialsDir, SECRET_ID_FILE), secretId);

    Console.WriteLine($"認證資訊已儲存到 {credentialsDir} 目錄");
}

 

對應位置如下圖:

 

透過 Vault CLI 啟動 Vault Agent

private async Task StartVaultAgent()
{
    Console.WriteLine("啟動 Vault Agent...");

    var startInfo = new ProcessStartInfo
    {
        FileName = "vault",
        Arguments = $"agent -config={AGENT_CONFIG_FILE} -log-level=trace", // 使用 trace 級別
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        UseShellExecute = false,
        CreateNoWindow = false,
        WorkingDirectory = Directory.GetCurrentDirectory()
    };

    using var process = new Process { StartInfo = startInfo };

    process.OutputDataReceived += (sender, e) =>
    {
        if (string.IsNullOrEmpty(e.Data) == false)
        {
            Console.WriteLine($"[AGENT] {e.Data}");

            // 特別關注這些關鍵訊息
            if (e.Data.Contains("template") || e.Data.Contains("rendered") ||
                e.Data.Contains("error") || e.Data.Contains("failed"))
            {
                Console.WriteLine($"*** 重要: {e.Data} ***");
            }
        }
    };

    process.ErrorDataReceived += (sender, e) =>
    {
        if (string.IsNullOrEmpty(e.Data) == false)
        {
            Console.WriteLine($"[ERROR] {e.Data}");
        }
    };

    process.Start();
    process.BeginOutputReadLine();
    process.BeginErrorReadLine();

    // 等待更長時間觀察日誌
    Console.WriteLine("觀察 Vault Agent 日誌 5 秒...");
    await Task.Delay(TimeSpan.FromSeconds(5));

    await VerifyVaultAgentFileAsync();
    await VerifyVaultProcessInfoAsync();
}

 

主程序步驟如下

  1. 建立 Vault Agent
  2. 從 vault-token 取得 token
  3. 取得機敏性資料
class Program
{
    private static string VaultServer = "http://127.0.0.1:8200/";
    private static string VaultToken = "你的 token";
    private static string VaultAgentAddress = "http://127.0.0.1:8100/";

    static async Task Main(string[] args)
    {
        Console.WriteLine("Vault Agent Starting...");

        Console.Write("Please enter your Vault token: ");
        string vaultToken = Console.ReadLine() ?? VaultToken;

        if (string.IsNullOrWhiteSpace(vaultToken))
        {
            Console.WriteLine("Error: Vault token cannot be empty.");
            return;
        }

        // 設定 Vault Agent
        var setup = new VaultAgentSetup2(new VaultApiClient(new HttpClient
        {
            BaseAddress = new Uri(VaultServer),
        }, vaultToken));

        await setup.SetupVaultAgentAsync();
        Console.WriteLine("Vault Agent Started.");

        Console.WriteLine("透過 Vault Agent 讀取秘密...");
        var agentToken = await File.ReadAllTextAsync("vault-token");
        var vaultApiClient = new VaultApiClient(new HttpClient
        {
            BaseAddress = new Uri(VaultAgentAddress),

        }, agentToken);
        vaultApiClient.UpdateToken(agentToken);
        var secretResult = await vaultApiClient.GetSecretAsync("dev/data/db/connection/identity");
        var secretData = secretResult["data"]?["data"] as JsonObject;
        if (secretData == null)
        {
            Console.WriteLine("Error: Unable to read secret data.");
            return;
        }
        if (secretData.ContainsKey("Account") && secretData.ContainsKey("Password"))
        {
            Console.WriteLine($"Account: {secretData["Account"]}");
            Console.WriteLine($"Password: {secretData["Password"]}");
            Console.WriteLine("讀取秘密成功");
        }
        else
        {
            Console.WriteLine("Error: Account or Password not found in secret data.");
        }
    }
}

 

啟動專案

輸入 Admin Token

 

除了讀取機敏性資料,我也觀察了一下相關的檔案、進程是不是已經被建立起來,也可以取得 Agent Token

 

Client

#Lab.HashiCorpVault.Client

操作 Vault 的行為,查找官方文件 + AI 就能實現出來,就不再多贅述

範例所有的操作都使用 Vault API/CLI

API

 

using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace Lab.HashiCorpVault.Client;

public class VaultApiClient(HttpClient client, string vaultToken)
{
    private string _vaultToken = vaultToken;

    public void UpdateToken(string token)
    {
        _vaultToken = token;
    }

    public async Task<JsonObject> EnableAuthMethodAsync(string authMethod, string path = null)
    {
        var payload = new
        {
            type = authMethod
        };

        // 如果 path 為 null 或空字串,則使用 authMethod 作為路徑
        string authPath = string.IsNullOrEmpty(path) ? authMethod : path;
    
        return await SendRequestAsync(HttpMethod.Post, $"/v1/sys/auth/{authPath}", payload);
    }

    public async Task<JsonObject> EnableSecretEngineAsync(string engine, string path)
    {
        var payload = new
        {
            type = engine
        };

        return await SendRequestAsync(HttpMethod.Post, $"/v1/sys/mounts/{path}", payload);
    }

    public async Task<JsonObject> WriteSecretAsync(string path, Dictionary<string, string> data)
    {
        var payload = new
        {
            data
        };

        return await SendRequestAsync(HttpMethod.Post, $"/v1/{path}", payload);
    }

    public async Task<JsonObject> WritePolicyAsync(string name, string policy)
    {
        var payload = new
        {
            policy
        };

        return await SendRequestAsync(HttpMethod.Put, $"/v1/sys/policies/acl/{name}", payload);
    }

    public async Task<JsonObject> CreateAppRoleAsync(string roleName, string policies, string tokenTtl, string tokenMaxTtl)
    {
        var payload = new
        {
            token_policies = policies,
            token_ttl = tokenTtl,
            token_max_ttl = tokenMaxTtl
        };

        return await SendRequestAsync(HttpMethod.Post, $"/v1/auth/approle/role/{roleName}", payload);
    }

    public async Task<JsonObject> GetRoleIdAsync(string roleName)
    {
        return await SendRequestAsync(HttpMethod.Get, $"/v1/auth/approle/role/{roleName}/role-id");
    }
    
    public async Task<JsonObject> GetSecretAsync(string path)
    {
        return await SendRequestAsync(HttpMethod.Get, $"/v1/{path}");
    }
    
    public async Task<JsonObject> GenerateSecretIdAsync(string roleName)
    {
        return await SendRequestAsync(HttpMethod.Post, $"/v1/auth/approle/role/{roleName}/secret-id");
    }

    public async Task<JsonObject> CreateTokenAsync(string policy, string ttl)
    {
        var payload = new
        {
            policies = new[] { policy },
            ttl
        };

        return await SendRequestAsync(HttpMethod.Post, "/v1/auth/token/create", payload);
    }

    private async Task<JsonObject> SendRequestAsync(HttpMethod method, string url, object payload = null)
    {
        var request = new HttpRequestMessage(method, url);

        request.Headers.Add("X-Vault-Token", _vaultToken);

        if (payload != null)
        {
            var json = JsonSerializer.Serialize(payload);
            request.Content = new StringContent(json, Encoding.UTF8, "application/json");
        }

        var response = await client.SendAsync(request);
        return await HandleResponseAsync(response);
    }

    private async Task<JsonObject> HandleResponseAsync(HttpResponseMessage response)
    {
        var content = await response.Content.ReadAsStringAsync();

        if (!response.IsSuccessStatusCode)
        {
            throw new Exception($"Vault API request failed with status {response.StatusCode}: {content}");
        }

        if (string.IsNullOrWhiteSpace(content))
        {
            return new JsonObject();
        }

        return JsonNode.Parse(content)?.AsObject() ?? new JsonObject();
    }
}

 

範例位置

https://github.com/yaochangyu/sample.dotblog/blob/b49603fc2d29178fd3a5092fc392919a0b681914/Secrets%20Manager/Lab.HashiCorpVault.Agent/Lab.HashiCorpVault.Client/VaultApiClient.cs

心得

這個範例斷斷續續的花了我一個月的時間,稍微了解了 Vault Agent 的生命週期,但是實務上還是有一些我覺得困惑的點,例如,Admin Token 的狀態傳遞?是要在服務一起動的時候就注入?

Agent 一啟動之後,就會開始在背景執行展期的工作,我嘗試著將 AppRole 的時間由原本的 1h 變成 1m

觀察一下 Agent Token 的狀態

$Env:VAULT_ADDR = "http://127.0.0.1:8200"
vault token lookup hvs.CAESIMKcxFpOsKRtOzPLMiQNulcgkBrrZaV8Abyejg7a-c-OGh4KHGh2cy5IM0RkNk1pNUxBQ01kcFVKcE01d2haNFc

 

可以觀察到,Token 的生命週期不斷的被展期

 

另外,Agent 一旦被關閉,整個流程就要重新來過

範例位置

這個範例使用 Vault CLI/Vault API,文章主要以 API 為主,檔名以 2  結尾的代表用 API。

https://github.com/yaochangyu/sample.dotblog/tree/b49603fc2d29178fd3a5092fc392919a0b681914/Secrets%20Manager/Lab.HashiCorpVault.Agent

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo