Created
May 6, 2025 20:59
-
-
Save andreimerlescu/c297b773641eb7f564b00a81fdf11c80 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Net.Http; | |
using System.Text; | |
using System.Text.Json; | |
using System.Threading.Tasks; | |
using Amazon.Runtime; | |
using Amazon.Runtime.CredentialManagement; | |
using Amazon.IdentityManagement; // For generating the signature. | |
using Amazon.IdentityManagement.Model; | |
namespace VaultSecretsManager | |
{ | |
/// <summary> | |
/// Represents the configuration needed to connect to HashiCorp Vault. | |
/// </summary> | |
public class VaultConfig | |
{ | |
/// <summary> | |
/// The base URL of the Vault server. For HCP, this is provided by HashiCorp. | |
/// </summary> | |
public string Address { get; set; } | |
/// <summary> | |
/// The path to the KV secrets engine. For example, "secret". | |
/// </summary> | |
public string KvPath { get; set; } = "secret"; | |
/// <summary> | |
/// The version of the KV secrets engine. 1 or 2. Defaults to 2. | |
/// </summary> | |
public int KvVersion { get; set; } = 2; | |
/// <summary> | |
/// The AWS Auth Mount Path. | |
/// </summary> | |
public string AwsAuthMountPath { get; set; } = "aws"; // Default to "aws" | |
/// <summary> | |
/// The role to authenticate against in Vault | |
/// </summary> | |
public string Role { get; set; } | |
} | |
/// <summary> | |
/// Represents a secret retrieved from Vault. | |
/// </summary> | |
public class VaultSecret | |
{ | |
/// <summary> | |
/// Indicates whether the secret was retrieved successfully. | |
/// </summary> | |
public bool Success { get; set; } | |
/// <summary> | |
/// The data of the secret, as a dictionary. Will be empty if Success is false. | |
/// </summary> | |
public Dictionary<string, string> Data { get; set; } = new Dictionary<string, string>(); | |
/// <summary> | |
/// The raw JSON response from Vault. Useful for debugging. | |
/// </summary> | |
public string RawResponse { get; set; } | |
/// <summary> | |
/// An error message, if the secret was not retrieved successfully. | |
/// </summary> | |
public string ErrorMessage { get; set; } | |
} | |
/// <summary> | |
/// A client for interacting with HashiCorp Vault's KV secrets engine. | |
/// </summary> | |
public class VaultClient | |
{ | |
private readonly VaultConfig _config; | |
private readonly HttpClient _httpClient; | |
/// <summary> | |
/// Initializes a new instance of the <see cref="VaultClient"/> class. | |
/// </summary> | |
/// <param name="config">The configuration for connecting to Vault.</param> | |
public VaultClient(VaultConfig config) | |
{ | |
_config = config ?? throw new ArgumentNullException(nameof(config)); | |
if (string.IsNullOrEmpty(_config.Address)) | |
{ | |
throw new ArgumentException("Vault address must be specified.", nameof(config.Address)); | |
} | |
if (string.IsNullOrEmpty(_config.Role)) | |
{ | |
throw new ArgumentException("Vault role must be specified for AWS auth.", nameof(config.Role)); | |
} | |
_httpClient = new HttpClient(); | |
} | |
private async Task<string> GetAWSTokenAsync() | |
{ | |
AWSCredentials credentials = null; | |
// 1. Try to get credentials from the default chain (environment, config, instance profile) | |
try | |
{ | |
credentials = new AWSCredentialsChain().GetAWSCredentials(); | |
} | |
catch (AmazonServiceException e) | |
{ | |
throw new Exception("Error getting AWS credentials from default chain: " + e.Message); | |
} | |
if (credentials == null) | |
{ | |
throw new Exception("No AWS credentials found in the default chain."); | |
} | |
// 2. Get the required information for the GetCallerIdentity request | |
GetCallerIdentityRequest request = new GetCallerIdentityRequest(); | |
GetCallerIdentityResponse response; | |
// 3. Create an IAM client | |
using (var client = new AmazonIdentityManagementServiceClient(credentials)) | |
{ | |
try | |
{ | |
response = await client.GetCallerIdentityAsync(request); | |
} | |
catch (AmazonServiceException e) | |
{ | |
throw new Exception("Error calling GetCallerIdentity: " + e.Message); | |
} | |
} | |
// 4. Construct the request payload for Vault | |
var requestData = new | |
{ | |
role = _config.Role, | |
// Use the output from GetCallerIdentity | |
iam_http_request_method = "POST", // Vault wants the method. | |
iam_request_url = _config.Address + "/v1/auth/" + _config.AwsAuthMountPath + "/login", // Vault wants the full URL. | |
iam_request_body = "" , // Vault wants empty body, | |
iam_request_headers = new Dictionary<string, string> | |
{ | |
{ "X-Vault-AWS-IAM-Server-ID", "vault.server" } // add the header. | |
}, | |
identity = response.Arn | |
}; | |
string jsonData = JsonSerializer.Serialize(requestData); | |
var content = new StringContent(jsonData, Encoding.UTF8, "application/json"); | |
// 5. Send the authentication request to Vault | |
string authUrl = $"{_config.Address}/v1/auth/{_config.AwsAuthMountPath}/login"; | |
HttpResponseMessage authResponse = await _httpClient.PostAsync(authUrl, content); | |
string authResponseContent = await authResponse.Content.ReadAsStringAsync(); | |
if (!authResponse.IsSuccessStatusCode) | |
{ | |
throw new Exception($"Error authenticating with Vault: {authResponse.StatusCode} - {authResponseContent}"); | |
} | |
// 6. Parse the response and extract the client token | |
JsonDocument authDocument = JsonDocument.Parse(authResponseContent); | |
JsonElement authRoot = authDocument.RootElement; | |
if (authRoot.TryGetProperty("auth", out JsonElement authElement) && | |
authElement.TryGetProperty("client_token", out JsonElement clientTokenElement)) | |
{ | |
return clientTokenElement.GetString(); | |
} | |
else | |
{ | |
throw new Exception("Failed to extract client token from Vault authentication response."); | |
} | |
} | |
private async Task EnsureVaultToken() | |
{ | |
if (string.IsNullOrEmpty(_httpClient.DefaultRequestHeaders.Authorization?.Parameter)) | |
{ | |
string token = await GetAWSTokenAsync(); | |
_httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); | |
} | |
} | |
/// <summary> | |
/// Retrieves a secret from Vault. | |
/// </summary> | |
/// <param name="path">The path to the secret, including the customer namespace. For example, "my-customer/path/to/secret".</param> | |
/// <returns>A <see cref="VaultSecret"/> object containing the secret data or an error message.</returns> | |
public async Task<VaultSecret> GetSecretAsync(string path) | |
{ | |
if (string.IsNullOrEmpty(path)) | |
{ | |
throw new ArgumentException("Path to the secret must be specified.", nameof(path)); | |
} | |
await EnsureVaultToken(); // Ensure we have a valid Vault token. | |
string vaultPath; | |
if (_config.KvVersion == 2) | |
{ | |
vaultPath = $"{_config.KvPath}/data/{path}"; | |
} | |
else // KV version 1 | |
{ | |
vaultPath = $"{_config.KvPath}/{path}"; | |
} | |
string url = $"{_config.Address}/v1/{vaultPath}"; | |
try | |
{ | |
HttpResponseMessage response = await _httpClient.GetAsync(url); | |
string rawResponse = await response.Content.ReadAsStringAsync(); // Store raw response | |
if (response.IsSuccessStatusCode) | |
{ | |
JsonDocument document = JsonDocument.Parse(rawResponse); | |
JsonElement root = document.RootElement; | |
if (_config.KvVersion == 2) | |
{ | |
if (root.TryGetProperty("data", out JsonElement dataElement) && dataElement.TryGetProperty("data", out JsonElement actualDataElement)) | |
{ | |
Dictionary<string, string> secretData = new Dictionary<string, string>(); | |
foreach (JsonProperty property in actualDataElement.EnumerateObject()) | |
{ | |
secretData.Add(property.Name, property.Value.GetString()); | |
} | |
return new VaultSecret | |
{ | |
Success = true, | |
Data = secretData, | |
RawResponse = rawResponse | |
}; | |
} | |
else | |
{ | |
return new VaultSecret | |
{ | |
Success = false, | |
ErrorMessage = "Failed to extract data from Vault response (KV v2).", | |
RawResponse = rawResponse | |
}; | |
} | |
} | |
else // KV version 1 | |
{ | |
Dictionary<string, string> secretData = new Dictionary<string, string>(); | |
foreach (JsonProperty property in root.EnumerateObject()) | |
{ | |
if (property.Value.ValueKind == JsonValueKind.Null) | |
continue; | |
secretData.Add(property.Name, property.Value.GetString()); | |
} | |
return new VaultSecret | |
{ | |
Success = true, | |
Data = secretData, | |
RawResponse = rawResponse | |
}; | |
} | |
} | |
else | |
{ | |
// Handle specific error cases based on status code (optional) | |
string errorMessage = $"Error retrieving secret from Vault: {response.StatusCode} - {rawResponse}"; | |
return new VaultSecret | |
{ | |
Success = false, | |
ErrorMessage = errorMessage, | |
RawResponse = rawResponse | |
}; | |
} | |
} | |
catch (Exception ex) | |
{ | |
return new VaultSecret | |
{ | |
Success = false, | |
ErrorMessage = $"Exception: {ex.Message}", | |
RawResponse = null // No response to include, the call failed. | |
}; | |
} | |
} | |
/// <summary> | |
/// Sets (creates or updates) a secret in Vault. | |
/// </summary> | |
/// <param name="path">The path to the secret, including the customer namespace. For example, "my-customer/path/to/secret".</param> | |
/// <param name="data">A dictionary containing the key-value pairs of the secret.</param> | |
/// <returns>A <see cref="VaultSecret"/> object indicating the result of the operation.</returns> | |
public async Task<VaultSecret> SetSecretAsync(string path, Dictionary<string, string> data) | |
{ | |
if (string.IsNullOrEmpty(path)) | |
{ | |
throw new ArgumentException("Path to the secret must be specified.", nameof(path)); | |
} | |
if (data == null || data.Count == 0) | |
{ | |
throw new ArgumentException("Data must contain at least one key-value pair.", nameof(data)); | |
} | |
await EnsureVaultToken(); // Ensure we have a valid Vault token. | |
string vaultPath; | |
string url; | |
StringContent content; | |
if (_config.KvVersion == 2) | |
{ | |
vaultPath = $"{_config.KvPath}/data/{path}"; | |
url = $"{_config.Address}/v1/{vaultPath}"; | |
// Wrap the data in a "data" object as required by KV version 2 | |
var requestData = new { data = data }; | |
string jsonData = JsonSerializer.Serialize(requestData); | |
content = new StringContent(jsonData, Encoding.UTF8, "application/json"); | |
} | |
else // KV version 1 | |
{ | |
vaultPath = $"{_config.KvPath}/{path}"; | |
url = $"{_config.Address}/v1/{vaultPath}"; | |
string jsonData = JsonSerializer.Serialize(data); | |
content = new StringContent(jsonData, Encoding.UTF8, "application/json"); | |
} | |
try | |
{ | |
HttpResponseMessage response = await _httpClient.PostAsync(url, content); | |
string rawResponse = await response.Content.ReadAsStringAsync(); | |
if (response.IsSuccessStatusCode) | |
{ | |
return new VaultSecret | |
{ | |
Success = true, | |
RawResponse = rawResponse | |
}; | |
} | |
else | |
{ | |
string errorMessage = $"Error setting secret in Vault: {response.StatusCode} - {rawResponse}"; | |
return new VaultSecret | |
{ | |
Success = false, | |
ErrorMessage = errorMessage, | |
RawResponse = rawResponse | |
}; | |
} | |
} | |
catch (Exception ex) | |
{ | |
return new VaultSecret | |
{ | |
Success = false, | |
ErrorMessage = $"Exception: {ex.Message}", | |
RawResponse = null | |
}; | |
} | |
} | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment