143 lines
4.1 KiB
C#
143 lines
4.1 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace FinalFactory.Rendezvous.Server.Observability;
|
|
|
|
internal sealed class AuditTrail
|
|
{
|
|
private readonly object _gate = new();
|
|
private readonly LinkedList<AuditEntry> _entries = [];
|
|
private readonly AuditOptions _options;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<AuditTrail> _logger;
|
|
private readonly RendezvousTelemetry _telemetry;
|
|
|
|
public AuditTrail(
|
|
IOptions<AuditOptions> options,
|
|
ILogger<AuditTrail> logger,
|
|
RendezvousTelemetry telemetry,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
_telemetry = telemetry;
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
public void Record(
|
|
string actorSubject,
|
|
string action,
|
|
string result,
|
|
string targetKind,
|
|
string targetIdentifier,
|
|
string correlationId)
|
|
{
|
|
DateTimeOffset now = _timeProvider.GetUtcNow();
|
|
AuditEntry entry = new(
|
|
now,
|
|
Fingerprint(actorSubject),
|
|
action,
|
|
result,
|
|
targetKind,
|
|
Fingerprint(targetIdentifier),
|
|
correlationId);
|
|
lock (_gate)
|
|
{
|
|
PurgeExpired(now);
|
|
|
|
while (_entries.Count >= _options.MaxEntries)
|
|
{
|
|
_entries.RemoveFirst();
|
|
}
|
|
|
|
_entries.AddLast(entry);
|
|
}
|
|
|
|
_telemetry.RecordAudit(action, result);
|
|
LogOperatorAction(
|
|
_logger,
|
|
entry.ActorFingerprint,
|
|
action,
|
|
result,
|
|
targetKind,
|
|
entry.TargetFingerprint,
|
|
correlationId);
|
|
}
|
|
|
|
public IReadOnlyDictionary<string, long> GetAggregateCounts()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
PurgeExpired(_timeProvider.GetUtcNow());
|
|
return _entries
|
|
.GroupBy(static entry => $"{entry.Action}:{entry.Result}", StringComparer.Ordinal)
|
|
.ToDictionary(
|
|
static group => group.Key,
|
|
static group => (long)group.Count(),
|
|
StringComparer.Ordinal);
|
|
}
|
|
}
|
|
|
|
internal IReadOnlyList<AuditEntry> GetEntriesForTests()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
PurgeExpired(_timeProvider.GetUtcNow());
|
|
return _entries.ToArray();
|
|
}
|
|
}
|
|
|
|
private void PurgeExpired(DateTimeOffset now)
|
|
{
|
|
DateTimeOffset oldest = now.AddDays(-_options.RetentionDays);
|
|
while (_entries.First is { Value.Timestamp: var timestamp }
|
|
&& timestamp < oldest)
|
|
{
|
|
_entries.RemoveFirst();
|
|
}
|
|
}
|
|
|
|
private static string Fingerprint(string value)
|
|
{
|
|
byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
|
return Convert.ToHexString(digest.AsSpan(0, 12));
|
|
}
|
|
|
|
private static readonly Action<ILogger, string, string, string, string, string, string, Exception?>
|
|
OperatorAction = LoggerMessage.Define<string, string, string, string, string, string>(
|
|
LogLevel.Information,
|
|
new EventId(100, nameof(LogOperatorAction)),
|
|
"Operator audit: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}");
|
|
|
|
private static void LogOperatorAction(
|
|
ILogger logger,
|
|
string actorFingerprint,
|
|
string action,
|
|
string result,
|
|
string targetKind,
|
|
string targetFingerprint,
|
|
string correlationId) => OperatorAction(
|
|
logger,
|
|
actorFingerprint,
|
|
action,
|
|
result,
|
|
targetKind,
|
|
targetFingerprint,
|
|
correlationId,
|
|
null);
|
|
}
|
|
|
|
internal sealed record AuditEntry(
|
|
DateTimeOffset Timestamp,
|
|
string ActorFingerprint,
|
|
string Action,
|
|
string Result,
|
|
string TargetKind,
|
|
string TargetFingerprint,
|
|
string CorrelationId)
|
|
{
|
|
public override string ToString() =>
|
|
$"[AuditEntry {Action}/{Result}; actor and target fingerprinted]";
|
|
}
|