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 }; } }