From d3c3ecd5e6e97c9b476e042c8ad13510196018c7 Mon Sep 17 00:00:00 2001 From: namdh Date: Sun, 20 Sep 2026 16:21:15 +0700 Subject: [PATCH] feat: add Logs --- api/Authentication/HttpCurrentUser.cs | 46 ++ api/Controllers/AuditController.cs | 43 ++ api/Controllers/UsersController.cs | 23 +- api/Program.cs | 4 + application/Audit/AuditContext.cs | 7 + application/Audit/AuditLogBehavior.cs | 48 ++ application/Audit/Contracts.cs | 41 ++ application/Audit/IAuditContext.cs | 7 + application/Audit/IAuditableRequest.cs | 9 + application/Audit/IHasEntityId.cs | 6 + application/Audit/Queries/GetActionLogs.cs | 38 ++ application/Audit/Queries/GetLoginLogs.cs | 36 ++ application/Auth/Commands/Login.cs | 31 +- application/Common/ICurrentUser.cs | 9 + application/Common/IUnitOfWork.cs | 2 + .../Repositories/IActionLogRepository.cs | 10 + .../Repositories/ILoginLogRepository.cs | 10 + .../Documents/Commands/CreateDocument.cs | 8 +- .../Documents/Commands/DeleteDocument.cs | 12 +- .../Documents/Commands/MoveDocument.cs | 12 +- .../Documents/Commands/UpdateDocument.cs | 9 +- application/Documents/Contracts.cs | 4 +- .../MasterData/Commands/CreateMasterData.cs | 8 +- .../MasterData/Commands/DeleteMasterData.cs | 12 +- .../MasterData/Commands/UpdateMasterData.cs | 9 +- application/MasterData/Contracts.cs | 5 +- .../Permissions/Commands/CreateRole.cs | 8 +- .../Permissions/Commands/DeleteRole.cs | 12 +- .../Permissions/Commands/UpdateRole.cs | 9 +- application/Permissions/Contracts.cs | 5 +- application/Permissions/ScreenCatalog.cs | 2 + .../Projects/Commands/AddProjectMember.cs | 12 +- .../Projects/Commands/ArchiveProject.cs | 12 +- .../Projects/Commands/CreateProject.cs | 8 +- .../Projects/Commands/RemoveProjectMember.cs | 14 +- .../UpdateMemberDocumentPermissions.cs | 12 +- .../Projects/Commands/UpdateProject.cs | 9 +- application/Projects/Contracts.cs | 4 +- application/Tasks/Commands/CreateTask.cs | 8 +- application/Tasks/Commands/DeleteTask.cs | 12 +- application/Tasks/Commands/UpdateTask.cs | 9 +- application/Tasks/Contracts.cs | 4 +- application/Users/Commands/AssignUserRole.cs | 34 + application/Users/Commands/CreateUser.cs | 8 +- application/Users/Commands/DeleteUser.cs | 12 +- application/Users/Commands/ResetPassword.cs | 12 +- .../Users/Commands/UnassignUserRole.cs | 34 + application/Users/Commands/UpdateUser.cs | 9 +- application/Users/Contracts.cs | 4 +- domain/Audit/ActionLog.cs | 15 + domain/Audit/LoginLog.cs | 13 + infrastructure/DependencyInjection.cs | 9 +- .../20260920085021_AddAuditLogs.Designer.cs | 594 ++++++++++++++++++ .../Migrations/20260920085021_AddAuditLogs.cs | 98 +++ .../Migrations/AppDbContextModelSnapshot.cs | 98 +++ infrastructure/Persistence/AppDbContext.cs | 3 + .../Configuration/ActionLogConfiguration.cs | 24 + .../Configuration/LoginLogConfiguration.cs | 21 + infrastructure/Persistence/DbSeeder.cs | 2 +- .../Repositories/ActionLogRepository.cs | 35 ++ .../Repositories/LoginLogRepository.cs | 25 + infrastructure/Persistence/UnitOfWork.cs | 4 + 62 files changed, 1577 insertions(+), 66 deletions(-) create mode 100644 api/Authentication/HttpCurrentUser.cs create mode 100644 api/Controllers/AuditController.cs create mode 100644 application/Audit/AuditContext.cs create mode 100644 application/Audit/AuditLogBehavior.cs create mode 100644 application/Audit/Contracts.cs create mode 100644 application/Audit/IAuditContext.cs create mode 100644 application/Audit/IAuditableRequest.cs create mode 100644 application/Audit/IHasEntityId.cs create mode 100644 application/Audit/Queries/GetActionLogs.cs create mode 100644 application/Audit/Queries/GetLoginLogs.cs create mode 100644 application/Common/ICurrentUser.cs create mode 100644 application/Common/Repositories/IActionLogRepository.cs create mode 100644 application/Common/Repositories/ILoginLogRepository.cs create mode 100644 application/Users/Commands/AssignUserRole.cs create mode 100644 application/Users/Commands/UnassignUserRole.cs create mode 100644 domain/Audit/ActionLog.cs create mode 100644 domain/Audit/LoginLog.cs create mode 100644 infrastructure/Migrations/20260920085021_AddAuditLogs.Designer.cs create mode 100644 infrastructure/Migrations/20260920085021_AddAuditLogs.cs create mode 100644 infrastructure/Persistence/Configuration/ActionLogConfiguration.cs create mode 100644 infrastructure/Persistence/Configuration/LoginLogConfiguration.cs create mode 100644 infrastructure/Persistence/Repositories/ActionLogRepository.cs create mode 100644 infrastructure/Persistence/Repositories/LoginLogRepository.cs diff --git a/api/Authentication/HttpCurrentUser.cs b/api/Authentication/HttpCurrentUser.cs new file mode 100644 index 0000000..48404cd --- /dev/null +++ b/api/Authentication/HttpCurrentUser.cs @@ -0,0 +1,46 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.JsonWebTokens; +using mws.backend.dotnet.application.Common; + +namespace mws.backend.dotnet.api.Authentication; + +public class HttpCurrentUser(IHttpContextAccessor accessor) : ICurrentUser +{ + public Guid? UserId + { + get + { + var value = accessor.HttpContext?.User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? accessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? accessor.HttpContext?.User.FindFirstValue("sub"); + return value is not null && Guid.TryParse(value, out var id) ? id : null; + } + } + + public string? Username => + accessor.HttpContext?.User.FindFirstValue(ClaimTypes.Name) + ?? accessor.HttpContext?.User.FindFirstValue("name"); + + public string? IpAddress + { + get + { + var context = accessor.HttpContext; + if (context is null) return null; + var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(forwarded)) + { + var first = forwarded.Split(',')[0].Trim(); + if (first.Length > 0) return Truncate(first, 45); + } + return Truncate(context.Connection.RemoteIpAddress?.ToString(), 45); + } + } + + public string? UserAgent => + Truncate(accessor.HttpContext?.Request.Headers.UserAgent.ToString(), 512); + + private static string? Truncate(string? value, int max) => + string.IsNullOrEmpty(value) ? value : (value.Length <= max ? value : value[..max]); +} diff --git a/api/Controllers/AuditController.cs b/api/Controllers/AuditController.cs new file mode 100644 index 0000000..9afe2b8 --- /dev/null +++ b/api/Controllers/AuditController.cs @@ -0,0 +1,43 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using mws.backend.dotnet.application.Audit; +using mws.backend.dotnet.application.Common; + +namespace mws.backend.dotnet.api.Controllers; + +[ApiController] +[Route("api/audit")] +[Authorize] +public class AuditController(ISender sender) : ControllerBase +{ + [HttpGet("actions")] + public async Task>> GetActions( + [FromQuery] DateTimeOffset? from, + [FromQuery] DateTimeOffset? to, + [FromQuery] string? actor, + [FromQuery] string? action, + [FromQuery] string? entityType, + [FromQuery] Guid? entityId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken ct = default) + { + var filter = new ActionLogFilter(from, to, actor, action, entityType, entityId); + return Ok(await sender.Send(new GetActionLogsQuery(User.GetUserId(), filter, page, pageSize), ct)); + } + + [HttpGet("logins")] + public async Task>> GetLogins( + [FromQuery] DateTimeOffset? from, + [FromQuery] DateTimeOffset? to, + [FromQuery] string? username, + [FromQuery] bool? success, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken ct = default) + { + var filter = new LoginLogFilter(from, to, username, success); + return Ok(await sender.Send(new GetLoginLogsQuery(User.GetUserId(), filter, page, pageSize), ct)); + } +} diff --git a/api/Controllers/UsersController.cs b/api/Controllers/UsersController.cs index 8e103ef..b5f731f 100644 --- a/api/Controllers/UsersController.cs +++ b/api/Controllers/UsersController.cs @@ -6,7 +6,6 @@ using Microsoft.EntityFrameworkCore; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Permissions; using mws.backend.dotnet.application.Users; -using mws.backend.dotnet.domain.Users; using mws.backend.dotnet.infrastructure.Persistence; namespace mws.backend.dotnet.api.Controllers; @@ -95,32 +94,14 @@ public class UsersController(ISender sender, AppDbContext db, IPermissionService [HttpPost("{id:guid}/roles")] public async Task AssignRole(Guid id, [FromBody] AssignRoleRequest req, CancellationToken ct) { - await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.Edit, ct); - - var user = await db.Users.Include(u => u.UserRoles).FirstOrDefaultAsync(u => u.Id == id, ct); - if (user is null) return NotFound("User not found"); - - if (!user.UserRoles.Any(ur => ur.RoleId == req.RoleId)) - { - user.UserRoles.Add(new UserRole { UserId = id, RoleId = req.RoleId }); - await db.SaveChangesAsync(ct); - } - + await sender.Send(new AssignUserRoleCommand(User.GetUserId(), id, req.RoleId), ct); return NoContent(); } [HttpDelete("{id:guid}/roles/{roleId:guid}")] public async Task UnassignRole(Guid id, Guid roleId, CancellationToken ct) { - await permissions.EnsureAsync(User.GetUserId(), "permissions", PermissionAction.Edit, ct); - - var ur = await db.UserRoles.FirstOrDefaultAsync(x => x.UserId == id && x.RoleId == roleId, ct); - if (ur != null) - { - db.UserRoles.Remove(ur); - await db.SaveChangesAsync(ct); - } - + await sender.Send(new UnassignUserRoleCommand(User.GetUserId(), id, roleId), ct); return NoContent(); } diff --git a/api/Program.cs b/api/Program.cs index 0c4d49f..8dae464 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -3,8 +3,10 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; +using mws.backend.dotnet.api.Authentication; using mws.backend.dotnet.api.Middleware; using mws.backend.dotnet.application.Auth; +using mws.backend.dotnet.application.Common; using mws.backend.dotnet.infrastructure; using mws.backend.dotnet.infrastructure.Persistence; @@ -46,6 +48,8 @@ builder.Services.AddSwaggerGen(options => }); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); var jwtSection = builder.Configuration.GetSection("Jwt"); var jwtSecret = jwtSection["Secret"]; diff --git a/application/Audit/AuditContext.cs b/application/Audit/AuditContext.cs new file mode 100644 index 0000000..1fba6dd --- /dev/null +++ b/application/Audit/AuditContext.cs @@ -0,0 +1,7 @@ +namespace mws.backend.dotnet.application.Audit; + +public class AuditContext : IAuditContext +{ + public Guid? EntityId { get; set; } + public string? EntityName { get; set; } +} diff --git a/application/Audit/AuditLogBehavior.cs b/application/Audit/AuditLogBehavior.cs new file mode 100644 index 0000000..0871f81 --- /dev/null +++ b/application/Audit/AuditLogBehavior.cs @@ -0,0 +1,48 @@ +using MediatR; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.domain.Audit; + +namespace mws.backend.dotnet.application.Audit; + +public class AuditLogBehavior( + IUnitOfWork uow, + ICurrentUser currentUser, + IAuditContext auditContext) + : IPipelineBehavior where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken ct) + { + if (request is not IAuditableRequest auditable) + { + return await next(); + } + + auditContext.EntityId = null; + auditContext.EntityName = null; + + var response = await next(); + + uow.ActionLogs.Add(new ActionLog + { + Id = Guid.NewGuid(), + ActorUserId = currentUser.UserId ?? Guid.Empty, + ActorUsername = TruncateRequired(currentUser.Username ?? "unknown", 100), + Action = auditable.Action, + EntityType = auditable.EntityType, + EntityId = auditable.EntityId ?? auditContext.EntityId ?? (response as IHasEntityId)?.EntityId, + EntityName = Truncate(auditable.EntityName ?? auditContext.EntityName, 255), + IpAddress = currentUser.IpAddress, + UserAgent = currentUser.UserAgent, + CreatedAt = DateTimeOffset.UtcNow, + }); + await uow.SaveChangesAsync(ct); + + return response; + } + + private static string TruncateRequired(string value, int max) => + value.Length <= max ? value : value[..max]; + + private static string? Truncate(string? value, int max) => + value is null || value.Length <= max ? value : value[..max]; +} diff --git a/application/Audit/Contracts.cs b/application/Audit/Contracts.cs new file mode 100644 index 0000000..09019d1 --- /dev/null +++ b/application/Audit/Contracts.cs @@ -0,0 +1,41 @@ +namespace mws.backend.dotnet.application.Audit; + +public record ActionLogFilter( + DateTimeOffset? From, + DateTimeOffset? To, + string? Actor, + string? Action, + string? EntityType, + Guid? EntityId); + +public record LoginLogFilter( + DateTimeOffset? From, + DateTimeOffset? To, + string? Username, + bool? Success); + +public class ActionLogDto +{ + public Guid Id { get; set; } + public Guid ActorUserId { get; set; } + public string ActorUsername { get; set; } = string.Empty; + public string Action { get; set; } = string.Empty; + public string EntityType { get; set; } = string.Empty; + public Guid? EntityId { get; set; } + public string? EntityName { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} + +public class LoginLogDto +{ + public Guid Id { get; set; } + public string Username { get; set; } = string.Empty; + public Guid? UserId { get; set; } + public bool Success { get; set; } + public string? FailureReason { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/application/Audit/IAuditContext.cs b/application/Audit/IAuditContext.cs new file mode 100644 index 0000000..189f744 --- /dev/null +++ b/application/Audit/IAuditContext.cs @@ -0,0 +1,7 @@ +namespace mws.backend.dotnet.application.Audit; + +public interface IAuditContext +{ + Guid? EntityId { get; set; } + string? EntityName { get; set; } +} diff --git a/application/Audit/IAuditableRequest.cs b/application/Audit/IAuditableRequest.cs new file mode 100644 index 0000000..a69b17d --- /dev/null +++ b/application/Audit/IAuditableRequest.cs @@ -0,0 +1,9 @@ +namespace mws.backend.dotnet.application.Audit; + +public interface IAuditableRequest +{ + string Action { get; } + string EntityType { get; } + Guid? EntityId => null; + string? EntityName => null; +} diff --git a/application/Audit/IHasEntityId.cs b/application/Audit/IHasEntityId.cs new file mode 100644 index 0000000..8b55c23 --- /dev/null +++ b/application/Audit/IHasEntityId.cs @@ -0,0 +1,6 @@ +namespace mws.backend.dotnet.application.Audit; + +public interface IHasEntityId +{ + Guid EntityId { get; } +} diff --git a/application/Audit/Queries/GetActionLogs.cs b/application/Audit/Queries/GetActionLogs.cs new file mode 100644 index 0000000..eaa875b --- /dev/null +++ b/application/Audit/Queries/GetActionLogs.cs @@ -0,0 +1,38 @@ +using MediatR; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Permissions; + +namespace mws.backend.dotnet.application.Audit; + +public record GetActionLogsQuery(Guid ActorUserId, ActionLogFilter Filter, int Page, int PageSize) + : IRequest>; + +public class GetActionLogsHandler(IUnitOfWork uow, IPermissionService permissions) + : IRequestHandler> +{ + public async Task> Handle(GetActionLogsQuery query, CancellationToken ct) + { + await permissions.EnsureAsync(query.ActorUserId, "actionlogs", PermissionAction.View, ct); + + var page = await uow.ActionLogs.SearchAsync(query.Filter, query.Page, query.PageSize, ct); + return new PagedResult + { + Items = page.Items.Select(x => new ActionLogDto + { + Id = x.Id, + ActorUserId = x.ActorUserId, + ActorUsername = x.ActorUsername, + Action = x.Action, + EntityType = x.EntityType, + EntityId = x.EntityId, + EntityName = x.EntityName, + IpAddress = x.IpAddress, + UserAgent = x.UserAgent, + CreatedAt = x.CreatedAt, + }).ToList(), + TotalCount = page.TotalCount, + Page = page.Page, + PageSize = page.PageSize, + }; + } +} diff --git a/application/Audit/Queries/GetLoginLogs.cs b/application/Audit/Queries/GetLoginLogs.cs new file mode 100644 index 0000000..171fd51 --- /dev/null +++ b/application/Audit/Queries/GetLoginLogs.cs @@ -0,0 +1,36 @@ +using MediatR; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Permissions; + +namespace mws.backend.dotnet.application.Audit; + +public record GetLoginLogsQuery(Guid ActorUserId, LoginLogFilter Filter, int Page, int PageSize) + : IRequest>; + +public class GetLoginLogsHandler(IUnitOfWork uow, IPermissionService permissions) + : IRequestHandler> +{ + public async Task> Handle(GetLoginLogsQuery query, CancellationToken ct) + { + await permissions.EnsureAsync(query.ActorUserId, "loginlogs", PermissionAction.View, ct); + + var page = await uow.LoginLogs.SearchAsync(query.Filter, query.Page, query.PageSize, ct); + return new PagedResult + { + Items = page.Items.Select(x => new LoginLogDto + { + Id = x.Id, + Username = x.Username, + UserId = x.UserId, + Success = x.Success, + FailureReason = x.FailureReason, + IpAddress = x.IpAddress, + UserAgent = x.UserAgent, + CreatedAt = x.CreatedAt, + }).ToList(), + TotalCount = page.TotalCount, + Page = page.Page, + PageSize = page.PageSize, + }; + } +} diff --git a/application/Auth/Commands/Login.cs b/application/Auth/Commands/Login.cs index ad005c1..93651b6 100644 --- a/application/Auth/Commands/Login.cs +++ b/application/Auth/Commands/Login.cs @@ -2,12 +2,13 @@ using AutoMapper; using MediatR; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Users; +using mws.backend.dotnet.domain.Audit; namespace mws.backend.dotnet.application.Auth; public record LoginCommand(LoginRequest Request) : IRequest; -public class LoginHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper) +public class LoginHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper, ICurrentUser currentUser) : IRequestHandler { public async Task Handle(LoginCommand command, CancellationToken ct) @@ -15,20 +16,46 @@ public class LoginHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IToke var username = command.Request.Username.Trim(); var user = await uow.Users.GetByUsernameWithRoleAsync(username, ct); - if (user is null || !passwordHasher.Verify(command.Request.Password, user.PasswordHash)) + if (user is null) { + await WriteLoginLogAsync(username, null, false, "Unknown username", ct); + throw new UnauthorizedException("Invalid username or password"); + } + + if (!passwordHasher.Verify(command.Request.Password, user.PasswordHash)) + { + await WriteLoginLogAsync(username, user.Id, false, "Invalid password", ct); throw new UnauthorizedException("Invalid username or password"); } if (!user.IsActive) { + await WriteLoginLogAsync(username, user.Id, false, "Account disabled", ct); throw new ForbiddenException("Account disabled"); } + await WriteLoginLogAsync(username, user.Id, true, null, ct); + return new LoginResponse { Token = tokenService.CreateToken(user.Id, user.Username), User = mapper.Map(user), }; } + + private async Task WriteLoginLogAsync(string username, Guid? userId, bool success, string? failureReason, CancellationToken ct) + { + uow.LoginLogs.Add(new LoginLog + { + Id = Guid.NewGuid(), + Username = username.Length <= 100 ? username : username[..100], + UserId = userId, + Success = success, + FailureReason = failureReason, + IpAddress = currentUser.IpAddress, + UserAgent = currentUser.UserAgent, + CreatedAt = DateTimeOffset.UtcNow, + }); + await uow.SaveChangesAsync(ct); + } } diff --git a/application/Common/ICurrentUser.cs b/application/Common/ICurrentUser.cs new file mode 100644 index 0000000..c4ce4ae --- /dev/null +++ b/application/Common/ICurrentUser.cs @@ -0,0 +1,9 @@ +namespace mws.backend.dotnet.application.Common; + +public interface ICurrentUser +{ + Guid? UserId { get; } + string? Username { get; } + string? IpAddress { get; } + string? UserAgent { get; } +} diff --git a/application/Common/IUnitOfWork.cs b/application/Common/IUnitOfWork.cs index 9cf3b78..ab8a098 100644 --- a/application/Common/IUnitOfWork.cs +++ b/application/Common/IUnitOfWork.cs @@ -10,6 +10,8 @@ public interface IUnitOfWork IDocumentRepository Documents { get; } IRoleRepository Roles { get; } IMasterDataRepository MasterData { get; } + IActionLogRepository ActionLogs { get; } + ILoginLogRepository LoginLogs { get; } Task SaveChangesAsync(CancellationToken ct = default); } diff --git a/application/Common/Repositories/IActionLogRepository.cs b/application/Common/Repositories/IActionLogRepository.cs new file mode 100644 index 0000000..d71423b --- /dev/null +++ b/application/Common/Repositories/IActionLogRepository.cs @@ -0,0 +1,10 @@ +using mws.backend.dotnet.application.Audit; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.domain.Audit; + +namespace mws.backend.dotnet.application.Common.Repositories; + +public interface IActionLogRepository : IRepository +{ + Task> SearchAsync(ActionLogFilter filter, int page, int pageSize, CancellationToken ct = default); +} diff --git a/application/Common/Repositories/ILoginLogRepository.cs b/application/Common/Repositories/ILoginLogRepository.cs new file mode 100644 index 0000000..3f3fbc4 --- /dev/null +++ b/application/Common/Repositories/ILoginLogRepository.cs @@ -0,0 +1,10 @@ +using mws.backend.dotnet.application.Audit; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.domain.Audit; + +namespace mws.backend.dotnet.application.Common.Repositories; + +public interface ILoginLogRepository : IRepository +{ + Task> SearchAsync(LoginLogFilter filter, int page, int pageSize, CancellationToken ct = default); +} diff --git a/application/Documents/Commands/CreateDocument.cs b/application/Documents/Commands/CreateDocument.cs index e941563..37f6105 100644 --- a/application/Documents/Commands/CreateDocument.cs +++ b/application/Documents/Commands/CreateDocument.cs @@ -1,13 +1,19 @@ using System.Text; using AutoMapper; using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Permissions; using mws.backend.dotnet.domain.Documents; namespace mws.backend.dotnet.application.Documents; -public record CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest; +public record CreateDocumentCommand(Guid UserId, Guid ProjectId, CreateDocumentRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "Document.Create"; + public string EntityType => "Document"; + public string? EntityName => Request.Title; +} public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentContentStore contentStore) : IRequestHandler diff --git a/application/Documents/Commands/DeleteDocument.cs b/application/Documents/Commands/DeleteDocument.cs index 0ae73ba..6029b10 100644 --- a/application/Documents/Commands/DeleteDocument.cs +++ b/application/Documents/Commands/DeleteDocument.cs @@ -1,12 +1,18 @@ using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Permissions; namespace mws.backend.dotnet.application.Documents; -public record DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest; +public record DeleteDocumentCommand(Guid UserId, Guid DocumentId) : IRequest, IAuditableRequest +{ + public string Action => "Document.Delete"; + public string EntityType => "Document"; + public Guid? EntityId => DocumentId; +} -public class DeleteDocumentHandler(IUnitOfWork uow, IDocumentContentStore contentStore) +public class DeleteDocumentHandler(IUnitOfWork uow, IDocumentContentStore contentStore, IAuditContext auditContext) : IRequestHandler { public async Task Handle(DeleteDocumentCommand command, CancellationToken ct) @@ -25,6 +31,8 @@ public class DeleteDocumentHandler(IUnitOfWork uow, IDocumentContentStore conten uow.Documents.Remove(doc); await uow.SaveChangesAsync(ct); + auditContext.EntityName = doc.Title; + foreach (var key in keys) { await contentStore.DeleteAsync(key, ct); diff --git a/application/Documents/Commands/MoveDocument.cs b/application/Documents/Commands/MoveDocument.cs index 15e7796..24215f6 100644 --- a/application/Documents/Commands/MoveDocument.cs +++ b/application/Documents/Commands/MoveDocument.cs @@ -1,13 +1,19 @@ using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Permissions; using mws.backend.dotnet.domain.Documents; namespace mws.backend.dotnet.application.Documents; -public record MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest; +public record MoveDocumentCommand(Guid UserId, Guid DocumentId, Guid? NewParentId) : IRequest, IAuditableRequest +{ + public string Action => "Document.Move"; + public string EntityType => "Document"; + public Guid? EntityId => DocumentId; +} -public class MoveDocumentHandler(IUnitOfWork uow) : IRequestHandler +public class MoveDocumentHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler { public async Task Handle(MoveDocumentCommand command, CancellationToken ct) { @@ -47,5 +53,7 @@ public class MoveDocumentHandler(IUnitOfWork uow) : IRequestHandler; +public record UpdateDocumentCommand(Guid UserId, Guid DocumentId, UpdateDocumentRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "Document.Update"; + public string EntityType => "Document"; + public Guid? EntityId => DocumentId; + public string? EntityName => Request.Title; +} public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentContentStore contentStore) : IRequestHandler diff --git a/application/Documents/Contracts.cs b/application/Documents/Contracts.cs index 0fd98c9..b32447e 100644 --- a/application/Documents/Contracts.cs +++ b/application/Documents/Contracts.cs @@ -1,3 +1,4 @@ +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.domain.Documents; namespace mws.backend.dotnet.application.Documents; @@ -21,9 +22,10 @@ public class MoveDocumentRequest public Guid? NewParentId { get; set; } } -public class DocumentDto +public class DocumentDto : IHasEntityId { public Guid Id { get; set; } + public Guid EntityId => Id; public Guid ProjectId { get; set; } public Guid? ParentId { get; set; } public string Title { get; set; } = string.Empty; diff --git a/application/MasterData/Commands/CreateMasterData.cs b/application/MasterData/Commands/CreateMasterData.cs index 0cfaeb7..5beac07 100644 --- a/application/MasterData/Commands/CreateMasterData.cs +++ b/application/MasterData/Commands/CreateMasterData.cs @@ -1,10 +1,16 @@ using AutoMapper; using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; namespace mws.backend.dotnet.application.MasterData; -public record CreateMasterDataCommand(SaveMasterDataRequest Request) : IRequest; +public record CreateMasterDataCommand(SaveMasterDataRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "MasterData.Create"; + public string EntityType => "MasterData"; + public string? EntityName => Request.Label; +} public class CreateMasterDataHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { diff --git a/application/MasterData/Commands/DeleteMasterData.cs b/application/MasterData/Commands/DeleteMasterData.cs index 48c0d42..b0b34f0 100644 --- a/application/MasterData/Commands/DeleteMasterData.cs +++ b/application/MasterData/Commands/DeleteMasterData.cs @@ -1,11 +1,17 @@ using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; namespace mws.backend.dotnet.application.MasterData; -public record DeleteMasterDataCommand(Guid Id) : IRequest; +public record DeleteMasterDataCommand(Guid Id) : IRequest, IAuditableRequest +{ + public string Action => "MasterData.Delete"; + public string EntityType => "MasterData"; + public Guid? EntityId => Id; +} -public class DeleteMasterDataHandler(IUnitOfWork uow) : IRequestHandler +public class DeleteMasterDataHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler { public async Task Handle(DeleteMasterDataCommand command, CancellationToken ct) { @@ -14,5 +20,7 @@ public class DeleteMasterDataHandler(IUnitOfWork uow) : IRequestHandler; +public record UpdateMasterDataCommand(Guid Id, SaveMasterDataRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "MasterData.Update"; + public string EntityType => "MasterData"; + public Guid? EntityId => Id; + public string? EntityName => Request.Label; +} public class UpdateMasterDataHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { diff --git a/application/MasterData/Contracts.cs b/application/MasterData/Contracts.cs index 3b84b68..bef3938 100644 --- a/application/MasterData/Contracts.cs +++ b/application/MasterData/Contracts.cs @@ -1,8 +1,11 @@ +using mws.backend.dotnet.application.Audit; + namespace mws.backend.dotnet.application.MasterData; -public class MasterDataDto +public class MasterDataDto : IHasEntityId { public Guid Id { get; set; } + public Guid EntityId => Id; public string Group { get; set; } = string.Empty; public string Label { get; set; } = string.Empty; public string Value { get; set; } = string.Empty; diff --git a/application/Permissions/Commands/CreateRole.cs b/application/Permissions/Commands/CreateRole.cs index fc0af5b..d367918 100644 --- a/application/Permissions/Commands/CreateRole.cs +++ b/application/Permissions/Commands/CreateRole.cs @@ -1,11 +1,17 @@ using AutoMapper; using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.domain.Roles; namespace mws.backend.dotnet.application.Permissions; -public record CreateRoleCommand(SaveRoleRequest Request) : IRequest; +public record CreateRoleCommand(SaveRoleRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "Role.Create"; + public string EntityType => "Role"; + public string? EntityName => Request.Name; +} public class CreateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { diff --git a/application/Permissions/Commands/DeleteRole.cs b/application/Permissions/Commands/DeleteRole.cs index 92179b4..fb5881b 100644 --- a/application/Permissions/Commands/DeleteRole.cs +++ b/application/Permissions/Commands/DeleteRole.cs @@ -1,11 +1,17 @@ using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; namespace mws.backend.dotnet.application.Permissions; -public record DeleteRoleCommand(Guid Id) : IRequest; +public record DeleteRoleCommand(Guid Id) : IRequest, IAuditableRequest +{ + public string Action => "Role.Delete"; + public string EntityType => "Role"; + public Guid? EntityId => Id; +} -public class DeleteRoleHandler(IUnitOfWork uow) : IRequestHandler +public class DeleteRoleHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler { public async Task Handle(DeleteRoleCommand command, CancellationToken ct) { @@ -24,5 +30,7 @@ public class DeleteRoleHandler(IUnitOfWork uow) : IRequestHandler; +public record UpdateRoleCommand(Guid Id, SaveRoleRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "Role.Update"; + public string EntityType => "Role"; + public Guid? EntityId => Id; + public string? EntityName => Request.Name; +} public class UpdateRoleHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { diff --git a/application/Permissions/Contracts.cs b/application/Permissions/Contracts.cs index bd46bf5..034b333 100644 --- a/application/Permissions/Contracts.cs +++ b/application/Permissions/Contracts.cs @@ -1,3 +1,5 @@ +using mws.backend.dotnet.application.Audit; + namespace mws.backend.dotnet.application.Permissions; public enum PermissionAction @@ -28,9 +30,10 @@ public class PermissionEntryDto public bool CanDelete { get; set; } } -public class RoleDto +public class RoleDto : IHasEntityId { public Guid Id { get; set; } + public Guid EntityId => Id; public string Name { get; set; } = string.Empty; public bool IsSystem { get; set; } public List Permissions { get; set; } = []; diff --git a/application/Permissions/ScreenCatalog.cs b/application/Permissions/ScreenCatalog.cs index 9aae8e2..c6e66df 100644 --- a/application/Permissions/ScreenCatalog.cs +++ b/application/Permissions/ScreenCatalog.cs @@ -13,6 +13,8 @@ public static class ScreenCatalog new("users", "Users", "/users"), new("permissions", "Permissions", "/settings/permission"), new("masterdata", "Master Data", "/settings/masterdata"), + new("actionlogs", "Action Logs", "/settings/actionlogs"), + new("loginlogs", "Login Logs", "/settings/loginlogs"), ]; public static readonly IReadOnlySet Keys = Screens.Select(s => s.Key).ToHashSet(); diff --git a/application/Projects/Commands/AddProjectMember.cs b/application/Projects/Commands/AddProjectMember.cs index 163a974..a7dedf2 100644 --- a/application/Projects/Commands/AddProjectMember.cs +++ b/application/Projects/Commands/AddProjectMember.cs @@ -1,12 +1,18 @@ using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.application.Projects; -public record AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest; +public record AddProjectMemberCommand(Guid UserId, Guid ProjectId, AddMemberRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "ProjectMember.Add"; + public string EntityType => "ProjectMember"; + public Guid? EntityId => Request.UserId; +} -public class AddProjectMemberHandler(IUnitOfWork uow) : IRequestHandler +public class AddProjectMemberHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler { public async Task Handle(AddProjectMemberCommand command, CancellationToken ct) { @@ -46,6 +52,8 @@ public class AddProjectMemberHandler(IUnitOfWork uow) : IRequestHandler "Project.Archive"; + public string EntityType => "Project"; + public Guid? EntityId => ProjectId; +} -public class ArchiveProjectHandler(IUnitOfWork uow, IPermissionService permissions) : IRequestHandler +public class ArchiveProjectHandler(IUnitOfWork uow, IPermissionService permissions, IAuditContext auditContext) : IRequestHandler { public async Task Handle(ArchiveProjectCommand command, CancellationToken ct) { @@ -18,5 +24,7 @@ public class ArchiveProjectHandler(IUnitOfWork uow, IPermissionService permissio project.Status = ProjectStatus.Archived; project.UpdatedAt = DateTime.UtcNow; await uow.SaveChangesAsync(ct); + + auditContext.EntityName = project.Name; } } diff --git a/application/Projects/Commands/CreateProject.cs b/application/Projects/Commands/CreateProject.cs index 7585cfe..7fbfb5f 100644 --- a/application/Projects/Commands/CreateProject.cs +++ b/application/Projects/Commands/CreateProject.cs @@ -1,12 +1,18 @@ using AutoMapper; using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Permissions; using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.application.Projects; -public record CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest; +public record CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "Project.Create"; + public string EntityType => "Project"; + public string? EntityName => Request.Name; +} public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper, IPermissionService permissions) : IRequestHandler { diff --git a/application/Projects/Commands/RemoveProjectMember.cs b/application/Projects/Commands/RemoveProjectMember.cs index 2bea193..6502abd 100644 --- a/application/Projects/Commands/RemoveProjectMember.cs +++ b/application/Projects/Commands/RemoveProjectMember.cs @@ -1,12 +1,18 @@ using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.application.Projects; -public record RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest; +public record RemoveProjectMemberCommand(Guid UserId, Guid ProjectId, Guid MemberUserId) : IRequest, IAuditableRequest +{ + public string Action => "ProjectMember.Remove"; + public string EntityType => "ProjectMember"; + public Guid? EntityId => MemberUserId; +} -public class RemoveProjectMemberHandler(IUnitOfWork uow) : IRequestHandler +public class RemoveProjectMemberHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler { public async Task Handle(RemoveProjectMemberCommand command, CancellationToken ct) { @@ -15,7 +21,7 @@ public class RemoveProjectMemberHandler(IUnitOfWork uow) : IRequestHandler; + Guid UserId, Guid ProjectId, Guid MemberUserId, UpdateMemberDocumentPermissionsRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "ProjectMember.UpdatePermissions"; + public string EntityType => "ProjectMember"; + public Guid? EntityId => MemberUserId; +} -public class UpdateMemberDocumentPermissionsHandler(IUnitOfWork uow) +public class UpdateMemberDocumentPermissionsHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler { public async Task Handle(UpdateMemberDocumentPermissionsCommand command, CancellationToken ct) @@ -39,6 +45,8 @@ public class UpdateMemberDocumentPermissionsHandler(IUnitOfWork uow) permission.CanDelete = request.CanDeleteDocuments; await uow.SaveChangesAsync(ct); + auditContext.EntityName = member.User.Username; + return new ProjectMemberDto { UserId = member.UserId, diff --git a/application/Projects/Commands/UpdateProject.cs b/application/Projects/Commands/UpdateProject.cs index 791db40..6a6e9d0 100644 --- a/application/Projects/Commands/UpdateProject.cs +++ b/application/Projects/Commands/UpdateProject.cs @@ -1,11 +1,18 @@ using AutoMapper; using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Permissions; namespace mws.backend.dotnet.application.Projects; -public record UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest; +public record UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "Project.Update"; + public string EntityType => "Project"; + public Guid? EntityId => ProjectId; + public string? EntityName => Request.Name; +} public class UpdateProjectHandler(IUnitOfWork uow, IMapper mapper, IPermissionService permissions) : IRequestHandler { diff --git a/application/Projects/Contracts.cs b/application/Projects/Contracts.cs index 61d1338..f052bee 100644 --- a/application/Projects/Contracts.cs +++ b/application/Projects/Contracts.cs @@ -1,3 +1,4 @@ +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.application.Projects; @@ -41,9 +42,10 @@ public class UpdateMemberDocumentPermissionsRequest public bool CanDeleteDocuments { get; set; } } -public class ProjectDto +public class ProjectDto : IHasEntityId { public Guid Id { get; set; } + public Guid EntityId => Id; public string Name { get; set; } = string.Empty; public string? Description { get; set; } public ProjectStatus Status { get; set; } diff --git a/application/Tasks/Commands/CreateTask.cs b/application/Tasks/Commands/CreateTask.cs index 31c4e19..e6d7c8c 100644 --- a/application/Tasks/Commands/CreateTask.cs +++ b/application/Tasks/Commands/CreateTask.cs @@ -1,5 +1,6 @@ using AutoMapper; using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.domain.Tasks; using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority; @@ -7,7 +8,12 @@ using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus; namespace mws.backend.dotnet.application.Tasks; -public record CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest; +public record CreateTaskCommand(Guid UserId, Guid ProjectId, CreateTaskRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "Task.Create"; + public string EntityType => "Task"; + public string? EntityName => Request.Title; +} public class CreateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { diff --git a/application/Tasks/Commands/DeleteTask.cs b/application/Tasks/Commands/DeleteTask.cs index ae3d938..5360b50 100644 --- a/application/Tasks/Commands/DeleteTask.cs +++ b/application/Tasks/Commands/DeleteTask.cs @@ -1,16 +1,24 @@ using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; namespace mws.backend.dotnet.application.Tasks; -public record DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest; +public record DeleteTaskCommand(Guid UserId, Guid TaskId) : IRequest, IAuditableRequest +{ + public string Action => "Task.Delete"; + public string EntityType => "Task"; + public Guid? EntityId => TaskId; +} -public class DeleteTaskHandler(IUnitOfWork uow) : IRequestHandler +public class DeleteTaskHandler(IUnitOfWork uow, IAuditContext auditContext) : IRequestHandler { public async Task Handle(DeleteTaskCommand command, CancellationToken ct) { var task = await TaskAccess.GetTaskForUserAsync(uow, command.UserId, command.TaskId, ct); uow.Tasks.Remove(task); await uow.SaveChangesAsync(ct); + + auditContext.EntityName = task.Title; } } diff --git a/application/Tasks/Commands/UpdateTask.cs b/application/Tasks/Commands/UpdateTask.cs index fa1f127..cee5c66 100644 --- a/application/Tasks/Commands/UpdateTask.cs +++ b/application/Tasks/Commands/UpdateTask.cs @@ -1,10 +1,17 @@ using AutoMapper; using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; namespace mws.backend.dotnet.application.Tasks; -public record UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest; +public record UpdateTaskCommand(Guid UserId, Guid TaskId, UpdateTaskRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "Task.Update"; + public string EntityType => "Task"; + public Guid? EntityId => TaskId; + public string? EntityName => Request.Title; +} public class UpdateTaskHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler { diff --git a/application/Tasks/Contracts.cs b/application/Tasks/Contracts.cs index 63bcf3e..a8ead60 100644 --- a/application/Tasks/Contracts.cs +++ b/application/Tasks/Contracts.cs @@ -1,3 +1,4 @@ +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.domain.Tasks; using TaskPriority = mws.backend.dotnet.domain.Tasks.TaskPriority; using TaskStatus = mws.backend.dotnet.domain.Tasks.TaskStatus; @@ -24,9 +25,10 @@ public class UpdateTaskRequest public DateTime? DueDate { get; set; } } -public class TaskDto +public class TaskDto : IHasEntityId { public Guid Id { get; set; } + public Guid EntityId => Id; public Guid ProjectId { get; set; } public string Title { get; set; } = string.Empty; public string? Description { get; set; } diff --git a/application/Users/Commands/AssignUserRole.cs b/application/Users/Commands/AssignUserRole.cs new file mode 100644 index 0000000..0b9edf8 --- /dev/null +++ b/application/Users/Commands/AssignUserRole.cs @@ -0,0 +1,34 @@ +using MediatR; +using mws.backend.dotnet.application.Audit; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Permissions; +using mws.backend.dotnet.domain.Users; + +namespace mws.backend.dotnet.application.Users; + +public record AssignUserRoleCommand(Guid ActorUserId, Guid TargetUserId, Guid RoleId) : IRequest, IAuditableRequest +{ + public string Action => "User.AssignRole"; + public string EntityType => "User"; + public Guid? EntityId => TargetUserId; +} + +public class AssignUserRoleHandler(IUnitOfWork uow, IPermissionService permissions, IAuditContext auditContext) + : IRequestHandler +{ + public async Task Handle(AssignUserRoleCommand command, CancellationToken ct) + { + await permissions.EnsureAsync(command.ActorUserId, "permissions", PermissionAction.Edit, ct); + + var user = await uow.Users.GetByIdWithRoleAsync(command.TargetUserId, ct) + ?? throw new NotFoundException("User not found"); + + if (user.UserRoles.All(ur => ur.RoleId != command.RoleId)) + { + user.UserRoles.Add(new UserRole { UserId = command.TargetUserId, RoleId = command.RoleId }); + await uow.SaveChangesAsync(ct); + } + + auditContext.EntityName = user.Username; + } +} diff --git a/application/Users/Commands/CreateUser.cs b/application/Users/Commands/CreateUser.cs index c34bfde..38dbf88 100644 --- a/application/Users/Commands/CreateUser.cs +++ b/application/Users/Commands/CreateUser.cs @@ -1,5 +1,6 @@ using AutoMapper; using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Auth; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Permissions; @@ -8,7 +9,12 @@ using mws.backend.dotnet.domain.Users; namespace mws.backend.dotnet.application.Users; -public record CreateUserCommand(Guid ActorUserId, CreateUserRequest Request) : IRequest; +public record CreateUserCommand(Guid ActorUserId, CreateUserRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "User.Create"; + public string EntityType => "User"; + public string? EntityName => Request.Username; +} public class CreateUserHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper) : IRequestHandler diff --git a/application/Users/Commands/DeleteUser.cs b/application/Users/Commands/DeleteUser.cs index 52f8f49..8d5802c 100644 --- a/application/Users/Commands/DeleteUser.cs +++ b/application/Users/Commands/DeleteUser.cs @@ -1,12 +1,18 @@ using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Permissions; namespace mws.backend.dotnet.application.Users; -public record DeleteUserCommand(Guid ActorUserId, Guid Id) : IRequest; +public record DeleteUserCommand(Guid ActorUserId, Guid Id) : IRequest, IAuditableRequest +{ + public string Action => "User.Delete"; + public string EntityType => "User"; + public Guid? EntityId => Id; +} -public class DeleteUserHandler(IUnitOfWork uow, IPermissionService permissions) : IRequestHandler +public class DeleteUserHandler(IUnitOfWork uow, IPermissionService permissions, IAuditContext auditContext) : IRequestHandler { private const string Screen = "users"; @@ -30,5 +36,7 @@ public class DeleteUserHandler(IUnitOfWork uow, IPermissionService permissions) uow.Users.Remove(user); await uow.SaveChangesAsync(ct); + + auditContext.EntityName = user.Username; } } diff --git a/application/Users/Commands/ResetPassword.cs b/application/Users/Commands/ResetPassword.cs index 3aaced1..aee4351 100644 --- a/application/Users/Commands/ResetPassword.cs +++ b/application/Users/Commands/ResetPassword.cs @@ -1,13 +1,19 @@ using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Auth; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Permissions; namespace mws.backend.dotnet.application.Users; -public record ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest; +public record ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "User.ResetPassword"; + public string EntityType => "User"; + public Guid? EntityId => Id; +} -public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions) +public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IAuditContext auditContext) : IRequestHandler { private const string Screen = "users"; @@ -27,5 +33,7 @@ public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHashe user.PasswordHash = passwordHasher.Hash(command.Request.NewPassword); user.UpdatedAt = DateTime.UtcNow; await uow.SaveChangesAsync(ct); + + auditContext.EntityName = user.Username; } } diff --git a/application/Users/Commands/UnassignUserRole.cs b/application/Users/Commands/UnassignUserRole.cs new file mode 100644 index 0000000..91524bf --- /dev/null +++ b/application/Users/Commands/UnassignUserRole.cs @@ -0,0 +1,34 @@ +using MediatR; +using mws.backend.dotnet.application.Audit; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Permissions; + +namespace mws.backend.dotnet.application.Users; + +public record UnassignUserRoleCommand(Guid ActorUserId, Guid TargetUserId, Guid RoleId) : IRequest, IAuditableRequest +{ + public string Action => "User.UnassignRole"; + public string EntityType => "User"; + public Guid? EntityId => TargetUserId; +} + +public class UnassignUserRoleHandler(IUnitOfWork uow, IPermissionService permissions, IAuditContext auditContext) + : IRequestHandler +{ + public async Task Handle(UnassignUserRoleCommand command, CancellationToken ct) + { + await permissions.EnsureAsync(command.ActorUserId, "permissions", PermissionAction.Edit, ct); + + var user = await uow.Users.GetByIdWithRoleAsync(command.TargetUserId, ct) + ?? throw new NotFoundException("User not found"); + + var userRole = user.UserRoles.FirstOrDefault(ur => ur.RoleId == command.RoleId); + if (userRole is not null) + { + user.UserRoles.Remove(userRole); + await uow.SaveChangesAsync(ct); + } + + auditContext.EntityName = user.Username; + } +} diff --git a/application/Users/Commands/UpdateUser.cs b/application/Users/Commands/UpdateUser.cs index 7dceb42..fe0619e 100644 --- a/application/Users/Commands/UpdateUser.cs +++ b/application/Users/Commands/UpdateUser.cs @@ -1,11 +1,18 @@ using AutoMapper; using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Permissions; namespace mws.backend.dotnet.application.Users; -public record UpdateUserCommand(Guid ActorUserId, Guid Id, UpdateUserRequest Request) : IRequest; +public record UpdateUserCommand(Guid ActorUserId, Guid Id, UpdateUserRequest Request) : IRequest, IAuditableRequest +{ + public string Action => "User.Update"; + public string EntityType => "User"; + public Guid? EntityId => Id; + public string? EntityName => Request.DisplayName; +} public class UpdateUserHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper) : IRequestHandler diff --git a/application/Users/Contracts.cs b/application/Users/Contracts.cs index cacc0cf..7b7bfd0 100644 --- a/application/Users/Contracts.cs +++ b/application/Users/Contracts.cs @@ -1,3 +1,4 @@ +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Permissions; namespace mws.backend.dotnet.application.Users; @@ -22,9 +23,10 @@ public class ResetPasswordRequest public string NewPassword { get; set; } = string.Empty; } -public class UserDto +public class UserDto : IHasEntityId { public Guid Id { get; set; } + public Guid EntityId => Id; public string Username { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public Guid RoleId { get; set; } diff --git a/domain/Audit/ActionLog.cs b/domain/Audit/ActionLog.cs new file mode 100644 index 0000000..8bc6a9e --- /dev/null +++ b/domain/Audit/ActionLog.cs @@ -0,0 +1,15 @@ +namespace mws.backend.dotnet.domain.Audit; + +public class ActionLog +{ + public Guid Id { get; set; } + public Guid ActorUserId { get; set; } + public string ActorUsername { get; set; } = string.Empty; + public string Action { get; set; } = string.Empty; + public string EntityType { get; set; } = string.Empty; + public Guid? EntityId { get; set; } + public string? EntityName { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/domain/Audit/LoginLog.cs b/domain/Audit/LoginLog.cs new file mode 100644 index 0000000..adb90f8 --- /dev/null +++ b/domain/Audit/LoginLog.cs @@ -0,0 +1,13 @@ +namespace mws.backend.dotnet.domain.Audit; + +public class LoginLog +{ + public Guid Id { get; set; } + public string Username { get; set; } = string.Empty; + public Guid? UserId { get; set; } + public bool Success { get; set; } + public string? FailureReason { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/infrastructure/DependencyInjection.cs b/infrastructure/DependencyInjection.cs index 534d478..2f70d27 100644 --- a/infrastructure/DependencyInjection.cs +++ b/infrastructure/DependencyInjection.cs @@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using MediatR; +using mws.backend.dotnet.application.Audit; using mws.backend.dotnet.application.Auth; using mws.backend.dotnet.application.Common; using mws.backend.dotnet.application.Documents; @@ -25,7 +27,12 @@ public static class DependencyInjection services.AddScoped(); services.AddAutoMapper(cfg => { }, typeof(MappingProfile).Assembly); - services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(IUnitOfWork).Assembly)); + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(typeof(IUnitOfWork).Assembly); + cfg.AddOpenBehavior(typeof(AuditLogBehavior<,>)); + }); + services.AddScoped(); services.AddScoped(); diff --git a/infrastructure/Migrations/20260920085021_AddAuditLogs.Designer.cs b/infrastructure/Migrations/20260920085021_AddAuditLogs.Designer.cs new file mode 100644 index 0000000..4c0ef24 --- /dev/null +++ b/infrastructure/Migrations/20260920085021_AddAuditLogs.Designer.cs @@ -0,0 +1,594 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using mws.backend.dotnet.infrastructure.Persistence; + +#nullable disable + +namespace mws.backend.dotnet.infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260920085021_AddAuditLogs")] + partial class AddAuditLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("mws.backend.dotnet.domain.Audit.ActionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EntityType"); + + b.ToTable("action_logs", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Audit.LoginLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Success"); + + b.HasIndex("Username"); + + b.ToTable("login_logs", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentSize") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("StorageKey") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UpdatedContentAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("ProjectId", "ParentId"); + + b.ToTable("documents", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.MasterData.MasterDataEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Group", "Value") + .IsUnique(); + + b.ToTable("master_data", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("ProjectId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("project_members", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Screen") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanCreate") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanEdit") + .HasColumnType("boolean"); + + b.Property("CanView") + .HasColumnType("boolean"); + + b.HasKey("ProjectId", "UserId", "Screen"); + + b.ToTable("project_member_permissions", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Screen") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanCreate") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanEdit") + .HasColumnType("boolean"); + + b.Property("CanView") + .HasColumnType("boolean"); + + b.HasKey("RoleId", "Screen"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Status"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.HasOne("mws.backend.dotnet.domain.Users.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CreatedByUser"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b => + { + b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project") + .WithMany("Members") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b => + { + b.HasOne("mws.backend.dotnet.domain.Projects.ProjectMember", null) + .WithMany() + .HasForeignKey("ProjectId", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b => + { + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b => + { + b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b => + { + b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("mws.backend.dotnet.domain.Users.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b => + { + b.Navigation("Permissions"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b => + { + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/infrastructure/Migrations/20260920085021_AddAuditLogs.cs b/infrastructure/Migrations/20260920085021_AddAuditLogs.cs new file mode 100644 index 0000000..e6d40ed --- /dev/null +++ b/infrastructure/Migrations/20260920085021_AddAuditLogs.cs @@ -0,0 +1,98 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace mws.backend.dotnet.infrastructure.Migrations +{ + /// + public partial class AddAuditLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "action_logs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ActorUserId = table.Column(type: "uuid", nullable: false), + ActorUsername = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Action = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + EntityType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + EntityId = table.Column(type: "uuid", nullable: true), + EntityName = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + IpAddress = table.Column(type: "character varying(45)", maxLength: 45, nullable: true), + UserAgent = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_action_logs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "login_logs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Username = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + UserId = table.Column(type: "uuid", nullable: true), + Success = table.Column(type: "boolean", nullable: false), + FailureReason = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + IpAddress = table.Column(type: "character varying(45)", maxLength: 45, nullable: true), + UserAgent = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_login_logs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_action_logs_Action", + table: "action_logs", + column: "Action"); + + migrationBuilder.CreateIndex( + name: "IX_action_logs_ActorUserId", + table: "action_logs", + column: "ActorUserId"); + + migrationBuilder.CreateIndex( + name: "IX_action_logs_CreatedAt", + table: "action_logs", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_action_logs_EntityType", + table: "action_logs", + column: "EntityType"); + + migrationBuilder.CreateIndex( + name: "IX_login_logs_CreatedAt", + table: "login_logs", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_login_logs_Success", + table: "login_logs", + column: "Success"); + + migrationBuilder.CreateIndex( + name: "IX_login_logs_Username", + table: "login_logs", + column: "Username"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "action_logs"); + + migrationBuilder.DropTable( + name: "login_logs"); + } + } +} diff --git a/infrastructure/Migrations/AppDbContextModelSnapshot.cs b/infrastructure/Migrations/AppDbContextModelSnapshot.cs index 7ee8112..9bc91a3 100644 --- a/infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -22,6 +22,104 @@ namespace mws.backend.dotnet.infrastructure.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("mws.backend.dotnet.domain.Audit.ActionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EntityType"); + + b.ToTable("action_logs", (string)null); + }); + + modelBuilder.Entity("mws.backend.dotnet.domain.Audit.LoginLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Success"); + + b.HasIndex("Username"); + + b.ToTable("login_logs", (string)null); + }); + modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b => { b.Property("Id") diff --git a/infrastructure/Persistence/AppDbContext.cs b/infrastructure/Persistence/AppDbContext.cs index 41d39f6..46b4e0b 100644 --- a/infrastructure/Persistence/AppDbContext.cs +++ b/infrastructure/Persistence/AppDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using mws.backend.dotnet.domain.Audit; using mws.backend.dotnet.domain.Documents; using mws.backend.dotnet.domain.MasterData; using mws.backend.dotnet.domain.Projects; @@ -20,6 +21,8 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet Roles => Set(); public DbSet RolePermissions => Set(); public DbSet MasterData => Set(); + public DbSet ActionLogs => Set(); + public DbSet LoginLogs => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/infrastructure/Persistence/Configuration/ActionLogConfiguration.cs b/infrastructure/Persistence/Configuration/ActionLogConfiguration.cs new file mode 100644 index 0000000..bebd31b --- /dev/null +++ b/infrastructure/Persistence/Configuration/ActionLogConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using mws.backend.dotnet.domain.Audit; + +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; + +public class ActionLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("action_logs"); + e.HasKey(x => x.Id); + e.Property(x => x.ActorUsername).HasMaxLength(100).IsRequired(); + e.Property(x => x.Action).HasMaxLength(100).IsRequired(); + e.Property(x => x.EntityType).HasMaxLength(50).IsRequired(); + e.Property(x => x.EntityName).HasMaxLength(255); + e.Property(x => x.IpAddress).HasMaxLength(45); + e.Property(x => x.UserAgent).HasMaxLength(512); + e.HasIndex(x => x.CreatedAt); + e.HasIndex(x => x.ActorUserId); + e.HasIndex(x => x.Action); + e.HasIndex(x => x.EntityType); + } +} diff --git a/infrastructure/Persistence/Configuration/LoginLogConfiguration.cs b/infrastructure/Persistence/Configuration/LoginLogConfiguration.cs new file mode 100644 index 0000000..94cbe07 --- /dev/null +++ b/infrastructure/Persistence/Configuration/LoginLogConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using mws.backend.dotnet.domain.Audit; + +namespace mws.backend.dotnet.infrastructure.Persistence.Configuration; + +public class LoginLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder e) + { + e.ToTable("login_logs"); + e.HasKey(x => x.Id); + e.Property(x => x.Username).HasMaxLength(100).IsRequired(); + e.Property(x => x.FailureReason).HasMaxLength(100); + e.Property(x => x.IpAddress).HasMaxLength(45); + e.Property(x => x.UserAgent).HasMaxLength(512); + e.HasIndex(x => x.CreatedAt); + e.HasIndex(x => x.Username); + e.HasIndex(x => x.Success); + } +} diff --git a/infrastructure/Persistence/DbSeeder.cs b/infrastructure/Persistence/DbSeeder.cs index 502e1ca..1509eb8 100644 --- a/infrastructure/Persistence/DbSeeder.cs +++ b/infrastructure/Persistence/DbSeeder.cs @@ -141,7 +141,7 @@ public static class DbSeeder "Member", screen => screen.Key switch { - "users" or "permissions" or "masterdata" => (View: false, Create: false, Edit: false, Delete: false), + "users" or "permissions" or "masterdata" or "actionlogs" or "loginlogs" => (View: false, Create: false, Edit: false, Delete: false), "projects" => (View: true, Create: false, Edit: false, Delete: false), _ => (View: true, Create: true, Edit: true, Delete: false), }, diff --git a/infrastructure/Persistence/Repositories/ActionLogRepository.cs b/infrastructure/Persistence/Repositories/ActionLogRepository.cs new file mode 100644 index 0000000..b22b612 --- /dev/null +++ b/infrastructure/Persistence/Repositories/ActionLogRepository.cs @@ -0,0 +1,35 @@ +using mws.backend.dotnet.application.Audit; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.domain.Audit; + +namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; + +public class ActionLogRepository(AppDbContext db) : RepositoryBase(db), IActionLogRepository +{ + public Task> SearchAsync(ActionLogFilter filter, int page, int pageSize, CancellationToken ct = default) + { + var query = Set.AsQueryable(); + + if (filter.From is { } from) query = query.Where(x => x.CreatedAt >= from); + if (filter.To is { } to) query = query.Where(x => x.CreatedAt <= to); + if (!string.IsNullOrWhiteSpace(filter.Actor)) + { + var lower = filter.Actor.Trim().ToLower(); + query = query.Where(x => x.ActorUsername.ToLower().Contains(lower)); + } + if (!string.IsNullOrWhiteSpace(filter.Action)) + { + var lower = filter.Action.Trim().ToLower(); + query = query.Where(x => x.Action.ToLower().Contains(lower)); + } + if (!string.IsNullOrWhiteSpace(filter.EntityType)) + { + var lower = filter.EntityType.Trim().ToLower(); + query = query.Where(x => x.EntityType.ToLower().Contains(lower)); + } + if (filter.EntityId is { } entityId) query = query.Where(x => x.EntityId == entityId); + + return query.OrderByDescending(x => x.CreatedAt).ToPagedResultAsync(page, pageSize, ct); + } +} diff --git a/infrastructure/Persistence/Repositories/LoginLogRepository.cs b/infrastructure/Persistence/Repositories/LoginLogRepository.cs new file mode 100644 index 0000000..4886926 --- /dev/null +++ b/infrastructure/Persistence/Repositories/LoginLogRepository.cs @@ -0,0 +1,25 @@ +using mws.backend.dotnet.application.Audit; +using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Common.Repositories; +using mws.backend.dotnet.domain.Audit; + +namespace mws.backend.dotnet.infrastructure.Persistence.Repositories; + +public class LoginLogRepository(AppDbContext db) : RepositoryBase(db), ILoginLogRepository +{ + public Task> SearchAsync(LoginLogFilter filter, int page, int pageSize, CancellationToken ct = default) + { + var query = Set.AsQueryable(); + + if (filter.From is { } from) query = query.Where(x => x.CreatedAt >= from); + if (filter.To is { } to) query = query.Where(x => x.CreatedAt <= to); + if (!string.IsNullOrWhiteSpace(filter.Username)) + { + var lower = filter.Username.Trim().ToLower(); + query = query.Where(x => x.Username.ToLower().Contains(lower)); + } + if (filter.Success is { } success) query = query.Where(x => x.Success == success); + + return query.OrderByDescending(x => x.CreatedAt).ToPagedResultAsync(page, pageSize, ct); + } +} diff --git a/infrastructure/Persistence/UnitOfWork.cs b/infrastructure/Persistence/UnitOfWork.cs index 1e29df1..b2c7397 100644 --- a/infrastructure/Persistence/UnitOfWork.cs +++ b/infrastructure/Persistence/UnitOfWork.cs @@ -17,6 +17,8 @@ public class UnitOfWork : IUnitOfWork Documents = new DocumentRepository(db); Roles = new RoleRepository(db); MasterData = new MasterDataRepository(db); + ActionLogs = new ActionLogRepository(db); + LoginLogs = new LoginLogRepository(db); } public IUserRepository Users { get; } @@ -25,6 +27,8 @@ public class UnitOfWork : IUnitOfWork public IDocumentRepository Documents { get; } public IRoleRepository Roles { get; } public IMasterDataRepository MasterData { get; } + public IActionLogRepository ActionLogs { get; } + public ILoginLogRepository LoginLogs { get; } public Task SaveChangesAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct); }