@@ -4,6 +4,8 @@ using Microsoft.Extensions.Configuration;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
using ROLAC.API.Services.Logging;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
@@ -13,6 +15,7 @@ public class AuthService : IAuthService
|
||||
private readonly ITokenService _tokenService;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPermissionService _permissions;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly int _refreshTokenExpiryDays;
|
||||
|
||||
public AuthService(
|
||||
@@ -20,12 +23,14 @@ public class AuthService : IAuthService
|
||||
ITokenService tokenService,
|
||||
AppDbContext db,
|
||||
IPermissionService permissions,
|
||||
IAuditLogger audit,
|
||||
IConfiguration config)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_tokenService = tokenService;
|
||||
_db = db;
|
||||
_permissions = permissions;
|
||||
_audit = audit;
|
||||
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
|
||||
}
|
||||
|
||||
@@ -38,13 +43,22 @@ public class AuthService : IAuthService
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(request.Email);
|
||||
if (user is null)
|
||||
{
|
||||
AuditLoginFailed(request.Email, "Unknown email", ipAddress);
|
||||
throw new UnauthorizedAccessException("Invalid credentials.");
|
||||
}
|
||||
|
||||
if (!await _userManager.CheckPasswordAsync(user, request.Password))
|
||||
{
|
||||
AuditLoginFailed(request.Email, "Wrong password", ipAddress, user.Id);
|
||||
throw new UnauthorizedAccessException("Invalid credentials.");
|
||||
}
|
||||
|
||||
if (!user.IsActive)
|
||||
{
|
||||
AuditLoginFailed(request.Email, "Account inactive", ipAddress, user.Id);
|
||||
throw new UnauthorizedAccessException("Account is inactive.");
|
||||
}
|
||||
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
||||
@@ -65,9 +79,22 @@ public class AuthService : IAuthService
|
||||
await _userManager.UpdateAsync(user);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
|
||||
entityName: nameof(AppUser), entityId: user.Id,
|
||||
summary: $"Login succeeded: {user.Email}",
|
||||
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
|
||||
|
||||
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
|
||||
}
|
||||
|
||||
private void AuditLoginFailed(string email, string reason, string? ipAddress, string? userId = null)
|
||||
=> _audit.Write(
|
||||
AuditActions.LoginFailed, AuditCategories.Security, LogLevelEnum.Warning,
|
||||
entityName: nameof(AppUser), entityId: userId,
|
||||
summary: $"Login failed ({reason}): {email}",
|
||||
userId: userId, userEmail: email, ipAddress: ipAddress);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Refresh
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -124,6 +151,11 @@ public class AuthService : IAuthService
|
||||
{
|
||||
token.RevokedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.Logout, AuditCategories.Security, LogLevelEnum.Information,
|
||||
entityName: nameof(AppUser), entityId: token.UserId,
|
||||
summary: "Logout", userId: token.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Disbursement;
|
||||
using ROLAC.API.DTOs.Shared;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
using ROLAC.API.Services.Disbursement;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Storage;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
@@ -15,10 +17,11 @@ public class DisbursementService : IDisbursementService
|
||||
private readonly IHttpContextAccessor _http;
|
||||
private readonly IFileStorage _storage;
|
||||
private readonly ICheckPrintService _print;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public DisbursementService(AppDbContext db, IHttpContextAccessor http,
|
||||
IFileStorage storage, ICheckPrintService print)
|
||||
{ _db = db; _http = http; _storage = storage; _print = print; }
|
||||
IFileStorage storage, ICheckPrintService print, IAuditLogger audit)
|
||||
{ _db = db; _http = http; _storage = storage; _print = print; _audit = audit; }
|
||||
|
||||
// The JWT carries the user id in the "sub" claim (NameClaimType="sub"); NameIdentifier
|
||||
// is absent at runtime. Check NameIdentifier first (tests), then "sub" (real tokens).
|
||||
@@ -157,6 +160,11 @@ public class DisbursementService : IDisbursementService
|
||||
|
||||
result.Created.Add(new IssuedCheckDto
|
||||
{ CheckId = check.Id, CheckNumber = checkNumber, PayeeName = p.PayeeName, Amount = amount });
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.CheckIssued, AuditCategories.Business, LogLevelEnum.Information,
|
||||
entityName: nameof(Check), entityId: check.Id.ToString(),
|
||||
summary: $"Check #{checkNumber} issued to {p.PayeeName} — {amount:C}");
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
@@ -227,6 +235,11 @@ public class DisbursementService : IDisbursementService
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.CheckVoided, AuditCategories.Business, LogLevelEnum.Warning,
|
||||
entityName: nameof(Check), entityId: c.Id.ToString(),
|
||||
summary: $"Check #{c.CheckNumber} voided ({reason})");
|
||||
}
|
||||
|
||||
// ── Receipt e-signature ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -4,6 +4,8 @@ using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.DTOs.Shared;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Storage;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
@@ -13,9 +15,10 @@ public class ExpenseService : IExpenseService
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
private readonly IFileStorage _storage;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage)
|
||||
{ _db = db; _http = http; _storage = storage; }
|
||||
public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage, IAuditLogger audit)
|
||||
{ _db = db; _http = http; _storage = storage; _audit = audit; }
|
||||
|
||||
// The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
|
||||
// so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it),
|
||||
@@ -211,6 +214,11 @@ public class ExpenseService : IExpenseService
|
||||
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot approve from status '{e.Status}'.");
|
||||
e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.ExpenseApproved, AuditCategories.Business, LogLevelEnum.Information,
|
||||
entityName: nameof(Expense), entityId: e.Id.ToString(),
|
||||
summary: $"Expense #{e.Id} approved: {e.Description} — {e.Amount:C}");
|
||||
}
|
||||
|
||||
public async Task RejectAsync(int id, string? reviewNotes)
|
||||
|
||||
@@ -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);
|
||||
@@ -3,6 +3,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
using ROLAC.API.Services.Logging;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
@@ -10,7 +12,9 @@ public class MonthlyStatementService : IMonthlyStatementService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
public MonthlyStatementService(AppDbContext db, IHttpContextAccessor http) { _db = db; _http = http; }
|
||||
private readonly IAuditLogger _audit;
|
||||
public MonthlyStatementService(AppDbContext db, IHttpContextAccessor http, IAuditLogger audit)
|
||||
{ _db = db; _http = http; _audit = audit; }
|
||||
|
||||
// See ExpenseService: the user id lives in the "sub" claim at runtime; NameIdentifier is for tests.
|
||||
private string CurrentUserId =>
|
||||
@@ -66,6 +70,11 @@ public class MonthlyStatementService : IMonthlyStatementService
|
||||
?? throw new KeyNotFoundException($"MonthlyStatement {id} not found.");
|
||||
s.IsFinalized = true; s.FinalizedAt = DateTimeOffset.UtcNow; s.FinalizedBy = CurrentUserId;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.StatementFinalized, AuditCategories.Business, LogLevelEnum.Information,
|
||||
entityName: nameof(MonthlyStatement), entityId: s.Id.ToString(),
|
||||
summary: $"Monthly statement {s.Year}-{s.Month:D2} finalized");
|
||||
}
|
||||
|
||||
private async Task RecomputeAsync(MonthlyStatement s)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
using ROLAC.API.Services.Logging;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
@@ -36,11 +39,19 @@ public class PermissionService : IPermissionService
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly SystemLogQueue _logQueue;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public PermissionService(IServiceScopeFactory scopeFactory, IMemoryCache cache)
|
||||
public PermissionService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IMemoryCache cache,
|
||||
SystemLogQueue logQueue,
|
||||
IHttpContextAccessor http)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_cache = cache;
|
||||
_logQueue = logQueue;
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action)
|
||||
@@ -174,6 +185,24 @@ public class PermissionService : IPermissionService
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
Invalidate();
|
||||
|
||||
// Singleton service can't use the scoped IAuditLogger — enqueue directly.
|
||||
var user = _http.HttpContext?.User;
|
||||
_logQueue.TryEnqueue(new AuditLog
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Level = LogLevelEnum.Warning,
|
||||
Action = AuditActions.PermissionChanged,
|
||||
Category = AuditCategories.Security,
|
||||
EntityName = "Role",
|
||||
EntityId = roleName,
|
||||
Summary = $"Permissions updated for role '{roleName}'",
|
||||
Changes = AuditChangeSerializer.BuildChanges(null, new { Role = roleName, Modules = rows }),
|
||||
UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier) ?? user?.FindFirstValue("sub"),
|
||||
UserEmail = user?.FindFirstValue("email"),
|
||||
IpAddress = _http.HttpContext?.Connection.RemoteIpAddress?.ToString(),
|
||||
CorrelationId = _http.HttpContext?.TraceIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
public void Invalidate() => _cache.Remove(CacheKey);
|
||||
|
||||
@@ -5,6 +5,8 @@ using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Shared;
|
||||
using ROLAC.API.DTOs.Users;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
using ROLAC.API.Services.Logging;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
@@ -12,11 +14,13 @@ public class UserManagementService : IUserManagementService
|
||||
{
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public UserManagementService(UserManager<AppUser> userManager, AppDbContext db)
|
||||
public UserManagementService(UserManager<AppUser> userManager, AppDbContext db, IAuditLogger audit)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_db = db;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
// ── GetPaged ─────────────────────────────────────────────────────────────
|
||||
@@ -154,6 +158,12 @@ public class UserManagementService : IUserManagementService
|
||||
|
||||
await _userManager.AddToRolesAsync(user, request.Roles);
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.RoleChanged, AuditCategories.Security, LogLevelEnum.Warning,
|
||||
entityName: nameof(AppUser), entityId: user.Id,
|
||||
summary: $"User created: {user.Email} with roles [{string.Join(", ", request.Roles)}]",
|
||||
after: new { user.Email, Roles = request.Roles });
|
||||
|
||||
return new CreateUserResult { UserId = user.Id, TempPassword = tempPassword };
|
||||
}
|
||||
|
||||
@@ -182,6 +192,13 @@ public class UserManagementService : IUserManagementService
|
||||
var toAdd = request.Roles.Except(currentRoles).ToList();
|
||||
if (toRemove.Count > 0) await _userManager.RemoveFromRolesAsync(user, toRemove);
|
||||
if (toAdd.Count > 0) await _userManager.AddToRolesAsync(user, toAdd);
|
||||
|
||||
if (toRemove.Count > 0 || toAdd.Count > 0)
|
||||
_audit.Write(
|
||||
AuditActions.RoleChanged, AuditCategories.Security, LogLevelEnum.Warning,
|
||||
entityName: nameof(AppUser), entityId: user.Id,
|
||||
summary: $"Roles changed for {user.Email}",
|
||||
before: new { Roles = currentRoles }, after: new { Roles = request.Roles });
|
||||
}
|
||||
|
||||
// ── Deactivate ───────────────────────────────────────────────────────────
|
||||
@@ -193,6 +210,11 @@ public class UserManagementService : IUserManagementService
|
||||
user.IsActive = false;
|
||||
user.LockoutEnd = DateTimeOffset.MaxValue;
|
||||
await _userManager.UpdateAsync(user);
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.UserDeactivated, AuditCategories.Security, LogLevelEnum.Warning,
|
||||
entityName: nameof(AppUser), entityId: user.Id,
|
||||
summary: $"User deactivated: {user.Email}");
|
||||
}
|
||||
|
||||
// ── ResetPassword ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user