using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GB5Shared.GB5Exception;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using VaultSharp;
using VaultSharp.Core;
namespace GB5Shared.Vault
{
///
/// Default implementation — KV v2 secrets, in-process caching,
/// and Polly-backed retry for transient Vault failures.
///
/// Thread-safe: and are both safe for
/// concurrent use, and this class holds no other mutable state, so it is registered as a singleton.
///
/// Secret values are never logged — only the vault path/key.
///
public sealed class VaultService : IVaultService
{
/// Fixed field name a secret's scalar value is stored under in the KV v2 payload.
private const string ValueField = "value";
private const string CacheKeyPrefix = "GB5Shared.Vault:";
private readonly IVaultClient _vault;
private readonly IMemoryCache _cache;
private readonly ILogger _logger;
private readonly ResiliencePipeline _resiliencePipeline;
private readonly VaultOptions _options;
public VaultService(
IVaultClient vault,
IMemoryCache cache,
ILogger logger,
ResiliencePipeline resiliencePipeline,
IOptions options)
{
_vault = vault;
_cache = cache;
_logger = logger;
_resiliencePipeline = resiliencePipeline;
_options = options.Value;
}
///
public async Task GetSecretAsync(string key, CancellationToken ct = default)
{
var cacheKey = CacheKeyPrefix + key;
if (_cache.TryGetValue(cacheKey, out string? cached) && cached is not null)
return cached;
_logger.LogDebug("Vault cache miss for path {VaultPath} — fetching from Vault", key);
try
{
var secret = await _resiliencePipeline.ExecuteAsync(
async innerCt => await _vault.V1.Secrets.KeyValue.V2
.ReadSecretAsync(path: key, mountPoint: _options.MountPoint)
.ConfigureAwait(false),
ct).ConfigureAwait(false);
var data = secret?.Data?.Data;
var value = data is not null && data.TryGetValue(ValueField, out var fieldValue) && fieldValue is not null
? fieldValue.ToString()
: data?.Values.FirstOrDefault()?.ToString();
if (string.IsNullOrEmpty(value))
throw new VaultOperationException($"Vault secret at path '{key}' returned empty or null data.");
_cache.Set(cacheKey, value, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromSeconds(_options.CacheTtlSeconds)
});
return value;
}
catch (VaultOperationException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to read Vault secret at path {VaultPath}", key);
throw new VaultOperationException(
$"Unable to read the secret for key '{key}' from Vault: {DescribeVaultFailure(ex)}", ex);
}
}
///
public async Task SetSecretAsync(string key, string value, CancellationToken ct = default)
{
try
{
var data = new Dictionary { [ValueField] = value };
await _resiliencePipeline.ExecuteAsync(
async innerCt => await _vault.V1.Secrets.KeyValue.V2
.WriteSecretAsync(path: key, data: data, mountPoint: _options.MountPoint)
.ConfigureAwait(false),
ct).ConfigureAwait(false);
_cache.Set(CacheKeyPrefix + key, value, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromSeconds(_options.CacheTtlSeconds)
});
_logger.LogDebug("Vault secret written at path {VaultPath}", key);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write Vault secret at path {VaultPath}", key);
throw new VaultOperationException(
$"Unable to write the secret for key '{key}' to Vault: {DescribeVaultFailure(ex)}", ex);
}
}
///
/// Renders a caller-facing description of why a Vault call failed. For
/// this surfaces Vault's own error list (e.g.
/// "permission denied", "invalid token") and HTTP status — never the secret value or
/// the configured token itself, only Vault's diagnostic text — so callers (and whoever
/// is debugging a bad token/policy) get the real reason instead of a generic message.
///
private static string DescribeVaultFailure(Exception ex) => ex switch
{
VaultApiException vaultEx when vaultEx.ApiErrors is not null && vaultEx.ApiErrors.Any() =>
$"{(int)vaultEx.HttpStatusCode} {vaultEx.HttpStatusCode} — {string.Join("; ", vaultEx.ApiErrors)}",
VaultApiException vaultEx =>
$"{(int)vaultEx.HttpStatusCode} {vaultEx.HttpStatusCode} — {vaultEx.Message}",
_ => ex.Message
};
}
}