shoc-backend/Api.SeaHavenIndustries/Infrastructure/AwsWorkOrderWebhookSecretProvider.cs
2026-07-24 21:03:50 -03:00

117 lines
4.7 KiB
C#

using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Infrastructure
{
public sealed class AwsWorkOrderWebhookSecretProvider : IWorkOrderWebhookSecretProvider
{
private readonly IAmazonSecretsManager _client;
private readonly IOptionsMonitor<WorkOrderWebhookOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AwsWorkOrderWebhookSecretProvider> _logger;
private readonly ConcurrentDictionary<string, CacheEntry> _cache =
new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks =
new(StringComparer.Ordinal);
public AwsWorkOrderWebhookSecretProvider(
IAmazonSecretsManager client,
IOptionsMonitor<WorkOrderWebhookOptions> options,
TimeProvider timeProvider,
ILogger<AwsWorkOrderWebhookSecretProvider> logger)
{
_client = client;
_options = options;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<WorkOrderWebhookSecretResult> GetSecretAsync(
string keyId,
CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
if (!options.KeySecrets.TryGetValue(keyId, out var secretArn)
|| string.IsNullOrWhiteSpace(secretArn))
{
if (_cache.TryRemove(keyId, out var removed))
CryptographicOperations.ZeroMemory(removed.Secret);
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey);
}
var now = _timeProvider.GetUtcNow();
if (_cache.TryGetValue(keyId, out var cached)
&& cached.SecretArn == secretArn
&& cached.ExpiresAt > now)
return FoundCopy(cached.Secret);
var keyLock = _locks.GetOrAdd(keyId, _ => new SemaphoreSlim(1, 1));
await keyLock.WaitAsync(cancellationToken);
try
{
now = _timeProvider.GetUtcNow();
if (_cache.TryGetValue(keyId, out cached)
&& cached.SecretArn == secretArn
&& cached.ExpiresAt > now)
return FoundCopy(cached.Secret);
var response = await _client.GetSecretValueAsync(
new GetSecretValueRequest { SecretId = secretArn },
cancellationToken);
var secret = ReadSecret(response);
if (secret.Length == 0)
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.Unavailable);
var ttl = TimeSpan.FromSeconds(Math.Clamp(options.SecretCacheSeconds, 1, 3600));
var replacement = new CacheEntry(secretArn, secret, now.Add(ttl));
_cache.AddOrUpdate(
keyId,
replacement,
(_, prior) =>
{
CryptographicOperations.ZeroMemory(prior.Secret);
return replacement;
});
return FoundCopy(secret);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
// AWS exceptions can contain the configured secret ARN. Keep
// diagnostics intentionally generic for this secret-bearing path.
_logger.LogError("Work-order webhook signing secret retrieval failed.");
return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.Unavailable);
}
finally
{
keyLock.Release();
}
}
private static byte[] ReadSecret(GetSecretValueResponse response)
{
if (!string.IsNullOrEmpty(response.SecretString))
return Encoding.UTF8.GetBytes(response.SecretString);
return response.SecretBinary?.ToArray() ?? Array.Empty<byte>();
}
private static WorkOrderWebhookSecretResult FoundCopy(byte[] secret) =>
new(WorkOrderWebhookSecretStatus.Found, (byte[])secret.Clone());
private sealed record CacheEntry(
string SecretArn,
byte[] Secret,
DateTimeOffset ExpiresAt);
}
}