93 lines
3.5 KiB
C#
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;
|
|
}
|
|
}
|