w4c-workflows-api/Services/Audit/OpenSearchActionAuditSink.cs
2026-09-12 01:02:46 +03:00

93 lines
3.5 KiB
C#

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace w4c_workflows.Services.Audit;
/// <summary>
/// Writes audit entries to OpenSearch as one document per action. The index is
/// per tenant and month (<c>w4c-actions-{tenant}-{yyyy.MM}</c>), so it matches
/// the <c>w4c-actions-*</c> pattern while staying small and easy to rotate.
/// Writes are best-effort: any failure is logged and swallowed.
/// </summary>
public sealed class OpenSearchActionAuditSink : IActionAuditSink
{
/// <summary>Named <see cref="IHttpClientFactory"/> client used for audit writes.</summary>
public const string ClientName = "action-audit";
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private static readonly Regex InvalidIndexChars = new("[^a-z0-9._-]", RegexOptions.Compiled);
private readonly IHttpClientFactory _http;
private readonly ActionAuditOptions _options;
private readonly ILogger<OpenSearchActionAuditSink>? _logger;
public OpenSearchActionAuditSink(
IHttpClientFactory http,
ActionAuditOptions options,
ILogger<OpenSearchActionAuditSink>? logger = null)
{
_http = http;
_options = options;
_logger = logger;
}
public async Task RecordAsync(ActionAuditEntry entry, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_options.Url))
return;
try
{
var index = IndexName(_options.IndexPrefix, entry.TenantId, entry.Timestamp);
using var request = BuildRequest(index, entry);
using var response = await _http.CreateClient(ClientName).SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
_logger?.LogWarning(
"Audit sink returned {Status} writing to {Index}",
(int)response.StatusCode, index);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// Auditing must never fail a run.
_logger?.LogWarning(ex, "Audit entry for tenant {Tenant} could not be written", entry.TenantId);
}
}
/// <summary>Builds the monthly, tenant-scoped index name; invalid characters are replaced.</summary>
public static string IndexName(string prefix, string tenant, DateTime timestamp)
{
var safePrefix = string.IsNullOrWhiteSpace(prefix) ? "w4c-actions" : prefix.Trim();
var safeTenant = InvalidIndexChars.Replace(tenant.ToLowerInvariant(), "-").Trim('-');
if (safeTenant.Length == 0)
safeTenant = "unknown";
return $"{safePrefix}-{safeTenant}-{timestamp:yyyy.MM}";
}
private HttpRequestMessage BuildRequest(string index, ActionAuditEntry entry)
{
var baseUri = new Uri(_options.Url.EndsWith('/') ? _options.Url : _options.Url + "/");
var request = new HttpRequestMessage(
HttpMethod.Post,
new Uri(baseUri, $"{index}/_doc"))
{
Content = new StringContent(JsonSerializer.Serialize(entry, Json), Encoding.UTF8, "application/json"),
};
if (!string.IsNullOrWhiteSpace(_options.Username))
{
var basic = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_options.Username}:{_options.Password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);
}
return request;
}
}