Skip to content

Commit

Permalink
feature : Implement Audit Trail (#1017)
Browse files Browse the repository at this point in the history
* feature : Add Auditing Backend for #1016

* add api endpoint to get user audit

* blazor changes for #1016

* add audit trail to navmenu

* fix image url
  • Loading branch information
iammukeshm committed Aug 28, 2024
1 parent bc260cf commit 8eb77ea
Show file tree
Hide file tree
Showing 33 changed files with 1,193 additions and 61 deletions.
13 changes: 13 additions & 0 deletions src/api/framework/Core/Audit/AuditTrail.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace FSH.Framework.Core.Audit;
public class AuditTrail
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string? Operation { get; set; }
public string? Entity { get; set; }
public DateTimeOffset DateTime { get; set; }
public string? PreviousValues { get; set; }
public string? NewValues { get; set; }
public string? ModifiedProperties { get; set; }
public string? PrimaryKey { get; set; }
}
5 changes: 5 additions & 0 deletions src/api/framework/Core/Audit/IAuditService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace FSH.Framework.Core.Audit;
public interface IAuditService
{
Task<List<AuditTrail>> GetUserTrailsAsync(Guid userId);
}
37 changes: 37 additions & 0 deletions src/api/framework/Core/Audit/TrailDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Collections.ObjectModel;
using System.Text.Json;

namespace FSH.Framework.Core.Audit;
public class TrailDto()
{
public Guid Id { get; set; }
public DateTimeOffset DateTime { get; set; }
public Guid UserId { get; set; }
public Dictionary<string, object?> KeyValues { get; } = new();
public Dictionary<string, object?> OldValues { get; } = new();
public Dictionary<string, object?> NewValues { get; } = new();
public Collection<string> ModifiedProperties { get; } = new();
public TrailType Type { get; set; }
public string? TableName { get; set; }

private static readonly JsonSerializerOptions serializerOptions = new()
{
WriteIndented = false,
};

public AuditTrail ToAuditTrail()
{
return new()
{
Id = Guid.NewGuid(),
UserId = UserId,
Operation = Type.ToString(),
Entity = TableName,
DateTime = DateTime,
PrimaryKey = JsonSerializer.Serialize(KeyValues, serializerOptions),
PreviousValues = OldValues.Count == 0 ? null : JsonSerializer.Serialize(OldValues, serializerOptions),
NewValues = NewValues.Count == 0 ? null : JsonSerializer.Serialize(NewValues, serializerOptions),
ModifiedProperties = ModifiedProperties.Count == 0 ? null : JsonSerializer.Serialize(ModifiedProperties, serializerOptions)
};
}
}
8 changes: 8 additions & 0 deletions src/api/framework/Core/Audit/TrailType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace FSH.Framework.Core.Audit;
public enum TrailType
{
None = 0,
Create = 1,
Update = 2,
Delete = 3
}
4 changes: 2 additions & 2 deletions src/api/framework/Infrastructure/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@ public static WebApplicationBuilder ConfigureFshFramework(this WebApplicationBui
builder.Services.ConfigureCaching(builder.Configuration);
builder.Services.AddExceptionHandler<CustomExceptionHandler>();
builder.Services.AddProblemDetails();

builder.Services.AddHealthChecks();
builder.Services.AddOptions<OriginOptions>().BindConfiguration(nameof(OriginOptions));

// Define module assemblies
var assemblies = new Assembly[]
{
typeof(FshCore).Assembly
typeof(FshCore).Assembly,
typeof(FshInfrastructure).Assembly
};

// Register validators
Expand Down
5 changes: 5 additions & 0 deletions src/api/framework/Infrastructure/FshInfrastructure.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace FSH.Framework.Infrastructure;
public class FshInfrastructure
{
public static string Name { get; set; } = "FshInfrastructure";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Collections.ObjectModel;
using FSH.Framework.Core.Audit;
using MediatR;

namespace FSH.Framework.Infrastructure.Identity.Audit;
public class AuditPublishedEvent : INotification
{
public AuditPublishedEvent(Collection<AuditTrail>? trails)
{
Trails = trails;
}
public Collection<AuditTrail>? Trails { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using FSH.Framework.Core.Audit;
using FSH.Framework.Infrastructure.Identity.Persistence;
using MediatR;
using Microsoft.Extensions.Logging;

namespace FSH.Framework.Infrastructure.Identity.Audit;
public class AuditPublishedEventHandler(ILogger<AuditPublishedEventHandler> logger, IdentityDbContext context) : INotificationHandler<AuditPublishedEvent>
{
public async Task Handle(AuditPublishedEvent notification, CancellationToken cancellationToken)
{
if (context == null) return;
logger.LogInformation("received audit trails");
try
{
await context.Set<AuditTrail>().AddRangeAsync(notification.Trails!, default);
await context.SaveChangesAsync(default);
}
catch
{
logger.LogError("error while saving audit trail");
}
return;
}
}
17 changes: 17 additions & 0 deletions src/api/framework/Infrastructure/Identity/Audit/AuditService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using FSH.Framework.Core.Audit;
using FSH.Framework.Infrastructure.Identity.Persistence;
using Microsoft.EntityFrameworkCore;

namespace FSH.Framework.Infrastructure.Identity.Audit;
public class AuditService(IdentityDbContext context) : IAuditService
{
public async Task<List<AuditTrail>> GetUserTrailsAsync(Guid userId)
{
var trails = await context.AuditTrails
.Where(a => a.UserId == userId)
.OrderByDescending(a => a.DateTime)
.Take(250)
.ToListAsync();
return trails;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using FSH.Framework.Core.Audit;
using FSH.Framework.Infrastructure.Auth.Policy;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace FSH.Framework.Infrastructure.Identity.Audit.Endpoints;

public static class GetUserAuditTrailEndpoint
{
internal static RouteHandlerBuilder MapGetUserAuditTrailEndpoint(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapGet("/{id:guid}/audit-trails", (Guid id, IAuditService service) =>
{
return service.GetUserTrailsAsync(id);
})
.WithName(nameof(GetUserAuditTrailEndpoint))
.WithSummary("Get user's audit trail details")
.RequirePermission("Permissions.AuditTrails.View")
.WithDescription("Get user's audit trail details.");
}
}
6 changes: 5 additions & 1 deletion src/api/framework/Infrastructure/Identity/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using FSH.Framework.Core.Identity.Roles;
using FSH.Framework.Core.Audit;
using FSH.Framework.Core.Identity.Roles;
using FSH.Framework.Core.Identity.Tokens;
using FSH.Framework.Core.Identity.Users.Abstractions;
using FSH.Framework.Core.Persistence;
using FSH.Framework.Infrastructure.Auth;
using FSH.Framework.Infrastructure.Identity.Audit;
using FSH.Framework.Infrastructure.Identity.Persistence;
using FSH.Framework.Infrastructure.Identity.Roles;
using FSH.Framework.Infrastructure.Identity.Roles.Endpoints;
Expand Down Expand Up @@ -30,6 +32,7 @@ internal static IServiceCollection ConfigureIdentity(this IServiceCollection ser
services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService<ICurrentUser>());
services.AddTransient<IUserService, UserService>();
services.AddTransient<IRoleService, RoleService>();
services.AddTransient<IAuditService, AuditService>();
services.BindDbContext<IdentityDbContext>();
services.AddScoped<IDbInitializer, IdentityDbInitializer>();
services.AddIdentity<FshUser, FshRole>(options =>
Expand All @@ -56,6 +59,7 @@ public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuil

var roles = app.MapGroup("api/roles").WithTags("roles");
roles.MapRoleEndpoints();

return app;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ internal static class IdentityConstants
public const string SchemaName = "identity";
public const string RootTenant = "root";
public const string DefaultPassword = "123Pa$$word!";
public const string DefaultProfilePicture = "/assets/defaults/profile-picture.webp";
public const string DefaultProfilePicture = "assets/defaults/profile-picture.webp";

public static class Roles
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Finbuckle.MultiTenant;
using FSH.Framework.Core.Audit;
using FSH.Framework.Infrastructure.Identity.RoleClaims;
using FSH.Framework.Infrastructure.Identity.Roles;
using FSH.Framework.Infrastructure.Identity.Users;
Expand All @@ -8,6 +9,18 @@

namespace FSH.Framework.Infrastructure.Identity.Persistence;

public class AuditTrailConfig : IEntityTypeConfiguration<AuditTrail>
{
public void Configure(EntityTypeBuilder<AuditTrail> builder)
{
builder
.ToTable("AuditTrails", IdentityConstants.SchemaName)
.IsMultiTenant();

builder.HasKey(a => a.Id);
}
}

public class ApplicationUserConfig : IEntityTypeConfiguration<FshUser>
{
public void Configure(EntityTypeBuilder<FshUser> builder)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Finbuckle.MultiTenant.Abstractions;
using Finbuckle.MultiTenant.EntityFrameworkCore;
using FSH.Framework.Core.Audit;
using FSH.Framework.Core.Persistence;
using FSH.Framework.Infrastructure.Identity.RoleClaims;
using FSH.Framework.Infrastructure.Identity.Roles;
Expand Down Expand Up @@ -28,6 +29,8 @@ public IdentityDbContext(IMultiTenantContextAccessor<FshTenantInfo> multiTenantC
TenantInfo = multiTenantContextAccessor.MultiTenantContext.TenantInfo!;
}

public DbSet<AuditTrail> AuditTrails { get; set; }

protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
Expand Down
18 changes: 17 additions & 1 deletion src/api/framework/Infrastructure/Identity/Tokens/TokenService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
using FSH.Framework.Core.Identity.Tokens.Features.Refresh;
using FSH.Framework.Core.Identity.Tokens.Models;
using FSH.Framework.Infrastructure.Auth.Jwt;
using FSH.Framework.Infrastructure.Identity.Audit;
using FSH.Framework.Infrastructure.Identity.Users;
using FSH.Framework.Infrastructure.Tenant;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
Expand All @@ -22,11 +24,13 @@ public sealed class TokenService : ITokenService
private readonly UserManager<FshUser> _userManager;
private readonly IMultiTenantContextAccessor<FshTenantInfo>? _multiTenantContextAccessor;
private readonly JwtOptions _jwtOptions;
public TokenService(IOptions<JwtOptions> jwtOptions, UserManager<FshUser> userManager, IMultiTenantContextAccessor<FshTenantInfo>? multiTenantContextAccessor)
private readonly IPublisher _publisher;
public TokenService(IOptions<JwtOptions> jwtOptions, UserManager<FshUser> userManager, IMultiTenantContextAccessor<FshTenantInfo>? multiTenantContextAccessor, IPublisher publisher)
{
_jwtOptions = jwtOptions.Value;
_userManager = userManager ?? throw new ArgumentNullException(nameof(userManager));
_multiTenantContextAccessor = multiTenantContextAccessor;
_publisher = publisher;
}

public async Task<TokenResponse> GenerateTokenAsync(TokenGenerationCommand request, string ipAddress, CancellationToken cancellationToken)
Expand Down Expand Up @@ -88,6 +92,18 @@ private async Task<TokenResponse> GenerateTokensAndUpdateUser(FshUser user, stri

await _userManager.UpdateAsync(user);

await _publisher.Publish(new AuditPublishedEvent(new()
{
new()
{
Id = Guid.NewGuid(),
Operation = "Token Generated",
Entity = "Identity",
UserId = new Guid(user.Id),
DateTime = DateTime.UtcNow,
}
}));

return new TokenResponse(token, user.RefreshToken, user.RefreshTokenExpiryTime);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Routing;
using FSH.Framework.Infrastructure.Identity.Audit.Endpoints;
using Microsoft.AspNetCore.Routing;

namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints;
internal static class Extensions
Expand All @@ -19,6 +20,7 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder
app.ToggleUserStatusEndpointEndpoint();
app.MapAssignRolesToUserEndpoint();
app.MapGetUserRolesEndpoint();
app.MapGetUserAuditTrailEndpoint();
return app;
}
}
2 changes: 1 addition & 1 deletion src/api/framework/Infrastructure/Persistence/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public static WebApplicationBuilder ConfigureDatabase(this WebApplicationBuilder
_logger.Information("for documentations and guides, visit https://www.fullstackhero.net");
_logger.Information("to sponsor this project, visit https://opencollective.com/fullstackhero");
});
builder.Services.AddScoped<ISaveChangesInterceptor, AuditableEntityInterceptor>();
builder.Services.AddScoped<ISaveChangesInterceptor, AuditInterceptor>();
return builder;
}

Expand Down
Loading

0 comments on commit 8eb77ea

Please sign in to comment.