diff --git a/API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs b/API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs index 4ed3f1e..5468373 100644 --- a/API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs +++ b/API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs @@ -26,7 +26,7 @@ public class AuditInterceptorTests var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; var mock = new Mock(); mock.Setup(x => x.HttpContext).Returns(ctx); - return new AuditSaveChangesInterceptor(mock.Object); + return new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object)); } [Fact] diff --git a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs index 9f027f8..1c147cb 100644 --- a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs @@ -86,7 +86,8 @@ public class AuthServiceTests Mock> umMock, Mock tsMock, AppDbContext db) - => new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object, BuildConfig()); + => new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object, + ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance, BuildConfig()); // ----------------------------------------------------------------------- // Login tests diff --git a/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs b/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs index ad54383..643acca 100644 --- a/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs @@ -49,7 +49,7 @@ public class DisbursementServiceTests return new AppDbContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) - .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); + .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options); } private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId) @@ -57,7 +57,7 @@ public class DisbursementServiceTests var http = new Mock(); var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; http.Setup(x => x.HttpContext).Returns(ctx); - return new DisbursementService(db, http.Object, fs, new FakePrint()); + return new DisbursementService(db, http.Object, fs, new FakePrint(), ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); } private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin") @@ -203,7 +203,7 @@ public class DisbursementServiceTests var http = new Mock(); var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; http.Setup(x => x.HttpContext).Returns(ctx); - return (new DisbursementService(db, http.Object, fs, print), db, fs, print); + return (new DisbursementService(db, http.Object, fs, print, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance), db, fs, print); } [Fact] diff --git a/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs index a77025c..afea594 100644 --- a/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs @@ -20,7 +20,7 @@ public class ExpenseCategoryServiceTests mock.Setup(x => x.HttpContext).Returns(ctx); return new AppDbContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); + .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options); } [Fact] diff --git a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs index 2ed6867..a91d059 100644 --- a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs @@ -33,7 +33,7 @@ public class ExpenseServiceTests mock.Setup(x => x.HttpContext).Returns(ctx); return new AppDbContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); + .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options); } private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1") @@ -52,7 +52,7 @@ public class ExpenseServiceTests var http = new Mock(); var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; http.Setup(x => x.HttpContext).Returns(ctx); - return new ExpenseService(db, http.Object, fs); + return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); } // Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier), @@ -62,7 +62,7 @@ public class ExpenseServiceTests var http = new Mock(); var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) }; http.Setup(x => x.HttpContext).Returns(ctx); - return new ExpenseService(db, http.Object, fs); + return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); } private static CreateExpenseRequest Reimb() => new() diff --git a/API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs b/API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs index b8c8ba7..9c8fdc4 100644 --- a/API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs @@ -23,7 +23,7 @@ public class GivingCategoryServiceTests private static AppDbContext BuildDb(string userId = "test-user") { - var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); + var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId))); return new AppDbContext( new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) diff --git a/API/ROLAC.API.Tests/Services/GivingServiceTests.cs b/API/ROLAC.API.Tests/Services/GivingServiceTests.cs index b540fa3..be925ba 100644 --- a/API/ROLAC.API.Tests/Services/GivingServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/GivingServiceTests.cs @@ -24,7 +24,7 @@ public class GivingServiceTests private static AppDbContext BuildDb(string userId = "test-user") { - var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); + var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId))); return new AppDbContext( new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) diff --git a/API/ROLAC.API.Tests/Services/MemberServiceTests.cs b/API/ROLAC.API.Tests/Services/MemberServiceTests.cs index c47a4f4..ae0f72c 100644 --- a/API/ROLAC.API.Tests/Services/MemberServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/MemberServiceTests.cs @@ -29,7 +29,7 @@ public class MemberServiceTests private static AppDbContext BuildDb(string userId = "test-user") { var accessor = BuildAccessor(userId); - var interceptor = new AuditSaveChangesInterceptor(accessor); + var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessor)); return new AppDbContext( new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) diff --git a/API/ROLAC.API.Tests/Services/MinistryServiceTests.cs b/API/ROLAC.API.Tests/Services/MinistryServiceTests.cs index 184f686..bb5ef1e 100644 --- a/API/ROLAC.API.Tests/Services/MinistryServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/MinistryServiceTests.cs @@ -20,7 +20,7 @@ public class MinistryServiceTests mock.Setup(x => x.HttpContext).Returns(ctx); return new AppDbContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); + .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options); } [Fact] diff --git a/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs b/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs index 1fa5b34..93a288f 100644 --- a/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs @@ -21,7 +21,7 @@ public class MonthlyStatementServiceTests mock.Setup(x => x.HttpContext).Returns(ctx); return new AppDbContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); + .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options); } private static MonthlyStatementService Build(AppDbContext db) @@ -29,7 +29,7 @@ public class MonthlyStatementServiceTests var mock = new Mock(); var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) }; mock.Setup(x => x.HttpContext).Returns(ctx); - return new MonthlyStatementService(db, mock.Object); + return new MonthlyStatementService(db, mock.Object, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); } [Fact] diff --git a/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs b/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs index 24ef989..394616c 100644 --- a/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs @@ -36,7 +36,7 @@ public class OfferingSessionServiceTests private static AppDbContext BuildDb(string userId = "test-user") { - var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); + var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId))); return new AppDbContext( new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) diff --git a/API/ROLAC.API.Tests/Services/PermissionServiceTests.cs b/API/ROLAC.API.Tests/Services/PermissionServiceTests.cs index dd6d115..45e4277 100644 --- a/API/ROLAC.API.Tests/Services/PermissionServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/PermissionServiceTests.cs @@ -49,7 +49,9 @@ public class PermissionServiceTests return new Harness { Provider = provider, - Service = new PermissionService(scopeFactory, cache), + Service = new PermissionService(scopeFactory, cache, + new ROLAC.API.Services.Logging.SystemLogQueue(), + new Microsoft.AspNetCore.Http.HttpContextAccessor()), }; } diff --git a/API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs b/API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs index 561504a..d679cba 100644 --- a/API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/UserManagementServiceTests.cs @@ -76,7 +76,7 @@ public class UserManagementServiceTests mgr.Setup(m => m.Users) .Returns(new List().AsQueryable()); - var svc = new UserManagementService(mgr.Object, db); + var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); var result = await svc.CreateAsync(new CreateUserRequest { MemberId = member.Id, @@ -97,7 +97,7 @@ public class UserManagementServiceTests var mgr = BuildUserManager(); mgr.Setup(m => m.Users) .Returns(new List().AsQueryable()); - var svc = new UserManagementService(mgr.Object, db); + var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); await Assert.ThrowsAsync(() => svc.CreateAsync(new CreateUserRequest @@ -131,7 +131,7 @@ public class UserManagementServiceTests // The service checks _userManager.Users — we need to return the existing user mgr.Setup(m => m.Users) .Returns(new List { existingUser }.AsQueryable()); - var svc = new UserManagementService(mgr.Object, db); + var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); await Assert.ThrowsAsync(() => svc.CreateAsync(new CreateUserRequest @@ -147,7 +147,7 @@ public class UserManagementServiceTests var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true }; var mgr = BuildUserManager(findResult: user); - var svc = new UserManagementService(mgr.Object, db); + var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); await svc.DeactivateAsync("u1"); @@ -160,7 +160,7 @@ public class UserManagementServiceTests { using var db = BuildDb(); var mgr = BuildUserManager(findResult: null); - var svc = new UserManagementService(mgr.Object, db); + var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); await Assert.ThrowsAsync(() => svc.DeactivateAsync("missing")); } @@ -173,7 +173,7 @@ public class UserManagementServiceTests using var db = BuildDb(); var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" }; var mgr = BuildUserManager(findResult: user); - var svc = new UserManagementService(mgr.Object, db); + var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); var pwd = await svc.ResetPasswordAsync("u1"); diff --git a/API/ROLAC.API.Tests/TestSupport/NullAuditLogger.cs b/API/ROLAC.API.Tests/TestSupport/NullAuditLogger.cs new file mode 100644 index 0000000..bf8a5b1 --- /dev/null +++ b/API/ROLAC.API.Tests/TestSupport/NullAuditLogger.cs @@ -0,0 +1,19 @@ +using ROLAC.API.Entities.Logging; +using ROLAC.API.Services.Logging; + +namespace ROLAC.API.Tests.TestSupport; + +/// No-op for unit tests that don't assert on audit output. +public sealed class NullAuditLogger : IAuditLogger +{ + public static readonly NullAuditLogger Instance = new(); + + 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) + { + // intentionally empty + } +} diff --git a/API/ROLAC.API/Authorization/Modules.cs b/API/ROLAC.API/Authorization/Modules.cs index 3500194..c6582bd 100644 --- a/API/ROLAC.API/Authorization/Modules.cs +++ b/API/ROLAC.API/Authorization/Modules.cs @@ -21,6 +21,8 @@ public static class Modules public const string Disbursements = "Disbursements"; public const string MealAttendance = "MealAttendance"; public const string Permissions = "Permissions"; + public const string SystemLogs = "SystemLogs"; + public const string AuditLogs = "AuditLogs"; /// All modules, in display order — drives the admin matrix UI. public static readonly IReadOnlyList All = @@ -39,6 +41,8 @@ public static class Modules Disbursements, MealAttendance, Permissions, + SystemLogs, + AuditLogs, ]; public static bool IsValid(string module) => All.Contains(module); diff --git a/API/ROLAC.API/Controllers/AuditLogsController.cs b/API/ROLAC.API/Controllers/AuditLogsController.cs new file mode 100644 index 0000000..68c777a --- /dev/null +++ b/API/ROLAC.API/Controllers/AuditLogsController.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.Authorization; +using ROLAC.API.DTOs.Logging; +using ROLAC.API.Services.Logging; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/audit-logs")] +[Authorize] +public class AuditLogsController : ControllerBase +{ + private readonly IAuditLogQueryService _svc; + public AuditLogsController(IAuditLogQueryService svc) => _svc = svc; + + [HttpGet] + [HasPermission(Modules.AuditLogs, PermissionActions.Read)] + public async Task GetPaged([FromQuery] AuditLogQuery query) + => Ok(await _svc.GetPagedAsync(query)); + + [HttpGet("{id:long}")] + [HasPermission(Modules.AuditLogs, PermissionActions.Read)] + public async Task GetById(long id) + { + var dto = await _svc.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + /// Category / action / level option lists for the filter UI. + [HttpGet("catalog")] + [HasPermission(Modules.AuditLogs, PermissionActions.Read)] + public IActionResult GetCatalog() => Ok(_svc.GetCatalog()); +} diff --git a/API/ROLAC.API/Controllers/SystemLogsController.cs b/API/ROLAC.API/Controllers/SystemLogsController.cs new file mode 100644 index 0000000..27fbad8 --- /dev/null +++ b/API/ROLAC.API/Controllers/SystemLogsController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.Authorization; +using ROLAC.API.DTOs.Logging; +using ROLAC.API.Entities.Logging; +using ROLAC.API.Services.Logging; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/system-logs")] +[Authorize] +public class SystemLogsController : ControllerBase +{ + private readonly ISystemLogQueryService _svc; + public SystemLogsController(ISystemLogQueryService svc) => _svc = svc; + + [HttpGet] + [HasPermission(Modules.SystemLogs, PermissionActions.Read)] + public async Task GetPaged([FromQuery] SystemLogQuery query) + => Ok(await _svc.GetPagedAsync(query)); + + [HttpGet("{id:long}")] + [HasPermission(Modules.SystemLogs, PermissionActions.Read)] + public async Task GetById(long id) + { + var dto = await _svc.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + /// All six severities, so the UI can offer every filter option regardless of data. + [HttpGet("levels")] + [HasPermission(Modules.SystemLogs, PermissionActions.Read)] + public IActionResult GetLevels() => Ok(Enum.GetNames()); +} diff --git a/API/ROLAC.API/DTOs/Logging/AuditLogDtos.cs b/API/ROLAC.API/DTOs/Logging/AuditLogDtos.cs new file mode 100644 index 0000000..cabf4b5 --- /dev/null +++ b/API/ROLAC.API/DTOs/Logging/AuditLogDtos.cs @@ -0,0 +1,50 @@ +using ROLAC.API.Entities.Logging; + +namespace ROLAC.API.DTOs.Logging; + +/// Row shape for the Audit Logs grid (no heavy Changes JSON). +public class AuditLogListItemDto +{ + public long Id { get; set; } + public DateTimeOffset Timestamp { get; set; } + public string Level { get; set; } = null!; + public string Action { get; set; } = null!; + public string Category { get; set; } = null!; + public string? EntityName { get; set; } + public string? EntityId { get; set; } + public string? Summary { get; set; } + public string? UserId { get; set; } + public string? UserEmail { get; set; } +} + +/// Full detail for the Audit Log dialog, including the before→after JSON. +public class AuditLogDetailDto : AuditLogListItemDto +{ + public string? Changes { get; set; } + public string? IpAddress { get; set; } + public string? CorrelationId { get; set; } +} + +/// Filters for the paged Audit Logs query. +public class AuditLogQuery +{ + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; + public DateTimeOffset? From { get; set; } + public DateTimeOffset? To { get; set; } + public string? Category { get; set; } + public string? Action { get; set; } + public string? EntityName { get; set; } + public string? EntityId { get; set; } + public string? UserId { get; set; } + public LogLevelEnum? MinLevel { get; set; } + public string? Search { get; set; } +} + +/// Option lists for the Audit Logs filter UI. +public class AuditCatalogDto +{ + public IReadOnlyList Categories { get; set; } = []; + public IReadOnlyList Actions { get; set; } = []; + public IReadOnlyList Levels { get; set; } = []; +} diff --git a/API/ROLAC.API/DTOs/Logging/SystemLogDtos.cs b/API/ROLAC.API/DTOs/Logging/SystemLogDtos.cs new file mode 100644 index 0000000..eee6502 --- /dev/null +++ b/API/ROLAC.API/DTOs/Logging/SystemLogDtos.cs @@ -0,0 +1,43 @@ +using ROLAC.API.Entities.Logging; + +namespace ROLAC.API.DTOs.Logging; + +/// Row shape for the System Logs grid (no heavy exception text). +public class SystemLogListItemDto +{ + public long Id { get; set; } + public DateTimeOffset Timestamp { get; set; } + public string Level { get; set; } = null!; + public string Category { get; set; } = null!; + public string Message { get; set; } = null!; + public bool HasException { get; set; } + public int? StatusCode { get; set; } + public string? RequestPath { get; set; } + public string? HttpMethod { get; set; } + public string? UserId { get; set; } + public string? CorrelationId { get; set; } +} + +/// Full detail for the System Log dialog, including the stack trace. +public class SystemLogDetailDto : SystemLogListItemDto +{ + public int? EventId { get; set; } + public string? Exception { get; set; } + public string? IpAddress { get; set; } +} + +/// Filters for the paged System Logs query. +public class SystemLogQuery +{ + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; + public DateTimeOffset? From { get; set; } + public DateTimeOffset? To { get; set; } + /// Lower bound on severity (inclusive). + public LogLevelEnum? MinLevel { get; set; } + /// Exact severity match (takes precedence over MinLevel when set). + public LogLevelEnum? Level { get; set; } + public string? Search { get; set; } + public string? UserId { get; set; } + public string? CorrelationId { get; set; } +} diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index a30f14a..33448a7 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data.Logging; using ROLAC.API.Entities; namespace ROLAC.API.Data; @@ -324,5 +325,12 @@ public class AppDbContext : IdentityDbContext entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.HasIndex(e => new { e.Year, e.Month }).IsUnique(); }); + + // ── SystemLog / AuditLog (append-only) ─────────────────────────────── + // Mapped here for SCHEMA only — there are deliberately no DbSets on this + // context, so business code can't write logs through the audited context. + // Runtime reads/writes go through the dedicated LogDbContext. Including + // them in the model lets the single startup migration create the tables. + LogModelConfiguration.Configure(builder); } } diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index d47e3c0..5852dde 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -87,6 +87,13 @@ public static class DbSeeder ("finance", Modules.MonthlyStatements, true, true, false, true), ("finance", Modules.ChurchProfile, true, true, false, false), ("finance", Modules.Disbursements, true, true, true, true), + + // Logs — read-only. System logs are technical (pastor only); audit logs have + // governance value, so finance and board members can read them too. + ("pastor", Modules.SystemLogs, true, false, false, false), + ("pastor", Modules.AuditLogs, true, false, false, false), + ("finance", Modules.AuditLogs, true, false, false, false), + ("board_member", Modules.AuditLogs, true, false, false, false), ]; public static async Task SeedRolePermissionsAsync(AppDbContext db) diff --git a/API/ROLAC.API/Data/Interceptors/AuditLogInterceptor.cs b/API/ROLAC.API/Data/Interceptors/AuditLogInterceptor.cs new file mode 100644 index 0000000..7106131 --- /dev/null +++ b/API/ROLAC.API/Data/Interceptors/AuditLogInterceptor.cs @@ -0,0 +1,177 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using ROLAC.API.Entities.Base; +using ROLAC.API.Entities.Logging; +using ROLAC.API.Services.Logging; + +namespace ROLAC.API.Data.Interceptors; + +/// +/// Writes a before→after row for every Create/Update/Delete of an +/// entity. Two-phase: snapshot changed values BEFORE save (while +/// original values are still available), then — AFTER save succeeds — read DB-generated keys and +/// enqueue the rows. Enqueuing (rather than inserting here) avoids a second SaveChanges, can't +/// fail the user's transaction, and never recurses through AppDbContext. +/// +public sealed class AuditLogInterceptor : SaveChangesInterceptor +{ + private readonly SystemLogQueue _queue; + private readonly CurrentUserAccessor _currentUser; + private readonly List _pending = []; + + public AuditLogInterceptor(SystemLogQueue queue, CurrentUserAccessor currentUser) + { + _queue = queue; + _currentUser = currentUser; + } + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, InterceptionResult result) + { + Capture(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + Capture(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) + { + Flush(); + return base.SavedChanges(eventData, result); + } + + public override ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) + { + Flush(); + return base.SavedChangesAsync(eventData, result, cancellationToken); + } + + public override void SaveChangesFailed(DbContextErrorEventData eventData) => _pending.Clear(); + + public override Task SaveChangesFailedAsync( + DbContextErrorEventData eventData, CancellationToken cancellationToken = default) + { + _pending.Clear(); + return Task.CompletedTask; + } + + // ── Phase 1: snapshot before save ───────────────────────────────────────── + private void Capture(DbContext? db) + { + if (db is null) + return; + + foreach (var entry in db.ChangeTracker.Entries()) + { + if (entry.Entity is not IAuditable) + continue; + + switch (entry.State) + { + case EntityState.Added: + _pending.Add(new PendingAudit(entry, AuditActions.Create, null, BuildValues(entry, current: true))); + break; + + case EntityState.Deleted: + _pending.Add(new PendingAudit(entry, AuditActions.Delete, BuildValues(entry, current: false), null)); + break; + + case EntityState.Modified: + var before = new Dictionary(); + var after = new Dictionary(); + foreach (var property in entry.Properties) + { + if (!property.IsModified) + continue; + var name = property.Metadata.Name; + before[name] = Read(name, property.OriginalValue); + after[name] = Read(name, property.CurrentValue); + } + if (after.Count == 0) + break; // no real change (e.g. only audit timestamps touched on a no-op) + + // A soft-delete (IsDeleted false→true) reads more naturally as a Delete. + var action = IsSoftDelete(after) ? AuditActions.Delete : AuditActions.Update; + _pending.Add(new PendingAudit(entry, action, before, after)); + break; + } + } + } + + // ── Phase 2: keys exist, enqueue ────────────────────────────────────────── + private void Flush() + { + if (_pending.Count == 0) + return; + + var userId = _currentUser.UserId; + var userEmail = _currentUser.Email; + var ip = _currentUser.IpAddress; + var corr = _currentUser.CorrelationId; + + foreach (var item in _pending) + { + _queue.TryEnqueue(new AuditLog + { + Timestamp = DateTimeOffset.UtcNow, + Level = LogLevelEnum.Information, + Action = item.Action, + Category = AuditCategories.DataChange, + EntityName = item.Entry.Metadata.ClrType.Name, + EntityId = ReadKey(item.Entry), + Changes = AuditChangeSerializer.BuildChanges(item.Before, item.After), + UserId = userId, + UserEmail = userEmail, + IpAddress = ip, + CorrelationId = corr, + }); + } + + _pending.Clear(); + } + + private static Dictionary BuildValues(EntityEntry entry, bool current) + { + var values = new Dictionary(); + foreach (var property in entry.Properties) + { + if (property.Metadata.IsPrimaryKey()) + continue; + var name = property.Metadata.Name; + values[name] = Read(name, current ? property.CurrentValue : property.OriginalValue); + } + return values; + } + + private static object? Read(string propertyName, object? value) => + AuditChangeSerializer.IsSensitive(propertyName) ? AuditChangeSerializer.MaskValue : value; + + private static bool IsSoftDelete(Dictionary after) => + after.TryGetValue("IsDeleted", out var value) && value is true; + + private static string? ReadKey(EntityEntry entry) + { + var key = entry.Metadata.FindPrimaryKey(); + if (key is null) + return null; + + var parts = key.Properties + .Select(p => entry.Property(p.Name).CurrentValue?.ToString()) + .Where(v => v is not null); + return string.Join(",", parts); + } + + private sealed record PendingAudit( + EntityEntry Entry, + string Action, + Dictionary? Before, + Dictionary? After); +} diff --git a/API/ROLAC.API/Data/Interceptors/AuditSaveChangesInterceptor.cs b/API/ROLAC.API/Data/Interceptors/AuditSaveChangesInterceptor.cs index c4b15e9..3f1c52a 100644 --- a/API/ROLAC.API/Data/Interceptors/AuditSaveChangesInterceptor.cs +++ b/API/ROLAC.API/Data/Interceptors/AuditSaveChangesInterceptor.cs @@ -1,15 +1,15 @@ -using System.Security.Claims; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using ROLAC.API.Entities.Base; +using ROLAC.API.Services.Logging; namespace ROLAC.API.Data.Interceptors; public class AuditSaveChangesInterceptor : SaveChangesInterceptor { - private readonly IHttpContextAccessor _http; + private readonly CurrentUserAccessor _currentUser; - public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http; + public AuditSaveChangesInterceptor(CurrentUserAccessor currentUser) => _currentUser = currentUser; public override InterceptionResult SavingChanges( DbContextEventData eventData, InterceptionResult result) @@ -30,8 +30,7 @@ public class AuditSaveChangesInterceptor : SaveChangesInterceptor { if (db is null) return; - var userId = _http.HttpContext?.User - .FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; + var userId = _currentUser.UserIdOrSystem; var now = DateTimeOffset.UtcNow; foreach (var entry in db.ChangeTracker.Entries()) diff --git a/API/ROLAC.API/Data/Logging/LogDbContext.cs b/API/ROLAC.API/Data/Logging/LogDbContext.cs new file mode 100644 index 0000000..2acba45 --- /dev/null +++ b/API/ROLAC.API/Data/Logging/LogDbContext.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Entities.Logging; + +namespace ROLAC.API.Data.Logging; + +/// +/// A minimal, write-mostly context dedicated to the SystemLog / AuditLog tables. It is the +/// structural break that prevents log-storms: it is registered WITHOUT the audit interceptors +/// and with a silent logger factory (see Program.cs), so persisting a log row produces no log +/// events that the DB sink would pick up. It shares the same physical database/connection as +/// AppDbContext, but the tables themselves are created by AppDbContext's migration — they are +/// only mapped here so this context can read/write them. +/// +public class LogDbContext : DbContext +{ + public LogDbContext(DbContextOptions options) : base(options) { } + + public DbSet SystemLogs => Set(); + public DbSet AuditLogs => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + LogModelConfiguration.Configure(builder); + } +} diff --git a/API/ROLAC.API/Data/Logging/LogModelConfiguration.cs b/API/ROLAC.API/Data/Logging/LogModelConfiguration.cs new file mode 100644 index 0000000..c6c1e20 --- /dev/null +++ b/API/ROLAC.API/Data/Logging/LogModelConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Entities.Logging; + +namespace ROLAC.API.Data.Logging; + +/// +/// Single source of truth for the SystemLog / AuditLog table schema. Applied by +/// (so the startup migration creates the tables) AND by +/// (so runtime reads/writes map to the same shape). +/// +public static class LogModelConfiguration +{ + public static void Configure(ModelBuilder builder) + { + builder.Entity(entity => + { + entity.ToTable("SystemLogs"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Level).HasConversion(); + entity.Property(e => e.Category).HasMaxLength(256).IsRequired(); + entity.Property(e => e.Message).IsRequired(); // text + entity.Property(e => e.RequestPath).HasMaxLength(2048); + entity.Property(e => e.HttpMethod).HasMaxLength(10); + entity.Property(e => e.UserId).HasMaxLength(450); + entity.Property(e => e.IpAddress).HasMaxLength(45); + entity.Property(e => e.CorrelationId).HasMaxLength(64); + + entity.HasIndex(e => e.Timestamp); + entity.HasIndex(e => e.Level); + entity.HasIndex(e => new { e.Timestamp, e.Level }); + entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL"); + }); + + builder.Entity(entity => + { + entity.ToTable("AuditLogs"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Level).HasConversion(); + entity.Property(e => e.Action).HasMaxLength(40).IsRequired(); + entity.Property(e => e.Category).HasMaxLength(40).IsRequired(); + entity.Property(e => e.EntityName).HasMaxLength(128); + entity.Property(e => e.EntityId).HasMaxLength(64); + entity.Property(e => e.Changes).HasColumnType("jsonb"); + entity.Property(e => e.Summary).HasMaxLength(512); + entity.Property(e => e.UserId).HasMaxLength(450); + entity.Property(e => e.UserEmail).HasMaxLength(256); + entity.Property(e => e.IpAddress).HasMaxLength(45); + entity.Property(e => e.CorrelationId).HasMaxLength(64); + + entity.HasIndex(e => e.Timestamp); + entity.HasIndex(e => new { e.Category, e.Timestamp }); + entity.HasIndex(e => new { e.EntityName, e.EntityId }); + entity.HasIndex(e => e.Action); + entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL"); + }); + } +} diff --git a/API/ROLAC.API/Entities/Base/IAuditable.cs b/API/ROLAC.API/Entities/Base/IAuditable.cs new file mode 100644 index 0000000..52326c5 --- /dev/null +++ b/API/ROLAC.API/Entities/Base/IAuditable.cs @@ -0,0 +1,10 @@ +namespace ROLAC.API.Entities.Base; + +/// +/// Opt-in marker: entities implementing this are diffed by AuditLogInterceptor, which +/// writes a before→after AuditLog row on every Create/Update/Delete. Applied only to business +/// entities the church cares about — not to internal/high-churn rows (RefreshToken, log tables). +/// +public interface IAuditable +{ +} diff --git a/API/ROLAC.API/Entities/Check.cs b/API/ROLAC.API/Entities/Check.cs index 50a7384..6807dc5 100644 --- a/API/ROLAC.API/Entities/Check.cs +++ b/API/ROLAC.API/Entities/Check.cs @@ -6,7 +6,7 @@ namespace ROLAC.API.Entities; /// expenses (its ). The payee name/address are snapshotted at /// issue time so the printed check is reproducible even if member data later changes. /// -public class Check : SoftDeleteEntity +public class Check : SoftDeleteEntity, IAuditable { public int Id { get; set; } public string CheckNumber { get; set; } = null!; diff --git a/API/ROLAC.API/Entities/ChurchProfile.cs b/API/ROLAC.API/Entities/ChurchProfile.cs index e00a338..088fabc 100644 --- a/API/ROLAC.API/Entities/ChurchProfile.cs +++ b/API/ROLAC.API/Entities/ChurchProfile.cs @@ -5,7 +5,7 @@ namespace ROLAC.API.Entities; /// Singleton (Id == 1) holding the issuing church's identity, bank details, and the /// running check-number counter used when disbursing checks. Seeded on startup. /// -public class ChurchProfile : AuditableEntity +public class ChurchProfile : AuditableEntity, IAuditable { public int Id { get; set; } public string Name { get; set; } = null!; diff --git a/API/ROLAC.API/Entities/Expense.cs b/API/ROLAC.API/Entities/Expense.cs index c4f737e..6975628 100644 --- a/API/ROLAC.API/Entities/Expense.cs +++ b/API/ROLAC.API/Entities/Expense.cs @@ -1,7 +1,7 @@ using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; -public class Expense : SoftDeleteEntity +public class Expense : SoftDeleteEntity, IAuditable { public int Id { get; set; } public int MinistryId { get; set; } diff --git a/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs b/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs index 125ab1e..2339744 100644 --- a/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs +++ b/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs @@ -1,7 +1,7 @@ using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; -public class ExpenseCategoryGroup : AuditableEntity +public class ExpenseCategoryGroup : AuditableEntity, IAuditable { public int Id { get; set; } public string Name_en { get; set; } = null!; diff --git a/API/ROLAC.API/Entities/ExpenseSubCategory.cs b/API/ROLAC.API/Entities/ExpenseSubCategory.cs index ea289ab..4011175 100644 --- a/API/ROLAC.API/Entities/ExpenseSubCategory.cs +++ b/API/ROLAC.API/Entities/ExpenseSubCategory.cs @@ -1,7 +1,7 @@ using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; -public class ExpenseSubCategory : AuditableEntity +public class ExpenseSubCategory : AuditableEntity, IAuditable { public int Id { get; set; } public int GroupId { get; set; } diff --git a/API/ROLAC.API/Entities/Giving.cs b/API/ROLAC.API/Entities/Giving.cs index 32ad486..1e60967 100644 --- a/API/ROLAC.API/Entities/Giving.cs +++ b/API/ROLAC.API/Entities/Giving.cs @@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; -public class Giving : AuditableEntity +public class Giving : AuditableEntity, IAuditable { public int Id { get; set; } public int? MemberId { get; set; } diff --git a/API/ROLAC.API/Entities/GivingCategory.cs b/API/ROLAC.API/Entities/GivingCategory.cs index 234ba02..048278c 100644 --- a/API/ROLAC.API/Entities/GivingCategory.cs +++ b/API/ROLAC.API/Entities/GivingCategory.cs @@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; -public class GivingCategory : AuditableEntity +public class GivingCategory : AuditableEntity, IAuditable { public int Id { get; set; } public string Name_en { get; set; } = null!; diff --git a/API/ROLAC.API/Entities/Logging/AuditLog.cs b/API/ROLAC.API/Entities/Logging/AuditLog.cs new file mode 100644 index 0000000..150374f --- /dev/null +++ b/API/ROLAC.API/Entities/Logging/AuditLog.cs @@ -0,0 +1,71 @@ +namespace ROLAC.API.Entities.Logging; + +/// +/// An append-only audit row recording a meaningful action: a data change (Create/Update/ +/// Delete with before→after values), a security event (login, role/permission change), or a +/// key business action (check issued, expense approved, ...). Does NOT inherit AuditableEntity. +/// +public class AuditLog +{ + public long Id { get; set; } + public DateTimeOffset Timestamp { get; set; } + public LogLevelEnum Level { get; set; } = LogLevelEnum.Information; + + /// One of . + public string Action { get; set; } = null!; + + /// One of — drives the UI grouping. + public string Category { get; set; } = null!; + + public string? EntityName { get; set; } + + /// String to cover int, Guid and string primary keys uniformly. + public string? EntityId { get; set; } + + /// JSON { "before": {...}, "after": {...} } (jsonb column); sensitive fields masked. + public string? Changes { get; set; } + + /// Human-readable one-liner, e.g. "Check #1042 issued to Acme — $1,200.00". + public string? Summary { get; set; } + + public string? UserId { get; set; } + /// Denormalized actor email — survives user deletion and avoids a join in the grid. + public string? UserEmail { get; set; } + public string? IpAddress { get; set; } + public string? CorrelationId { get; set; } +} + +/// Canonical audit action names (stored verbatim in ). +public static class AuditActions +{ + public const string Create = "Create"; + public const string Update = "Update"; + public const string Delete = "Delete"; + public const string Login = "Login"; + public const string Logout = "Logout"; + public const string LoginFailed = "LoginFailed"; + public const string RoleChanged = "RoleChanged"; + public const string UserDeactivated = "UserDeactivated"; + public const string PermissionChanged = "PermissionChanged"; + public const string CheckIssued = "CheckIssued"; + public const string CheckVoided = "CheckVoided"; + public const string ExpenseApproved = "ExpenseApproved"; + public const string StatementFinalized = "StatementFinalized"; + + public static readonly IReadOnlyList All = + [ + Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged, + UserDeactivated, PermissionChanged, CheckIssued, CheckVoided, + ExpenseApproved, StatementFinalized, + ]; +} + +/// Top-level audit grouping (stored verbatim in ). +public static class AuditCategories +{ + public const string DataChange = "DataChange"; + public const string Security = "Security"; + public const string Business = "Business"; + + public static readonly IReadOnlyList All = [DataChange, Security, Business]; +} diff --git a/API/ROLAC.API/Entities/Logging/LogLevelEnum.cs b/API/ROLAC.API/Entities/Logging/LogLevelEnum.cs new file mode 100644 index 0000000..3216e31 --- /dev/null +++ b/API/ROLAC.API/Entities/Logging/LogLevelEnum.cs @@ -0,0 +1,38 @@ +using MsLogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace ROLAC.API.Entities.Logging; + +/// +/// Persisted severity for system and audit logs. Byte-backed so it stores compactly +/// as smallint and sorts/filters by ordinal. Deliberately omits the +/// sentinel (value 6) — "None" means "log nothing" and +/// is meaningless once a row already exists. +/// +public enum LogLevelEnum : byte +{ + Trace = 0, + Debug = 1, + Information = 2, + Warning = 3, + Error = 4, + Critical = 5, +} + +public static class LogLevelMap +{ + /// + /// Maps a framework to our persisted enum. + /// falls through to + /// (it never reaches the sink because the floor filter drops it first). + /// + public static LogLevelEnum FromMs(MsLogLevel level) => level switch + { + MsLogLevel.Trace => LogLevelEnum.Trace, + MsLogLevel.Debug => LogLevelEnum.Debug, + MsLogLevel.Information => LogLevelEnum.Information, + MsLogLevel.Warning => LogLevelEnum.Warning, + MsLogLevel.Error => LogLevelEnum.Error, + MsLogLevel.Critical => LogLevelEnum.Critical, + _ => LogLevelEnum.Critical, + }; +} diff --git a/API/ROLAC.API/Entities/Logging/SystemLog.cs b/API/ROLAC.API/Entities/Logging/SystemLog.cs new file mode 100644 index 0000000..71acada --- /dev/null +++ b/API/ROLAC.API/Entities/Logging/SystemLog.cs @@ -0,0 +1,31 @@ +namespace ROLAC.API.Entities.Logging; + +/// +/// An append-only operational log row — one per persisted framework/app log event, +/// including every unhandled API exception captured by ExceptionHandlingMiddleware. +/// Intentionally does NOT inherit AuditableEntity: these rows are never updated and +/// must not be re-stamped or re-audited (that would recurse through the log pipeline). +/// +public class SystemLog +{ + public long Id { get; set; } + public DateTimeOffset Timestamp { get; set; } + public LogLevelEnum Level { get; set; } + + /// The ILogger category (source), e.g. "ROLAC.API.Controllers.GivingsController". + public string Category { get; set; } = null!; + public int? EventId { get; set; } + public string Message { get; set; } = null!; + + /// Full exception.ToString() (type + message + stack), when present. + public string? Exception { get; set; } + + public string? RequestPath { get; set; } + public string? HttpMethod { get; set; } + public int? StatusCode { get; set; } + + /// The acting user id ("sub" claim), or null for background/system events. + public string? UserId { get; set; } + public string? IpAddress { get; set; } + public string? CorrelationId { get; set; } +} diff --git a/API/ROLAC.API/Entities/Member.cs b/API/ROLAC.API/Entities/Member.cs index 005f626..3a7a68b 100644 --- a/API/ROLAC.API/Entities/Member.cs +++ b/API/ROLAC.API/Entities/Member.cs @@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; -public class Member : SoftDeleteEntity +public class Member : SoftDeleteEntity, IAuditable { public int Id { get; set; } public string FirstName_en { get; set; } = null!; diff --git a/API/ROLAC.API/Entities/Ministry.cs b/API/ROLAC.API/Entities/Ministry.cs index 461fab4..f63a9da 100644 --- a/API/ROLAC.API/Entities/Ministry.cs +++ b/API/ROLAC.API/Entities/Ministry.cs @@ -1,6 +1,8 @@ +using ROLAC.API.Entities.Base; + namespace ROLAC.API.Entities; -public class Ministry +public class Ministry : IAuditable { public int Id { get; set; } public string Name_en { get; set; } = null!; diff --git a/API/ROLAC.API/Entities/MonthlyStatement.cs b/API/ROLAC.API/Entities/MonthlyStatement.cs index a97bf4e..0008631 100644 --- a/API/ROLAC.API/Entities/MonthlyStatement.cs +++ b/API/ROLAC.API/Entities/MonthlyStatement.cs @@ -1,7 +1,7 @@ using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; -public class MonthlyStatement : AuditableEntity +public class MonthlyStatement : AuditableEntity, IAuditable { public int Id { get; set; } public int Year { get; set; } diff --git a/API/ROLAC.API/Entities/OfferingSession.cs b/API/ROLAC.API/Entities/OfferingSession.cs index 7de4718..7d0afc2 100644 --- a/API/ROLAC.API/Entities/OfferingSession.cs +++ b/API/ROLAC.API/Entities/OfferingSession.cs @@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; -public class OfferingSession : AuditableEntity +public class OfferingSession : AuditableEntity, IAuditable { public int Id { get; set; } public DateOnly SessionDate { get; set; } diff --git a/API/ROLAC.API/Middleware/ExceptionHandlingMiddleware.cs b/API/ROLAC.API/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..03addf3 --- /dev/null +++ b/API/ROLAC.API/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; + +namespace ROLAC.API.Middleware; + +/// +/// Catches any unhandled exception from the downstream pipeline, logs it (which flows through +/// the DB sink into SystemLogs at Error level with full stack + StatusCode 500), and returns a +/// clean RFC7807 problem+json response. Stack traces are never leaked to the client outside +/// Development. Registered as the FIRST middleware so it also catches auth/authorization faults. +/// +public sealed class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IHostEnvironment _env; + + public ExceptionHandlingMiddleware( + RequestDelegate next, ILogger logger, IHostEnvironment env) + { + _next = next; + _logger = logger; + _env = env; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + // The client went away — not a server error; don't log as 500. + return; + } + catch (Exception ex) + { + await HandleAsync(context, ex); + } + } + + private async Task HandleAsync(HttpContext context, Exception exception) + { + // Logged here → picked up by the DB sink (Error ≥ Warning floor) with full ex.ToString(). + _logger.LogError( + exception, + "Unhandled exception for {Method} {Path} (traceId {TraceId})", + context.Request.Method, context.Request.Path, context.TraceIdentifier); + + if (context.Response.HasStarted) + { + // Too late to write a clean body; the log row above is still captured. + return; + } + + var problem = new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "An unexpected error occurred.", + Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1", + }; + problem.Extensions["traceId"] = context.TraceIdentifier; + if (_env.IsDevelopment()) + problem.Detail = exception.ToString(); + + context.Response.Clear(); + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/problem+json"; + + await context.Response.WriteAsync(JsonSerializer.Serialize(problem, JsonSerializerOptions)); + } + + private static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index c64254f..4db086e 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -885,6 +885,143 @@ namespace ROLAC.API.Migrations b.ToTable("GivingCategories"); }); + modelBuilder.Entity("ROLAC.API.Entities.Logging.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Changes") + .HasColumnType("jsonb"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EntityId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EntityName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("Summary") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId") + .HasFilter("\"UserId\" IS NOT NULL"); + + b.HasIndex("Category", "Timestamp"); + + b.HasIndex("EntityName", "EntityId"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Logging.SystemLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("HttpMethod") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestPath") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId") + .HasFilter("\"UserId\" IS NOT NULL"); + + b.HasIndex("Timestamp", "Level"); + + b.ToTable("SystemLogs", (string)null); + }); + modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b => { b.Property("Id") diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index a8be930..8a1b651 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -6,11 +6,15 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Logging.Abstractions; using ROLAC.API.Data; using ROLAC.API.Data.Interceptors; +using ROLAC.API.Data.Logging; using ROLAC.API.Entities; using ROLAC.API.Json; +using ROLAC.API.Middleware; using ROLAC.API.Services; +using ROLAC.API.Services.Logging; var builder = WebApplication.CreateBuilder(args); var config = builder.Configuration; @@ -19,10 +23,31 @@ var config = builder.Configuration; // Database // --------------------------------------------------------------------------- builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddDbContext((sp, opt) => opt.UseNpgsql(config.GetConnectionString("DefaultConnection")) - .AddInterceptors(sp.GetRequiredService())); + .AddInterceptors( + sp.GetRequiredService(), + sp.GetRequiredService())); + +// Dedicated context for log writes — NO interceptors and a silent logger factory, so persisting +// a log row produces no log events the DB sink would pick up (breaks recursion / log-storms). +builder.Services.AddDbContext(opt => + opt.UseNpgsql(config.GetConnectionString("DefaultConnection")) + .UseLoggerFactory(NullLoggerFactory.Instance)); + +// --------------------------------------------------------------------------- +// System + audit logging (custom EF DB sink) +// --------------------------------------------------------------------------- +builder.Services.Configure(config.GetSection("Logging:Database")); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Identity (API-only — no cookie auth; JWT is the default scheme) @@ -200,6 +225,10 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }); +// First in the pipeline: catch every unhandled exception, log it to SystemLogs, and return +// a clean problem+json. Placed after UseForwardedHeaders so the logged client IP is correct. +app.UseMiddleware(); + // Apply migrations + seed on startup using (var scope = app.Services.CreateScope()) { diff --git a/API/ROLAC.API/Services/AuthService.cs b/API/ROLAC.API/Services/AuthService.cs index 10d1817..39a1332 100644 --- a/API/ROLAC.API/Services/AuthService.cs +++ b/API/ROLAC.API/Services/AuthService.cs @@ -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); } } diff --git a/API/ROLAC.API/Services/DisbursementService.cs b/API/ROLAC.API/Services/DisbursementService.cs index e1beae9..da3ac53 100644 --- a/API/ROLAC.API/Services/DisbursementService.cs +++ b/API/ROLAC.API/Services/DisbursementService.cs @@ -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 ───────────────────────────────────────────────────── diff --git a/API/ROLAC.API/Services/ExpenseService.cs b/API/ROLAC.API/Services/ExpenseService.cs index 6ad8893..c838363 100644 --- a/API/ROLAC.API/Services/ExpenseService.cs +++ b/API/ROLAC.API/Services/ExpenseService.cs @@ -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) diff --git a/API/ROLAC.API/Services/Logging/AuditChangeSerializer.cs b/API/ROLAC.API/Services/Logging/AuditChangeSerializer.cs new file mode 100644 index 0000000..924b9c2 --- /dev/null +++ b/API/ROLAC.API/Services/Logging/AuditChangeSerializer.cs @@ -0,0 +1,71 @@ +using System.Text.Json; + +namespace ROLAC.API.Services.Logging; + +/// +/// Serializes audit before/after payloads to JSON and masks sensitive property names. +/// Shared by and the EF audit interceptor so masking is consistent. +/// +public static class AuditChangeSerializer +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + /// Property names whose values are replaced with wherever they appear. + private static readonly HashSet SensitiveNames = new(StringComparer.OrdinalIgnoreCase) + { + "BankAccountNumber", + "BankRoutingNumber", + "PasswordHash", + "Password", + "SecurityStamp", + "ConcurrencyStamp", + }; + + public const string MaskValue = "***"; + + public static bool IsSensitive(string propertyName) => SensitiveNames.Contains(propertyName); + + /// Builds the { before, after } JSON; returns null when both sides are empty. + public static string? BuildChanges(object? before, object? after) + { + if (before is null && after is null) + return null; + + var payload = new Dictionary(); + if (before is not null) payload["before"] = MaskObject(before); + if (after is not null) payload["after"] = MaskObject(after); + + return JsonSerializer.Serialize(payload, JsonOptions); + } + + /// Serializes a value (e.g. a property dictionary built by the interceptor) to JSON. + public static string Serialize(object value) => JsonSerializer.Serialize(value, JsonOptions); + + /// + /// 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). + /// + private static object MaskObject(object value) + { + if (value is IDictionary dict) + { + var masked = new Dictionary(); + foreach (var (key, val) in dict) + masked[key] = IsSensitive(key) ? MaskValue : val; + return masked; + } + + var result = new Dictionary(); + 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; + } +} diff --git a/API/ROLAC.API/Services/Logging/AuditLogQueryService.cs b/API/ROLAC.API/Services/Logging/AuditLogQueryService.cs new file mode 100644 index 0000000..6d07248 --- /dev/null +++ b/API/ROLAC.API/Services/Logging/AuditLogQueryService.cs @@ -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> GetPagedAsync(AuditLogQuery query); + Task GetByIdAsync(long id); + AuditCatalogDto GetCatalog(); +} + +/// Read-only, paged access to the AuditLogs table via the dedicated LogDbContext. +public sealed class AuditLogQueryService : IAuditLogQueryService +{ + private readonly LogDbContext _db; + + public AuditLogQueryService(LogDbContext db) => _db = db; + + public async Task> 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 + { + Items = items, TotalCount = total, Page = page, PageSize = pageSize, + }; + } + + public async Task 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(), + }; +} diff --git a/API/ROLAC.API/Services/Logging/AuditLogger.cs b/API/ROLAC.API/Services/Logging/AuditLogger.cs new file mode 100644 index 0000000..6532b86 --- /dev/null +++ b/API/ROLAC.API/Services/Logging/AuditLogger.cs @@ -0,0 +1,52 @@ +using ROLAC.API.Entities.Logging; + +namespace ROLAC.API.Services.Logging; + +/// +/// Scoped : fills actor/request context from +/// and enqueues the row onto the shared queue (no direct DB +/// write, so it can't fail a business transaction or recurse through AppDbContext). +/// +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); + } +} diff --git a/API/ROLAC.API/Services/Logging/CurrentUserAccessor.cs b/API/ROLAC.API/Services/Logging/CurrentUserAccessor.cs new file mode 100644 index 0000000..54bbde9 --- /dev/null +++ b/API/ROLAC.API/Services/Logging/CurrentUserAccessor.cs @@ -0,0 +1,30 @@ +using System.Security.Claims; + +namespace ROLAC.API.Services.Logging; + +/// +/// 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. +/// +public sealed class CurrentUserAccessor +{ + private readonly IHttpContextAccessor _http; + + public CurrentUserAccessor(IHttpContextAccessor http) => _http = http; + + /// The acting user id, or null when unauthenticated / off the request thread. + public string? UserId => + _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? _http.HttpContext?.User.FindFirstValue("sub"); + + /// The acting user id, or "system" for background/unauthenticated work. + 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; +} diff --git a/API/ROLAC.API/Services/Logging/DatabaseLoggerOptions.cs b/API/ROLAC.API/Services/Logging/DatabaseLoggerOptions.cs new file mode 100644 index 0000000..126531a --- /dev/null +++ b/API/ROLAC.API/Services/Logging/DatabaseLoggerOptions.cs @@ -0,0 +1,27 @@ +using MsLogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace ROLAC.API.Services.Logging; + +/// +/// Bound from configuration section Logging:Database. Controls what the DB sink persists. +/// +public sealed class DatabaseLoggerOptions +{ + /// The minimum level actually written to the SystemLogs table. Default: Warning. + public MsLogLevel MinimumLevel { get; set; } = MsLogLevel.Warning; + + /// + /// 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. + /// + public string[] ExcludedCategories { get; set; } = + [ + "Microsoft.EntityFrameworkCore", + "Npgsql", + "Microsoft.AspNetCore.Hosting.Diagnostics", + "Microsoft.AspNetCore.Routing", + "ROLAC.API.Services.Logging", + "ROLAC.API.Data.Logging", + ]; +} diff --git a/API/ROLAC.API/Services/Logging/DbLoggerProvider.cs b/API/ROLAC.API/Services/Logging/DbLoggerProvider.cs new file mode 100644 index 0000000..e122eff --- /dev/null +++ b/API/ROLAC.API/Services/Logging/DbLoggerProvider.cs @@ -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; + +/// +/// A singleton that turns framework/app log events into +/// rows enqueued onto . 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. +/// +[ProviderAlias("Database")] +public sealed class DbLoggerProvider : ILoggerProvider +{ + private readonly SystemLogQueue _queue; + private readonly DatabaseLoggerOptions _options; + private readonly IHttpContextAccessor _http; + private readonly ConcurrentDictionary _loggers = new(); + + public DbLoggerProvider( + SystemLogQueue queue, IOptions 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(); +} + +/// The per-category logger. Drops events below the floor or in excluded categories. +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 state) where TState : notnull => null; + + public bool IsEnabled(MsLogLevel logLevel) => + !_excluded && logLevel != MsLogLevel.None && logLevel >= _options.MinimumLevel; + + public void Log( + MsLogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func 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); + } +} diff --git a/API/ROLAC.API/Services/Logging/IAuditLogger.cs b/API/ROLAC.API/Services/Logging/IAuditLogger.cs new file mode 100644 index 0000000..28e16cc --- /dev/null +++ b/API/ROLAC.API/Services/Logging/IAuditLogger.cs @@ -0,0 +1,29 @@ +using ROLAC.API.Entities.Logging; + +namespace ROLAC.API.Services.Logging; + +/// +/// 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 AuditLogInterceptor; use this for the +/// semantic action + human summary the raw diff can't express. +/// +public interface IAuditLogger +{ + /// + /// Build and enqueue an audit row. / are + /// serialized into the Changes JSON. Never throws — failures are dropped like all logs. + /// + 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); +} diff --git a/API/ROLAC.API/Services/Logging/LogWriterBackgroundService.cs b/API/ROLAC.API/Services/Logging/LogWriterBackgroundService.cs new file mode 100644 index 0000000..9698ed3 --- /dev/null +++ b/API/ROLAC.API/Services/Logging/LogWriterBackgroundService.cs @@ -0,0 +1,102 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data.Logging; +using ROLAC.API.Entities.Logging; + +namespace ROLAC.API.Services.Logging; + +/// +/// The single consumer that drains and batch-inserts rows through +/// the dedicated (a fresh DI scope per batch). Persistence failures +/// are swallowed to Console.Error only — they must never propagate back into the logging +/// pipeline or crash the host. +/// +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(MaxBatchSize); + var auditBatch = new List(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); + } + + /// Brief debounce so bursts coalesce; returns false once the window elapses. + private static async Task WaitForMoreAsync(TimeSpan window, CancellationToken token) + { + try + { + await Task.Delay(window, token); + return false; + } + catch (OperationCanceledException) + { + return false; + } + } + + private async Task FlushAsync( + List systemBatch, List auditBatch, CancellationToken token) + { + if (systemBatch.Count == 0 && auditBatch.Count == 0) + return; + + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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(); + } + } +} diff --git a/API/ROLAC.API/Services/Logging/SystemLogQueryService.cs b/API/ROLAC.API/Services/Logging/SystemLogQueryService.cs new file mode 100644 index 0000000..d68f36f --- /dev/null +++ b/API/ROLAC.API/Services/Logging/SystemLogQueryService.cs @@ -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> GetPagedAsync(SystemLogQuery query); + Task GetByIdAsync(long id); +} + +/// Read-only, paged access to the SystemLogs table via the dedicated LogDbContext. +public sealed class SystemLogQueryService : ISystemLogQueryService +{ + private readonly LogDbContext _db; + + public SystemLogQueryService(LogDbContext db) => _db = db; + + public async Task> 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 + { + Items = items, TotalCount = total, Page = page, PageSize = pageSize, + }; + } + + public async Task 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(); + } +} diff --git a/API/ROLAC.API/Services/Logging/SystemLogQueue.cs b/API/ROLAC.API/Services/Logging/SystemLogQueue.cs new file mode 100644 index 0000000..0ac9cd0 --- /dev/null +++ b/API/ROLAC.API/Services/Logging/SystemLogQueue.cs @@ -0,0 +1,32 @@ +using System.Threading.Channels; +using ROLAC.API.Entities.Logging; + +namespace ROLAC.API.Services.Logging; + +/// +/// 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 TryWrite; 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. +/// +public sealed class SystemLogQueue +{ + private readonly Channel _channel = + Channel.CreateBounded(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 ReadAllAsync(CancellationToken cancellationToken) => + _channel.Reader.ReadAllAsync(cancellationToken); +} + +/// Either a SystemLog or an AuditLog — exactly one is non-null. +public sealed record LogEnvelope(SystemLog? System, AuditLog? Audit); diff --git a/API/ROLAC.API/Services/MonthlyStatementService.cs b/API/ROLAC.API/Services/MonthlyStatementService.cs index 2392936..dceb4ef 100644 --- a/API/ROLAC.API/Services/MonthlyStatementService.cs +++ b/API/ROLAC.API/Services/MonthlyStatementService.cs @@ -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) diff --git a/API/ROLAC.API/Services/PermissionService.cs b/API/ROLAC.API/Services/PermissionService.cs index 8182ca5..299b1dd 100644 --- a/API/ROLAC.API/Services/PermissionService.cs +++ b/API/ROLAC.API/Services/PermissionService.cs @@ -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 HasPermissionAsync(IEnumerable 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); diff --git a/API/ROLAC.API/Services/UserManagementService.cs b/API/ROLAC.API/Services/UserManagementService.cs index 0354cb0..10ad508 100644 --- a/API/ROLAC.API/Services/UserManagementService.cs +++ b/API/ROLAC.API/Services/UserManagementService.cs @@ -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 _userManager; private readonly AppDbContext _db; + private readonly IAuditLogger _audit; - public UserManagementService(UserManager userManager, AppDbContext db) + public UserManagementService(UserManager 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 ──────────────────────────────────────────────────────── diff --git a/API/ROLAC.API/appsettings.json b/API/ROLAC.API/appsettings.json index 4151397..4a36c7b 100644 --- a/API/ROLAC.API/appsettings.json +++ b/API/ROLAC.API/appsettings.json @@ -3,6 +3,17 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" + }, + "Database": { + "MinimumLevel": "Warning", + "ExcludedCategories": [ + "Microsoft.EntityFrameworkCore", + "Npgsql", + "Microsoft.AspNetCore.Hosting.Diagnostics", + "Microsoft.AspNetCore.Routing", + "ROLAC.API.Services.Logging", + "ROLAC.API.Data.Logging" + ] } }, "AllowedHosts": "*", diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index e9741b1..1d0029a 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -21,6 +21,8 @@ import { CheckRegisterPageComponent } from './features/disbursement/pages/check- import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component'; import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component'; import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component'; +import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component'; +import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component'; export const routes: Routes = [ // Public routes @@ -39,85 +41,150 @@ export const routes: Routes = [ canActivate: [AuthGuard], children: [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, - { path: 'dashboard', component: DashboardComponent }, + { + path: 'dashboard', + component: DashboardComponent, + data: { title: 'Dashboard', titleZh: '首頁', section: 'Home' }, + }, { path: 'admin/members', component: MembersPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.Members, action: 'read' } }, + data: { + permission: { module: PermissionModules.Members, action: 'read' }, + title: 'Member Management', titleZh: '會友管理', section: 'Admin', + }, }, { path: 'admin/users', component: UsersPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.Users, action: 'read' } }, + data: { + permission: { module: PermissionModules.Users, action: 'read' }, + title: 'User Management', titleZh: '使用者管理', section: 'Admin', + }, }, { path: 'admin/permissions', component: PermissionsPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.Permissions, action: 'read' } }, + data: { + permission: { module: PermissionModules.Permissions, action: 'read' }, + title: 'Role Permissions', titleZh: '權限設定', section: 'Admin', + }, + }, + { + path: 'admin/logs/system', + component: SystemLogsPageComponent, + canActivate: [PermissionGuard], + data: { + permission: { module: PermissionModules.SystemLogs, action: 'read' }, + title: 'System Logs', titleZh: '系統日誌', section: 'Admin', + }, + }, + { + path: 'admin/logs/audit', + component: AuditLogsPageComponent, + canActivate: [PermissionGuard], + data: { + permission: { module: PermissionModules.AuditLogs, action: 'read' }, + title: 'Audit Logs', titleZh: '稽核日誌', section: 'Admin', + }, }, { path: 'finance/dashboard', component: FinanceDashboardPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.FinanceDashboard, action: 'read' } }, + data: { + permission: { module: PermissionModules.FinanceDashboard, action: 'read' }, + title: 'Finance Dashboard', titleZh: '財務儀表板', section: 'Finance', + }, }, { path: 'finance/giving-categories', component: GivingCategoriesPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.GivingCategories, action: 'read' } }, + data: { + permission: { module: PermissionModules.GivingCategories, action: 'read' }, + title: 'Giving Types', titleZh: '奉獻類型', section: 'Finance', + }, }, { path: 'finance/givings', component: GivingsPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.Givings, action: 'read' } }, + data: { + permission: { module: PermissionModules.Givings, action: 'read' }, + title: 'Givings', titleZh: '單筆奉獻', section: 'Finance', + }, }, { path: 'finance/offering-session', component: OfferingSessionPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.OfferingSessions, action: 'read' } }, + data: { + permission: { module: PermissionModules.OfferingSessions, action: 'read' }, + title: 'Sunday Offering Entry', titleZh: '主日奉獻錄入', section: 'Finance', + }, + }, + { + path: 'reimbursements', + component: MyReimbursementsPageComponent, + data: { title: 'My Reimbursements', titleZh: '我的報銷', section: 'Finance' }, }, - { path: 'reimbursements', component: MyReimbursementsPageComponent }, { path: 'finance/expenses', component: ExpensesPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.Expenses, action: 'read' } }, + data: { + permission: { module: PermissionModules.Expenses, action: 'read' }, + title: 'Expenses', titleZh: '支出', section: 'Finance', + }, }, { path: 'finance/expense-categories', component: ExpenseCategoriesPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.ExpenseCategories, action: 'read' } }, + data: { + permission: { module: PermissionModules.ExpenseCategories, action: 'read' }, + title: 'Expense Categories', titleZh: '費用類別', section: 'Finance', + }, }, { path: 'finance/monthly-statement', component: MonthlyStatementPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.MonthlyStatements, action: 'read' } }, + data: { + permission: { module: PermissionModules.MonthlyStatements, action: 'read' }, + title: 'Monthly Statement', titleZh: '月報表', section: 'Finance', + }, }, { path: 'finance/disbursements', component: DisbursementPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.Disbursements, action: 'read' } }, + data: { + permission: { module: PermissionModules.Disbursements, action: 'read' }, + title: 'Disbursement Management', titleZh: '支票開立', section: 'Finance', + }, }, { path: 'finance/check-register', component: CheckRegisterPageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.Disbursements, action: 'read' } }, + data: { + permission: { module: PermissionModules.Disbursements, action: 'read' }, + title: 'Check Register', titleZh: '支票登記簿', section: 'Finance', + }, }, { path: 'finance/church-profile', component: ChurchProfilePageComponent, canActivate: [PermissionGuard], - data: { permission: { module: PermissionModules.ChurchProfile, action: 'read' } }, + data: { + permission: { module: PermissionModules.ChurchProfile, action: 'read' }, + title: 'Church Profile', titleZh: '教會資料', section: 'Finance', + }, }, ] }, diff --git a/APP/src/app/core/models/permission.model.ts b/APP/src/app/core/models/permission.model.ts index 60e9ec9..b2f6fa4 100644 --- a/APP/src/app/core/models/permission.model.ts +++ b/APP/src/app/core/models/permission.model.ts @@ -29,6 +29,8 @@ export const PermissionModules = { Disbursements: 'Disbursements', MealAttendance: 'MealAttendance', Permissions: 'Permissions', + SystemLogs: 'SystemLogs', + AuditLogs: 'AuditLogs', } as const; /** A required permission, used in route data and the *appHasPermission directive. */ diff --git a/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.html b/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.html index d5c6816..bd77670 100644 --- a/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.html +++ b/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.html @@ -1,8 +1,4 @@
- -