Add audit logs.
ci-cd-vm / ci-cd (push) Successful in 4m2s

This commit is contained in:
Chris Chen
2026-06-23 12:13:47 -07:00
parent 870eeec82a
commit 62592c29ae
106 changed files with 2522 additions and 311 deletions
@@ -0,0 +1,71 @@
using System.Text.Json;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Serializes audit before/after payloads to JSON and masks sensitive property names.
/// Shared by <see cref="AuditLogger"/> and the EF audit interceptor so masking is consistent.
/// </summary>
public static class AuditChangeSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Property names whose values are replaced with <see cref="MaskValue"/> wherever they appear.</summary>
private static readonly HashSet<string> SensitiveNames = new(StringComparer.OrdinalIgnoreCase)
{
"BankAccountNumber",
"BankRoutingNumber",
"PasswordHash",
"Password",
"SecurityStamp",
"ConcurrencyStamp",
};
public const string MaskValue = "***";
public static bool IsSensitive(string propertyName) => SensitiveNames.Contains(propertyName);
/// <summary>Builds the <c>{ before, after }</c> JSON; returns null when both sides are empty.</summary>
public static string? BuildChanges(object? before, object? after)
{
if (before is null && after is null)
return null;
var payload = new Dictionary<string, object?>();
if (before is not null) payload["before"] = MaskObject(before);
if (after is not null) payload["after"] = MaskObject(after);
return JsonSerializer.Serialize(payload, JsonOptions);
}
/// <summary>Serializes a value (e.g. a property dictionary built by the interceptor) to JSON.</summary>
public static string Serialize(object value) => JsonSerializer.Serialize(value, JsonOptions);
/// <summary>
/// Masks a free-form object by reflecting over its public properties. Used for the explicit
/// IAuditLogger.Write path (the interceptor masks per-property as it builds its dictionary).
/// </summary>
private static object MaskObject(object value)
{
if (value is IDictionary<string, object?> dict)
{
var masked = new Dictionary<string, object?>();
foreach (var (key, val) in dict)
masked[key] = IsSensitive(key) ? MaskValue : val;
return masked;
}
var result = new Dictionary<string, object?>();
foreach (var prop in value.GetType().GetProperties())
{
if (!prop.CanRead || prop.GetIndexParameters().Length > 0)
continue;
result[prop.Name] = IsSensitive(prop.Name) ? MaskValue : prop.GetValue(value);
}
return result;
}
}
@@ -0,0 +1,101 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
public interface IAuditLogQueryService
{
Task<PagedResult<AuditLogListItemDto>> GetPagedAsync(AuditLogQuery query);
Task<AuditLogDetailDto?> GetByIdAsync(long id);
AuditCatalogDto GetCatalog();
}
/// <summary>Read-only, paged access to the AuditLogs table via the dedicated LogDbContext.</summary>
public sealed class AuditLogQueryService : IAuditLogQueryService
{
private readonly LogDbContext _db;
public AuditLogQueryService(LogDbContext db) => _db = db;
public async Task<PagedResult<AuditLogListItemDto>> GetPagedAsync(AuditLogQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var rows = _db.AuditLogs.AsNoTracking().AsQueryable();
if (query.From is not null) rows = rows.Where(l => l.Timestamp >= query.From);
if (query.To is not null) rows = rows.Where(l => l.Timestamp <= query.To);
if (query.MinLevel is not null) rows = rows.Where(l => l.Level >= query.MinLevel);
if (!string.IsNullOrWhiteSpace(query.Category)) rows = rows.Where(l => l.Category == query.Category);
if (!string.IsNullOrWhiteSpace(query.Action)) rows = rows.Where(l => l.Action == query.Action);
if (!string.IsNullOrWhiteSpace(query.EntityName)) rows = rows.Where(l => l.EntityName == query.EntityName);
if (!string.IsNullOrWhiteSpace(query.EntityId)) rows = rows.Where(l => l.EntityId == query.EntityId);
if (!string.IsNullOrWhiteSpace(query.UserId)) rows = rows.Where(l => l.UserId == query.UserId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim().ToLower();
rows = rows.Where(l =>
(l.Summary != null && l.Summary.ToLower().Contains(term)) ||
(l.EntityName != null && l.EntityName.ToLower().Contains(term)) ||
(l.UserEmail != null && l.UserEmail.ToLower().Contains(term)));
}
var total = await rows.CountAsync();
var items = await rows
.OrderByDescending(l => l.Timestamp)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(l => new AuditLogListItemDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Action = l.Action,
Category = l.Category,
EntityName = l.EntityName,
EntityId = l.EntityId,
Summary = l.Summary,
UserId = l.UserId,
UserEmail = l.UserEmail,
})
.ToListAsync();
return new PagedResult<AuditLogListItemDto>
{
Items = items, TotalCount = total, Page = page, PageSize = pageSize,
};
}
public async Task<AuditLogDetailDto?> GetByIdAsync(long id)
{
return await _db.AuditLogs.AsNoTracking()
.Where(l => l.Id == id)
.Select(l => new AuditLogDetailDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Action = l.Action,
Category = l.Category,
EntityName = l.EntityName,
EntityId = l.EntityId,
Summary = l.Summary,
UserId = l.UserId,
UserEmail = l.UserEmail,
Changes = l.Changes,
IpAddress = l.IpAddress,
CorrelationId = l.CorrelationId,
})
.FirstOrDefaultAsync();
}
public AuditCatalogDto GetCatalog() => new()
{
Categories = AuditCategories.All,
Actions = AuditActions.All,
Levels = Enum.GetNames<LogLevelEnum>(),
};
}
@@ -0,0 +1,52 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Scoped <see cref="IAuditLogger"/>: fills actor/request context from
/// <see cref="CurrentUserAccessor"/> and enqueues the row onto the shared queue (no direct DB
/// write, so it can't fail a business transaction or recurse through AppDbContext).
/// </summary>
public sealed class AuditLogger : IAuditLogger
{
private readonly SystemLogQueue _queue;
private readonly CurrentUserAccessor _currentUser;
public AuditLogger(SystemLogQueue queue, CurrentUserAccessor currentUser)
{
_queue = queue;
_currentUser = currentUser;
}
public void Write(
string action,
string category,
LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null,
string? entityId = null,
string? summary = null,
object? before = null,
object? after = null,
string? userId = null,
string? userEmail = null,
string? ipAddress = null)
{
var log = new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = level,
Action = action,
Category = category,
EntityName = entityName,
EntityId = entityId,
Summary = summary,
Changes = AuditChangeSerializer.BuildChanges(before, after),
UserId = userId ?? _currentUser.UserId,
UserEmail = userEmail ?? _currentUser.Email,
IpAddress = ipAddress ?? _currentUser.IpAddress,
CorrelationId = _currentUser.CorrelationId,
};
_queue.TryEnqueue(log);
}
}
@@ -0,0 +1,30 @@
using System.Security.Claims;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// One place to resolve the acting user + request context from the current HttpContext, so the
/// "sub" claim quirk (JWT uses NameClaimType="sub" + MapInboundClaims=false, leaving
/// ClaimTypes.NameIdentifier null) lives in a single spot. Used by the audit interceptor,
/// IAuditLogger, the exception middleware, and the timestamp-stamping interceptor.
/// </summary>
public sealed class CurrentUserAccessor
{
private readonly IHttpContextAccessor _http;
public CurrentUserAccessor(IHttpContextAccessor http) => _http = http;
/// <summary>The acting user id, or null when unauthenticated / off the request thread.</summary>
public string? UserId =>
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? _http.HttpContext?.User.FindFirstValue("sub");
/// <summary>The acting user id, or "system" for background/unauthenticated work.</summary>
public string UserIdOrSystem => UserId ?? "system";
public string? Email => _http.HttpContext?.User.FindFirstValue("email");
public string? IpAddress => _http.HttpContext?.Connection.RemoteIpAddress?.ToString();
public string? CorrelationId => _http.HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,27 @@
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Bound from configuration section <c>Logging:Database</c>. Controls what the DB sink persists.
/// </summary>
public sealed class DatabaseLoggerOptions
{
/// <summary>The minimum level actually written to the SystemLogs table. Default: Warning.</summary>
public MsLogLevel MinimumLevel { get; set; } = MsLogLevel.Warning;
/// <summary>
/// Category prefixes never persisted — prevents recursion and log-storms. The DB sink
/// excludes EF/Npgsql (their SQL firehose), the ASP.NET request firehose, and its own
/// writer namespace.
/// </summary>
public string[] ExcludedCategories { get; set; } =
[
"Microsoft.EntityFrameworkCore",
"Npgsql",
"Microsoft.AspNetCore.Hosting.Diagnostics",
"Microsoft.AspNetCore.Routing",
"ROLAC.API.Services.Logging",
"ROLAC.API.Data.Logging",
];
}
@@ -0,0 +1,87 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using ROLAC.API.Entities.Logging;
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// A singleton <see cref="ILoggerProvider"/> that turns framework/app log events into
/// <see cref="SystemLog"/> rows enqueued onto <see cref="SystemLogQueue"/>. It depends only on
/// singletons (queue, options, IHttpContextAccessor) and NEVER touches a DbContext — that is
/// what makes the enqueue-only design safe from the singleton logging pipeline.
/// </summary>
[ProviderAlias("Database")]
public sealed class DbLoggerProvider : ILoggerProvider
{
private readonly SystemLogQueue _queue;
private readonly DatabaseLoggerOptions _options;
private readonly IHttpContextAccessor _http;
private readonly ConcurrentDictionary<string, DbLogger> _loggers = new();
public DbLoggerProvider(
SystemLogQueue queue, IOptions<DatabaseLoggerOptions> options, IHttpContextAccessor http)
{
_queue = queue;
_options = options.Value;
_http = http;
}
public ILogger CreateLogger(string categoryName) =>
_loggers.GetOrAdd(categoryName, name => new DbLogger(name, _queue, _options, _http));
public void Dispose() => _loggers.Clear();
}
/// <summary>The per-category logger. Drops events below the floor or in excluded categories.</summary>
internal sealed class DbLogger : ILogger
{
private readonly string _category;
private readonly SystemLogQueue _queue;
private readonly DatabaseLoggerOptions _options;
private readonly IHttpContextAccessor _http;
private readonly bool _excluded;
public DbLogger(
string category, SystemLogQueue queue, DatabaseLoggerOptions options, IHttpContextAccessor http)
{
_category = category;
_queue = queue;
_options = options;
_http = http;
_excluded = options.ExcludedCategories.Any(prefix =>
category.StartsWith(prefix, StringComparison.Ordinal));
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(MsLogLevel logLevel) =>
!_excluded && logLevel != MsLogLevel.None && logLevel >= _options.MinimumLevel;
public void Log<TState>(
MsLogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
var context = _http.HttpContext;
var log = new SystemLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelMap.FromMs(logLevel),
Category = _category,
EventId = eventId.Id == 0 ? null : eventId.Id,
Message = formatter(state, exception),
Exception = exception?.ToString(),
RequestPath = context?.Request.Path.Value,
HttpMethod = context?.Request.Method,
UserId = context?.User.FindFirst("sub")?.Value,
IpAddress = context?.Connection.RemoteIpAddress?.ToString(),
CorrelationId = context?.TraceIdentifier,
};
_queue.TryEnqueue(log);
}
}
@@ -0,0 +1,29 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Records audit events that don't flow through EF change tracking — security actions
/// (login/logout/role changes) and key business actions (check issued, expense approved, ...).
/// Data-change audits are produced automatically by <c>AuditLogInterceptor</c>; use this for the
/// semantic action + human summary the raw diff can't express.
/// </summary>
public interface IAuditLogger
{
/// <summary>
/// Build and enqueue an audit row. <paramref name="before"/>/<paramref name="after"/> are
/// serialized into the <c>Changes</c> JSON. Never throws — failures are dropped like all logs.
/// </summary>
void Write(
string action,
string category,
LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null,
string? entityId = null,
string? summary = null,
object? before = null,
object? after = null,
string? userId = null,
string? userEmail = null,
string? ipAddress = null);
}
@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// The single consumer that drains <see cref="SystemLogQueue"/> and batch-inserts rows through
/// the dedicated <see cref="LogDbContext"/> (a fresh DI scope per batch). Persistence failures
/// are swallowed to <c>Console.Error</c> only — they must never propagate back into the logging
/// pipeline or crash the host.
/// </summary>
public sealed class LogWriterBackgroundService : BackgroundService
{
private const int MaxBatchSize = 200;
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(1);
private readonly SystemLogQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
public LogWriterBackgroundService(SystemLogQueue queue, IServiceScopeFactory scopeFactory)
{
_queue = queue;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var systemBatch = new List<SystemLog>(MaxBatchSize);
var auditBatch = new List<AuditLog>(MaxBatchSize);
try
{
await foreach (var envelope in _queue.ReadAllAsync(stoppingToken))
{
if (envelope.System is not null) systemBatch.Add(envelope.System);
if (envelope.Audit is not null) auditBatch.Add(envelope.Audit);
// Coalesce a short burst into one round-trip; flush on size or a brief idle.
if (systemBatch.Count + auditBatch.Count >= MaxBatchSize)
{
await FlushAsync(systemBatch, auditBatch, stoppingToken);
continue;
}
if (!await WaitForMoreAsync(FlushInterval, stoppingToken))
await FlushAsync(systemBatch, auditBatch, stoppingToken);
}
}
catch (OperationCanceledException)
{
// Shutting down — drain whatever is buffered.
}
await FlushAsync(systemBatch, auditBatch, CancellationToken.None);
}
/// <summary>Brief debounce so bursts coalesce; returns false once the window elapses.</summary>
private static async Task<bool> WaitForMoreAsync(TimeSpan window, CancellationToken token)
{
try
{
await Task.Delay(window, token);
return false;
}
catch (OperationCanceledException)
{
return false;
}
}
private async Task FlushAsync(
List<SystemLog> systemBatch, List<AuditLog> auditBatch, CancellationToken token)
{
if (systemBatch.Count == 0 && auditBatch.Count == 0)
return;
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<LogDbContext>();
if (systemBatch.Count > 0) db.SystemLogs.AddRange(systemBatch);
if (auditBatch.Count > 0) db.AuditLogs.AddRange(auditBatch);
await db.SaveChangesAsync(token);
}
catch (Exception ex)
{
// Last resort: never throw out of the log writer. Include the inner exception —
// the EF wrapper message alone ("An error occurred while saving...") hides the cause.
var detail = ex.InnerException is null ? ex.Message : $"{ex.Message} -> {ex.InnerException.Message}";
await Console.Error.WriteLineAsync(
$"[LogWriter] Failed to persist {systemBatch.Count} system + {auditBatch.Count} audit rows: {detail}");
}
finally
{
systemBatch.Clear();
auditBatch.Clear();
}
}
}
@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
public interface ISystemLogQueryService
{
Task<PagedResult<SystemLogListItemDto>> GetPagedAsync(SystemLogQuery query);
Task<SystemLogDetailDto?> GetByIdAsync(long id);
}
/// <summary>Read-only, paged access to the SystemLogs table via the dedicated LogDbContext.</summary>
public sealed class SystemLogQueryService : ISystemLogQueryService
{
private readonly LogDbContext _db;
public SystemLogQueryService(LogDbContext db) => _db = db;
public async Task<PagedResult<SystemLogListItemDto>> GetPagedAsync(SystemLogQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var rows = _db.SystemLogs.AsNoTracking().AsQueryable();
if (query.From is not null) rows = rows.Where(l => l.Timestamp >= query.From);
if (query.To is not null) rows = rows.Where(l => l.Timestamp <= query.To);
if (query.Level is not null) rows = rows.Where(l => l.Level == query.Level);
else if (query.MinLevel is not null) rows = rows.Where(l => l.Level >= query.MinLevel);
if (!string.IsNullOrWhiteSpace(query.UserId))
rows = rows.Where(l => l.UserId == query.UserId);
if (!string.IsNullOrWhiteSpace(query.CorrelationId))
rows = rows.Where(l => l.CorrelationId == query.CorrelationId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim().ToLower();
rows = rows.Where(l =>
l.Message.ToLower().Contains(term) || l.Category.ToLower().Contains(term));
}
var total = await rows.CountAsync();
var items = await rows
.OrderByDescending(l => l.Timestamp)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(l => new SystemLogListItemDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Category = l.Category,
Message = l.Message,
HasException = l.Exception != null,
StatusCode = l.StatusCode,
RequestPath = l.RequestPath,
HttpMethod = l.HttpMethod,
UserId = l.UserId,
CorrelationId = l.CorrelationId,
})
.ToListAsync();
return new PagedResult<SystemLogListItemDto>
{
Items = items, TotalCount = total, Page = page, PageSize = pageSize,
};
}
public async Task<SystemLogDetailDto?> GetByIdAsync(long id)
{
return await _db.SystemLogs.AsNoTracking()
.Where(l => l.Id == id)
.Select(l => new SystemLogDetailDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Category = l.Category,
Message = l.Message,
HasException = l.Exception != null,
StatusCode = l.StatusCode,
RequestPath = l.RequestPath,
HttpMethod = l.HttpMethod,
UserId = l.UserId,
CorrelationId = l.CorrelationId,
EventId = l.EventId,
Exception = l.Exception,
IpAddress = l.IpAddress,
})
.FirstOrDefaultAsync();
}
}
@@ -0,0 +1,32 @@
using System.Threading.Channels;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// A singleton, bounded in-memory queue decoupling log producers (the ILogger hot path, the
/// audit interceptor, singleton services) from the single background DB writer. Enqueue is a
/// non-blocking <c>TryWrite</c>; when full the OLDEST entry is dropped (DropWrite) rather than
/// blocking a request thread or throwing — logging must never throw or stall business code.
/// Carries both SystemLog and AuditLog rows via a small union envelope.
/// </summary>
public sealed class SystemLogQueue
{
private readonly Channel<LogEnvelope> _channel =
Channel.CreateBounded<LogEnvelope>(new BoundedChannelOptions(capacity: 4096)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false,
});
public bool TryEnqueue(SystemLog log) => _channel.Writer.TryWrite(new LogEnvelope(log, null));
public bool TryEnqueue(AuditLog log) => _channel.Writer.TryWrite(new LogEnvelope(null, log));
public IAsyncEnumerable<LogEnvelope> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
/// <summary>Either a SystemLog or an AuditLog — exactly one is non-null.</summary>
public sealed record LogEnvelope(SystemLog? System, AuditLog? Audit);